2026-07-31-usb-ticket-photo-layout-plan.md 6.3 KB

USB Ticket Photo Layout Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make USB/CSN thermal-printer output match the approved reference-photo layout while leaving the serial path unchanged.

Architecture: Add a pure USB layout builder that returns ordered write, feed, QR-code, and cut operations. Execute those operations through a small CSN device interface so layout ordering and short-write failures can be tested without printer hardware; PrintTicket retains database lookup and port lifecycle ownership.

Tech Stack: Go 1.25, standard library testing, ESC/POS raw commands, existing CsnPrinterLibs.dll wrapper.

Global Constraints

  • Only the USB/CSN path changes; the serial path remains behaviorally unchanged.
  • The QR payload remains digital_ticket.ticket_no and its width is exactly 8.
  • Title and vehicle use GS ! 0x10 for double width and GS ! 0x00 to reset.
  • Empty channel and vehicle values omit their complete lines.
  • No new dependency is added.

Task 1: Build and test the USB photo layout

Files:

  • Create: internal/modules/printer/service/usb_layout.go
  • Create: internal/modules/printer/service/usb_layout_test.go

Interfaces:

  • Consumes: parking-lot name, channel, plate, formatted entry time, slip number, and ticket number.
  • Produces: buildUSBTicketLayout(data usbTicketData) []usbPrintOp and executeUSBLayout(device usbPrintDevice, ops []usbPrintOp) error.

  • [x] Step 1: Write failing layout tests

Use this fixed input:

data := usbTicketData{
    lotName: "KTI AIRPORT", channelCode: "IN11", plateNo: "2BC1875",
    entryTime: "30-07-2026 11:14:59", slipNo: "54600",
    ticketNo: "0123456789abcdef0123456789abcdef",
}

Assert this exact ordered text:

[]string{
    "Parking Ticket\n", "KTI AIRPORT\n", strings.Repeat("-", 42) + "\n", "\n",
    "PARK AT YOUR OWN RISK\n", "KTI AIRPORT A-General Car\n",
    "In-Time::30-07-2026 11:14:59\n", "InGate::IN11\n", "Slip No::54600\n",
    "Veh No::2BC1875\n", "SCAN AND PAY WITH NEW\n", "THE CANADIA BANK APP\n",
}

Also assert title and vehicle are bracketed by GS ! 0x10 and GS ! 0x00, only one divider exists, and the QR operation uses the ticket number with width 8. A second case with empty channel and plate must omit InGate:: and Veh No::.

  • Step 2: Run tests and verify RED

Run:

go test ./internal/modules/printer/service -run 'TestBuildUSBTicketLayout|TestExecuteUSBLayout' -count=1

Expected: compilation fails because the USB layout types and functions do not exist.

  • Step 3: Implement the operation model and exact layout

Create:

type usbPrintOpKind uint8
const ( usbOpWrite usbPrintOpKind = iota; usbOpFeed; usbOpQRCode; usbOpCut )

type usbPrintOp struct {
    kind usbPrintOpKind
    data []byte
    count int
    qrData string
    qrWidth int
}

type usbTicketData struct {
    lotName, channelCode, plateNo string
    entryTime, slipNo, ticketNo string
}

Generate this sequence: initialize; centered Font A double-width bold title and reset; centered Font B lot subtitle; one 42-character divider; one blank line; centered risk text; left-aligned combined lot/type line; Font A time, optional gate, and slip; optional double-width bold vehicle and reset; centered one-line feed, QR width 8, one-line feed; Font B corrected payment lines; three-line feed; full cut.

Use exact size and font commands:

var (
    usbSizeNormal  = []byte{GS, '!', 0x00}
    usbDoubleWidth = []byte{GS, '!', 0x10}
    usbFontA       = []byte{ESC, 'M', 0}
    usbFontB       = []byte{ESC, 'M', 1}
)
  • Step 4: Implement and test the device executor

Define:

type usbPrintDevice interface {
    Write([]byte) int
    FeedLines(int)
    QRCode(string, int) bool
    FullCut()
}

Stop on any write count different from the requested length and return fmt.Errorf("USB打印短写: 写入%d/%d字节", n, len(op.data)); return fmt.Errorf("USB二维码打印失败") when QRCode returns false. The CSN adapter must call Pos_EscQrcode, because the vendor SDK limits Pos_Qrcode width to 1..6 while Pos_EscQrcode supports width 8. Use a fake device to prove later operations do not run after either failure.

  • Step 5: Run focused tests and verify GREEN

Run go test ./internal/modules/printer/service -run 'TestBuildUSBTicketLayout|TestExecuteUSBLayout' -count=1.

Expected: PASS.

Task 2: Integrate the layout into PrintTicket

Files:

  • Modify: internal/modules/printer/service/service.go:53-109
  • Test: internal/modules/printer/service/usb_layout_test.go

Interfaces:

  • Consumes: the Task 1 builder and executor.
  • Produces: unchanged public PrintTicket(printerID uint, lotName, channelCode, plateNo string, ticketID uint, ticketNo string) error behavior with the new USB layout.

  • [x] Step 1: Add the CSN adapter

type csnUSBDevice struct{}
func (csnUSBDevice) Write(data []byte) int { return Csn_WriteData(data) }
func (csnUSBDevice) FeedLines(n int) { csn_FeedLines(n) }
func (csnUSBDevice) QRCode(data string, width int) bool { return csn_QRCode(data, width) }
func (csnUSBDevice) FullCut() { csn_FullCut() }
  • Step 2: Replace only the USB inline layout

Build usbTicketData from the existing dynamic values, execute it with csnUSBDevice{}, return an execution error before updating status, and retain the current online update after success. Do not modify the serial branch.

  • Step 3: Format and run package tests

Run:

gofmt -w internal/modules/printer/service/usb_layout.go internal/modules/printer/service/usb_layout_test.go internal/modules/printer/service/service.go
go test ./internal/modules/printer/service -count=1

Expected: PASS.

  • Step 4: Run repository-level verification

Run go test ./internal/modules/printer/... -count=1, then go test ./... -count=1.

Expected: printer tests pass. Report any unrelated repository failure with its package and error output.

  • Step 5: Inspect the final diff

Run git diff --check and inspect the three printer-service files. Confirm only the USB path changed and all dynamic data sources remain intact.