feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission

Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
  (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
  Finished list). Orders freeze names + price; their life is signed (carwash_order,
  carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
  config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
  chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
  signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
  price off the fee (floored at 0), resolved at done and anchored at the order's intake
  (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
  the wash. Long durations render y/d/h/m.

Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
  re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
  vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
  till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
  open shift with till badges + filter; drawer hub switches tills.

Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.

Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-09-05 13:23:09 +02:00
parent 23d6379be8
commit a9ccf9e20c
46 changed files with 3966 additions and 510 deletions
+81
View File
@@ -67,13 +67,74 @@ export const sq = {
name: {
parking: "Parkimi",
validation: "Validime tregtare (Bar)",
carwash: "Lavazh",
},
},
wash: {
tillTitle: "Arka e lavazhit",
tillHint: "Paratë e marra te lavazhi regjistrohen në arkën e lavazhit — hap fillimisht turnin e lavazhit. Turni i kabinës nuk vlen.",
tillOtherHint: "{{operator}} e ka turnin e lavazhit; vetëm ai mund të marrë para te lavazhi.",
drawerNow: "Arka e lavazhit tani",
intake: "Lavazh i ri",
ticketPh: "Bileta e parkimit (skano ose shkruaj)",
lookup: "Kërko",
notFound: "Nuk ka sesion për këtë biletë.",
closed: "Ky sesion është mbyllur.",
ticket: "Bileta",
plate: "Targa",
enteredAt: "Hyri",
alreadyOpen: "Kjo biletë ka tashmë një porosi lavazhi të hapur.",
category: "Kategoria e mjetit",
service: "Shërbimi",
price: "Çmimi",
noPrice: "nuk ka çmim për këtë kombinim",
payAt: "Pagesa",
payAtBooth: "Në kabinë",
payAtBay: "Në lavazh",
payAtBoothHint: "Shtohet në llogarinë e parkimit; barriera e daljes hapet pas pagesës në kabinë.",
payAtBayHint: "Paratë merren këtu; klienti del duke skanuar biletën te lexuesi i daljes (sponsorizimi i parkimit duhet ta mbulojë tarifën).",
create: "Krijo porosinë",
created: "Porosia u krijua.",
queue: "Porositë e hapura",
empty: "Asgjë për të larë.",
time: "Ora",
what: "Lavazhi",
status: "Statusi",
statusOpen: "në proces",
statusDone: "mbaroi",
paid: "paguar",
unpaid: "papaguar",
done: "Mbaroi",
payCash: "Paguar cash",
payCard: "Paguar me kartë",
void: "Anulo",
voidReason: "Arsyeja",
categories: "Kategoritë e mjeteve",
services: "Shërbimet",
prices: "Çmimet",
pricesHint: "Një çmim për çdo kategori × shërbim. Lëre bosh një qelizë që ai kombinim të mos shitet.",
addCategory: "kategori",
addService: "shërbim",
active: "aktiv",
save: "Ruaj",
saved: "U ruajt.",
finished: "Të mbaruara",
finishedEmpty: "Ende asnjë lavazh i mbaruar.",
voided: "anuluar",
by: "Nga",
cash: "cash",
card: "kartë",
sponsorship: "Zbritje parkimi",
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
sponsorshipLabel: "Lavazh",
},
update: {
available: "Përditësim i disponueshëm",
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis? (Instalimi kërkon fjalëkalimin e administratorit.)",
},
nav: {
wash: "Lavazh",
carwash: "Lavazh",
booth: "Kabina",
shift: "Turni",
setup: "Konfigurimi",
@@ -261,6 +322,9 @@ export const sq = {
evtCashReview: "SHQYRTIM",
evtConfigChange: "KONFIG",
evtValidation: "VALIDIM",
evtCarwashOrder: "LAVAZH",
evtCarwashPayment: "PAGESË LAVAZHI",
charges: "Shtesa",
decision: { authorize: "autorizuar", deny: "refuzuar" },
evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR",
@@ -788,6 +852,11 @@ export const sq = {
modeTimeCredit: "Minutat e para falas",
modeFixed: "Zbritje shume (shkruhet në skanim)",
modePercent: "Zbritje në përqindje",
modeDoneTolerance: "Falas gjatë lavazhit (+ tolerancë)",
modeDoneToleranceHint: "Koha nga porosia e lavazhit deri te \"mbaroi\", plus minutat e tolerancës, zbritet nga parkimi. Koha e parkuar para porosisë dhe pas tolerancës paguhet sipas tarifës.",
modeWashPrice: "Çmimi i lavazhit zbritet nga parkimi",
modeWashPriceHint: "Tarifa e parkimit minus çmimin e lavazhit; asnjëherë nën zero.",
toleranceMinutes: "Toleranca pas mbarimit (minuta)",
minutes: "Minuta falas",
percent: "Përqindja (%)",
maxAmount: "Tavani i zbritjes për validim",
@@ -917,6 +986,18 @@ export const sq = {
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
openNow: "Hap turnin tani",
opening: "Duke hapur…",
// Arkat — turni i përket një arke (kabina / lavazhi), jo gjithë sitit.
tillOpen: "Hap turnin e {{till}}",
tillClose: "Mbyll turnin e {{till}}",
tillHeldByShort: "{{till}}: {{operator}}",
tillNoShift: "Pa turn {{till}}",
},
till: {
booth: "kabinës",
carwash: "lavazhit",
boothLong: "Arka e kabinës",
carwashLong: "Arka e lavazhit",
all: "Të gjitha arkat",
},
shifts: {
title: "Historiku i turneve",