Przejdź do treści

Edge cases — wszystkie sytuacje "poza happy path"

Dokument zbiera nieoczywiste scenariusze, znane bugi i scenariusze recovery.

Wygaśnięcie (EXPIRED)

sequenceDiagram
    participant CR as Cron worker<br/>(TODO)
    participant DB as Postgres
    participant MQ as RabbitMQ

    Note over CR: co N minut:
    CR->>DB: SELECT shipments<br/>WHERE status='DEPOSITED'<br/>AND expiresAt < NOW
    CR->>DB: UPDATE status='EXPIRED'
    CR->>MQ: publish 'shipment.expired'
    Note over CR,DB: ⚠ Locker zostaje OCCUPIED!<br/>Admin musi ręcznie wyjąć paczkę<br/>i zwolnić skrytkę z panelu

Nie zaimplementowane

Cron worker dla expiration jeszcze nie istnieje. Model Shipment.expiresAt jest ustawiany przy create (NOW + 7 dni), ale żaden worker nie flipuje statusu. TODO: dodać ExpirationWorker co godzinę.

Anulowanie przez admina

Admin w panelu może anulować shipment w dowolnym non-terminal state:

stateDiagram-v2
    [*] --> CANCEL: admin tap "Anuluj"
    CANCEL --> verify_status
    verify_status --> reject: status terminal<br/>(PICKED_UP, CANCELLED, EXPIRED)
    verify_status --> proceed: CREATED / ASSIGNED / DEPOSITED
    proceed --> update_shipment: status=CANCELLED, cancelledAt=NOW
    update_shipment --> check_locker
    check_locker --> free_reserved: locker RESERVED<br/>→ status=FREE
    check_locker --> mark_broken: locker OCCUPIED z paczką<br/>→ status=BROKEN<br/>(admin musi wyjąć)
    check_locker --> noop: brak lockera (CREATED bez assign)
    free_reserved --> emit
    mark_broken --> emit
    noop --> emit
    emit --> [*]: publish shipment.cancelled

Re-assign przez admina

Admin może przepisać shipment z jednej maszyny/skrytki do innej:

POST /shipments/:id/assign-locker { machineId: 'new-uuid', lockerId: 'new-locker-uuid' }

  • Wymaga status IN (CREATED, ASSIGNED) (nie po DEPOSITED!)
  • Cancel poprzedniej rezerwacji (locker FREE)
  • Reserve nowej (locker RESERVED)
  • Update shipment + emit shipment.assigned

Brak komunikacji z masterem Modbus mid-cycle

sequenceDiagram
    participant K as Kiosk
    participant H as Modbus driver
    participant M as Master

    K->>H: hardware.open(N)
    H->>M: write MW100=N
    M-->>H: ACK
    H-->>K: opened event
    Note over K: phase=open, czeka close

    Note over H,M: firewall block / kabel padł ↓
    H->>M: poll door byte (co 250ms)
    H--xM: timeout 3x z rzędu
    H->>K: emit fault event<br/>(driver markBridgeDown)
    K->>K: _autoReportMachineFault('E07-DRIVER')<br/>phase=faultReported

    Note over K: belt-and-braces:<br/>UI-side fallback poll (every 2s)<br/>też próbuje isClosed() bezpośrednio
    Note over K,H: jeśli sieć wróci → fallback poll wykryje close

Locker oznaczony BROKEN — recovery

BROKEN ustawiany przez: - deposit/abandon-locker (RESERVED → BROKEN po fault podczas open) - pickup Zgłoś obsłudze (OCCUPIED → BROKEN, "sensor disagrees with user") - admin manual w panelu

stateDiagram-v2
    state BROKEN_lifecycle {
        [*] --> BROKEN: trigger (3 ścieżki)
        BROKEN --> admin_inspect: panel notyfikacja
        admin_inspect --> physical_check: technik sprawdza<br/>(czy locker pusty, czy zamek OK)
        physical_check --> reset_FREE: locker OK<br/>tap "Oznacz jako wolna"<br/>w panelu
        physical_check --> replace_part: zamek/sensor uszkodzony<br/>wymiana części<br/>potem reset_FREE
        reset_FREE --> [*]: locker dostępny<br/>dla deposit znów
    }

