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 mit id (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 mit next_departure („próxima salida"), Abfahrt/Ankunft, Kapazität, Status.
  • recipients — Empfänger in DR (Name, Cédula/Pass-Ref, Adresse/Zone, Telefon); referenziert optional einen contacts-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_refunds ist 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, PK id uuid default gen_random_uuid(), Audit-Spalten created_at/updated_at timestamptz (via Trigger), plus created_by uuid references app_users(id) (K-19; nicht contacts). Stabile Codes sind TEXT-Enums in Lookup-Tabellen (NIE in Logik verdrahtet), nicht Postgres-enum. Geldbeträge numeric(12,2). Alle Status/Typen referenzieren Lookups per FK auf code.

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#phaselabel_es (Website)label_delabel_en
EMPTY_DELIVERED1CHLlevamos (caja vacía entregada)Leerbox geliefertEmpty box delivered
PACKED2CHEmbalado / listoGepacktPacked
PICKED_UP3CHRecogemosAbgeholtPicked up
CH_DEPOT4CHAlmacenamos en SuizaCH-Depot/LagerIn CH warehouse
CONTAINER_LOADED5OCEANDespachamos (en contenedor)Container verladenLoaded in container
SHIPPED6OCEANZarpó / embarcadoVerschifftShipped
IN_TRANSIT7OCEANEn tránsito marítimoAuf SeeIn transit
DR_CUSTOMS8OCEANAduanasDR-ZollDR customs
DR_DEPOT9RDAlmacén RDDR-LagerDR warehouse
DELIVERED10RDDistribuido y entregadoZugestelltDelivered

DR_DEPOT (Entscheid 2026-06-28): eigener DR-Lager-Schritt zwischen Zoll und Zustellung (sort_order 9; DELIVERED rückt auf 10, bleibt is_terminal). Damit sind es 10 Tracking-Schritte. (Eine RD-Nachwiegung findet nicht stattboxes.weight_rd_kg bleibt 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=Verloren
  • shipment_statuses: DRAFT=Entwurf, PACKING=In Verpackung, READY=Versandbereit, IN_CONTAINER=Im Container, SHIPPED=Verschifft, ARRIVED=Angekommen, DELIVERED=Zugestellt, CLOSED=Abgeschlossen
  • container_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.

  • containerscode ('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.)
  • ordersorder_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).
  • shipmentsshipment_no, order_id, recipient_id, container_id (null bis verladen), status (→ shipment_statuses); plus 3-Fahrer-FKs (pickup_/transport_/delivery_driver_id, G5) und sender_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: aus box_products abgeleitet), Verknüpfung Auftrag/Sendung/Container; optional sscc (§12).
  • tracking_events — append-only Historie (kein UPDATE/DELETE): box_id, status, occurred_at, actor_id (erfassender Login), source (manual/qr_scan/…), optional geo_*/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_refunds ist dort konsolidiert (K-13); deposit_orders.box_id (1 returnable Box ⇒ ≤1 offener Depot-Auftrag, 1:1) ist nativ, kein ALTER mehr. Hier nur der fachliche Auszug:

  • deposit_refunds schließt jeden Depot-Auftrag genau einmal ab (unique(deposit_order_id)) mit kinddeposit_refund_kinds:
    • RETURNED — Box zurück, Depot rückerstattet (Cash-Ausgang in Höhe amount_chf, Zahlweise via payment_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_chf derselben deposit_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.

TabelleADMINBUCHHALTUNGOPERATIONSFAHRERAFFILIATEREADONLY
containersALLALLALLSELECTSELECT
ordersALLALLSELECT, UPDATE(status)SELECTSELECT
shipmentsALLALLALLSELECT, UPDATE(status)SELECT
boxesALLALLALLSELECT, UPDATE(condition,current_status via fn)SELECT
tracking_eventsINSERT+SELECTINSERT+SELECTINSERT+SELECTINSERT+SELECTSELECT
recipientsALLALLSELECT, INSERTSELECTSELECT(masked)
deposit_ordersALLALLINSERT, SELECTSELECTSELECT
deposit_paymentsALLALLINSERT, SELECTSELECT
deposit_refundsALLALLINSERT, SELECTSELECT
Lookups (tracking_*, *_statuses, box_conditions)ALLSELECTSELECTSELECTSELECTSELECT

Wichtige Policy-Details:

  • tracking_events: kein UPDATE, kein DELETE für irgendeine Rolle (Revisionssicherheit, OR 957). Policy nur for 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_status darf nicht frei per UPDATE gesetzt werden, sondern nur über die SECURITY-DEFINER-Funktion fn_record_tracking_event() (siehe §4.2), die Event + Statuswechsel atomar macht. Direktes UPDATE auf current_status wird per Spalten-Policy/Trigger blockiert.

  • recipients: readonly sieht Cédula/Telefon maskiert (View v_recipients_masked), Klartext nur admin/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 (CRU für ADMIN, R für alle übrigen Rollen) — enable RLS + Policy pro Tabelle in derselben Migration nicht vergessen.


4. Kern-Workflows

4.1 Auftrag → Boxen materialisieren

  1. Offerte wird accepted (Modul 4) → erzeugt order (Status OPEN, total_chf gespiegelt, referral_code_id durchgereicht).
  2. Office/Ops öffnet Auftrag, wählt pro Offertenposition Anzahl + Box-Typ → System legt N boxes an (je eigene UUID + box_no, current_status = EMPTY_DELIVERED, is_returnable aus box_products.is_returnable des gewählten Typs übernommen — F-22).
  3. Für jede returnable Box (is_returnable, z. B. Fässer) wird automatisch ein deposit_order mit box_id und deposit_total_chf = qty × box_products.default_deposit_chf erzeugt (Status offen).
  4. Etiketten/QR werden generiert (QR-Inhalt = box.id als URL caja.dominicanoexpress/b/{uuid}), Batch-Druck als PDF.
  5. Edge: Stornierte Offerte/Auftrag → Boxen dürfen nur gelöscht werden, solange current_status = EMPTY_DELIVERED und kein Depot bezahlt; sonst nur „Auftrag stornieren" mit Begründung (Boxen bleiben für Audit, order.status = CANCELLED).
  6. Edge: Box-Typ nachträglich geändert → erlaubt nur solange noch kein PACKED-Event; danach neue Box anlegen, alte als DAMAGED/Storno markieren.

4.2 Tracking-Status setzen (Einzel-Box, der häufigste Vorgang)

  1. Feldteam scannt Box-QR (oder öffnet Box-Detail) → sieht current_status + nächsten Soll-Schritt.
  2. 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).
  3. 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 Event source='manual' + Notiz).
  4. Funktion schreibt tracking_events-Zeile und setzt boxes.current_status atomar (eine Transaktion). Bei DELIVERED zusätzlich boxes.delivered_at = occurred_at.
  5. Bei Erfolg: Toast „Caja BX-… → Aduanas" + optimistische UI-Aktualisierung.
  6. Edge — Idempotenz: kommt dasselbe client_event_id aus der Offline-Queue zweimal an → Unique-Index verhindert Duplikat, Funktion gibt das bestehende Event zurück (kein Fehler).
  7. Edge — Konflikt: Box ist serverseitig schon weiter als der offline erfasste Schritt → Event wird trotzdem als historischer Eintrag gespeichert, current_status aber 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

  1. Office gruppiert Boxen eines Auftrags zu shipment (1 Empfänger pro Sendung), wählt/erstellt recipient.
  2. Container auswählen (Status OPEN) → Bulk-Aktion „X Boxen auf Container CNT-2026-07": setzt boxes.container_id + shipments.container_id + Bulk-Tracking-Event CONTAINER_LOADED für alle Boxen (eine fn_bulk_track).
  3. Kapazitätsprüfung: v_container_load.boxes_loadedcapacity_boxes; Überbuchung → Warnung (nicht hart blockiert, Ops entscheidet).
  4. Container schliessen (CLOSED) → keine neuen Boxen mehr; departed_at setzen → Bulk-Event SHIPPED für alle.
  5. Edge: Box nachträglich aus geschlossenem Container nehmen (verpasst/beschädigt) → nur admin/ops, erzeugt Storno-Event + setzt container_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

  1. Container ARRIVED (Ist-Ankunft) → optional Bulk-Event IN_TRANSIT→ bei Hafenankunft.
  2. Zoll: Container CLEARED → Bulk-Event DR_CUSTOMS für alle Boxen.
  3. Fahrer/DR-Team stellt Box für Box zu: scannt QR am Ziel → DELIVERED (mit Foto + optional GPS + Empfänger-Bestätigung). Setzt box.delivered_at.
  4. Wenn alle Boxen einer Sendung DELIVEREDv_shipment_progress.all_delivered = true → Sendung DELIVERED; wenn alle Sendungen des Auftrags zugestellt → order.status = DELIVERED.
  5. Edge: Empfänger nicht angetroffen → kein DELIVERED, sondern Notiz-Event (source='manual', Status bleibt DR_CUSTOMS), Wiederzustellung geplant.

4.5 Depot-Lifecycle: Rückzahlung oder Verfall

  1. Depot bezahlt (beim Auftrag/Anzahlung): deposit_payment erfasst → erzeugt automatisch genau einen movements-Eintrag (DEPOSIT_CASH Abono, Kanal nach payment_methods.channel), source_type='deposit_payment', upsert auf source_id (siehe §6). F-14: die Abono-Bewegung wird per payment_links mit der Soll-Bewegung des deposit_order (AP-3, deposit_orders.movement_id) verknüpft — Zuordnung über deposit_payments.deposit_order_id, nicht per reference-Match; so finden Soll und Abono in v_anzahlungen sicher zusammen. Box-Depot Status → PAID/PARTIAL.
  2. Box kommt zurück (Fass returnable): Ops erfasst deposit_refund mit kind='RETURNED', amount_chf = bezahlter Depotbetrag, Zahlweise → erzeugt automatisch movements-Ausgang (source_type='deposit_refund'). Box condition ggf. auf GOOD/WORN aktualisiert, is_returnable bleibt (Box wieder im Pool).
  3. Box kommt nicht zurück / Frist abgelaufen: deposit_refund mit kind='FORFEITED', amount_chf=0. Kein Cash-Ausgang; stattdessen wird das einbehaltene Depot zur Einnahme umqualifiziert (Buchung INCOME, siehe §6 Edge — 🔲 zu bestätigen ob als Ertrag oder neutral). Box condition='LOST'.
  4. Teilrückerstattung (Box beschädigt): kind='PARTIAL', amount_chf < bezahlt → Cash-Ausgang in Höhe amount_chf, Differenz analog Verfall.
  5. Edge: Einweg-Box (is_returnable=false, z. B. Karton-Caja) hat gar kein Depot → kein deposit_order, kein Refund-Schritt.
  6. 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 → erzeugt order (quote_id, total_chf, referral_code_id). Auftrag ist read-only bzgl. Beträgen.
  • Modul 1 (CRM): orders.customer_id, recipients.contact_idcontacts (Golden Record). tracking_events.actor_id referenziert 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_id zieht sich Auftrag → Sendung durch. F-13/F-04: Die Provision entsteht (commission_entries INSERT pending) jedoch beim Übergang invoices.status → PAID (Ledger-Trigger, Dok 30 §2.7), nicht bei DELIVERED. DELIVERED ist die fachliche Freigabe-Voraussetzung für commission_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 Eventmovement_type (Code)KanalBetragsource_type / source_id
Depot-Zahlung erfasst (deposit_payment)DEPOSIT_CASHnach 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 Zahlweiseamount_chf (Ausgang)deposit_refund / deposit_refunds.id
Depot verfallen (kind=FORFEITED)INCOME (einbehaltenes Depot → Ertrag) 🔲– (keine Kassenbewegung, nur Umqualifizierung)0 Cash / Ertragsbuchungdeposit_refund / deposit_refunds.id
  • Doppelbuchungs-Schutz: movements hat Unique-Constraint auf (source_type, source_id); alle Auto-Postings via upsert — eine Depotzahlung = genau ein movement, Korrektur überschreibt, dupliziert nicht. Aus Quellen erzeugte movements dü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 bei invoices → PAID, F-13).
  • container.status → CLOSED ⇒ Bulk SHIPPED; ⇒ optional Kunden-Notification „tu envío zarpó".
  • deposit_payment insert/update ⇒ Auto-Posting (oben).

