Logistik / Boxen / Tracking / Container
Modul 5 der Caja-Plattform. Das operative Herz: hier wird aus einem akzeptierten Auftrag eine physische Sendung aus einzeln verfolgbaren Boxen (UUID/QR), die entlang der 10 Tracking-Schritte vom Schweizer Depot bis zur Haustür des Empfängers in der Dominikanischen Republik wandern, monatlich per Container verschifft werden, und deren Box-Depots am Ende zurückgezahlt oder verfallen. Jedes physische Ereignis (Box gepackt, Container geschlossen, Depot zurück) erzeugt nachvollziehbare Daten — und wo Geld fliesst, automatisch eine Buchung im
movements-Ledger.
1. Zweck & Scope
Dieses Modul bildet den physischen Warenfluss von Dominicano Express ab: Es übernimmt einen Auftrag (order) aus dem Offerten-Modul, materialisiert ihn in einzelne Boxen/Fässer (boxes) mit je eigener UUID und QR-Code, und verfolgt jede Box als zeitgestempelte Statushistorie (tracking_events) entlang der 9 Soll-Schritte EMPTY_DELIVERED → PACKED → PICKED_UP → CH_DEPOT → CONTAINER_LOADED → SHIPPED → IN_TRANSIT → DR_CUSTOMS → DELIVERED. Boxen werden zu einer Sendung (shipments) gebündelt, einem Empfänger in DR (recipients) zugeordnet und auf einen monatlichen Container (containers) mit „próxima salida"-Datum verladen. Das Modul managt zusätzlich den Depot-Lifecycle der wiederverwendbaren Boxen (Box raus → Depot bezahlt → Box zurück → Depot-Rückzahlung oder Verfall) und ist damit ein Auslöser für Finanzbuchungen (Anzahlung/Depot-Cashflow). Nicht im Scope: Preisberechnung/Rabatte (Modul 3+4), die eigentliche Debitoren-/Kassenführung (Modul 6, wird hier nur gespeist), KYC/OCR des Empfängers (Modul 2, wird hier nur referenziert).
2. Domänenmodell
Kern-Idee: order ist die kaufmännische Klammer (1 Auftrag = 1 Kunde = 1 Rechnung). shipment ist die logistische Klammer (welche Boxen reisen zusammen zu welchem Empfänger). box ist die kleinste verfolgbare Einheit (1 physisches Objekt = 1 UUID = 1 QR). tracking_event ist die unveränderbare Historie. container ist das Transportmittel (monatlich). Eine Box trägt immer ihren aktuellen Status redundant als current_status (für schnelle Listen/Filter), die Wahrheit ist aber die Event-Kette.
ER-Skizze
Diagramm wird geladen …
Entitäten in Kurzform:
orders— Auftrag aus akzeptierter Offerte (quote). Hält Kunde (contacts), Affiliate-Code (durchgereicht), Gesamtbetrag (read-only Spiegel der Offerte), Auftragsstatus.shipments— logistisches Bündel: welche Boxen reisen zusammen, an welchen Empfänger in DR, auf welchem Container. Trägt den aggregierten „Reise-Status".boxes— einzelne physische Box/Fass mitid(UUID = QR-Inhalt),box_no(menschenlesbar), Typ (box_products),current_status, Zustand (condition), Verknüpfung Auftrag + Sendung + Container.tracking_events— append-only Statushistorie pro Box; jedes Event = ein Schritt der 9er-Kette + Zeitstempel + Actor + optional Geo/Foto/Notiz.containers— monatlicher Container mitnext_departure(„próxima salida"), Abfahrt/Ankunft, Kapazität, Status.recipients— Empfänger in DR (Name, Cédula/Pass-Ref, Adresse/Zone, Telefon); referenziert optional einencontacts-Satz (Rolle „Empfänger-in-DR").box_products— geteilter Katalog (Mediana/Jumbo/Maxi/Mega, Barril 120/150/220 L, Zafacón 150/240 L) inkl.default_deposit_chf.deposit_orders/deposit_payments/deposit_refunds— Depot-Lifecycle (geteilt mit Modul 6 Finanzen).deposit_refundsist der Neubau dieses Moduls für Rückzahlung/Verfall.
3. Supabase-Schema
Maßgebliche Schema-Quelle ist Dok 30 (kanonisches, konsolidiertes Supabase-Schema). Dieses Kapitel führt keine Voll-DDL der gemeinsam genutzten Tabellen mehr, sondern verweist je Abschnitt auf Dok 30 und gibt nur fachliche Feld-Auszüge — so kann das Schema nicht zwischen zwei Dokumenten driften. Konvention (Dok 30): alle Tabellen
schema public, PKid uuid default gen_random_uuid(), Audit-Spaltencreated_at/updated_at timestamptz(via Trigger), pluscreated_by uuid references app_users(id)(K-19; nichtcontacts). Stabile Codes sind TEXT-Enums in Lookup-Tabellen (NIE in Logik verdrahtet), nicht Postgres-enum. Geldbeträgenumeric(12,2). Alle Status/Typen referenzieren Lookups per FK aufcode.
3.1 Lookup: Tracking-Status (die 10 Schritte)
Stabile Codes (intern, EN-basiert für Logik-Neutralität), Labels DE/ES/EN aus der Website + Glossar. phase gruppiert für die UI (CH / Océano / RD). sort_order = Schrittnummer 1–10.
Kanonische DDL: Dok 30 §3.5 (
tracking_statuses,tracking_phases). Hier nur die fachliche Status-Tabelle (DE/ES/EN-Anzeige) als Auszug — Dok 30 ist die maßgebliche Schema-Quelle.
| code | # | phase | label_es (Website) | label_de | label_en |
|---|---|---|---|---|---|
EMPTY_DELIVERED | 1 | CH | Llevamos (caja vacía entregada) | Leerbox geliefert | Empty box delivered |
PACKED | 2 | CH | Embalado / listo | Gepackt | Packed |
PICKED_UP | 3 | CH | Recogemos | Abgeholt | Picked up |
CH_DEPOT | 4 | CH | Almacenamos en Suiza | CH-Depot/Lager | In CH warehouse |
CONTAINER_LOADED | 5 | OCEAN | Despachamos (en contenedor) | Container verladen | Loaded in container |
SHIPPED | 6 | OCEAN | Zarpó / embarcado | Verschifft | Shipped |
IN_TRANSIT | 7 | OCEAN | En tránsito marítimo | Auf See | In transit |
DR_CUSTOMS | 8 | OCEAN | Aduanas | DR-Zoll | DR customs |
DR_DEPOT | 9 | RD | Almacén RD | DR-Lager | DR warehouse |
DELIVERED | 10 | RD | Distribuido y entregado | Zugestellt | Delivered |
DR_DEPOT(Entscheid 2026-06-28): eigener DR-Lager-Schritt zwischen Zoll und Zustellung (sort_order9;DELIVEREDrückt auf 10, bleibtis_terminal). Damit sind es 10 Tracking-Schritte. (Eine RD-Nachwiegung findet nicht statt —boxes.weight_rd_kgbleibt ungenutzt.)
Phasen (
tracking_phases):CH='Suiza / Antes del Envío',OCEAN='Océano / En Tránsito',RD='República Dominicana'. Kanonische DDL: Dok 30 §3.5.
3.2 Lookup: Box-Zustand & Sendungs-Status
Kanonische DDL: Dok 30 §3.5 (
box_conditions,shipment_statuses,container_statuses). Hier nur die fachlichen Code-Werte als Auszug:
box_conditions:NEW=Neu,GOOD=Gut,WORN=Abgenutzt,DAMAGED=Beschädigt,LOST=Verlorenshipment_statuses:DRAFT=Entwurf,PACKING=In Verpackung,READY=Versandbereit,IN_CONTAINER=Im Container,SHIPPED=Verschifft,ARRIVED=Angekommen,DELIVERED=Zugestellt,CLOSED=Abgeschlossencontainer_statuses:PLANNED=Geplant,OPEN=Offen (Buchung möglich),CLOSED=Geschlossen,DEPARTED=Abgefahren,ARRIVED=Angekommen,CLEARED=Verzollt,COMPLETED=Erledigt
3.3 Kern-Tabellen
Kanonische DDL: Dok 30 §7 (
containers,shipments,boxes,tracking_events), §2.6 (orders) und §2.3 (recipients). Dok 30 ist die maßgebliche Schema-Quelle — die dortigen Tabellen sind ein Superset und enthalten u. a. zusätzliche IST-Gap-/SSCC-Felder sowie die kanonischen FK-Ziele (created_by/actor_id → app_users, K-19). Hier nur ein fachlicher Feld-Auszug zur Orientierung; bei Abweichungen gilt Dok 30.
containers—code('2026-07'/'CNT-2026-07-A'),status(→container_statuses),next_departure(„próxima salida"),departed_at/eta_dr/arrived_at/cleared_at,capacity_cbm(führend) +capacity_boxes,shipping_line/bl_number.recipients— Empfänger in DR: Name, KYC (cedula_number/id_doc_type),preferred_lang(F-07, Default ES), DR-Adresse (province/municipality/…),zone_id(→zones, Preis/Logistik),contact_id(optionaler Golden-Record-Link). (Dok 30 §2.3 ist Superset von CRM+Logistik, K-04.)orders—order_no('A-2026-0042'),quote_id(Herkunft Modul 4),customer_id(→contacts),referral_code_id/affiliate_id(durchgereicht),status(→order_statuses),total_chf(read-only Spiegel der Offerte).shipments—shipment_no,order_id,recipient_id,container_id(null bis verladen),status(→shipment_statuses); plus 3-Fahrer-FKs (pickup_/transport_/delivery_driver_id, G5) undsender_snapshot/recipient_snapshot(G1, eingefroren).boxes— kleinste verfolgbare Einheit:id(UUID = QR-Inhalt),box_no(Etikett),box_product_id,current_status(→tracking_statuses, redundanter Schnell-Filter),condition,weight_kg,is_returnable(F-22: ausbox_productsabgeleitet), Verknüpfung Auftrag/Sendung/Container; optionalsscc(§12).tracking_events— append-only Historie (kein UPDATE/DELETE):box_id,status,occurred_at,actor_id(erfassender Login),source(manual/qr_scan/…), optionalgeo_*/photo_path/signer_name/proof_method,client_event_id(Offline-Idempotenz).
3.4 Depot-Lifecycle (geteilt mit Modul 6)
Kanonische DDL: Dok 30 §5 (
deposit_orders,deposit_payments,deposit_refunds,deposit_refund_kinds). Der frühere „Neubau"deposit_refundsist dort konsolidiert (K-13);deposit_orders.box_id(1 returnable Box ⇒ ≤1 offener Depot-Auftrag, 1:1) ist nativ, keinALTERmehr. Hier nur der fachliche Auszug:
deposit_refundsschließt jeden Depot-Auftrag genau einmal ab (unique(deposit_order_id)) mitkind→deposit_refund_kinds:RETURNED— Box zurück, Depot rückerstattet (Cash-Ausgang in Höheamount_chf, Zahlweise viapayment_method_id).FORFEITED— Box nicht zurück, Depot verfallen (amount_chf=0; finanziell neutral, keine Ertragsbuchung, AP-6).PARTIAL— Teilrückerstattung bei beschädigter Box (amount_chf< bezahlt).
- Deckelung:
deposit_refunds.amount_chf ≤ Σ deposit_payments.amount_chfderselbendeposit_order(s. §7).
3.5 Views
-- Aktueller Stand pro Box inkl. Schrittnummer und Phase (für Listen/Filter/Karten).
create view v_box_tracking as
select b.id, b.box_no, b.order_id, b.shipment_id, b.container_id,
b.current_status, ts.sort_order as step_no, ts.phase,
b.condition, b.weight_kg,
(select max(occurred_at) from tracking_events e
where e.box_id = b.id and e.status = b.current_status) as status_since
from boxes b join tracking_statuses ts on ts.code = b.current_status;
-- Sendungs-Fortschritt = niedrigster Box-Schritt (Sendung ist so weit wie ihre langsamste Box).
create view v_shipment_progress as
select s.id, s.shipment_no, s.order_id, s.container_id, s.status,
count(b.id) as box_count,
min(ts.sort_order) as min_step,
max(ts.sort_order) as max_step,
bool_and(b.current_status = 'DELIVERED') as all_delivered
from shipments s
left join boxes b on b.shipment_id = s.id
left join tracking_statuses ts on ts.code = b.current_status
group by s.id;
-- Container-Auslastung (gegen Kapazität).
create view v_container_load as
select c.id, c.code, c.status, c.next_departure, c.capacity_boxes,
count(b.id) as boxes_loaded,
case when c.capacity_boxes is not null and c.capacity_boxes > 0
then round(100.0 * count(b.id) / c.capacity_boxes, 1) end as load_pct
from containers c
left join boxes b on b.container_id = c.id
group by c.id;
-- Offene Box-Depots (returnable Boxen ohne Refund-Abschluss) — für Depot-Lifecycle-Liste.
create view v_open_box_deposits as
select d.id as deposit_order_id, d.box_id, b.box_no, b.current_status,
d.deposit_total_chf,
coalesce(sum(p.amount_chf),0) as paid_chf,
greatest(d.deposit_total_chf - coalesce(sum(p.amount_chf),0),0) as open_pay_chf
from deposit_orders d
join boxes b on b.id = d.box_id and b.is_returnable
left join deposit_payments p on p.deposit_order_id = d.id
where not exists (select 1 from deposit_refunds r where r.deposit_order_id = d.id)
group by d.id, b.box_no, b.current_status;
3.6 RLS-Skizze (pro Tabelle: wer darf was)
Rollen aus Modul 8 (kanonische Codes): ADMIN (Marcel), BUCHHALTUNG (Mariela), OPERATIONS (Markus), FAHRER (Arkys), AFFILIATE, READONLY. RLS prüft Rollen ausschliesslich über has_role('CODE') / is_internal() / current_affiliate_id() (Quelle: Tabelle user_roles), nicht über einen JWT-Claim. Annahme: alle hier genannten Tabellen haben enable row level security.
| Tabelle | ADMIN | BUCHHALTUNG | OPERATIONS | FAHRER | AFFILIATE | READONLY |
|---|---|---|---|---|---|---|
containers | ALL | ALL | ALL | SELECT | – | SELECT |
orders | ALL | ALL | SELECT, UPDATE(status) | SELECT | – | SELECT |
shipments | ALL | ALL | ALL | SELECT, UPDATE(status) | – | SELECT |
boxes | ALL | ALL | ALL | SELECT, UPDATE(condition,current_status via fn) | – | SELECT |
tracking_events | INSERT+SELECT | INSERT+SELECT | INSERT+SELECT | INSERT+SELECT | – | SELECT |
recipients | ALL | ALL | SELECT, INSERT | SELECT | – | SELECT(masked) |
deposit_orders | ALL | ALL | INSERT, SELECT | SELECT | – | SELECT |
deposit_payments | ALL | ALL | INSERT, SELECT | – | – | SELECT |
deposit_refunds | ALL | ALL | INSERT, SELECT | – | – | SELECT |
Lookups (tracking_*, *_statuses, box_conditions) | ALL | SELECT | SELECT | SELECT | SELECT | SELECT |
Wichtige Policy-Details:
-
tracking_events: kein UPDATE, kein DELETE für irgendeine Rolle (Revisionssicherheit, OR 957). Policy nurfor insert with check (auth.uid() is not null)+for select. Korrektur eines falschen Events erfolgt durch ein Storno-Event (neues Event mit Notiz „Korrektur von …"), nicht durch Löschen.alter table tracking_events enable row level security; create policy te_insert on tracking_events for insert with check (auth.uid() is not null and actor_id = auth.uid()); create policy te_select on tracking_events for select using (true); -- KEIN update/delete-Policy ⇒ standardmässig verboten. -
boxes.current_statusdarf nicht frei per UPDATE gesetzt werden, sondern nur über die SECURITY-DEFINER-Funktionfn_record_tracking_event()(siehe §4.2), die Event + Statuswechsel atomar macht. Direktes UPDATE aufcurrent_statuswird per Spalten-Policy/Trigger blockiert. -
recipients:readonlysieht Cédula/Telefon maskiert (Viewv_recipients_masked), Klartext nuradmin/office/ops(KYC-Datenminimierung). -
affiliate: sieht nichts aus diesem Modul direkt (Provisionen laufen über Modul 7); kein SELECT auf boxes/shipments. -
F-19 — Lookup-RLS: Alle Lookup-Tabellen dieses Moduls (
tracking_statuses,tracking_phases,box_conditions,shipment_statuses,container_statuses,order_statuses,deposit_refund_kinds) folgen der zentralen RLS-Policy in Dok 30 §13 (CRUfür ADMIN,Rfür alle übrigen Rollen) —enable RLS+ Policy pro Tabelle in derselben Migration nicht vergessen.
4. Kern-Workflows
4.1 Auftrag → Boxen materialisieren
- Offerte wird
accepted(Modul 4) → erzeugtorder(StatusOPEN,total_chfgespiegelt,referral_code_iddurchgereicht). - Office/Ops öffnet Auftrag, wählt pro Offertenposition Anzahl + Box-Typ → System legt N
boxesan (je eigene UUID +box_no,current_status = EMPTY_DELIVERED,is_returnableausbox_products.is_returnabledes gewählten Typs übernommen — F-22). - Für jede returnable Box (
is_returnable, z. B. Fässer) wird automatisch eindeposit_ordermitbox_idunddeposit_total_chf = qty × box_products.default_deposit_chferzeugt (Status offen). - Etiketten/QR werden generiert (QR-Inhalt =
box.idals URLcaja.dominicanoexpress/b/{uuid}), Batch-Druck als PDF. - Edge: Stornierte Offerte/Auftrag → Boxen dürfen nur gelöscht werden, solange
current_status = EMPTY_DELIVEREDund kein Depot bezahlt; sonst nur „Auftrag stornieren" mit Begründung (Boxen bleiben für Audit,order.status = CANCELLED). - Edge: Box-Typ nachträglich geändert → erlaubt nur solange noch kein
PACKED-Event; danach neue Box anlegen, alte alsDAMAGED/Storno markieren.
4.2 Tracking-Status setzen (Einzel-Box, der häufigste Vorgang)
- Feldteam scannt Box-QR (oder öffnet Box-Detail) → sieht
current_status+ nächsten Soll-Schritt. - Tippt neuen Status (oder swipt „nächster Schritt") → ruft
fn_record_tracking_event(box_id, new_status, occurred_at, geo?, photo?, note?, client_event_id). - Funktion validiert die Schritt-Reihenfolge (Standard: nur Vorwärts oder selber Schritt; Rückwärts/Sprung nur mit Flag
allow_out_of_order+ Begründung → eigenes Eventsource='manual'+ Notiz). - Funktion schreibt
tracking_events-Zeile und setztboxes.current_statusatomar (eine Transaktion). BeiDELIVEREDzusätzlichboxes.delivered_at = occurred_at. - Bei Erfolg: Toast „Caja BX-… → Aduanas" + optimistische UI-Aktualisierung.
- Edge — Idempotenz: kommt dasselbe
client_event_idaus der Offline-Queue zweimal an → Unique-Index verhindert Duplikat, Funktion gibt das bestehende Event zurück (kein Fehler). - Edge — Konflikt: Box ist serverseitig schon weiter als der offline erfasste Schritt → Event wird trotzdem als historischer Eintrag gespeichert,
current_statusaber nicht zurückgesetzt (max(sort_order) gewinnt). UI zeigt Hinweis „bereits weiter".
create or replace function fn_record_tracking_event(
p_box_id uuid, p_status text, p_occurred_at timestamptz default now(),
p_geo_lat numeric default null, p_geo_lng numeric default null,
p_photo text default null, p_note text default null,
p_client_event_id text default null, p_allow_out_of_order boolean default false
) returns tracking_events
language plpgsql security definer set search_path = public as $$
declare v_cur smallint; v_new smallint; v_evt tracking_events;
begin
-- Idempotenz: existiert das Offline-Event schon?
if p_client_event_id is not null then
select * into v_evt from tracking_events where client_event_id = p_client_event_id;
if found then return v_evt; end if;
end if;
select ts.sort_order into v_cur from boxes b
join tracking_statuses ts on ts.code = b.current_status where b.id = p_box_id;
select sort_order into v_new from tracking_statuses where code = p_status;
if v_new is null then raise exception 'unknown status %', p_status; end if;
if v_new < v_cur and not p_allow_out_of_order then
raise exception 'out-of-order step (% -> %) needs allow_out_of_order', v_cur, v_new;
end if;
insert into tracking_events(box_id,status,occurred_at,actor_id,source,
geo_lat,geo_lng,photo_path,note,client_event_id)
values(p_box_id,p_status,p_occurred_at,auth.uid(),
case when p_client_event_id is null then 'manual' else 'qr_scan' end,
p_geo_lat,p_geo_lng,p_photo,p_note,p_client_event_id)
returning * into v_evt;
-- current_status nur vorwärts überschreiben (max-Schritt gewinnt)
update boxes set current_status = p_status,
delivered_at = case when p_status='DELIVERED' then p_occurred_at else delivered_at end,
updated_at = now()
where id = p_box_id and v_new >= v_cur;
return v_evt;
end $$;
4.3 Sendung bilden & auf Container verladen
- Office gruppiert Boxen eines Auftrags zu
shipment(1 Empfänger pro Sendung), wählt/erstelltrecipient. - Container auswählen (Status
OPEN) → Bulk-Aktion „X Boxen auf Container CNT-2026-07": setztboxes.container_id+shipments.container_id+ Bulk-Tracking-EventCONTAINER_LOADEDfür alle Boxen (einefn_bulk_track). - Kapazitätsprüfung:
v_container_load.boxes_loaded≤capacity_boxes; Überbuchung → Warnung (nicht hart blockiert, Ops entscheidet). - Container schliessen (
CLOSED) → keine neuen Boxen mehr;departed_atsetzen → Bulk-EventSHIPPEDfür alle. - Edge: Box nachträglich aus geschlossenem Container nehmen (verpasst/beschädigt) → nur
admin/ops, erzeugt Storno-Event + setztcontainer_id = null, Box fällt auf vorherigen Status zurück (explizites Rückwärts-Event mit Begründung).
4.4 Ankunft, Zoll, Zustellung in DR
- Container
ARRIVED(Ist-Ankunft) → optional Bulk-EventIN_TRANSIT→ bei Hafenankunft. - Zoll: Container
CLEARED→ Bulk-EventDR_CUSTOMSfür alle Boxen. - Fahrer/DR-Team stellt Box für Box zu: scannt QR am Ziel →
DELIVERED(mit Foto + optional GPS + Empfänger-Bestätigung). Setztbox.delivered_at. - Wenn alle Boxen einer Sendung
DELIVERED→v_shipment_progress.all_delivered = true→ SendungDELIVERED; wenn alle Sendungen des Auftrags zugestellt →order.status = DELIVERED. - Edge: Empfänger nicht angetroffen → kein
DELIVERED, sondern Notiz-Event (source='manual', Status bleibtDR_CUSTOMS), Wiederzustellung geplant.
4.5 Depot-Lifecycle: Rückzahlung oder Verfall
- Depot bezahlt (beim Auftrag/Anzahlung):
deposit_paymenterfasst → erzeugt automatisch genau einenmovements-Eintrag (DEPOSIT_CASHAbono, Kanal nachpayment_methods.channel),source_type='deposit_payment',upsertaufsource_id(siehe §6). F-14: die Abono-Bewegung wird perpayment_linksmit der Soll-Bewegung desdeposit_order(AP-3,deposit_orders.movement_id) verknüpft — Zuordnung überdeposit_payments.deposit_order_id, nicht perreference-Match; so finden Soll und Abono inv_anzahlungensicher zusammen. Box-Depot Status →PAID/PARTIAL. - Box kommt zurück (Fass returnable): Ops erfasst
deposit_refundmitkind='RETURNED',amount_chf = bezahlter Depotbetrag, Zahlweise → erzeugt automatischmovements-Ausgang (source_type='deposit_refund'). Boxconditionggf. aufGOOD/WORNaktualisiert,is_returnablebleibt (Box wieder im Pool). - Box kommt nicht zurück / Frist abgelaufen:
deposit_refundmitkind='FORFEITED',amount_chf=0. Kein Cash-Ausgang; stattdessen wird das einbehaltene Depot zur Einnahme umqualifiziert (BuchungINCOME, siehe §6 Edge — 🔲 zu bestätigen ob als Ertrag oder neutral). Boxcondition='LOST'. - Teilrückerstattung (Box beschädigt):
kind='PARTIAL',amount_chf < bezahlt→ Cash-Ausgang in Höheamount_chf, Differenz analog Verfall. - Edge: Einweg-Box (
is_returnable=false, z. B. Karton-Caja) hat gar kein Depot → keindeposit_order, kein Refund-Schritt. - Edge: Refund versucht, aber Depot war nie (voll) bezahlt → Refund-Betrag wird auf tatsächlich bezahlten Betrag gedeckelt (
amount_chf ≤ paid_chf), sonst Validierungsfehler.
5. UI-Screens (Mobile/Tablet-First)
Grundlayout (Atlas): Handy = Bottom-Tab-Bar (Boxen · Scan · Container · Mehr) + Drawer; Tablet = Haupt-Formfaktor, zweispaltig (Liste links, Detail rechts); Desktop = Sidebar + Command-Palette (⌘K). Alle Listen sind adaptiv: Tabelle (Tablet/Desktop) ↔ Karten-Stack (Handy, kein Horizontal-Scroll). Touch-Targets ≥ 44 px, Status-Wechsel als Swipe, Selects als Bottom-Sheet.
5.1 Scan-Screen (Camera-First, der wichtigste Screen)
- Zweck: Box-QR scannen → sofort Box-Kontext + Quick-Action „nächster Schritt".
- Handy/Tablet: Vollbild-Kamera, QR-Reticle. Nach Scan slidet ein Bottom-Sheet hoch: Box-Nr, Typ, aktueller Status (Badge), grosser Primär-Button „→ {nächster Schritt}" (thumb-zone, ≥ 56 px), Sekundär „Anderer Status" (öffnet Bottom-Sheet-Select der 10 Schritte), Buttons „Foto" + „Notiz". Mehrfach-Scan-Modus: nach Bestätigung Kamera bleibt offen → nächste Box (Container-Verladung am Stück).
- Desktop: Scan via Webcam oder USB-Scanner (Tastatur-Wedge) ins Suchfeld; gleicher Bottom-Sheet als Modal.
- Offline: Scan + Statuswechsel werden in IndexedDB-Queue geschrieben (jeweils mit
client_event_id), Badge „3 ausstehend"; Sync sobald online. Optimistische UI zeigt neuen Status sofort.
5.2 Boxen-Liste
- Zweck: alle Boxen filtern/finden (nach Auftrag, Status, Container, Zustand).
- Tablet/Desktop: TanStack-Table — Spalten Box-Nr · Auftrag · Typ · Status (Badge + Schritt-# ) · Container · Zustand · letzte Bewegung. Sticky Filter-Bar, Spalten-Sort.
- Handy: Karten statt Tabelle — pro Box eine Karte: Box-Nr fett, Status-Badge farbig, Mini-Stepper (1–9 Punkte), Auftrag/Empfänger klein. Swipe rechts = „nächster Schritt", Swipe links = „Foto/Notiz". Filter als Bottom-Sheet (Chips: Phase CH/Océano/RD, Status, Container).
- Empty-State: „Noch keine Boxen — Auftrag öffnen und Boxen anlegen." Skeletons beim Laden.
5.3 Box-Detail / Tracking-Timeline
- Zweck: vollständige Historie einer Box + manuelle Aktion.
- Alle Geräte: vertikale Timeline der
tracking_events(Icon je Phase, Zeitstempel, Actor, Foto-Thumbnail, Notiz). Oben Header mit QR-Bild, Box-Nr, aktueller Status, Depot-Status-Chip. Aktions-Leiste unten (sticky am Handy): „Status setzen", „Foto", „Drucken". - Geo-Events zeigen kleinen Karten-Pin (optional). Storno-Events visuell durchgestrichen + „Korrektur".
5.4 Container-Board
- Zweck: „próxima salida" planen, Boxen verladen, Auslastung sehen.
- Tablet/Desktop: Karten-Board je Container (Status-Spalte: Geplant · Offen · Geschlossen · Unterwegs · Angekommen), Container-Karte zeigt
next_departure, Load-Bar (load_pct), Box-Anzahl. Klick → Container-Detail mit Box-Tabelle. - Handy: vertikaler Karten-Stack, je Container eine Karte mit Progress-Bar + „próxima salida"-Datum prominent. CTA „Boxen verladen" → Scan-Screen im Mehrfach-Modus.
- Verladen: Quick-Entry — Bottom-Sheet „Auf welchen Container?" (Select), dann Boxen scannen; Live-Zähler „12 / 40 verladen".
5.5 Container-Detail & Bulk-Aktionen
- Header: Container-Code, Status,
next_departure/departed_at/eta_dr, Reederei/BL. - Box-Liste (adaptiv Tabelle↔Karten) mit Multi-Select (Checkbox-Spalte / Long-Press am Handy) → Bulk-Bar: „Alle → SHIPPED", „Alle → DR_CUSTOMS", „Aus Container entfernen".
- Kapazitäts-Warnung als Inline-Alert wenn überbucht.
5.6 Auftrag-Detail (Logistik-Sicht)
- Zweck: vom Auftrag aus Boxen anlegen, Sendungen bilden, Empfänger zuordnen.
- Tabs: Boxen · Sendungen · Depot. „Boxen anlegen" = Quick-Entry mit
inputmode="numeric"(Anzahl je Typ), erzeugt UUIDs + Druck-Batch. - Sendung bilden: Boxen auswählen → Empfänger-Picker (Bottom-Sheet, Suche im
recipients/contacts).
5.7 Depot-Lifecycle-Screen
- Zweck: offene Box-Depots managen, Rückgabe/Verfall buchen.
- Liste aus
v_open_box_deposits: Box-Nr · Kunde · Depot total · bezahlt · Status · aktueller Tracking-Status. - Pro Zeile Aktionen: „Box zurück → Depot erstatten" (Bottom-Sheet: Betrag vorausgefüllt, Zahlweise-Select, bucht
movements), „Depot verfallen lassen" (Bestätigungs-Dialog + Begründung). - Handy: Karten mit Swipe „zurück" / „verfallen".
6. Integrationen & Verbindungen zu anderen Modulen
Geteilte Entitäten (eingehend/ausgehend):
- Modul 4 (Offerten): liefert
quote→ erzeugtorder(quote_id,total_chf,referral_code_id). Auftrag ist read-only bzgl. Beträgen. - Modul 1 (CRM):
orders.customer_id,recipients.contact_id→contacts(Golden Record).tracking_events.actor_idreferenziert den erfassenden Mitarbeiter-Kontakt. - Modul 3 (Produkte/Preise):
box_products(Katalog +default_deposit_chf),delivery_zones(Zielzone des Empfängers). - Modul 2 (WhatsApp/OCR-KYC):
recipients.id_doc_*werden aus dem OCR-Ausweis-Scan vorbefüllt (Empfänger-Matching beim Zoll). - Modul 7 (Affiliate):
referral_code_idzieht sich Auftrag → Sendung durch. F-13/F-04: Die Provision entsteht (commission_entriesINSERTpending) jedoch beim Überganginvoices.status → PAID(Ledger-Trigger, Dok 30 §2.7), nicht beiDELIVERED.DELIVEREDist die fachliche Freigabe-Voraussetzung fürcommission_entries.status → approved(Modul 16 §4.6), kein DB-Trigger der Entstehung. - Modul 8 (Plattform): RLS-Rollen, i18n-Lookups, Audit-Log (jedes
tracking_event+ Statuswechsel + Depot-Buchung), Storage (Box-Fotos, Etiketten-PDF).
Auto-Posting ins movements-Ledger (Kerngewinn ggü. Excel — operative Events erzeugen Buchungen):
| Operatives Event | movement_type (Code) | Kanal | Betrag | source_type / source_id |
|---|---|---|---|---|
Depot-Zahlung erfasst (deposit_payment) | DEPOSIT_CASH | nach payment_methods.channel (cash/bank) | bezahlter Depotbetrag (Eingang) | deposit_payment / deposit_payments.id |
Box zurück → Depot erstattet (kind=RETURNED/PARTIAL) | EXPENSE (Depot-Rückzahlung) | nach Zahlweise | amount_chf (Ausgang) | deposit_refund / deposit_refunds.id |
Depot verfallen (kind=FORFEITED) | INCOME (einbehaltenes Depot → Ertrag) 🔲 | – (keine Kassenbewegung, nur Umqualifizierung) | 0 Cash / Ertragsbuchung | deposit_refund / deposit_refunds.id |
- Doppelbuchungs-Schutz:
movementshat Unique-Constraint auf(source_type, source_id); alle Auto-Postings viaupsert— eine Depotzahlung = genau ein movement, Korrektur überschreibt, dupliziert nicht. Aus Quellen erzeugtemovementsdürfen nicht manuell gelöscht werden (nur Ursprung bearbeiten). - Kanal-Klassifikation: nie per Textmatch, immer
payment_methods.channel(harte Spec-Regel). period_key(MM/YYYY) der Buchung leitet sich aus dem Zahlungs-/Refund-Datum ab (finanzieller Monat), nicht aus dem Versand-/Tracking-Datum (operativer Monat) — analog Excel-Regel „Fecha envío vs. Fecha pago".
Events (intern, lösen Folgeaktionen aus):
box.status → DELIVERED⇒ prüfe Sendungs-/Auftragsabschluss; ⇒ Notification an Kunde (Modul 8, WhatsApp „entregado"); ⇒ erfüllt die Freigabe-Voraussetzung für die Affiliate-Provision (Modul 7; Entstehung der Provision aber beiinvoices → PAID, F-13).container.status → CLOSED⇒ BulkSHIPPED; ⇒ optional Kunden-Notification „tu envío zarpó".deposit_paymentinsert/update ⇒ Auto-Posting (oben).
7. Validierungen & Edge-Cases
- UUID/QR-Eindeutigkeit:
boxes.idist PK (global eindeutig = QR-Inhalt).box_nozusätzlich unique (case-insensitiv) für Etikett/Mensch. QR enthält nur die UUID-URL, keine Kundendaten (Datenschutz, falls Box verloren geht). - Schritt-Reihenfolge: Standard nur vorwärts/selber Schritt; Rückwärts nur mit
allow_out_of_order+ Begründung (eigenes Event).current_statuswird nie rückwärts überschrieben (max-Schritt gewinnt) — Schutz gegen verspätet einsynchronisierte Offline-Events. - Offline-Idempotenz: jedes Feld-Event trägt
client_event_id; Unique-Index verhindert Doppel-Sync. Re-Sync gibt bestehendes Event zurück (kein 409-Fehler für den Nutzer). - Tracking-Events unveränderbar: kein UPDATE/DELETE (RLS). Fehler werden durch Storno-/Korrektur-Event geheilt.
- Container-Kapazität: Überbuchung warnt, blockiert nicht (Ops-Entscheid).
boxes_loadedaus View, nicht gespeichert. - Depot-Refund deckeln:
deposit_refunds.amount_chf ≤ Σ deposit_payments.amount_chfder gleichendeposit_order; sonst Validierungsfehler. Prodeposit_ordergenau ein Refund-Abschluss (unique(deposit_order_id)). - Einweg vs. returnable:
is_returnable=false⇒ kein Depot, kein Refund-Schritt, taucht nicht inv_open_box_depositsauf. - Empfänger-Pflicht: Sendung kann
READY/IN_CONTAINERnur erreichen, wennrecipient_idgesetzt (Zoll braucht Empfänger). Validierung beim Statuswechsel. - Box ohne Sendung verladen: möglich (Container-Verladung kann der Sendungsbildung vorausgehen), aber Warnung „Box ohne Empfänger/Sendung".
- Storno-Auftrag: Boxen mit
current_status > EMPTY_DELIVEREDoder bezahltem Depot werden nicht gelöscht (Audit), nurorder.status=CANCELLED+ Notiz. - Geo/Foto optional: kein harter Zwang (Feldteam unterwegs, Akku/Netz), aber bei
DELIVEREDist Foto empfohlen (UI-Hinweis, nicht blockierend — 🔲 ob Pflicht). - Zeitzonen:
occurred_atimmertimestamptz(UTC gespeichert), Anzeige in CH- bzw. DR-Zeit je Phase.
8. Compliance-/Sicherheits-Hinweise
- OR 957 / GeBüV (Revisionssicherheit):
tracking_eventsund alle Auto-Postings insmovements-Ledger sind append-only, kein UPDATE/DELETE per RLS. Korrekturen ausschliesslich als neue (Storno-)Einträge mit Bezug zum Original → unveränderbares Journal. Jede Box-Statusänderung, Container-Bewegung und Depot-Buchung landet zusätzlich im zentralenaudit_log(wer/wann/alt/neu, Modul 8). - Auto-Posting-Integrität: Geld-relevante Ereignisse (Depot-Zahlung/-Rückzahlung) erzeugen Buchungen nur über
source_type+source_id+upsert(keine Doppelbuchung, keine verwaisten Buchungen). Aus Quellen stammendemovementssind manuell nicht löschbar. - revDSG / DSG (Datenschutz): Empfänger-Daten (
recipients) inkl. Cédula/Pass sind besonders schützenswert (KYC). Datenminimierung: Klartext-Dokumentnummer nur füradmin/office/ops;readonlysieht maskiert. QR-Code trägt keine Personendaten (nur UUID). Foto-Belege im Storage mit RLS, signierte URLs, kein öffentlicher Bucket. - KYC/Zoll:
recipients.id_doc_*dient dem Empfänger-Matching beim DR-Zoll (SchrittDR_CUSTOMS); Herkunft aus OCR-Scan (Modul 2), Aufbewahrung nur so lange wie für Zoll/Buchhaltung nötig. - Zugriff Feldteam:
driverdarf nur Tracking-Events erfassen (INSERT) und lesen — keine Finanz-/Stammdaten-Schreibrechte. Auth via Supabase Magic-Link, Session auf mobilem Gerät. - Offline-Daten: lokale Queue (IndexedDB) enthält ggf. Box-/Empfängerbezug → Gerät-Verschlüsselung empfohlen; Queue nach erfolgreichem Sync löschen.
9. Offene Punkte
- 🔲 Verfallenes Depot — Verbuchung: Wird ein einbehaltenes (verfallenes) Box-Depot als Ertrag (
INCOME) gebucht oder finanziell neutral nur als „Depot geschlossen" geführt? (Steuerliche/MWST-Implikation — mit Treuhänder klären.) - ✅ Depot-Verfallsfrist (2026-06-28): 1 Monat nach
DELIVERED→ nicht zurückgegebenes Fass wird automatisch alsFORFEITEDvorgeschlagen; Verfall ist finanziell neutral (keine Ertragsbuchung, AP-6). - ✅ DR-Lager (2026-06-28): eigener Status
DR_DEPOTzwischenDR_CUSTOMSundDELIVERED(§3.1, jetzt 10 Schritte). - ✅ Liefernachweis (2026-06-28): der Empfänger wählt die Methode (Unterschrift / Ablage+Foto / Selbstdeklaration, §10.3); mindestens eine Nachweisform ist Pflicht.
- ✅ Container-Kapazität (2026-06-28): m³ (
capacity_cbm) ist führend für die Auslastungs-Warnung;capacity_boxesnur ergänzend. - ✅ Box ↔ Depot-Kardinalität (2026-06-28): 1:1 (
deposit_orders.box_id) — 1 Fass = 1 Pfand, kein Mengen-Depot. - 🔲 Returnable-Definition: Welche Produkte sind tatsächlich returnable (Pfand)? Annahme: Fässer/Zafacones ja, Karton-Cajas nein — vom Operations-Team bestätigen lassen.
- 🔲 Empfänger = Besteller? Häufig ist der DR-Empfänger eine andere Person als der CH-Kunde; manchmal identisch. Soll bei Identität automatisch derselbe
contacts-Satz mit Doppelrolle verknüpft werden?
10. IST-Gap-Nachträge (P0 aus Alt-ERP-Analyse)
Diese Anforderungen stammen aus der verifizierten Gap-Analyse des Alt-ERP (
docs/legacy-ist/95-caja-gap-analyse.md). Die zugehörigen Schemafelder sind in Dok 30 §7 ergänzt.
10.1 Zoll-/Aduana-Belege & Sendungsliste (G2 — BR-39/40/41)
Das Alt-ERP erzeugte vier Belegtypen; Caja muss mindestens die zoll-/betriebskritischen nachbilden (ohne sie ist die DR-Verzollung eines Containers nicht möglich):
| Beleg | Inhalt | Datenquelle |
|---|---|---|
| Aduana-Manifest (Excel + PDF, je Container) | Box-/Sendungsliste mit Empfänger-cedula_number, content_note, weight_rd_kg, containers.bl_number (= Documento de Embarque), Absender-Identität | v_customs_manifest (Container → Boxen → Sendung → Snapshot/Recipient) |
| Zoll-Rechnung | Rechnung in Zoll-Form (Empfänger, Positionen, Werte) | invoices + bill_to_snapshot + Boxen |
| Sendungsliste | je Sendung: statusabhängiges Datum (§10.4), Status, Absender+Adresse (Snapshot), Empfänger+Adresse, 3 Fahrernamen | shipments + Snapshots + Fahrer-FK |
| Lieferbeleg/Einzelrechnung | Box-Detail + Liefernachweis (Foto + Unterschrift §10.3) | boxes + tracking_events |
- Ablage als
storage_objects.doc_class ∈ {customs_report, shipment_list, invoice_pdf, delivery_proof}(Dok 30 §6.3, ergänzt). - Export-Modi: PDF (inline-Druck + Download) und Excel (Download) — analog IST.
- UI: Report-Auslöser im Container-Board (§5.4) und in der Sendungsliste; eigener „Aduana"-Export je Container.
10.2 Zweiter Wiegepunkt RD (G3 — BR-20)
Entscheid 2026-06-28: keine RD-Nachwiegung — boxes.weight_rd_kg (Dok 30 §7) bleibt ungenutzt/optional (Feld erhalten für späteren Bedarf). Falls künftig aktiviert: Nachwiegung beim DR_DEPOT-Schritt, nicht preisrelevant (Zoll/Nachweis). Das Aduana-Manifest (§10.1) nutzt das CH-Gewicht weight_kg.
10.3 Liefernachweis: Empfänger wählt die Methode (G6 — BR-28; Entscheid 2026-06-28)
Beim Schritt DELIVERED wählt der Empfänger die Nachweis-Methode (analog Schweizer Post). tracking_events.proof_method ∈ signature | deposit | self_declared; mindestens eine Nachweisform ist Pflicht:
signature— persönlich mit Unterschrift:tracking_events.signer_name+ Unterschrift (Touch-Canvas → Bild) alsstorage_objects.doc_class='signature'.deposit— Ablage/Deponierung (z.B. im Treppenhaus, mit Abstellerlaubnis): Foto des Ablageorts (doc_class='delivery_proof') + optional GPS, ohne Unterschrift.self_declared— Selbstdeklaration: digitale Zustellbestätigung durch Empfänger/Fahrer, wenn weder Unterschrift noch Ablage möglich.
Damit ist der frühere offene Punkt E11 (Unterschrift Pflicht?) entschieden: nicht eine fixe Pflichtform, sondern eine vom Empfänger gewählte aus drei Methoden.
10.4 3-Fahrer-Modell + Fahrer-RLS (G5 — BR-33/BR-05)
Eine Sendung trägt drei optionale Fahrer-Zuordnungen (shipments.pickup_driver_id / transport_driver_id / delivery_driver_id → app_users):
- Ermöglicht die im RBAC zugesagte Fahrer-Sicht-Einschränkung (BR-05): RLS-Policy
FAHRERaufshipments/boxes/tracking_eventsfiltert auf Zeilen, in denen der eingeloggte Fahrer einer der drei ist (statt heute ungefiltert). RLS-Matrix Dok 30 §13 entsprechend nachziehen. - 🔲 Geschäftsentscheid E10: drei dispositive Rollen behalten oder genügt
tracking_events.actor_id(„wer scannt")?
10.5 Datums-Semantik je Status (G10 / L-04 — BR-12)
Das Alt-ERP leitet das fachlich relevante Datum aus dem Status ab (Por llevar→Versand, Por recoger→Abholung, Lager RD→Lieferung, sonst Erfassung). In Caja als abgeleitete View (v_box_milestones/v_shipment_progress) aus tracking_events.occurred_at je Schritt — kein zusätzliches Schema nötig, aber für Listen/Filter/Reports verbindlich spezifizieren. Mapping IST-Status (10) → tracking_statuses (9, ggf. + DR_DEPOT, s. §9) dokumentieren.
11. IST-Paritäts-Nachträge (Welle 2 — Action-/Listen-/Report-Ebene)
Schließt die Feature-Detail-Lücken aus der Paritäts-Analyse (
docs/legacy-ist/95-caja-gap-analyse.md), die über die P0-Folds (§10) hinausgehen — damit ist Block A des Alt-ERP feature-vollständig.
11.1 Notification-Trigger-Matrix (BR-15)
Status-Nachrichten gehen an beide Parteien (CH-Absender + Empfänger-DR, #16) und immer — kein Zahlungs-Gate (#17, Entscheid 2026-06-28). Caja präzisiert über die notifications-Outbox (Dok 30 §6.3), welcher Schritt über welchen Kanal erreicht (Whitelist gegen Spam):
| Tracking-Schritt | Kanal | Empfänger | Auslöser |
|---|---|---|---|
PICKED_UP | beide (CH-Absender + Empfänger-DR) | fn_record_tracking_event | |
CONTAINER_LOADED | beide | Bulk-Verladung (§4.3) | |
SHIPPED | WhatsApp + Mail | beide | Container CLOSED/DEPARTED |
DR_CUSTOMS | beide | Container CLEARED | |
DR_DEPOT | beide | DR-Lager-Eingang | |
DELIVERED | WhatsApp + Mail | beide | DELIVERED-Event |
übrige (EMPTY_DELIVERED/PACKED/CH_DEPOT/IN_TRANSIT) | – | – | kein Trigger (intern) |
Regeln: Template SHIPMENT_STATUS; Empfänger = beide (#16); Versand immer, kein Zahlungs-Gate (#17 — eine offene Rechnung unterdrückt keine Nachricht); Sprache aus recipients.preferred_lang bzw. contacts.preferred_lang; Idempotenz dedupe_key='shipment_status:<box_id>:<status>:<recipient>'; einziges legitimes skipped = fehlende Empfängeradresse (no_channel). Damit ist E5 entschieden (beide, immer).
11.2 Massen-Statuswechsel = Einzel-Konsistenz (BR-16)
Das Alt-ERP hatte mit UpdateInvoicesStatus einen Massen-Pfad, der bewusst weder History noch Mail schrieb (Inkonsistenz-Bug). Caja eliminiert das: Bulk läuft über fn_bulk_track(), das pro Box ein reguläres tracking_events-INSERT erzeugt → dieselben Folgen wie der Einzelvorgang: audit_log-Eintrag, dieselbe Notification-Matrix (§11.1, beide Empfänger, kein Gate) inkl. Dedupe, dieselbe Reihenfolge-Validierung (§4.2; nicht erlaubte Boxen werden mit Sammel-Fehlerbericht übersprungen). Notifications werden gebündelt in die Outbox geschrieben (ein Queue-Batch). → Kein „Bulk = stumm"-Loch.
11.3 Box-Stammdaten-Sperre (Ersatz für IST-IsSent, BR-22/26)
Sobald eine Box CONTAINER_LOADED (5) erreicht, sind ihre logistik-/zollrelevanten Stammdaten read-only: box_product_id, length_cm/width_cm/height_cm, weight_kg, content_note. Erlaubt bleiben nur weight_rd_kg (RD-Nachwiegung, §10.2), condition und neue tracking_events. Durchsetzung per Spalten-Trigger auf boxes (raise exception 'box locked after CONTAINER_LOADED'), nicht nur im UI. Das ersetzt die implizite IST-Invariante „IsSent=true ⇒ Felder gesperrt" (im Alt-ERP Seiteneffekt der Container-Zuweisung) explizit und auditiert. Korrektur an gesperrter Box: per Rückwärts-Event (allow_out_of_order+Begründung, nur ADM/OPS) aus dem Container nehmen, korrigieren, neu verladen.
11.4 Fahrer-Picker & FK-Ziel (BR-33/BR-05)
Im Sendungs-Detail je Rolle (Abholung/Transport/Zustellung) ein Autocomplete-Select (Bottom-Sheet am Handy), Kandidaten = Nutzer mit Rolle FAHRER, max. 10 Treffer (= IST-GetDropdownList-Limit), leer = „nicht zugewiesen". Die Fahrer-Sicht-Einschränkung (BR-05) ist über die FAHRER-Zeilen-RLS (Dok 30 §13 Fussnote 12) erfüllt. 🔲 E10b — Fahrer ohne Login: FK-Ziel ist aktuell app_users (nur Fahrer mit Account wählbar); IST-Fahrer waren People ohne Login. Falls Fahrer ohne Caja-Account disponiert werden, FK-Ziel auf contacts (Rolle fahrer) bzw. eine drivers-Tabelle umstellen (tracking_events.actor_id bleibt = scannender Login).
11.5 Absender-Zuordnung (Klarstellung, IST GetDropdownList SenderPersonId)
Der Absender wird in Caja nicht auf der Sendung gewählt, sondern ist über orders.customer_id (→ contacts, CRM-Kontakt-Selector) bereits beim Auftrag festgelegt; die Sendung erbt ihn, der eingefrorene Stand liegt in shipments.sender_snapshot (BR-30). Eine separate Absender-Autocomplete auf Sendungsebene entfällt durch die Auftrags-Architektur.
11.6 Status-Zähl-Badges (BR-38/43, Ersatz für GetInvoiceStatusesWithCount)
create view v_tracking_status_counts as
select ts.code, ts.sort_order, ts.label_de, ts.label_es, ts.label_en,
count(b.id) filter (where ts.code <> 'DELIVERED') as box_count
-- IST-Eigenheit BR-38/43: DELIVERED bewusst als 0 (Kacheln = "offene" Arbeit)
from tracking_statuses ts
left join boxes b on b.current_status = ts.code
group by ts.code, ts.sort_order, ts.label_de, ts.label_es, ts.label_en
order by ts.sort_order;
UI (§5.2/§5.4): Status-Kacheln mit Box-Zähler je Schritt aus v_tracking_status_counts, als Filter-Chips klickbar. 🔲 bestätigen, ob DELIVERED wie im IST als 0 erscheint oder echt gezählt wird.
11.7 Listen-Semantik: Datumsfilter & Sortierung (BR-35 / BR-37/44)
- Inklusiver
ToDate-Filter (BR-35): obere Datumsgrenze tagesinklusiv —occurred_at < (bis_date + interval '1 day')(spiegelt ISTAddDays(1).AddMilliseconds(-1)); gilt auch fürentry_date(Finanzen). Verhindert, dass Sendungen des letzten Tags herausfallen. - Sortierung (behebt IST-Bug BR-37/44): Spaltensortierung ist funktional (server-seitig
order by), Defaultcreated_at desc; vom Nutzer gewählte Sortierung wird angewandt (die IST-Hartüberschreibung aufId descwird nicht übernommen).
11.8 Report-Export-Modi & Belegkopf (BR-40/41) + Absender-Doktyp im Manifest (BR-48)
| Beleg (§10.1) | print | pdf | excel |
|---|---|---|---|
| Lieferbeleg/Einzelrechnung | PDF inline | PDF Download | – |
| Zoll-Rechnung | PDF inline | PDF Download | – |
| Sendungsliste | PDF inline | PDF Download | Excel Download |
| Aduana-Manifest | – | PDF Download | immer Excel Download |
print ⇒ Content-Disposition: inline; pdf/excel ⇒ attachment. Belegkopf (BR-41): Logo + Firmen-Stammdaten aus app_settings (company_info, logo_path; ersetzt IST AppSettings["companyInfo"]); Einzelrechnung/Lieferbeleg zusätzlich Unterschrift (doc_class='signature') + signer_name. Aduana-Manifest (§10.1) erweitert um Spalte Absender-Dokumenttyp + -Nr. aus shipments.sender_snapshot (eingefroren) bzw. contacts.id_doc_type — damit alle drei IST-Zollfelder abgedeckt: DocumentoEmbarque=containers.bl_number, TipoDocumentoRemitente=contacts.id_doc_type, TipoDocumentoReceiver=recipients.id_doc_type (BR-48).
12. GS1 SSCC — standardisierte Transport-Identifikation (optional, „SSCC-ready")
Über das Alt-ERP hinausgehende Erweiterung (GS1 Schweiz, Serial Shipping Container Code). Caja wird SSCC-fähig entworfen, ohne sich an eine GS1-Mitgliedschaft zu binden: ohne Aktivierung läuft alles über den UUID; mit Aktivierung kommt der SSCC als zusätzliche, weltweit standardisierte Kennung dazu — ohne Schema-Umbau.
12.1 Zweck & Abgrenzung
Eine Box ist eine Transport-/Logistikeinheit — der textbook-Anwendungsfall für SSCC. Der SSCC ist ein weltweit eindeutiger 18-stelliger Schlüssel, der jede Transporteinheit unternehmensübergreifend identifizierbar/rückverfolgbar macht (Standard-Etikette per GS1-128-Barcode, Datenaustausch via EDI/EPCIS, Lieferavis/ASN). Nutzen für Dominicano Express: scanbare Standard-Etiketten für Reederei/Spediteur/Zoll/DR-Zusteller, anschlussfähig an Logistiksysteme.
Abgrenzung: SSCC identifiziert die Box (Logistikeinheit). Der See-Container hat seine eigene Reederei-/BL-Nummer (containers.bl_number/code), keinen SSCC. Verwandte GS1-Schlüssel (optional, später): GLN für Standorte/Depots, GTIN für Box-Produkttypen (box_products).
12.2 Datenmodell
boxes.sscc text unique(nullable, 18-stellig numerisch; Dok 30 §7) — gesetzt sobald GS1 aktiv.app_settings-Keys (Dok 30 §6.1):gs1_sscc_enabled(Feature-Flag),gs1_company_prefix(GCP von GS1 Schweiz),gs1_extension_digit(0–9, Default 0).- Der UUID
boxes.idbleibt der interne Primärschlüssel und QR-Inhalt — immer vorhanden, auch ohne GS1. Der SSCC ist die externe standardisierte Kennung.
12.3 Aufbau & Generierung
SSCC (18-stellig) = Erweiterungsziffer (1) + GCP (7–10) + serialisierte Nummer (auffüllend) + Prüfziffer (1). Beispiel mit 7-stelliger GCP: 3 7612345 000000002 3.
- Serien-Vergabe: dedizierte Postgres-Sequenz
sscc_serial_seq(monoton, kein Recycling). Funktionfn_next_sscc()(SECURITY DEFINER) baut Erweiterungsziffer + GCP + linksbündig 0-gepufferte Seriennummer + Mod-10-Prüfziffer (GS1-Standard: Gewichte 3,1,3,1… von rechts;check = (10 − (summe mod 10)) mod 10). - No-Reuse (GS1-Regel): ein vergebener SSCC wird nie wiederverwendet (Sequenz nur vorwärts; bei Storno/Neuanlage neuer SSCC).
- Vergabezeitpunkt: beim Materialisieren der Box (§4.1), falls
gs1_sscc_enabled; sonst bleibtssccNULL.
12.4 Transportetikette
Bei aktivem SSCC trägt die Box-Etikette (§4.1.4) zusätzlich/alternativ zum QR einen GS1-128-Barcode mit Application Identifier (00) = SSCC (alternativ GS1 DataMatrix), plus menschenlesbaren SSCC. Jede Box = eine eigene Transportetikette mit eigenem SSCC. Ohne Aktivierung bleibt es beim QR (= UUID).
12.5 Aktivierung (Geschäftsentscheid)
✅ Entscheid 2026-06-28 (#22): GS1-Schweiz-Mitgliedschaft wird beschafft → SSCC wird aktiviert. Sobald die GCP (Global Company Prefix) vorliegt: app_settings.gs1_company_prefix setzen + gs1_sscc_enabled=true → Boxen erhalten ab Materialisierung einen SSCC, Etiketten tragen den GS1-128-Barcode. Bis dahin läuft alles über UUID/QR (kein Schema-Umbau). Beschaffung der Mitgliedschaft/GCP: gemeinsam (Marcel + Claude).