Utskriftsformatet
Kassan ritar aldrig själv. Allt som ska ut på papper - kvitto, X- och
Z-rapport, journalutskrift, kortterminalens rapport, verifikation och
tillgodokvitto - byggs först som en kvittolayout, typen
ReceiptLayout, och skickas som JSON till skrivartjänsten
receiptprinter. Det är skrivartjänsten som vet vilken skrivare som
sitter i andra änden och gör om layouten till en rasterbild, till
ESC/POS-text, till Worldline-terminalens textformat eller till en PDF.
Det här skiljer på två frågor som annars blandas ihop: vad som står på kvittot (kassans sak, och en del av det fiskala underlaget) och hur det ser ut på just den här skrivaren (skrivartjänstens sak). En egen skrivartjänst behöver alltså bara förstå formatet nedan; den behöver inte veta något om moms, kontrollenheter eller kvittonummer.
Typen finns i två exemplar som hålls lika för hand:
rust-poc/src/receipt_layout/mod.rs på kassans sida och
rust-poc/receiptprinter/src/receipt_layout_types.rs på mottagarens.
Skrivartjänsten är en fristående crate som inte beror på kassan, så
det är trådformatet - inte en delad Rust-typ - som är kontraktet. Ett
test på kassans sida (serializes_to_designmd_documented_shape) låser
formatet.
Kvittolayouten
ReceiptLayout
├── receipt_width "flexible"
├── rows[] text | image | qr
├── document_number kvittonummer, 0 = inget dokument
└── footer[] fria fotrader
| fält | typ | betydelse |
|---|---|---|
receipt_width | sträng | Breddläge. Bara "flexible" finns, och ingen renderare i receiptprinter läser fältet - bredden bestäms helt av skrivartjänstens konfiguration. |
rows | lista av rader | Kvittots innehåll uppifrån och ned. Varje rad har fältet type: "text", "image" eller "qr". |
document_number | heltal | Kvittonumret layouten hör till (samma nummer som receipt_number i journalens PAYMENT). 0 betyder att layouten inte hör till något numrerat dokument - X/Z-rapporter, journalutskrifter och verifikationer sätter 0. Får saknas (blir 0). Receiptprinter läser det inte. |
footer | lista av strängar | Fria fotrader som skrivs ut under rows, var och en som en vänsterställd rad i stilen normal. Kommer från company_info.json. Utelämnas när den är tom; bara kvittot sätter den. |
Belopp är redan formaterade strängar när de kommer hit ("35:00"),
inte heltal i öre. Layouten är en presentation, inte data att räkna på.
Textrad ("type": "text")
En textrad har en av två former. En kolumn: columns har ett enda
element och align säger var det hamnar. Flera kolumner:
tabs_percent och alignments har ett element per kolumn. Typen
tvingar inte fram den ena eller andra formen; det är en konvention som
alla layoutbyggare i kassan följer. Det som avgör hur raden tolkas är
om tabs_percent är tom eller inte.
| fält | typ | betydelse |
|---|---|---|
style | sträng | Radens stil, se tabellen nedan. Okända namn ritas som normal. |
columns | lista av strängar | Texten, en sträng per kolumn. |
align | "left", "right", "center" | Justering i enkolumnsformen. Utelämnas när den inte är satt; saknas den i enkolumnsform blir den left. Ignoreras när tabs_percent har värden. |
tabs_percent | lista av tal | Varje kolumns startposition i procent av den utskrivbara bredden. Utelämnas när den är tom. |
alignments | lista av "left"/"right"/"center" | Varje kolumns justering, parallell med columns. Saknade element blir left. Utelämnas när den är tom. |
column_styles | lista av sträng eller null | Stil per kolumn, parallell med columns. null, ett saknat element eller en tom lista betyder “radens style”. Utelämnas när den är tom. |
row_id | heltal | event_id för den LINE_ADDED raden visar. Sätts bara av layouten för ett pågående kvitto (displayen, se Displayformatet); alla utskrivna layouter saknar det. Båda renderarna i receiptprinter ignorerar fältet. |
Stilnamnen, som de tolkas av de olika renderarna:
style | rasterbild (print_layout.rs) | ESC/POS-text (escpos_text.rs) | Worldline (samport_printer.rs) | PDF (voucher_pdf.rs) |
|---|---|---|---|---|
normal | 24 pt, normal vikt | inga stilkoder | inga stilkoder | normal |
header | 30 pt, fet | fet (ESC E), normal storlek | fet | fet, större |
bold_total | 24 pt, fet | fet (ESC E) | fet | fet |
dbl_height | dubbel höjd | GS ! dubbel höjd, samma bredd | dubbel (höjd och bredd) | dubbel höjd |
dbl_width | dubbel bredd | GS ! dubbel bredd | dubbel | dubbel bredd (Tz 200) |
dbl_size | dubbel höjd och bredd | GS ! dubbel höjd och bredd | dubbel | dubbel höjd och bredd |
Worldline-terminalens
skrivare har bara ett “dubbelt” läge, och det gör tecknen både högre
och bredare, så alla tre dbl_* blir samma sak där.
Kassans layoutbyggare använder i dag normal, header, bold_total
och dbl_size som radstil (dbl_size för rubriken KVITTO), och
dbl_width som kolumnstil för beloppet på kvittots SUBTOTAL-rad.
Bildrad ("type": "image")
| fält | typ | betydelse |
|---|---|---|
source | sträng | Logiskt namn på en bild som finns lagrad hos skrivartjänsten (se /asset/:id nedan), till exempel "logo". Aldrig bildens bytes. |
align | "left"/"right"/"center" | Var bilden placeras. |
width_percent | tal | Bildens bredd i procent av den utskrivbara bredden. Höjden följer bildens proportioner. |
En bild som inte finns lagrad, eller inte går att avkoda, ritas som en
platshållarruta i rasterläget - aldrig ett avbrott, eftersom ett kvitto
är ett lagstadgat dokument och inte får falla på en logga. I textläge
(CHD6800) skrivs "[BILD: <source>]", utom när source är "logo" och
en logga har laddats upp: då skrivs den ur skrivarens NV-minne med
FS p. Worldline-terminalen hoppar över bildrader helt, och PDF:en
gör det också.
QR-rad ("type": "qr")
| fält | typ | betydelse |
|---|---|---|
data | sträng | Det som ska kodas. Används för tillgodokvittot. |
align | "left"/"right"/"center" | Placering. |
width_percent | tal | Kodens sida i procent av den utskrivbara bredden (koden är kvadratisk). |
Raden bär data, inte en bild: skrivaren vet sin egen punktbredd och kan
rita modulerna skarpt, där en skalad bitmapp kan bli oläsbar. I
rasterläget ritas koden in i bilden; går den inte att koda blir det en
platshållarruta. I ESC/POS-textläget skickas Epsons QR-kommando med
fast modulstorlek 6 (width_percent används inte) - koden säger
själv att sekvensen inte är verifierad på en CHD6800. Worldline
skriver QR med sitt eget kommando och centrerar bara när align är
"center". PDF:en hoppar över QR-rader.
Kolumner och procenttabbar
tabs_percent anger var varje kolumn börjar, i procent av den
utskrivbara bredden - inte hur bred den är. En rad med
"tabs_percent": [0.0, 85.0] och "alignments": ["left", "right"] har
första kolumnen från vänsterkanten och andra kolumnen från 85 %.
Kvittots momsrader använder [5.0, 85.0] för att dra in moms och
brutto en bit.
I rasterläget (layout_text_row_pixels) går kolumn i från sin
tabb till nästa kolumns tabb, och den sista kolumnen till högerkanten.
En vänsterställd kolumn börjar vid sin tabb, en högerställd slutar vid
sitt spanns slut och en centrerad centreras i spannet. En kolumn som
inte får plats klipps aldrig: nästa kolumn knuffas åt höger (minst ett
mellanslag), och en högerställd text som är för lång växer åt vänster.
Blir bilden bredare än pappret skalas hela bilden ned till skrivarens
bredd före rastreringen. Alla kolumner på en rad delar samma baslinje,
och radhöjden är den högsta av kolumnernas stilar.
I textläget (CHD6800 och Worldline, render_text_row) används
procentsatserna inte alls - att listan inte är tom väljer bara
flerkolumnsvägen. Skälet är skrivarnas fasta teckenrutnät och att
CHD6800:ns skrivare inte förstår några positioneringskommandon. I
stället fördelas en rads tecken så här:
- Kolumnerna behandlas från höger. Den sista kolumnen - normalt beloppet - får hela sin bredd först och kortas aldrig.
- Mellan två kolumner reserveras ett mellanslag.
- Det som blir över går till kolumnerna till vänster. Den första kolumnen - normalt en benämning - kortas av om det inte räcker.
- Allt överskott hamnar som utfyllnad i första kolumnen, så den sista kolumnen ligger mot högerkanten.
En enkolumnsrad kortas till radens bredd och placeras med inledande
mellanslag enligt align. Bredden är 30 tecken på CHD6800
(DEFAULT_WIDTH_CHARS, uppmätt på riktig hårdvara) och 34 på
Worldline-terminalen (flaggan --worldline-width-chars). En kolumn i
dbl_width/dbl_size räknas som två tecken per tecken på CHD6800; på
Worldline räknas alla dbl_* dubbelt, och en rad med någon dubbel
kolumn får två teckens marginal eftersom terminalens dubbla tecken är
något mer än dubbelt så breda. Text kodas som CP437 på CHD6800 och som
latin-1 på Worldline (där ESC-tecken tas bort så att texten inte kan
ge kommandon); tecken som saknas i CP437 blir ?.
En egen skrivartjänst som skriver text bör följa samma prioritering: beloppet får aldrig kortas, benämningen får det.
Hur kassan skickar
Kassan läser skrivartjänstens adress ur config/printer_service.json
(printer_service.base_url, till exempel http://127.0.0.1:8080) och
skickar med blockerande HTTP och 20 sekunders timeout
(printer_http_sink.rs). Ordningen efter ett köp eller en rapport är:
POST /print_layoutmed layouten.- Om svaret är 2xx:
POST /feed_cutmed{"feed": 3, "cut": true}(RECEIPT_FEED_LINES).
Matning och kniv är avsiktligt ett eget anrop. /print_layout hör ihop
med vad ett dokument innehåller; när en utskriftsomgång är slut är en
annan fråga, och en anropare som vill skriva flera dokument i rad ska
kunna klippa en gång på slutet.
Hur ett misslyckat anrop hanteras beror på dokumentet:
- X- och Z-rapporter och journalutskrifter skrivs ut av kassan
själv, genom dess
PrintSink(receipt_sink.rs).web_tillochinteractivesätter dess layoutskrivare till receiptprinter (till::layout_printer_via_http), som gör de två anropen ovan. Utskriften räknas som lyckad när/print_layoutsvarat 2xx; en misslyckad/feed_cutefteråt loggas bara. Utfallet journalförs iX_REPORT_PRINT_ATTEMPT,Z_REPORT_PRINT_ATTEMPTochJOURNAL_PRINT_ATTEMPT(se Journalminnet). För Z-rapporten är utskriften en spärr: svarar skrivaren inte stängs inte perioden, utan kassan frågar om ett nytt försök och väntar på Ja (försök igen) eller Nej (avbryt; perioden förblir öppen). - Kvitton, kortterminalens rapport, verifikationen och övriga
dokument är “skjut och glöm” i
web_till: misslyckas utskriften loggas det, men ingenting i kassan rullas tillbaka och operatören får ingen fråga.
Efter en Z-rapport skickas också verifikationen (/print_layout),
SIE-filen (/export/...) och verifikationen och Z-rapporten som PDF
(/render_pdf med arkivhuvuden), i den ordningen.
Skrivartjänstens HTTP-gränssnitt
Receiptprinter lyssnar som standard på port 8080 (--http :8080). Det
finns två servrar med olika uppsättning vägar: den vanliga
(http_server.rs, för USB-, nätverks-, Windows- och
Worldline-skrivare) och den seriella (serial_http_server.rs, för
CHD6800:ns inbyggda skrivare, startas med --serial-http).
| väg | vanlig | seriell | kropp | svar |
|---|---|---|---|---|
POST /print_layout | ja | ja | ReceiptLayout | PrintResponse |
POST /feed_cut | ja | ja | {"feed": n, "cut": bool} | PrintResponse |
POST /print | ja | - | PNG/JPEG, se nedan | PrintResponse |
POST /render_pdf | ja | - | ReceiptLayout | PDF-bytes |
POST /open_drawer | ja | - | ingen | PrintResponse |
GET /status | ja | - | - | status-JSON |
GET / | ja | - | - | HTML-sida som beskriver API:t |
POST/GET/DELETE /asset/:id | ja | ja | se nedan | AssetResponse / PNG |
POST/GET /export/:voucher/:name, GET /export/:voucher, GET /export | ja | ja | se nedan | se nedan |
Den vanliga servern har också /setup-vägarna för inställningssidan;
de är inte en del av utskriftsformatet och beskrivs inte här.
Svaret PrintResponse
{"success": true, "message": "printed successfully"}
| väg | lyckat message | HTTP-status |
|---|---|---|
/print_layout, /print | "printed successfully" | 200 |
/feed_cut | "fed/cut successfully" | 200 |
/open_drawer | "drawer opened" | 200 |
Ogiltig JSON ger 400 med {"success": false, "message": "invalid JSON: ..."}. Ett fel från skrivaren ger 500 med felet i message. Kassan
tittar bara på statuskoden.
POST /print_layout
Kroppen är en ReceiptLayout. Vad som händer beror på skrivaren:
- Rasterskrivare (USB, nätverk, Windows-kö): layouten ritas som en
gråskalebild i skrivarens bredd (
--width, standard 576 punkter), med det inbäddade typsnittet DejaVu Sans Mono, ditheras med Floyd-Steinberg och skickas som ESC/POS- eller StarPRNT-raster. Tjänsten matar och klipper aldrig själv efter en/print_layout; det gör anroparen med ett eget/feed_cut, som kassan alltid skickar efteråt. Flaggorna--cutoch--feedgäller bara/print. - CHD6800 (seriell server): layouten blir ESC/POS-text enligt
textläget ovan, inleds med
ESC @och val av teckentabell PC437 och avslutas med tre radmatningar. Ingen kniv. - Worldline-terminalen (
--worldline): layouten blir Samports textformat och skickas till terminalensPOST /Prints. Bildrader hoppas över; QR skrivs med terminalens eget kommando. Avslutas med tre tomma rader så att kvittot kan rivas av.
POST /feed_cut
{"feed": 3, "cut": true}. Båda fälten kan saknas och är då 0 och
false; en tom kropp är en giltig no-op. Worldline-terminalen har ingen
kniv och ignorerar cut (matar minst en rad).
POST /print
Den äldre vägen: kassan renderar själv och skickar en färdig bild.
Antingen multipart med fältet file, eller JSON:
{"type": "png", "data": "<base64>", "cut": false, "feed": 2}
type måste finnas men används bara som etikett - bildformatet
avläses ur själva bytesen. cut och feed är valfria och ersätter
tjänstens standardvärden för just det jobbet. Worldline-terminalen
vägrar med ett fel, eftersom den inte tar grafik.
POST /render_pdf
Samma ReceiptLayout som /print_layout, men svaret är en PDF
(Content-Type: application/pdf) i stället för en utskrift. Ingen
skrivare berörs, så anropet kan inte blockeras av en pågående utskrift.
Remsan ritas centrerad och inramad på ett A4-ark, 80 mm bred, som text
med inbäddat typsnitt. Bild- och QR-rader hoppas över; fotraderna
kommer med.
Svarshuvuden:
| huvud | värde |
|---|---|
X-Pdf-Overflowed | true om remsan inte fick plats på en sida och kapades nedtill, annars false. Uppdelning i spalter och sidor finns inte ännu. |
X-Pdf-Archived | skipped när inga arkivhuvuden skickades, true när filen arkiverades, annars error: .... |
Skickar anroparen både X-Export-Voucher och X-Export-Name sparas
PDF:en också i exportarkivet, i katalogen för den verifikationen. Kassan
använder namnen verifikat.pdf och zrapport.pdf. Ett misslyckat
arkiv fäller aldrig svaret.
POST /open_drawer
Ingen kropp. Fungerar bara mot nätverksskrivare och Windows-kö (StarPRNT:s lådkommando); för USB och Worldline svarar tjänsten 500 med ett fel som säger det. Kassan anropar inte vägen i dag.
GET /status
{
"healthy": true,
"printer": "usb",
"printer_connected": true,
"usb_connected": true,
"usb_vendor": "0519",
"usb_product": "000b",
"serial_connected": true,
"serial_port": "/dev/ttyACM0",
"ws_addr": ":8765",
"width": 576,
"asset_dir": "/opt/rgo/assets",
"uptime_seconds": 512.3
}
Statuskoden är 200 när healthy är sant, annars 503, så att en
övervakning kan nöja sig med koden. healthy avgörs bara av den
valda skrivaren och är samma värde som printer_connected. printer
säger vilken skrivare som valts och hur den kontrolleras, i samma
ordning som tjänsten väljer skrivare:
printer | kontroll |
|---|---|
"worldline" | TCP-anslutning till Worldline-terminalens adress lyckas (inom 0,5 s). |
"windows" | Windows-kön med det inställda namnet finns. |
"network" | TCP-anslutning till nätverksskrivaren lyckas (port 9100 om ingen anges, inom 0,5 s). |
"usb" | USB-skrivaren med inställt vendor/product-id finns. |
usb_connected och serial_connected redovisas fortfarande för sig,
men påverkar inte healthy; en installation utan seriell port eller
med en annan skrivare än USB kan alltså vara frisk. Kontrollen säger att
skrivaren går att nå, inte att den har papper.
/asset/:id - loggan
POST /asset/logo med {"type": "png", "data": "<base64>"} lagrar en
bild under namnet logo. type får utelämnas, men anges den måste den
vara png. Svaret:
{"success": true, "message": "stored logo", "width": 576, "height": 168}
GET /asset/:id returnerar PNG:en, DELETE /asset/:id tar bort den
(404 om den inte fanns). På den seriella servern rastreras loggan
också in i skrivarens NV-minne vid uppladdning, så att varje kvitto
bara behöver skicka fyra byte för att skriva den. Det är namnet här
som en bildrads source pekar på.
/export - arkivet för bokföringsfiler
Kroppen är själva filen, utan JSON eller base64 (en SIE-fil är CP437-bytes, inte UTF-8-text).
POST /export/:voucher/:namesparar filen. Svar:{"success": true, "message": "stored <voucher>/<name>", "path": "..."}.GET /export/:voucher/:namehämtar den (application/pdfför.pdf, annarsapplication/octet-stream).GET /export/:voucherlistar katalogen:{"voucher": "...", "sie": ..., "attachments": ..., "other": ...}.GET /exportlistar alla verifikationer:{"vouchers": [...]}.
Kassan namnger katalogen <kassabeteckning>_voucher_<serie><nummer>,
till exempel kassa-1_voucher_z000011, och filerna bokforing.si,
verifikat.pdf och zrapport.pdf.
Exempel: ett kvitto
Så här ser kroppen till POST /print_layout ut för ett kontantköp av
två likadana varor med streckkod, med logga och fottext. Raderna är
de som build_receipt_layout_with_logo (receipt_layout/kvitto.rs)
bygger, i samma ordning. Två slag av samma vara slås ihop till en rad
med en detaljrad under; i journalen ligger de kvar som två LINE_ADDED.
{
"receipt_width": "flexible",
"rows": [
{"type": "image", "source": "logo", "align": "center", "width_percent": 60.0},
{"type": "text", "style": "dbl_size", "columns": ["KVITTO"], "align": "center"},
{"type": "text", "style": "normal", "columns": ["Kvitto nr 42"], "align": "center"},
{"type": "text", "style": "header", "columns": ["Exempelbutiken AB"], "align": "center"},
{"type": "text", "style": "normal", "columns": ["Org.nr", "556000-0000"],
"tabs_percent": [0.0, 50.0], "alignments": ["left", "left"]},
{"type": "text", "style": "normal", "columns": ["Exempelgatan 1"], "align": "center"},
{"type": "text", "style": "normal", "columns": ["123 45 Exempelstad"], "align": "center"},
{"type": "text", "style": "normal", "columns": ["Kaffe", "70:00"],
"tabs_percent": [0.0, 85.0], "alignments": ["left", "right"]},
{"type": "text", "style": "normal", "columns": [" 2 x 35:00"], "align": "left"},
{"type": "text", "style": "normal", "columns": [" 7300000000000"], "align": "left"},
{"type": "text", "style": "bold_total", "columns": ["SUBTOTAL", "70:00"],
"tabs_percent": [0.0, 85.0], "alignments": ["left", "right"],
"column_styles": [null, "dbl_width"]},
{"type": "text", "style": "normal", "columns": ["12% netto", "62:50"],
"tabs_percent": [0.0, 85.0], "alignments": ["left", "right"]},
{"type": "text", "style": "normal", "columns": ["moms", "7:50"],
"tabs_percent": [5.0, 85.0], "alignments": ["left", "right"]},
{"type": "text", "style": "normal", "columns": ["brutto", "70:00"],
"tabs_percent": [5.0, 85.0], "alignments": ["left", "right"]},
{"type": "text", "style": "bold_total", "columns": ["BETALT (KONTANT)", "70:00"],
"tabs_percent": [0.0, 85.0], "alignments": ["left", "right"]},
{"type": "text", "style": "normal", "columns": ["Mottaget", "100:00"],
"tabs_percent": [0.0, 85.0], "alignments": ["left", "right"]},
{"type": "text", "style": "normal", "columns": ["Växel", "30:00"],
"tabs_percent": [0.0, 85.0], "alignments": ["left", "right"]},
{"type": "text", "style": "normal", "columns": ["Kontrollenhet", "EXEMPEL00000000001"],
"tabs_percent": [0.0, 85.0], "alignments": ["left", "right"]},
{"type": "text", "style": "normal", "columns": ["Kontrollkod:"], "align": "left"},
{"type": "text", "style": "normal", "columns": ["AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"], "align": "left"},
{"type": "text", "style": "normal", "columns": [";BBBBBBBBBBBBBBBBBBBBBBBBBB"], "align": "left"}
],
"document_number": 42,
"footer": ["Tack för ditt köp!", "Öppet vardagar 10-18"]
}
Några saker att lägga märke till:
- Tomma fält utelämnas: en enkolumnsrad har varken
tabs_percentelleralignments, en flerkolumnsrad har ingetalign. - Kontrollkoden delas vid semikolonet; andra halvan börjar med
;. Mottaget/Växelfinns bara för kontant med angivet belopp.- Vid kortköp läggs kortterminalens egna kvittorader in som enkolumnsrader mellan betalraderna och kontrollkoden.
Svaret vid lyckad utskrift, och därefter kassans matning:
POST /print_layout -> 200 {"success": true, "message": "printed successfully"}
POST /feed_cut {"feed": 3, "cut": true}
-> 200 {"success": true, "message": "fed/cut successfully"}
Och ett fel från skrivaren:
{"success": false, "message": "open USB printer: ..."}
med status 500.
Andra dokument i samma format
Det finns inget separat format för rapporter eller verifikationer -
alla är ReceiptLayout:
| dokument | byggs av | anmärkning |
|---|---|---|
| kvitto | receipt_layout/kvitto.rs | document_number = kvittonumret, footer från company_info.json |
| X/Z-rapport | receipt_layout/rapport.rs (report_data_to_receipt_layout) | document_number 0, ingen fottext |
| journalutskrift | receipt_layout/journal.rs | varje journalrad pretty-printad som JSON, en enkolumnsrad per textrad |
| kortterminalens rapport | receipt_layout/kortterminal.rs | |
| verifikation | receipt_layout/verifikation.rs | skrivs ut och skickas till /render_pdf |
| tillgodokvitto | receipt_layout/vardehandling.rs | innehåller en QR-rad |
Verifikationens PDF är alltså inte en egen A4-layout utan samma remsa som skrivs ut, ritad på ett A4-ark. Det håller papper och arkiv lika.
Kassan har också en egen, stilblind textrenderare
(receipt_sink::render_receipt_layout_as_text, 35 tecken bred) som
skriver kvitton och rapporter till kassans standardutdata - för
rapporterna bara när ingen skrivartjänst är inställd. Den följer
tabs_percent bokstavligt och struntar i stilar; den är inte en del av
skrivartjänsten.