7. Validierungen & Edge-Cases

  • UUID/QR-Eindeutigkeit: boxes.id ist PK (global eindeutig = QR-Inhalt). box_no zusä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_status wird 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_loaded aus View, nicht gespeichert.
  • Depot-Refund deckeln: deposit_refunds.amount_chf ≤ Σ deposit_payments.amount_chf der gleichen deposit_order; sonst Validierungsfehler. Pro deposit_order genau ein Refund-Abschluss (unique(deposit_order_id)).
  • Einweg vs. returnable: is_returnable=false ⇒ kein Depot, kein Refund-Schritt, taucht nicht in v_open_box_deposits auf.
  • Empfänger-Pflicht: Sendung kann READY/IN_CONTAINER nur erreichen, wenn recipient_id gesetzt (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_DELIVERED oder bezahltem Depot werden nicht gelöscht (Audit), nur order.status=CANCELLED + Notiz.
  • Geo/Foto optional: kein harter Zwang (Feldteam unterwegs, Akku/Netz), aber bei DELIVERED ist Foto empfohlen (UI-Hinweis, nicht blockierend — 🔲 ob Pflicht).
  • Zeitzonen: occurred_at immer timestamptz (UTC gespeichert), Anzeige in CH- bzw. DR-Zeit je Phase.

8. Compliance-/Sicherheits-Hinweise

  • OR 957 / GeBüV (Revisionssicherheit): tracking_events und alle Auto-Postings ins movements-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 zentralen audit_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 stammende movements sind 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ür admin/office/ops; readonly sieht 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 (Schritt DR_CUSTOMS); Herkunft aus OCR-Scan (Modul 2), Aufbewahrung nur so lange wie für Zoll/Buchhaltung nötig.
  • Zugriff Feldteam: driver darf 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 als FORFEITED vorgeschlagen; Verfall ist finanziell neutral (keine Ertragsbuchung, AP-6).
  • DR-Lager (2026-06-28): eigener Status DR_DEPOT zwischen DR_CUSTOMS und DELIVERED (§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_boxes nur 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):

BelegInhaltDatenquelle
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ätv_customs_manifest (Container → Boxen → Sendung → Snapshot/Recipient)
Zoll-RechnungRechnung in Zoll-Form (Empfänger, Positionen, Werte)invoices + bill_to_snapshot + Boxen
Sendungslisteje Sendung: statusabhängiges Datum (§10.4), Status, Absender+Adresse (Snapshot), Empfänger+Adresse, 3 Fahrernamenshipments + Snapshots + Fahrer-FK
Lieferbeleg/EinzelrechnungBox-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-Nachwiegungboxes.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_methodsignature | deposit | self_declared; mindestens eine Nachweisform ist Pflicht:

  • signature — persönlich mit Unterschrift: tracking_events.signer_name + Unterschrift (Touch-Canvas → Bild) als storage_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_idapp_users):

  • Ermöglicht die im RBAC zugesagte Fahrer-Sicht-Einschränkung (BR-05): RLS-Policy FAHRER auf shipments/boxes/tracking_events filtert 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 immerkein 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-SchrittKanalEmpfängerAuslöser
