Просмотр исходного кода

Print the org number on the letterhead, and the design the invoice picked (#401)

Two bugs from the same report.

The letterhead asked invoice.showOrgNumber, a switch the invoice settings
page carried until the designer replaced it with the header's own field
switches and it was deleted. No organization onboarded since has the row,
and read as === 'true' that meant hidden, so the org number could not be
printed in the header at all however plainly the designer showed it, with
nothing left in the UI to turn it on. Absent now means shown; an explicit
'false' from the era when the switch existed is still obeyed, and issued
invoices keep the boolean frozen into their snapshot.

The designer stamped the layout version on the copy it wrote to settings
and not on the design row, so a design picked by name on an invoice read
back as a layout predating the designer and printed the classic header
and title, while the same design as the workshop default printed
correctly. The row is stamped now, and a row without the stamp is read as
what it can only be: a designer layout that lost it. That fixes designs
already saved, without a backfill.
Bernt Christian Egeland 2 недель назад
Родитель
Сommit
605ad25b2e

+ 124 - 0
src/__tests__/features/invoices/design-source-parity.test.ts

@@ -0,0 +1,124 @@
+// @vitest-environment node
+/**
+ * Two ways the same design reached the printer, and the two bugs that made
+ * them disagree. Reported together by a workshop in September 2026.
+ *
+ * The org number was enabled on the header in the designer and printed there
+ * in the preview, but never on the sheet. The header asked a retired setting,
+ * `invoice.showOrgNumber`, that the old invoice settings page used to write.
+ * The designer replaced that switch with the header's own field switch and the
+ * old one was deleted, so no organization onboarded since has the row at all;
+ * read as `=== 'true'` it was false for every one of them.
+ *
+ * And a design picked by name on an invoice printed with the header and title
+ * of the classic pre-designer sheet, while the same design as the workshop
+ * default printed correctly. The designer stamped `version` on the copy it put
+ * in settings and not on the row, so the row read back as a layout predating
+ * the designer.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { buildInvoicePrintSpec } from '@/features/invoice-designer/Pdf/buildInvoicePrint'
+import { liveInvoiceSettings } from '@/features/invoices/Lib/assembleInvoicePrint'
+import {
+  designSourceFromSettings,
+  designSourceFromStored,
+  templateConfigFromSource,
+} from '@/features/invoice-designer/Lib/designSource'
+import {
+  DESIGNER_LAYOUT_VERSION,
+  getDefaultInvoiceLayout,
+} from '@/features/settings/Schema/invoiceLayoutSchema'
+import type { InvoiceData } from '@/features/vehicles/Components/invoice-pdf/types'
+
+const data: InvoiceData = {
+  id: 'svc-design',
+  title: 'Service',
+  description: null,
+  type: 'repair',
+  serviceDate: new Date('2026-08-14'),
+  shopName: 'Egeland Auto',
+  techName: null,
+  mileage: null,
+  diagnosticNotes: null,
+  invoiceNotes: null,
+  subtotal: 100,
+  taxRate: 0,
+  taxAmount: 0,
+  totalAmount: 100,
+  cost: 100,
+  invoiceNumber: 'INV-2026-1001',
+  partItems: [],
+  laborItems: [{ description: 'Work', hours: 1, rate: 100, total: 100 }],
+  customer: null,
+  vehicle: null,
+}
+
+const ORG_NUMBER = 'NO911782162'
+
+function headerText(settings: Record<string, string>): string {
+  const spec = buildInvoicePrintSpec({
+    data,
+    workshop: { name: 'Egeland Auto', address: 'Eigeland 50', phone: '91131664', email: '' },
+    // Read through the assembler, so the rule under test is the real one and
+    // not a copy of it that cannot go stale.
+    invoiceSettings: liveInvoiceSettings({ 'invoice.orgNumber': ORG_NUMBER, ...settings }),
+    template: { layoutConfig: { ...getDefaultInvoiceLayout(), version: DESIGNER_LAYOUT_VERSION } },
+  })
+  return JSON.stringify(spec.blocks.filter((b) => b.id === 'header'))
+}
+
+describe('the org number on the letterhead', () => {
+  it('prints for a workshop that never had the retired switch', () => {
+    expect(headerText({})).toContain(ORG_NUMBER)
+  })
+
+  it('prints for one whose switch was on', () => {
+    expect(headerText({ 'invoice.showOrgNumber': 'true' })).toContain(ORG_NUMBER)
+  })
+
+  it('stays off for one that deliberately turned the switch off', () => {
+    expect(headerText({ 'invoice.showOrgNumber': 'false' })).not.toContain(ORG_NUMBER)
+  })
+})
+
+/** What the designer holds in hand while somebody works on a design. */
+function editedLayout() {
+  const layout = getDefaultInvoiceLayout()
+  layout.sections = layout.sections.map((s) =>
+    s.id === 'document_title' ? { ...s, visible: true } : s
+  )
+  return layout
+}
+
+function blockIds(template: ReturnType<typeof templateConfigFromSource>): string[] {
+  return buildInvoicePrintSpec({ data, template }).blocks.map((b) => b.id ?? '')
+}
+
+describe('a design row saved before the stamp was written to it', () => {
+  it('reads back as a designer layout, not a pre-designer one', () => {
+    const source = designSourceFromStored(editedLayout(), {})
+    expect(source?.layout.version).toBe(DESIGNER_LAYOUT_VERSION)
+  })
+
+  it('leaves a row that states its own version alone', () => {
+    const source = designSourceFromStored({ ...editedLayout(), version: 2 }, {})
+    expect(source?.layout.version).toBe(2)
+  })
+
+  it('prints the sheet the same design prints as the workshop default', () => {
+    const layout = editedLayout()
+    // What the designer wrote to settings: stamped.
+    const asDefault = designSourceFromSettings(
+      { 'invoice.layoutConfig': JSON.stringify({ ...layout, version: DESIGNER_LAYOUT_VERSION }) },
+      'invoice'
+    )
+    // What it wrote to the row: the same layout, no stamp.
+    const asRow = designSourceFromStored(layout, {})
+    expect(asRow).not.toBeNull()
+
+    expect(blockIds(templateConfigFromSource(asRow!))).toEqual(
+      blockIds(templateConfigFromSource(asDefault))
+    )
+  })
+})

+ 8 - 4
src/features/invoice-designer/Components/InvoiceDesigner.tsx

@@ -626,6 +626,13 @@ export function InvoiceDesigner({
     try {
       const prefix = docType === 'invoice' ? 'invoice' : 'quote'
 
+      // The stamp that graduates this organization from the classic
+      // pre-designer rendering to whatever this designer shows. The row needs
+      // it as much as the settings copy below: a row saved without it printed
+      // the classic header and title whenever an invoice picked the design by
+      // name, while the same design as the workshop default printed correctly.
+      const stamped = { ...layout, version: DESIGNER_LAYOUT_VERSION }
+
       // The row is the design: saved on the server under its name, which is
       // also what makes the same name update in place rather than fill the
       // gallery with near-copies.
@@ -633,7 +640,7 @@ export function InvoiceDesigner({
         id: existing?.id,
         documentType: docType,
         name,
-        layout: JSON.parse(JSON.stringify(layout)) as InvoiceLayoutConfig,
+        layout: JSON.parse(JSON.stringify(stamped)) as InvoiceLayoutConfig,
         template: { ...template },
       })
       if (!savedResult.success || !savedResult.data) {
@@ -647,9 +654,6 @@ export function InvoiceDesigner({
         setActiveDesigns((prev) => ({ ...prev, [docType]: active }))
       }
 
-      // The stamp that graduates this organization from the classic
-      // pre-designer rendering to whatever this designer shows.
-      const stamped = { ...layout, version: DESIGNER_LAYOUT_VERSION }
       await Promise.all([
         docType === 'invoice' ? saveInvoiceLayoutConfig(stamped) : saveQuoteLayoutConfig(stamped),
         setSettings({

+ 17 - 2
src/features/invoice-designer/Lib/designSource.ts

@@ -1,5 +1,6 @@
 import { z } from 'zod'
 import {
+  DESIGNER_LAYOUT_VERSION,
   invoiceLayoutConfigSchema,
   mergeWithDefaults,
   type InvoiceLayoutConfig,
@@ -51,13 +52,27 @@ export const designTemplateSchema = z
 
 export const DESIGN_DOCUMENT_TYPES: DocumentType[] = ['invoice', 'quote']
 
-/** Reads a stored design row's JSON columns back into a source, or null. */
+/**
+ * Reads a stored design row's JSON columns back into a source, or null.
+ *
+ * A row with no `version` is read as a designer layout rather than as a
+ * pre-designer one. Only the designer writes these rows, so every one of them
+ * is a designer layout by definition; the stamp was simply left off the row
+ * while the copy that went to settings got it. Without this, the same design
+ * printed one way as the workshop default and another way when an invoice
+ * picked it by name, which is what the classic fallback does to the header
+ * and the title.
+ */
 export function designSourceFromStored(layout: unknown, template: unknown): DesignSource | null {
   const parsedTemplate = designTemplateSchema.safeParse(template ?? {})
   if (!parsedTemplate.success) return null
   const parsedLayout = invoiceLayoutConfigSchema.partial().safeParse(layout ?? {})
   if (!parsedLayout.success) return null
-  return { layout: parsedLayout.data, template: parsedTemplate.data }
+  const stored = parsedLayout.data
+  return {
+    layout: stored.version === undefined ? { ...stored, version: DESIGNER_LAYOUT_VERSION } : stored,
+    template: parsedTemplate.data,
+  }
 }
 
 function parseLayoutSetting(value: string | undefined): Partial<InvoiceLayoutConfig> {

+ 11 - 3
src/features/invoices/Lib/assembleInvoicePrint.ts

@@ -213,14 +213,22 @@ export async function currentLook(
   )
 }
 
-function liveInvoiceSettings(settingsMap: Record<string, string>): InvoiceSettingsProps {
+/** Exported for the tests that pin down what an absent setting means. */
+export function liveInvoiceSettings(settingsMap: Record<string, string>): InvoiceSettingsProps {
   return {
     bankAccount: settingsMap['invoice.bankAccount'] || '',
     orgNumber: settingsMap['invoice.orgNumber'] || '',
     paymentTerms: settingsMap['invoice.paymentTerms'] || '',
     footerNote: settingsMap['invoice.footerNote'] || '',
-    showBankAccount: settingsMap['invoice.showBankAccount'] === 'true',
-    showOrgNumber: settingsMap['invoice.showOrgNumber'] === 'true',
+    // Absent means shown. These two predate the designer, when the invoice
+    // settings page carried a switch for each; the designer replaced them with
+    // the header's own field switches and the switch was deleted, so no
+    // organization onboarded since has ever had the row. Read as `=== 'true'`
+    // they were false for all of them, and the org number could not be printed
+    // in the header at all, whatever the designer showed. An organization that
+    // did turn the old switch off still has its 'false' and is still obeyed.
+    showBankAccount: settingsMap['invoice.showBankAccount'] !== 'false',
+    showOrgNumber: settingsMap['invoice.showOrgNumber'] !== 'false',
     lineItemsInclTax: settingsMap['invoice.lineItemsInclTax'] === 'true',
     dueDays: Number(settingsMap['invoice.dueDays']) || 0,
     currencyCode: settingsMap['workshop.currencyCode'] || 'USD',