W panelu /dashboard/system/machines/{id} → grid lockerów → klik na BROKEN locker → modal "Oznacz jako FREE" / "Oznacz jako BROKEN".

Bug #5 — pickup "Zgłoś obsłudze" auto-completed

Problem: gdy recipient w pickup flow klika "Zgłoś obsłudze", obecny kod ZAWSZE zakłada że recipient wziął paczkę (sensor lies). Wywołuje _completeBackend → status PICKED_UP + locker BROKEN.

Scenariusz bugowy (raportowany przez operatora): 1. Recipient otwiera skrytkę 2. NIE wyjmuje paczki (np. zmienił zdanie) 3. Trzyma drzwi otwarte 20s+ 4. Watchdog odpala userConfirmClosed dialog 5. Recipient klika "Zgłoś obsłudze" 6. Backend marked PICKED_UP — ALE PACZKA JEST DALEJ W SKRYTCE

Plan fix (TODO):

sequenceDiagram
    actor R as Recipient
    participant K as Kiosk

    R->>K: tap "Zgłoś obsłudze"
    K->>R: Dialog "Czy odebrałeś paczkę?"<br/>[TAK / NIE]

    alt TAK
        Note over K: aktualne zachowanie<br/>markPickedUp + locker BROKEN
    end

    alt NIE
        Note over K: NOWE: nie wywołuj _completeBackend<br/>shipment ZOSTAJE DEPOSITED<br/>locker OCCUPIED<br/>fault report z reasonem
    end

Częściowo naprawione v1.1.20 (close event after fault no longer auto-completes), ale "Zgłoś obsłudze" path nadal autocompleted. Dialog dodać w następnej iteracji.

Bug znaleziony, naprawiony — historia

Wersja Bug Fix
v1.1.11 deposit/abandon-locker resetował DEPOSITED → CREATED bez warunku, depositedAt zostawał, locker stranded OCCUPIED Guard: refuse if status DEPOSITED, dodać depositedAt: null do reset, free OCCUPIED→FREE, nowy endpoint /deposit/cancel-after-deposit dla rollback
v1.1.12 RFID karta swipe → cyfry trzykrotnie wpadają do buffer'a (3 capture paths registered handler 2× lub 3×) → backend dostaje 30-char garbage UID Dedup po (timeStamp, physicalKey, logicalKey) w _recentSigs
v1.1.13 UID dedup window 5s blokował deliberate retry operatora Skrócone do 500ms + clearLastFired() po fail
v1.1.14 Multi-path PS bridge lag 1.3s vs keyboard → drugi fire mimo 500ms okna → double-push UserMenu Dedup window 500ms → 2000ms, defensive ModalRoute.isCurrent guard przed Navigator.push
v1.1.15 Po logout RfidCaptureService stan stale (_lastFiredUid + busy + cardTabActive) → pierwszy swipe ignorowany _resetAfterLogout() helper wywoływany po pop UserMenu
v1.1.16 Pierwszy swipe po świeżym start LoginScreen dropowany — tab listener nie firował dla initial state Explicit clearLastFired() + focus reclaim w addPostFrameCallback
v1.1.17 Tab-gate (_cardTabActive) sam w sobie zawodny (operator obejścia przez PIN→KARTA swap) Cofnięte całkowicie — capture always-on, PinPad jest tap-only więc bezpieczne
v1.1.18 Po OTA restart Flutter nie miał focus → HID keyboard emulation nie docierała do okna → bridge hook nic nie widzi C++ runner Win32Window::Show() dodaje AttachThreadInput + SetForegroundWindow trick
v1.1.19 Brak frontend awareness że bridge nie żyje BridgeHealthMonitor w Dart: tailuje rfid-bridge.log, auto-restart przez Process.start, banner w login + auto-fault report
v1.1.20 _onCloseDetected wywoływał onClosed() zawsze, sensor flicker mid-fault → silent markDeposited mimo czerwonego screena Guard: if (_phase != _Phase.open) return
v1.1.21 "Spróbuj ponownie" w fault dialog próbował re-open drzwi które już są otwarte (gdy fault to "drzwi nie zamknięte") Track _phasePriorToFault, ukryj retry gdy fault z _Phase.open / userConfirm*, zmień tytuł na "Drzwi nie zostały zamknięte"