PICKED_UPWhatsAppbeide (CH-Absender + Empfänger-DR)fn_record_tracking_event
CONTAINER_LOADEDWhatsAppbeideBulk-Verladung (§4.3)
SHIPPEDWhatsApp + MailbeideContainer CLOSED/DEPARTED
DR_CUSTOMSWhatsAppbeideContainer CLEARED
DR_DEPOTWhatsAppbeideDR-Lager-Eingang
DELIVEREDWhatsApp + MailbeideDELIVERED-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 IST AddDays(1).AddMilliseconds(-1)); gilt auch für entry_date (Finanzen). Verhindert, dass Sendungen des letzten Tags herausfallen.
  • Sortierung (behebt IST-Bug BR-37/44): Spaltensortierung ist funktional (server-seitig order by), Default created_at desc; vom Nutzer gewählte Sortierung wird angewandt (die IST-Hartüberschreibung auf Id desc wird nicht übernommen).

11.8 Report-Export-Modi & Belegkopf (BR-40/41) + Absender-Doktyp im Manifest (BR-48)

Beleg (§10.1)printpdfexcel
Lieferbeleg/EinzelrechnungPDF inlinePDF Download
Zoll-RechnungPDF inlinePDF Download
SendungslistePDF inlinePDF DownloadExcel Download
Aduana-ManifestPDF Downloadimmer Excel Download

printContent-Disposition: inline; pdf/excelattachment. 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.id bleibt 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). Funktion fn_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 bleibt sscc NULL.

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).