diff --git a/docs/architecture/printing.md b/docs/architecture/printing.md index 61d23aefe..3358d6097 100644 --- a/docs/architecture/printing.md +++ b/docs/architecture/printing.md @@ -160,11 +160,12 @@ four whitespace-separated tokens read as a sentence. | WebUSB | `frontend/src/lib/printer/PrinterService.ts` | Encoder output, sent over the browser's WebUSB API. | | Delivery slip | `frontend/src/lib/printer/delivery-slip-encoder.ts` and `delivery-slip-web-print.ts` | A `DeliverySlipDocument`'s contact facts, rendered as bytes or HTML. | | Tax bill | `frontend/src/lib/printer/tax-bill-encoder.ts` | A raw `Bill` and `Tenant`, not a document. | -| Payment ticket slip | `printPaymentTicket` in `main/printers/thermal.ts` | Order metadata, item lines, balance due, and ESC/POS QR code (`FLOCAFE-TICKET:`) for multi-counter ordering. | +| Payment ticket slip | `printPaymentTicket` in `main/printers/thermal.ts` | Order metadata, item lines, balance due, centered `FLOCAFE-TICKET:` text, and (when `raster.enabled` is true) an ESC/POS QR code for multi-counter ordering. | The tax bill encoder and payment ticket slip generator are exceptions to "renderers consume documents": the tax bill encoder reads `Bill` directly for per-component tax detail, while -`printPaymentTicket` formats a compact payment ticket slip and QR code from order items and totals. +`printPaymentTicket` formats a compact payment ticket slip and optional QR code from order items +and totals. ### Browser HTML printing @@ -190,10 +191,12 @@ cropped content to at most 576x240 dots (384x240 for 58 mm), normalize grayscale 384- and 576-dot printers. `receipt_logo` stores an adaptively compressed data URL (`<= 200 KB` target, `250 KB` cap) while `receipt_logo_raster` stores only the packed variants (`originalDataUrl` is left empty in new payloads, with `parseReceiptLogoData()` accepting both empty and populated -values). Browser bills center the image above the business header. The classic and compact ESC/POS -renderers and frontend receipt encoders select a raster variant for the paper width and emit it with -`GS v 0` via `{RAW_BYTES:...}`, which the mixed-mode text rasterizer skips so non-Latin headers -never overwrite the logo bitmap. Kitchen tickets do not use the bill logo setting. +values). Browser bills center the image above the business header. When `rasterCapabilityEnabled()` +is true, the classic and compact ESC/POS renderers and frontend receipt encoders select a raster +variant for the paper width and emit it with `GS v 0` via `{RAW_BYTES:...}`, which the mixed-mode +text rasterizer skips so non-Latin headers never overwrite the logo bitmap. Profiles with +`raster.enabled: false` (such as `escpos-impact-76`) omit `{RAW_BYTES:...}` logo bitmaps. Kitchen +tickets do not use the bill logo setting. ## Transports @@ -212,23 +215,34 @@ so it must not embed device names or user data. ## Printer profiles -`main/printers/profiles.ts` ships four profiles: +`main/printers/profiles.ts` ships five profiles: | Id | Make and model | Paper | Font A columns | | --- | --- | --- | --- | | `xprinter-xp-v320m-v330m` | Xprinter XP-V320M / XP-V330M | 72 mm, `cols-48` | 48 | +| `escpos-impact-76` | Epson TM-U220 / 76mm Impact | 64 mm, `cols-32` | 32 | | `epson-tm-series` | Epson TM Series ESC/POS | `cols-48` | 48 | | `generic-escpos-80` | Generic ESC/POS 80 mm | `cols-42` | 42 | | `generic-escpos-58` | Generic ESC/POS 58 mm | `cols-32` | 32 | -All four declare `raster.enabled: true` with modes `mixed` and `whole-receipt`, at 576 dots of -width except the 58 mm profile, which declares 384. - -`resolvePrinterProfile()` resolves in three steps: an explicit `profile_id` if it names a known -profile, then a substring match against each profile's make, model, and aliases, then the paper -width, which selects `generic-escpos-58` for a width starting `58mm` and `generic-escpos-80` -otherwise. A name that matches nothing does not produce "generic for everything": it produces one -of the two generic profiles, chosen by paper width. +The four thermal profiles declare `raster.enabled: true` with modes `mixed` and `whole-receipt`, at +576 dots of width except the 58 mm profile, which declares 384. `escpos-impact-76` targets 76 mm +ribbon impact printers (`TM-U220`, `SRP-275`, `XP-76`) with `raster.enabled: false` (`widthDots: 0`, +`modes: []`), so bitmap logos (`GS v 0`), `GS ( k` QR codes, and text-to-bitmap raster bands are +never sent to an impact head. + +`resolvePrinterProfile()` resolves in three steps: an explicit `profile_id` persisted on `printers` +(exposed as `configured_profile_id` alongside the resolved `profile_id` on `/api/printers` +responses) if it names a known profile, then a substring match against each profile's make, model, +and aliases, then the paper width, which selects `generic-escpos-58` for a width starting `58mm` and +`generic-escpos-80` otherwise. A name that matches nothing does not produce "generic for +everything": it produces one of the two generic profiles, chosen by paper width. OS printer +detection (`detectConnectedPrinters()`) also annotates each queue with +`compatibility: 'escpos_thermal' | 'escpos_impact' | 'driver_only'` via +`classifyDetectedPrinterCompatibility()`, keeping driver-only queues (such as Star `TSP100` / +`TSP143` / `SP700` / `mC-Print`, Epson `EcoTank` / `WorkForce` / `Stylus` inkjets, label printers, +and PDF queues) visible in Settings with a browser-print switch instead of misclassifying them as +raw ESC/POS thermal printers. `capabilitiesForPrinter()` then caps the profile's raster width to the configured paper width when that width is narrower than the profile's hardware, so a 42-column configuration on 80 mm hardware diff --git a/docs/guides/testing-printers.md b/docs/guides/testing-printers.md index 3ceddbd0d..5911bbac9 100644 --- a/docs/guides/testing-printers.md +++ b/docs/guides/testing-printers.md @@ -21,22 +21,20 @@ npm run test:raster ## Which profile applies to a printer -`main/printers/profiles.ts` ships four profiles: `xprinter-xp-v320m-v330m`, `epson-tm-series`, -`generic-escpos-80`, and `generic-escpos-58`. A printer with no matching profile is not -unsupported; it runs on one of the two generic profiles, chosen by paper width. +See [printer profiles](../architecture/printing.md#printer-profiles) for the shipped profiles in +`main/printers/profiles.ts`, their raster capabilities, and the resolution order. A printer with no +matching name alias runs on one of the two generic profiles chosen by paper width, or on an explicit +`profile_id` selected in **Settings -> Printers**. To determine which profile a printer actually resolves to: 1. In FloCafe, open **Settings -> Printers**, add the printer, and save it. -2. Read the resolved `profile_id` from the stored printer row, or call the profile listing endpoint - that the printer settings page uses. +2. Read the resolved `profile_id` (and optional `configured_profile_id`) from the stored printer + row, or call the profile listing endpoint that the printer settings page uses. 3. If it resolved to a generic profile but you expected a specific one, the name did not match any - alias. `matchSupportedPrinterProfile()` substring-matches the lower-cased make, model, and alias - list, so the stored name has to contain one of the profile's tokens. Recording the name string - you entered is part of the report. - -Every profile declares raster capability. The 58 mm profile declares 384 dots of width; the other -three declare 576. + alias and no explicit profile was selected. `matchSupportedPrinterProfile()` substring-matches + the lower-cased make, model, and alias list, so an auto-detected name has to contain one of the + profile's tokens. Recording the name string you entered is part of the report. ## Test 1: chunked network printing diff --git a/docs/printers.md b/docs/printers.md index 372ce2d99..7aa61709c 100644 --- a/docs/printers.md +++ b/docs/printers.md @@ -66,27 +66,21 @@ rendered. ## Printer profiles and paper width -FloCafe resolves a printer to a capability profile in three steps, in order: - -1. An explicitly selected profile id on the configured printer, if one is set. -2. A match against the printer's name, make, or model. Matching is a case-insensitive substring - test against each profile's make, model, and alias list, in profile order. -3. The paper width, which selects a generic profile: `58mm` selects the 58 mm generic, anything - else selects the 80 mm generic. - -Four profiles ship. Detection does **not** return the generic profile for everything: a name -containing an Xprinter XP-V320M or XP-V330M, or a string containing `v320m` or `v330m`, resolves -to the Xprinter profile. A name containing `epson tm`, `tm-t88`, `tm-t82`, `tm-t20`, or `tm-m30` -resolves to the Epson TM series profile. Only a name that matches nothing falls through to the -generic profile chosen by paper width. - -The generic profiles are the conservative ones for native text encoding (ASCII-only, with no text -shaping), while the Xprinter and Epson profiles add the Latin code pages and Latin script -representability. All four shipped profiles enable the capability-gated -[raster printing](architecture/printing.md#raster-printing) path, so lines containing characters -outside the printer's native code pages are rendered as raster bands. If raster rendering is -unavailable or fails on a financial row, FloCafe refuses the print rather than omitting an amount, -reporting `Receipt not printed: ...` with the affected row and next steps. +When adding or editing a printer in **Settings > Printers**, choose a **Printer Type / Profile** or +leave it on **Auto-detect from printer name**, and set the printable column width (`32`, `42`, or +`48` columns). Selecting a profile sets its default column width unless you override it manually. +The `76mm Impact / Dot-Matrix (Epson TM-U220, Bixolon SRP-275)` profile (`escpos-impact-76`) uses +32-column text mode and omits bitmap logos, QR graphics, and raster bands. + +When **Settings > Printers** detects OS queues that do not accept raw ESC/POS commands (such as Star +`TSP100` / `TSP143` / `SP700` / `mC-Print`, Epson `EcoTank` / `WorkForce` / `Stylus` inkjets, label +printers, and PDF queues), it marks them **Driver / Browser print only** and offers a **Use Browser +Print** button instead of adding them as raw thermal printers. + +For the shipped profile table, profile resolution order, native code-page vs. +[raster printing](architecture/printing.md#raster-printing) capabilities, and +[financial-row refusal](architecture/printing.md#capabilities-and-refusal), see +[printer profiles](architecture/printing.md#printer-profiles). ## Arabic-script text diff --git a/docs/reference/api.md b/docs/reference/api.md index db1944d36..c7d51528f 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -576,8 +576,8 @@ Router: `main/routes/printers.ts`. Full path: `/api/printers`. | `GET` | `/detect` | any authenticated role | none | - | | `GET` | `/supported` | any authenticated role | none | - | | `GET` | `/:id` | any authenticated role | path: `id` | Single printer. | -| `POST` | `/` | `ROLE_ACCESS.ownerManager` | body: `connection_type`, `ip_address`, `port`, `paper_width`, `text_size`, `is_default`, `cash_drawer_pulse_enabled`, `name` | - | -| `PUT` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `connection_type`, `ip_address`, `port`, `paper_width`, `text_size`, `is_default`, `cash_drawer_pulse_enabled`, `name` | - | +| `POST` | `/` | `ROLE_ACCESS.ownerManager` | body: `connection_type`, `ip_address`, `port`, `paper_width`, `text_size`, `profile_id`, `is_default`, `cash_drawer_pulse_enabled`, `name` | - | +| `PUT` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `connection_type`, `ip_address`, `port`, `paper_width`, `text_size`, `profile_id`, `is_default`, `cash_drawer_pulse_enabled`, `name` | - | | `DELETE` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id` | - | | `POST` | `/:id/set-default` | `ROLE_ACCESS.ownerManager` | path: `id` | - | | `POST` | `/:id/test` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `rasterProbe` | - | @@ -586,7 +586,7 @@ Router: `main/routes/printers.ts`. Full path: `/api/printers`. | `POST` | `/print-menu` | `catalog.view` and `printing.execute` | body: boolean `includeInactive`, `includeOutOfStock`, `includeHidden`, `includeDescriptions`, `includeModifiers`; optional `paperWidth` (`58` or `80`) and `printerId` | `200` physical-print success, or `webusb: true` with byte array for browser delivery; `400` invalid input or `printer_not_configured`, `404` unknown `printerId` (`printer_not_found`), `409` missing regional configuration, `422` empty selection, `502` print failure. Prints to `printerId` when given, otherwise to the configured default, across connection types. | | `POST` | `/print-bill` | `printing.execute` | body: `billId`, `orderId`, `isReprint`, `preview`, `useUnicode`, `arabicShaping` | Body `billId` or `orderId`, plus `isReprint`, `preview`, `useUnicode`, `arabicShaping`. `preview` returns the rendered payload without sending it to the device. Returns `403` (`BILL_PRINTING_DISABLED`) for the `server` role when `server_app_bill_printing_enabled` is off. | | `POST` | `/print-kot` | `printing.execute` | body: `orderId`, `stationName`, `items`, `useUnicode`, `arabicShaping` | Body `orderId`, optional `stationName` and `items`, plus `useUnicode` and `arabicShaping`. Returns `403` (`KOT_PRINTING_DISABLED`) when `kot_printing_enabled` is off, and `409` when `orderId` is an unreleased scheduled order. | -| `POST` | `/print-payment-ticket` | `printing.execute` | body: `orderId` (or `order_id`), optional `printerId` (or `printer_id`), `useUnicode`, `preview` | Prints a payment ticket slip with order number, non-terminal items, totals, and QR code to the default or specified printer, or returns `{ webusb: true, bytes, printerName }` for browser delivery, or `{ preview: true }`. `400` on missing order id or when no printer is configured, `404` when order is not found, `409` if regional settings are missing, and `502` on print failure. | +| `POST` | `/print-payment-ticket` | `printing.execute` | body: `orderId` (or `order_id`), optional `printerId` (or `printer_id`), `useUnicode`, `preview` | Prints a payment ticket slip with order number, non-terminal items, totals, and (when `raster.enabled` is true) a QR code to the default or specified printer, or returns `{ webusb: true, bytes, printerName }` for browser delivery, or `{ preview: true }`. `400` on missing order id or when no printer is configured, `404` when order is not found, `409` if regional settings are missing, and `502` on print failure. | | `GET` | `/delivery-slip-payment/:orderId` | `printing.execute` | path: `orderId` | `200` with optional `payment` fields `status`, `amount`, `amountDue`, `formattedAmount`, `formattedAmountDue`, `method`, `methods`, and `expectedMethod` (the order's expected collection method, absent when unknown); `404` if the order does not exist. Uses the order total when no bill exists, otherwise the latest bill or all same-order bills in that bill's split group and their stored balances. | | `POST` | `/print-delivery-slip` | `printing.execute` | body: `orderId`, `useUnicode`, `arabicShaping` | Body `orderId` plus optional `useUnicode` and `arabicShaping`. Includes the full customer number when either `bill_delivery_show_customer_phone_always` or `bill_show_customer_phone` is on, and omits it only when both are off. Prints the delivery address, delivery note, order note, items, and payment status; an unpaid balance names the expected collection method. No bill is required: without a bill the slip shows the order total; otherwise it uses the selected bill balance(s), including any positive balance on refund-marked bills, as the amount due. Paid bills show the captured method(s) and total; refunds retain their refund status; see [product invariants](product-invariants.md). | diff --git a/docs/reference/translation-terminology.md b/docs/reference/translation-terminology.md index d456fd6e3..eb8215ebc 100644 --- a/docs/reference/translation-terminology.md +++ b/docs/reference/translation-terminology.md @@ -57,20 +57,20 @@ than a preference. `tests/translations.test.ts` rejects a `vi.json` value that i The same NFC requirement is enforced for `ha.json`, `jv.json`, `mr.json`, `ne.json`, `pa.json`, `ru.json`, `sw.json`, and `th.json`. -## Albanian and Vietnamese on a generic thermal printer +## Albanian and Vietnamese on thermal and impact printers -`LATIN_THERMAL_CAPABILITIES` in `shared/print/thermal-capabilities.ts` covers Latin-script printers -with `cp437`, `cp850`, `cp858`, and `windows1252` code pages, and transliteration to ASCII. +`GENERIC_THERMAL_CAPABILITIES` (`scripts: ['ascii']`) in `shared/print/thermal-capabilities.ts` +transliterates only through the fixed `LATIN_ASCII_MAP`, so Albanian `Ç`/`ç` and `Ë`/`ë` become +`C`/`c` and `E`/`e`, and German umlauts (`Ä`/`Ö`/`Ü`/`ß`) fold the same way. Characters outside +that map remain unrepresentable on `GENERIC_THERMAL_CAPABILITIES` unless raster rendering is +enabled. -Albanian is transliterated through a fixed `LATIN_ASCII_MAP`, so `Ç` and `ç` become `C` and `c`, and -`Ë` and `ë` become `E` and `e`. The map exists because those two characters are outside CP437. German -umlauts are transliterated the same way in the same table. A translation is free to use the full -character set; the printer path handles the reduction. +`LATIN_THERMAL_CAPABILITIES` (`scripts: ['ascii', 'latin']`) covers Latin-script printers with +`cp437`, `cp850`, `cp858`, and `windows1252` code pages plus fallback transliteration. Native +code-page coverage (without transliteration, as checked by `shouldUseOrderTypeFallback()`) for +Vietnamese strings is: -Vietnamese is a different case, because many of its characters are outside every supported code page. -Verified against `LATIN_THERMAL_CAPABILITIES`: - -| String | Representable | Code page | +| String | Native code-page match | Code page | | --- | --- | --- | | `Món` | yes | `cp437` | | `Khách hàng` | yes | `cp437` | @@ -84,12 +84,14 @@ Verified against `LATIN_THERMAL_CAPABILITIES`: | `Đơn vị` | no | none | | `Tiếng Việt` | no | none | -The characters that fail are those carrying a tone mark: `ộ`, `đ`, `ạ`, `ố`, `ơ`, and the stacked -`ế` in `Tiếng`. Note that the language endonym itself is not representable, so a printer-specific -surface that prints the language name needs a fallback too. - -This is a property of the printer, not of the translation. A Vietnamese string that fails this table -still renders correctly in the UI, on a browser receipt, and on a printer with a raster path. +The characters lacking a native single-byte code page are those carrying a tone mark, horn, or +stroke: `ộ`, `đ`, `ạ`, `ố`, `ơ`, and the stacked `ế` in `Tiếng`. When no single native code page +can represent a Latin-script string on `LATIN_THERMAL_CAPABILITIES` (and raster rendering is not +handling it), `normalizeThermalText()` applies `LATIN_ASCII_MAP`, `LATIN_EXTENDED_ASCII_MAP` +(`Æ/æ`, `Œ/œ`, `Ø/ø`, `Ł/ł`, `Đ/đ`, `Ð/ð`, `Þ/þ`, `Ğ/ğ`, `İ/ı`, `Ş/ş`, `Ơ/ơ`, `Ư/ư`), and Unicode +NFD `\p{Diacritic}` stripping on Latin characters so non-raster Latin profiles (such as +`escpos-impact-76`) fold those strings to ASCII (`cp437`). A translation is free to use the full +character set; the printer path handles the reduction or raster fallback. ## Print capability is owned by the printer profile diff --git a/frontend/e2e/database-export-import.spec.ts b/frontend/e2e/database-export-import.spec.ts index 8260d69ff..da092771a 100644 --- a/frontend/e2e/database-export-import.spec.ts +++ b/frontend/e2e/database-export-import.spec.ts @@ -211,7 +211,7 @@ test.describe('Database Settings JSON Export and Import', () => { const contaminatedPayload = { app: 'FloDesktop', - schema_version: '124', + schema_version: '125', data: { settings: [], categories: [], diff --git a/frontend/e2e/qa-settings-receipts-printers.spec.ts b/frontend/e2e/qa-settings-receipts-printers.spec.ts index 3ecef74a3..897ec50d2 100644 --- a/frontend/e2e/qa-settings-receipts-printers.spec.ts +++ b/frontend/e2e/qa-settings-receipts-printers.spec.ts @@ -365,8 +365,8 @@ test.describe('@ci-tier2 Settings > Printers', () => { const formSection = page.locator('h3', { hasText: 'Add Printer' }).locator('..'); const selects = formSection.locator('select'); await selects.nth(0).selectOption('usb'); - await selects.nth(1).selectOption('cols-32'); - await selects.nth(2).selectOption('extra_large'); + await selects.nth(2).selectOption('cols-32'); + await selects.nth(3).selectOption('extra_large'); await formSection.getByRole('button', { name: 'Add Printer', exact: true }).click(); didCreatePrinter = true; } else { @@ -611,4 +611,215 @@ test.describe('@ci-tier2 Settings > Printers', () => { } } }); + + test('76mm impact printer profile selection and driver_only Browser Print onboarding work end-to-end', async ({ + page, + }) => { + test.setTimeout(90_000); + const evidenceDir = process.env.EVIDENCE_DIR; + + await page.route('**/api/printers/detect', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + printers: [ + { + name: 'EPSON TM-U220 Receipt', + make: 'Epson', + model: 'TM-U Series', + connectionType: 'usb', + deviceUri: 'usb://EPSON/TM-U220', + status: 'idle', + isDefault: false, + paperWidth: 'cols-32', + profileId: 'escpos-impact-76', + compatibility: 'escpos_impact', + }, + { + name: 'Star TSP100 Cutter (TSP143)', + make: 'Star Micronics', + model: 'Star POS', + connectionType: 'usb', + deviceUri: 'usb://Star/TSP143', + status: 'idle', + isDefault: false, + paperWidth: 'cols-48', + compatibility: 'driver_only', + }, + { + name: 'EPSON EcoTank L3250', + make: 'Epson', + model: 'Epson Inkjet', + connectionType: 'usb', + deviceUri: 'usb://EPSON/L3250', + status: 'idle', + isDefault: false, + paperWidth: 'cols-48', + compatibility: 'driver_only', + }, + ], + }), + }); + }); + + await openPrintersTab(page); + const token = await page.evaluate(() => localStorage.getItem('token')); + const headers = { Authorization: `Bearer ${token}` }; + + const printMethod = page + .getByText('Print Method', { exact: true }) + .first() + .locator('xpath=following-sibling::select[1]'); + const originalMethod = await printMethod.inputValue(); + + let createdImpactId: string | null = null; + try { + await printMethod.selectOption('escpos'); + + const installedToggle = page.getByRole('button', { name: /Installed on this computer/ }); + await expect(installedToggle).toBeVisible(); + await installedToggle.click(); + + await expect(page.getByText('EPSON TM-U220 Receipt')).toBeVisible(); + await expect(page.getByText('76mm Impact', { exact: true })).toBeVisible(); + await expect(page.getByText('Star TSP100 Cutter (TSP143)')).toBeVisible(); + await expect(page.getByText('EPSON EcoTank L3250')).toBeVisible(); + await expect(page.getByText('Driver / Browser print only').first()).toBeVisible(); + await expect( + page + .getByText( + 'This printer does not use raw ESC/POS commands. Use Browser Print instead of adding it as a raw thermal printer.', + ) + .first(), + ).toBeVisible(); + + if (evidenceDir && fs.existsSync(evidenceDir)) { + await page.screenshot({ + path: path.join(evidenceDir, '01_detected_printers_impact_and_driver_only.png'), + fullPage: false, + }); + } + + const switchBtn = page.getByRole('button', { name: 'Use Browser Print' }).first(); + await expect(switchBtn).toBeVisible(); + await switchBtn.click(); + + await expect(printMethod).toHaveValue('browser'); + await expect(page.getByText('Switched Print Method to Browser Print')).toBeVisible(); + const persistedMethod = await page.evaluate(() => { + const raw = localStorage.getItem('flo-printer-settings'); + return raw ? JSON.parse(raw)?.state?.printMethod : null; + }); + expect(persistedMethod).toBe('browser'); + + if (evidenceDir && fs.existsSync(evidenceDir)) { + await page.screenshot({ + path: path.join(evidenceDir, '02_switched_to_browser_print.png'), + fullPage: false, + }); + } + + // Navigating away and back without clicking Save preserves the 1-click Browser Print switch + await page.goto(`${BASE}/settings`); + await openPrintersTab(page); + await expect(printMethod).toHaveValue('browser'); + + await page.getByRole('button', { name: 'Add Manually', exact: true }).click(); + await page.getByPlaceholder('e.g. Kitchen Printer').fill('EPSON TM-U220D Kitchen'); + const formSection = page.locator('h3', { hasText: 'Add Printer' }).locator('..'); + const selects = formSection.locator('select'); + await selects.nth(0).selectOption('usb'); + // Default paper_width starts at cols-42 before selecting impact profile + await expect(selects.nth(2)).toHaveValue('cols-42'); + await selects.nth(1).selectOption('escpos-impact-76'); + // Selecting escpos-impact-76 auto-defaults Printable Columns to cols-32 and reveals impact hint + await expect(selects.nth(2)).toHaveValue('cols-32'); + await expect( + page.getByText( + 'Impact ribbon printers use 32-column text mode and skip bitmap logos and QR graphics.', + ), + ).toBeVisible(); + + if (evidenceDir && fs.existsSync(evidenceDir)) { + await formSection.screenshot({ + path: path.join(evidenceDir, '03_add_printer_impact_profile_form.png'), + }); + } + + await formSection.getByRole('button', { name: 'Add Printer', exact: true }).click(); + await expect(page.getByText('EPSON TM-U220D Kitchen', { exact: true }).first()).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByText(/32 columns · Epson TM-U220 \/ 76mm Impact/)).toBeVisible(); + + if (evidenceDir && fs.existsSync(evidenceDir)) { + await page.screenshot({ + path: path.join(evidenceDir, '04_configured_impact_printer_card.png'), + fullPage: false, + }); + } + + const listRes = await page.request.get(`${BASE}/api/printers`, { headers }); + expect(listRes.ok()).toBeTruthy(); + const { printers } = (await listRes.json()) as { + printers: Array<{ + id: string; + name: string; + paper_width: string; + configured_profile_id: string | null; + profile_id: string; + profile_name: string; + capabilities: { raster: { enabled: boolean } }; + }>; + }; + const createdImpact = printers.find((p) => p.name === 'EPSON TM-U220D Kitchen'); + expect(createdImpact).toBeDefined(); + createdImpactId = createdImpact!.id; + expect(createdImpact!.paper_width).toBe('cols-32'); + expect(createdImpact!.configured_profile_id).toBe('escpos-impact-76'); + expect(createdImpact!.profile_id).toBe('escpos-impact-76'); + expect(createdImpact!.profile_name).toBe('Epson TM-U220 / 76mm Impact'); + expect(createdImpact!.capabilities.raster.enabled).toBe(false); + + // Adversarial check: backend rejects unknown profile_id on POST and PUT + const badPost = await page.request.post(`${BASE}/api/printers`, { + headers, + data: { + name: 'Invalid Profile Printer', + connection_type: 'usb', + profile_id: 'unknown-profile', + }, + }); + expect(badPost.status()).toBe(400); + const badPostBody = (await badPost.json()) as { error: string }; + expect(badPostBody.error).toContain('profile_id must be a supported printer profile ID'); + + const badPut = await page.request.put(`${BASE}/api/printers/${createdImpactId}`, { + headers, + data: { profile_id: 'unknown-profile' }, + }); + expect(badPut.status()).toBe(400); + } finally { + await page.unroute('**/api/printers/detect').catch(() => undefined); + if (createdImpactId) { + await page.request + .delete(`${BASE}/api/printers/${createdImpactId}`, { headers }) + .catch(() => undefined); + } + await openPrintersTab(page); + const pmSelect = page + .getByText('Print Method', { exact: true }) + .first() + .locator('xpath=following-sibling::select[1]'); + await pmSelect.selectOption(originalMethod); + if ( + await saveChanges(page) + .isVisible() + .catch(() => false) + ) { + await saveAndReloadPrintersTab(page); + } + } + }); }); diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx index 086267a8a..5cfa3c38e 100644 --- a/frontend/src/app/(dashboard)/settings/page.tsx +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -5978,6 +5978,11 @@ export default function SettingsPage() { kdsEnabledSetting={kdsEnabledSetting} pulseCustomMethods={pulseCustomMethods} markHydrationTouched={markHydrationTouched} + onPersistPrintMethod={(method) => { + setPrintMethod(method); + printingFormRef.current = { ...printingFormRef.current, printMethod: method }; + setSavedPrinting((prev) => ({ ...prev, printMethod: method })); + }} confirm={confirm} /> diff --git a/frontend/src/components/settings/PrintersSettingsTab.tsx b/frontend/src/components/settings/PrintersSettingsTab.tsx index 3ea6d4941..964a8967e 100644 --- a/frontend/src/components/settings/PrintersSettingsTab.tsx +++ b/frontend/src/components/settings/PrintersSettingsTab.tsx @@ -47,6 +47,7 @@ export type HwPrinter = { paper_width: string; text_size?: PrinterTextSize; is_default: number; + configured_profile_id?: string | null; profile_id?: string; profile_name?: string; }; @@ -63,6 +64,7 @@ export type DetectedPrinter = { port?: number; paperWidth?: string; profileId?: string; + compatibility?: 'escpos_thermal' | 'escpos_impact' | 'driver_only'; }; export type PrinterForm = { @@ -72,6 +74,7 @@ export type PrinterForm = { port: string; paper_width: string; text_size: PrinterTextSize; + profile_id: string; }; export type PrintingForm = { @@ -132,6 +135,7 @@ const emptyPrinterForm: PrinterForm = { port: '9100', paper_width: 'cols-42', text_size: 'normal', + profile_id: '', }; export interface PrintersSettingsTabProps { @@ -149,6 +153,7 @@ export interface PrintersSettingsTabProps { kdsEnabledSetting: boolean; pulseCustomMethods: string[]; markHydrationTouched: (field: string) => void; + onPersistPrintMethod?: (method: 'escpos' | 'browser') => void | Promise; confirm: ( message: string, options?: { title?: string; confirmLabel?: string; destructive?: boolean }, @@ -170,13 +175,15 @@ export function PrintersSettingsTab({ kdsEnabledSetting, pulseCustomMethods, markHydrationTouched, + onPersistPrintMethod, confirm, }: PrintersSettingsTabProps) { const t = useTranslations('settings'); const tCommon = useTranslations('common'); - const { refreshHardwarePrinter } = usePrinterStore(); + const { refreshHardwarePrinter, setPrintMethod } = usePrinterStore(); const [printerForm, setPrinterForm] = useState(emptyPrinterForm); + const [paperWidthTouched, setPaperWidthTouched] = useState(false); const [showPrinterForm, setShowPrinterForm] = useState(false); const [editingPrinterId, setEditingPrinterId] = useState(null); const [savingPrinter, setSavingPrinter] = useState(false); @@ -250,6 +257,7 @@ export function PrintersSettingsTab({ name: string; connection_type: 'network' | 'usb'; paper_width: string; + profile_id?: string; ip_address?: string; port?: number; } = { @@ -257,6 +265,9 @@ export function PrintersSettingsTab({ connection_type: p.connectionType === 'network' ? 'network' : 'usb', paper_width: normalizePrinterWidthValue(p.paperWidth), }; + if (p.profileId === 'escpos-impact-76') { + payload.profile_id = p.profileId; + } if (p.connectionType === 'network') { payload.ip_address = p.ipAddress || ''; payload.port = p.port || 9100; @@ -274,6 +285,7 @@ export function PrintersSettingsTab({ const openAddPrinter = () => { setPrinterForm(emptyPrinterForm); + setPaperWidthTouched(false); setEditingPrinterId(null); setShowPrinterForm(true); }; @@ -286,7 +298,9 @@ export function PrintersSettingsTab({ port: String(p.port || 9100), paper_width: normalizePrinterWidthValue(p.paper_width), text_size: p.text_size ?? 'normal', + profile_id: p.configured_profile_id || '', }); + setPaperWidthTouched(false); setEditingPrinterId(p.id); setShowPrinterForm(true); }; @@ -305,6 +319,7 @@ export function PrintersSettingsTab({ port: printerForm.connection_type === 'network' ? Number(printerForm.port) : undefined, paper_width: printerForm.paper_width, text_size: printerForm.text_size, + profile_id: printerForm.profile_id || null, }; if (editingPrinterId) { await api.put(`/printers/${editingPrinterId}`, payload); @@ -435,6 +450,8 @@ export function PrintersSettingsTab({ (h) => h.name.toLowerCase() === p.name.toLowerCase(), ); const isAdding = addingDetectedName === p.name; + const isDriverOnly = p.compatibility === 'driver_only'; + const isImpact = p.compatibility === 'escpos_impact'; const dotColor = p.status === 'idle' ? 'bg-green-500' @@ -460,7 +477,7 @@ export function PrintersSettingsTab({ )}
-
+
{p.name} @@ -468,6 +485,17 @@ export function PrintersSettingsTab({ {statusLabel} + {isImpact && ( + + {t('printerCompatibilityImpact')} + + )} + {isDriverOnly && ( + + + {t('printerCompatibilityDriverOnly')} + + )}

{p.make !== 'Unknown' ? `${p.make} ${p.model}` : p.model} @@ -482,11 +510,37 @@ export function PrintersSettingsTab({ ) : ( '' )} - {p.paperWidth ? ` · ${printWidthLabel(p.paperWidth)}` : ''} - {p.profileId ? ` · ${t('printerSupportedProfile')}` : ''} + {!isDriverOnly && p.paperWidth + ? ` · ${printWidthLabel(p.paperWidth)}` + : ''} + {!isDriverOnly && p.profileId + ? ` · ${t('printerSupportedProfile')}` + : ''}

+ {isDriverOnly && ( +

+ {t('printerDriverOnlyWarning')} +

+ )}
- {alreadyAdded ? ( + {isDriverOnly ? ( + + ) : alreadyAdded ? ( {' '} {t('printerAdded')} @@ -692,13 +746,57 @@ export function PrintersSettingsTab({
)} +
+ + + {printerForm.profile_id === 'escpos-impact-76' && ( +

+ {t('printerProfileImpactHint')} +

+ )} +
+