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 cron —
ExpirationWorkerco godzinę flipuje DEPOSITED → EXPIRED - Orphan ASSIGNED cron — cleanup wiszących rezerwacji po N minutach