Inactivity timeout — co zostaje w stanie

flowchart TD
    A[Operator zaczyna sesję] --> B{Gdzie aktualnie jest?}
    B -- Login/Menu --> C[InactivityGuard fires 5s + 10s countdown]
    B -- LockerOpenScreen --> D[Hold token blokuje InactivityGuard]
    B -- ConfirmDialog --> D

    C --> E[Auto pop_until login]
    E --> F{Status paczki?}
    F -- przed assign --> G[shipment CREATED ✓ czyste]
    F -- po assign przed open --> H[⚠ shipment ASSIGNED + locker RESERVED]
    F -- w LockerOpenScreen --> I[Niemożliwe — hold blokuje]

    H --> J[TODO: cleanup cron flipuje<br/>ASSIGNED → CREATED po N minutach]

Open: cleanup orphan ASSIGNED

Brak cron'a który czyści shipmenty stuck w ASSIGNED gdy operator porzuca session po deposit/begin-existing ale przed otwarciem drzwi. Admin musi ręcznie zwolnić w panelu lub kurier próbuje znów.

Sieć offline — SyncService

Etap 4.5 wprowadził lokalny outbox dla operacji kiosku gdy sieć nie działa:

sequenceDiagram
    participant K as Kiosk
    participant L as Local DB<br/>(sqflite)
    participant B as Backend

    Note over K: sieć dead, deposit happy path
    K->>L: outbox INSERT 'deposit.completed'<br/>{ shipmentId, depositedAt }
    K->>L: cached_shipments UPDATE<br/>status=DEPOSITED (lokalne)
    Note over K: UI: 🟢 success<br/>(user widzi sukces natychmiast)

    Note over K,B: sieć wraca ↓
    K->>B: POST /machines/me/replay<br/>{ events: [outbox queue] }
    B->>B: idempotent: każdy event<br/>WHERE status='ASSIGNED' (no-op if already DEPOSITED)
    B-->>K: 200 OK<br/>+ ev rows oznaczone CONSUMED
    K->>L: outbox DELETE consumed
    Note over B: panel widzi DEPOSITED<br/>z opóźnieniem (max 2 min)

Działa też dla pickup. Conflict resolution: jeśli backend zwraca 409 (np. shipment już PICKED_UP gdzieś indziej), kiosk dropuje z outbox + surfaces w "offline conflicts panel" (TODO UI).

Cleanup zasobów po session

flowchart LR
    A[Pop LockerOpenScreen] --> B[dispose:<br/>cancel ALL timers]
    B --> C[release hold token]
    C --> D[remove event subscription]

    A2[Logout/Inactivity bounce] --> E[_resetAfterLogout]
    E --> F[_rfid.clearLastFired]
    F --> G[_rfid.setBusy false]
    G --> H[_maybeReclaimFocus addPostFrameCallback]

Bridge health — trzy linie obrony

flowchart LR
    A[Bridge hid-reader.ps1] -.->|crash| B
    B[Layer 1: Task Scheduler<br/>SmartBoxHidReader<br/>RestartCount=9999] -->|fail-on-success-exit| C
    C[Layer 2: Agent watchdog<br/>smartbox-kiosk-agent.ps1<br/>co 30s check] -->|process not found / log stale >120s| D
    D[Layer 3: Flutter BridgeHealthMonitor<br/>co 30s tail rfid-bridge.log<br/>auto-restart max 3×] -->|all fail| E
    E[Fault report do panelu<br/>HID_BRIDGE_DOWN + banner UI]

Jeśli żadna z 3 warstw nie odzyska bridge'a → operator widzi czerwony banner "Czytnik kart nie odpowiada" + paczka na panelu w System → Zgłoszenia.

Następne kroki

  • Bug #5 fix — dialog "Czy odebrałeś paczkę?" w pickup fault flow
  • Etap 7 — SMTP emails + external webhooks dla wszystkich shipment events
  • Expiration cronExpirationWorker co godzinę flipuje DEPOSITED → EXPIRED
  • Orphan ASSIGNED cron — cleanup wiszących rezerwacji po N minutach