diff --git a/FREEZE_2.0_WAREHOUSE_MONOLISTA.md b/FREEZE_2.0_WAREHOUSE_MONOLISTA.md new file mode 100644 index 0000000..03fc621 --- /dev/null +++ b/FREEZE_2.0_WAREHOUSE_MONOLISTA.md @@ -0,0 +1,70 @@ +# Warehouse monolista - freeze 2.0 + +Data: 02/10/2026. +Titolo release: warehouse monolista. +Tag Git: `warehouse-monolista-2.0`. + +## Scopo e contenuto + +Punto di ripristino prima dello sviluppo del prelievo guidato di due liste. +La versione conserva la prenotazione singola e i cicli F1/F2 separati. +Include i sorgenti e gli script SQL gia' versionati, oltre alla correzione +del documento fisso F1/F2, ai suoi test e alla relativa documentazione. + +La denominazione 2.0 identifica questo freeze, non una rinumerazione di tutti +i moduli. Il barcode conserva la versione visibile 1.0.27; repository 1.0.14, +servizio 1.0.25. Il registro delle versioni resta `version_info.py`. + +La specifica `specifica_prelievo_due_pickinglist.md` descrive lo sviluppo +successivo, NON una funzionalita' gia' implementata in questa release. +Comprende il vincolo di due liste nella stessa area operativa (5 oppure 6). + +## Verifiche del freeze + +Gli 11 test di `tests/test_barcode_document_binding.py` sono passati con: + +```bat +python -B -m unittest discover -s tests -v +``` + +Si tratta di test automatici con repository simulato, non di un collaudo +completo della GUI o del terminalino fisico. Non eseguono movimenti sul DB. + +## Database e file locali + +La correzione F1/F2 non richiede nuovi oggetti SQL. Le patch gia' necessarie +alla versione precedente restano nel repository, inclusa +`apply_online_python_wms_full_patch.sql`. Questo freeze non esegue patch SQL +e non costituisce un backup dei database Mediseawall e SAM. + +Gli script in `sql_tests/` servono soltanto alla preparazione locale dei test: +NON lanciarli in produzione. Il ripristino delle sole testate non annulla +eventuali movimenti successivi; leggere `sql_tests/README.md`. + +Configurazione locale `db_connection.json`, log, cache, impostazioni IDE, +registrazioni e vecchi ZIP locali non vengono aggiunti a questo commit. +Conservare separatamente configurazione e backup coerenti dei database +prima di modificare schema o dati per il futuro prelievo abbinato. + +## Recuperare esattamente il freeze + +Chiudere l'applicazione. Nella cartella del repository, con working tree pulito: + +```bat +git fetch origin --tags +git switch --detach warehouse-monolista-2.0 +git describe --tags --exact-match +python -m pip install -r requirements.txt +``` + +Se Git segnala modifiche locali, fermarsi e salvarle prima: non forzare la +sovrascrittura della configurazione o dei sorgenti. Per una prima installazione: + +```bat +git clone --branch warehouse-monolista-2.0 --single-branch https://gitea.alessandrobonvicini.it/administrator/ware_house.git C:\flywms-monolista-2.0 +``` + +Il checkout di un tag e' intenzionalmente in detached HEAD: identifica una +versione fissa. `git pull origin main` non e' il comando per restare su questo +freeze. Il ritorno al codice precedente non ripristina il database: eventuali +future patch richiederanno il rispettivo rollback compatibile o un backup. diff --git a/barcode_repository.py b/barcode_repository.py index 0f33b75..cf0cfa6 100644 --- a/barcode_repository.py +++ b/barcode_repository.py @@ -29,6 +29,7 @@ SELECT TOP (1) FROM dbo.py_ViewPackingListPrelievo AS pl WHERE Ordinamento > 0 AND IDStato = :id_stato + AND (:documento IS NULL OR pl.Documento = TRY_CONVERT(int, :documento)) AND NOT EXISTS ( SELECT 1 FROM dbo.py_BarcodePickingListSkip AS s @@ -39,7 +40,7 @@ WHERE Ordinamento > 0 AND s.IDStato = pl.IDStato AND s.Risolto = 0 ) -ORDER BY OrdinePrelievoGruppo, Ordinamento, Pallet; +ORDER BY OrdinePrelievoGruppo, Ordinamento, Pallet, Documento; """ SQL_PICKING_BY_PALLET = """ @@ -55,6 +56,7 @@ SELECT TOP (1) IDStato FROM dbo.py_ViewPackingListPrelievo WHERE Pallet COLLATE Latin1_General_CI_AS = :pallet COLLATE Latin1_General_CI_AS + AND (:documento IS NULL OR Documento = TRY_CONVERT(int, :documento)) ORDER BY OrdinePrelievoGruppo, Ordinamento, Pallet; """ @@ -222,6 +224,7 @@ SELECT TOP (1) COUNT(*) AS SkippedCount FROM dbo.py_BarcodePickingListSkip AS s WHERE s.IDStato = :id_stato + AND (:documento IS NULL OR s.Documento = :documento) AND s.Risolto = 0 AND EXISTS ( SELECT 1 @@ -229,11 +232,20 @@ WHERE s.IDStato = :id_stato WHERE CAST(pl.Documento AS varchar(50)) COLLATE Latin1_General_CI_AS = s.Documento COLLATE Latin1_General_CI_AS AND pl.IDStato = :id_stato + AND CAST(pl.Pallet AS varchar(50)) COLLATE Latin1_General_CI_AS = + s.Pallet COLLATE Latin1_General_CI_AS ) GROUP BY s.Documento ORDER BY MIN(s.DataOra); """ +SQL_PICKING_DOCUMENT_REMAINING = """ +SELECT COUNT(DISTINCT Pallet) AS RemainingCount +FROM dbo.py_XMag_ViewPackingListStorico +WHERE Documento = TRY_CONVERT(int, :documento) + AND Cella <> 9999; +""" + SQL_RELEASE_PICKING_DOCUMENT = """ SET NOCOUNT ON; DECLARE @RC int = 0; @@ -290,17 +302,21 @@ class BarcodeRepository: def __init__(self, db_client): self.db_client = db_client - async def fetch_next_picking(self, id_stato: int) -> dict[str, Any] | None: - """Return the next pallet proposed by the legacy F1/F2 queue logic.""" + async def fetch_next_picking(self, id_stato: int, documento: str | None = None) -> dict[str, Any] | None: + """Select a document initially, then fetch only within that document.""" - res = await self.db_client.query_json(SQL_NEXT_PICKING, {"id_stato": int(id_stato)}) + res = await self.db_client.query_json(SQL_NEXT_PICKING, { + "id_stato": int(id_stato), "documento": documento, + }) rows = _rows_to_dicts(res) return rows[0] if rows else None - async def fetch_picking_by_pallet(self, pallet: str) -> dict[str, Any] | None: + async def fetch_picking_by_pallet(self, pallet: str, documento: str | None = None) -> dict[str, Any] | None: """Return one picking row for the given pallet, if still present in the queue.""" - res = await self.db_client.query_json(SQL_PICKING_BY_PALLET, {"pallet": str(pallet or "").strip()}) + res = await self.db_client.query_json(SQL_PICKING_BY_PALLET, { + "pallet": str(pallet or "").strip(), "documento": documento, + }) rows = _rows_to_dicts(res) return rows[0] if rows else None @@ -376,13 +392,21 @@ class BarcodeRepository: ), ) - async def fetch_active_skipped_document(self, id_stato: int) -> dict[str, Any] | None: - """Return a reserved document whose remaining pallets were all skipped.""" + async def fetch_active_skipped_document(self, id_stato: int, documento: str | None = None) -> dict[str, Any] | None: + """Find skipped residuals, restricted to the active document when set.""" - res = await self.db_client.query_json(SQL_ACTIVE_SKIPPED_DOCUMENT, {"id_stato": int(id_stato)}) + res = await self.db_client.query_json(SQL_ACTIVE_SKIPPED_DOCUMENT, { + "id_stato": int(id_stato), "documento": documento, + }) rows = _rows_to_dicts(res) return rows[0] if rows else None + async def count_picking_document_remaining(self, documento: str) -> int: + """Check residuals independently of priority before declaring completion.""" + res = await self.db_client.query_json(SQL_PICKING_DOCUMENT_REMAINING, {"documento": documento}) + rows = _rows_to_dicts(res) + return int(rows[0]["RemainingCount"]) if rows else 0 + async def release_picking_document(self, *, documento: str, operator_id: int) -> int: """Release a reserved picking-list document without closing its residual rows.""" diff --git a/barcode_service.py b/barcode_service.py index ebcb5dd..a7eed7b 100644 --- a/barcode_service.py +++ b/barcode_service.py @@ -60,6 +60,8 @@ class BarcodeService: self.repository = repository self.operator_id = int(operator_id) self._current_priority_state = -1 + # Independent document identities survive confirmations, F1/F2 and pause. + self._priority_documents: dict[int, str] = {} self._state = BarcodeViewState() @property @@ -72,6 +74,7 @@ class BarcodeService: """Return the client to its neutral state.""" self._current_priority_state = -1 + self._priority_documents.clear() self._state = BarcodeViewState() return self._state @@ -121,6 +124,7 @@ class BarcodeService: if state.mode not in ("priority_high", "priority_low"): return self._state self._current_priority_state = 1 if state.mode == "priority_high" else 0 + self._priority_documents[self._current_priority_state] = state.document self._state = replace(state, scanned_pallet="", auto_advance_delay_ms=0) return self._state @@ -163,20 +167,25 @@ class BarcodeService: return BarcodeActionResult(True, self._state, self._state.status_text) async def start_priority_queue(self, id_stato: int) -> BarcodeActionResult: - """Load the next item of the selected legacy priority queue.""" + """Keep each F1/F2 cycle bound to its document until the cycle stops.""" - row = await self.repository.fetch_next_picking(id_stato) - self._current_priority_state = int(id_stato) + id_stato = int(id_stato) + active_document = self._priority_documents.get(id_stato) + row = await self.repository.fetch_next_picking(id_stato, documento=active_document) + self._current_priority_state = id_stato queue_label = "Alta priorita' (F1)" if int(id_stato) == 1 else "Bassa priorita' (F2)" if not row: - skipped_doc = await self.repository.fetch_active_skipped_document(int(id_stato)) + skipped_doc = await self.repository.fetch_active_skipped_document(id_stato, documento=active_document) if skipped_doc: documento = str(skipped_doc.get("Documento") or "").strip() skipped_count = int(skipped_doc.get("SkippedCount") or 0) - await self.repository.release_picking_document( + rc = await self.repository.release_picking_document( documento=documento, operator_id=self.operator_id, ) + if rc != 0: + raise RuntimeError(f"Impossibile sospendere PL {documento}: RC={rc}") + self._priority_documents.pop(id_stato, None) self._current_priority_state = -1 self._state = BarcodeViewState( mode="manual_unload", @@ -187,17 +196,36 @@ class BarcodeService: destination_readonly=False, ) return BarcodeActionResult(True, self._state) + status_text = "Pronto." + remaining = 0 + if active_document: + remaining = await self.repository.count_picking_document_remaining(active_document) + status_text = ( + f"PL {active_document}: {remaining} UDC residue, coda non disponibile. Verifica la prenotazione." + if remaining else f"PL {active_document} terminata. Premi F1 o F2 per iniziare." + ) + log_runtime_event("Barcode WMS", f"PICKING CYCLE STOP documento={active_document} priority={id_stato} remaining={remaining}") + # A changed reservation or visibility filter must not silently + # replace an unfinished document on the next press of F1/F2. + if not remaining: + self._priority_documents.pop(id_stato, None) self._current_priority_state = -1 self._state = BarcodeViewState( mode="manual_unload", queue_label=queue_label, - status_text="Pronto.", + status_text=status_text, status_color=self.RED, destination_barcode=self.NON_SCAFFALATA_BARCODE, destination_readonly=False, ) return BarcodeActionResult(True, self._state) + document = str(row.get("Documento") or "").strip() + if not document or (active_document is not None and document != active_document): + raise RuntimeError(f"Cambio inatteso documento picking: atteso={active_document}, ricevuto={document}") + self._priority_documents[id_stato] = document + if active_document is None: + log_runtime_event("Barcode WMS", f"PICKING CYCLE START documento={document} priority={id_stato}") customer = f"{row.get('CodNazione') or ''} - {row.get('NAZIONE') or ''}".strip(" -") source_location = self._display_location( cella=row.get("Cella"), @@ -289,7 +317,10 @@ class BarcodeService: picking_before_move = ( None if current_location - else await self.repository.fetch_picking_by_pallet(pallet) + else await self.repository.fetch_picking_by_pallet( + pallet, + documento=self._priority_documents.get(self._current_priority_state) if is_picking_unload else None, + ) ) if not current_location and not picking_before_move: if is_direct_load: @@ -651,7 +682,9 @@ class BarcodeService: and last_priority_state in (0, 1) ) picking_row = ( - await self.repository.fetch_picking_by_pallet(barcode_pallet) + await self.repository.fetch_picking_by_pallet( + barcode_pallet, documento=self._priority_documents.get(last_priority_state), + ) if is_priority_shipping_confirmation else None ) diff --git a/fix_barcode_documento_fisso_f1_f2.md b/fix_barcode_documento_fisso_f1_f2.md new file mode 100644 index 0000000..0077863 --- /dev/null +++ b/fix_barcode_documento_fisso_f1_f2.md @@ -0,0 +1,65 @@ +# Documento fisso durante il ciclo barcode F1/F2 + +## Difetto e requisito + +Prima della correzione ogni avanzamento interrogava tutte le UDC con IDStato +uguale alla priorita' richiesta. Per F2, che include tutte le liste non +prenotate, il documento poteva quindi cambiare senza intervento dell'operatore. + +Una volta proposta una lista, ogni successiva UDC del ciclo deve appartenere +a quel documento. F1 e F2 ricordano ciascuno la propria lista: F2 -> F1 -> F2 +deve riportare al documento inizialmente assegnato a F2, anche se cambia +l'ordine globale delle UDC disponibili. + +## Implementazione locale + +- BarcodeService conserva un documento per priorita', separato dallo stato + visuale temporaneo di conferma o pausa. +- La prima richiesta senza documento usa il criterio di ordinamento vigente. + Non viene introdotta una selezione per data del documento. +- Le successive richieste applicano il filtro Documento nelle query di + avanzamento, ricerca skip e ricerca della UDC per conferma del movimento. +- Un documento differente ricevuto dal repository causa un errore esplicito. +- La pausa per movimenti liberi e il passaggio tra F1/F2 conservano i documenti. +- Quando termina il documento, il ciclo si ferma e mostra il relativo messaggio. + Una successiva pressione esplicita di un tasto priorita' puo' avviare un nuovo + ciclo; la fine del precedente non attiva automaticamente un altro documento. +- Quando rimangono soltanto UDC saltate, viene gestita la sospensione della + lista corrente senza passare a un'altra lista disponibile nella coda. +- Se cambiano prenotazione o visibilita' e ci sono ancora residui nello storico, + compare un messaggio di verifica e il documento viene mantenuto in memoria. +- Un errore della query non cancella il documento memorizzato. + +Gli skip sono conteggiati soltanto se la relativa UDC e' ancora residua. +Le movimentazioni libere mantengono la loro logica; il filtro Documento e' +applicato soltanto alle ricerche nel contesto del picking. + +## Limiti e installazione + +Questa correzione riguarda la sessione del terminalino. La memoria dei documenti +viene azzerata con reset del servizio o riavvio del programma. Non introduce +ancora due prenotazioni persistenti o un percorso automatico comune per colonna: +questi appartengono alla successiva modifica funzionale da specificare. + +Non serve una patch SQL: cambiano le SELECT eseguite dal codice Python, non gli +oggetti del database. Versioni: barcode_client 1.0.27 (titolo visibile), +barcode_repository 1.0.14, barcode_service 1.0.25. + +## Verifiche + +Esecuzione senza GUI e senza movimentazioni reali: + +```bat +python -m unittest discover -s tests -p test_barcode_document_binding.py -v +``` + +Gli 11 test coprono avanzamento con documenti intercalati, passaggi ripetuti +F2/F1/F2, pausa con movimento libero, salto UDC, sospensione con soli skip, +fine lista, cambio prenotazione, errore query, rifiuto di documento inatteso, +isolamento della conferma e passaggio del parametro al repository. + +Ulteriori 5 verifiche SELECT su SQL Server, con righe sintetiche in CTE, +hanno controllato selezione iniziale, filtro documento, assenza di fallback, +skip effettivamente residui e isolamento della conferma. Nessun dato reale +e' stato aggiornato per questi test. Il collaudo fisico sul terminalino resta +da effettuare. diff --git a/specifica_prelievo_due_pickinglist.md b/specifica_prelievo_due_pickinglist.md new file mode 100644 index 0000000..10bf497 --- /dev/null +++ b/specifica_prelievo_due_pickinglist.md @@ -0,0 +1,439 @@ +# Prelievo guidato di due picking list sul terminalino + +Data: 02/10/2026. Revisione: 0.3. + +**Stato: specifica da rileggere e concordare prima dell'implementazione.** +La funzione di prelievo abbinato descritta qui non e' ancora implementata. +I punti indicati come aperti o proposti non sono decisioni gia' approvate. + +## 1. Obiettivo operativo + +Consentire al responsabile di prenotare una oppure due picking list dal +programma desktop. Quando sono due, il terminalino deve guidare un unico +percorso di prelievo che considera le UDC residue di entrambe. + +L'operatore deve percorrere il magazzino seguendo l'ordine previsto, senza +ritornare alle prime colonne perche' una lista non conteneva UDC in quelle +posizioni. Il sistema sceglie di volta in volta il documento della prossima +UDC; l'operatore riconosce il cambio dal numero del documento e dal colore. + +Il prelievo abbinato riguarda un operatore che lavora due documenti nello +stesso giro. La gestione di piu' operatori contemporanei richiede ulteriori +decisioni e non e' definita da questa specifica. + +## 2. Termini usati + +| Termine | Significato | +|---|---| +| Lista 1 / F1 | Prima lista assegnata alla coppia, con identita' stabile | +| Lista 2 / F2 | Seconda lista assegnata alla coppia, con identita' stabile | +| Lista proposta | Documento della prossima UDC indicata dal sistema | +| Colonna o campata | Colonna di uno specifico scaffale e magazzino | +| Cella | Singola posizione dentro la colonna, a un determinato livello | +| Percorso comune | Ordine delle posizioni applicato alle UDC di entrambe le liste | +| UDC residua | UDC ancora da prelevare, non gia' nella locazione spedita | + +Area 5 e Area 6 sono i nomi operativi dei magazzini MDE5 e MDE6. +Non corrispondono necessariamente agli ID 5 e 6 della tabella legacy Aree. +La colonna 1 dello scaffale A e la colonna 1 dello scaffale B sono distinte. + +## 3. Situazione attuale e difetto da non reintrodurre + +La prenotazione Python attuale prevede un unico documento. Storicamente F1 +interroga la priorita' prenotata e F2 quella non prenotata. Quest'ultima puo' +contenere piu' documenti, quindi non identifica di per se' una seconda lista. + +E' stata introdotta nel codice locale una correzione che fissa il documento +del ciclo per ciascun tasto. Il passaggio F2 -> F1 -> F2 conserva il documento +precedentemente avviato con F2. Il dettaglio e' in +[fix_barcode_documento_fisso_f1_f2.md](fix_barcode_documento_fisso_f1_f2.md). + +La nuova funzione estende il modello: entrambi i documenti devono essere +esplicitamente assegnati alla coppia. Il cambio automatico sara' ammesso +soltanto fra questi due documenti e per una ragione di percorso. +Nessuna terza lista deve subentrare silenziosamente. + +## 4. Selezione e prenotazione sul desktop + +### R01. Una o due liste + +Gestione Picking List deve permettere di prenotare al massimo due liste per +il prelievo abbinato. Deve restare utilizzabile anche con una sola lista. +Il semplice clic per consultare il dettaglio non deve modificare implicitamente +una prenotazione: selezione per consultazione e assegnazione al giro devono +avere un comportamento chiaramente distinguibile. + +### R02. Identita' della coppia + +Il desktop deve rendere riconoscibile quale documento e' Lista 1/F1 e quale +e' Lista 2/F2. L'assegnazione non dipende dall'ordinamento momentaneo della +griglia o dalla posizione della prossima UDC. + +Il gesto per assegnare i ruoli e' ancora da definire: ad esempio due slot +espliciti, oppure prima e seconda scelta rese chiaramente visibili. + +### R02a. Le due liste devono appartenere alla stessa area + +Il prelievo abbinato e' consentito esclusivamente fra due liste della stessa +area operativa: entrambe Area 5/MDE5 oppure entrambe Area 6/MDE6. +Non e' consentito abbinare una lista dell'Area 5 a una dell'Area 6. +Il sistema deve rifiutare l'abbinamento con un messaggio comprensibile, +senza salvare una coppia parziale o alterare le prenotazioni preesistenti. +Il vincolo deve essere verificato anche lato servizio/database, non soltanto +mediante la disponibilita' dei pulsanti nell'interfaccia desktop. + +Prima dell'implementazione va definita la fonte autorevole dell'area della +lista, specialmente se contiene UDC non scaffalate o senza giacenza WMS. +La cella virtuale condivisa 1000 non dimostra da sola l'appartenenza all'area. +Non dedurre il magazzino dal numero documento o dagli ID legacy delle Aree. +Il trattamento delle liste internamente miste o con area indeterminata resta +da concordare; non considerarle implicitamente compatibili con entrambe. + +### R03. Un'unica sequenza personalizzata + +Con due liste selezionate, il comando Sequenza di prelievo riguarda entrambe. +L'editor deve presentare l'unione degli scaffali/gruppi presenti nelle due liste, +includendo quelli presenti soltanto in una di esse e il gruppo Non scaff. + +Non si devono eseguire due ordinamenti personalizzati incompatibili nello +stesso giro. La sequenza della coppia deve costituire l'unico riferimento. + +La scelta fra eventuali personalizzazioni gia' salvate sulle singole liste +resta aperta. Non e' ammessa una fusione silenziosa che produca un ordine +diverso da quello mostrato al responsabile. + +## 5. Percorsi delle aree: conservare il comportamento esistente + +### R04. Area 6 / MDE6 + +Conservare il percorso attuale: scaffali nell'ordine previsto, colonne in +ordine crescente all'interno dello scaffale e livelli dal pavimento verso +l'alto. Il documento [ordinamento.md](ordinamento.md) riporta per i dati +analizzati la sequenza interna a -> b -> c -> d -> e. + +La modifica non deve rinumerare Celle.Ordinamento ne' inventare nuovi percorsi +per celle eventualmente configurate con eccezioni. + +### R05. Area 5 / MDE5 + +Conservare l'ordinamento esistente delle celle. Nel default e' il percorso +storico rappresentato da Celle.Ordinamento, con serpentina fra scaffali. + +Esempio documentato del blocco 1A/2B: + +```text +1A.1 -> 2B.1 -> 2B.2 -> 1A.2 -> 1A.3 -> 2B.3 -> ... +``` + +All'interno delle campate analizzate l'ordine e' e -> d -> c -> b -> a, +quindi dall'alto verso il basso. E' confermato che anche la prima UDC +proposta deve rispettare questo verso: non partire dal pavimento in area 5. +Non sostituire questo percorso con un generico ordinamento numerico +scaffale/colonna/livello dell'area 6. + +### R06. Effetto della personalizzazione + +La personalizzazione mantiene il significato attuale: cambia la precedenza +dei gruppi/scaffali, mentre dentro ogni gruppo si conserva l'ordine delle celle. +Con due liste, la stessa scelta si applica all'intero contenuto della coppia. + +Per esempio, porre tutto 1A prima di tutto 2B elimina intenzionalmente +l'alternanza fra quei due scaffali, come avviene gia' nella personalizzazione +attuale. Non sarebbe corretto promettere contemporaneamente tale precedenza +e il mantenimento della serpentina globale. + +I gruppi omessi da una personalizzazione parziale devono essere accodati con +il criterio attuale, senza perdere le relative UDC. + +## 6. Scelta della prossima UDC + +### R07. Confronto continuo delle due liste + +A ogni passo confrontare le UDC ancora lavorabili di entrambe le liste e +scegliere quella che viene prima nel percorso comune. +Questa regola vale all'avvio, fra colonne consecutive, dopo un movimento e +quando uno dei documenti non ha UDC nella colonna corrente. + +Non si deve usare F1 come guida unica, cercando F2 soltanto nelle colonne +incontrate da F1: questo lascerebbe indietro le colonne presenti solo in F2. + +### R08. Il percorso prevale sull'identita' della lista + +Non esiste una precedenza fissa F1-prima-di-F2 nella stessa colonna. +Se nell'area 6 i livelli dal basso verso l'alto appartengono a F2, F1 e F2, +il prelievo deve seguire F2 -> F1 -> F2. + +La precedente ipotesi di esaurire tutte le UDC F1 della colonna e solo dopo +quelle F2 e' superata: la scelta si compie sulla posizione a ogni passo. + +### R09. Nessuna UDC persa e nessun documento estraneo + +Il contenuto lavorato e' l'unione delle UDC residue delle due liste. +Le UDC su piu' righe lotto devono essere considerate una sola volta come +unita' fisica di prelievo. Ogni proposta deve mantenere l'identita' del +documento a cui appartiene. + +Non introdurre la scelta automatica di una terza lista, neanche al termine +di una delle due. La gestione delle UDC saltate e' trattata al punto 10. + +## 7. Avvio neutro e tasti F1/F2 + +### R10. Avvio scelto dal sistema + +Quando il terminalino mostra Pronto per la coppia, il sistema deve avere gia' +individuato la prima proposta. L'operatore non decide arbitrariamente da quale +documento partire. + +Se la prima UDC appartiene alla Lista 2, F2 e' abilitato e F1 disabilitato; +se appartiene alla Lista 1, vale il contrario. Il blocco deve valere sia per +i pulsanti sullo schermo sia per i tasti fisici. + +### R11. Guida durante il giro + +Nel prelievo abbinato deve essere selezionabile soltanto la lista proposta +dal percorso. Il cambio automatico verso l'altro documento non richiede una +nuova scelta dell'operatore. + +La precedente modalita' manuale F1/F2 e il nuovo giro guidato non devono +essere confusi: nel giro guidato i due tasti identificano documenti stabili, +ma non autorizzano a scavalcare il percorso stabilito. + +### Chiarimento confermato: la prima cella dipende dal percorso dell'area + +Si parte dalla prima UDC secondo il percorso effettivo comune, rispettando +anche all'avvio l'ordine dell'area e l'eventuale personalizzazione. +La precedente espressione "cella piu' bassa" non e' una regola universale: + +- Area 5: nella campata prevista dal percorso, prima la cella occupata + richiesta piu' in alto, poi quelle inferiori (e -> d -> c -> b -> a). +- Area 6: nella colonna prevista dal percorso, prima la cella occupata + richiesta piu' vicina al pavimento, poi quelle superiori (a -> b -> c -> d -> e). + +Il confronto comprende entrambe le liste: in area 5, se F1 ha una UDC al +livello a e F2 ne ha una al livello d della stessa campata, si propone prima +F2/d e poi F1/a. Non conta quale documento occupa il ruolo di Lista 1. +Restano la serpentina del default area 5, l'ordine comune eventualmente +personalizzato e la precedenza delle Non scaff. descritta in R14. + +## 8. Identificazione visiva sul terminalino + +### R12. Colori stabili + +| Documento assegnato | Colore di contesto | +|---|---| +| Lista 1 / F1 | Rosellino | +| Lista 2 / F2 | Giallino | + +Il colore segue il documento e non la sua posizione nel giro: se inizia F2, +lo sfondo iniziale e' giallino. La stessa lista conserva il proprio colore +nei successivi passaggi. + +Il numero della picking list e il ruolo Lista 1/Lista 2 devono essere leggibili +insieme all'UDC e alla cella proposta: il colore non deve essere l'unica +informazione per capire quale documento si sta lavorando. + +La barra esito mantiene il significato funzionale dei propri colori +(successo, errore, attesa), distinguibile dallo sfondo di contesto. +Il layout deve restare utilizzabile sul terminalino 240 x 320. + +## 9. Ciclo di scansione e conferma + +### R13. Sequenza ordinaria + +1. Il sistema propone UDC, cella sorgente e documento; applica il colore relativo. +2. L'operatore legge il barcode della UDC proposta. +3. Rimangono i controlli attuali di corrispondenza e avvio automatico della lettura. +4. Il movimento di picking porta la UDC in spedita, 7G.1.1, destinazione 9000000. +5. Il terminalino mostra l'esito positivo per i tre secondi previsti. +6. Il sistema propone la prossima UDC della coppia secondo il percorso comune. +7. Se cambia documento, aggiorna insieme colore, riferimento lista e tasti abilitati. + +L'esito dei tre secondi appartiene al movimento appena concluso, non al +documento che verra' proposto dopo. Non deve comparire un contesto misto. + +Un cambio programma della UDC visualizzata non deve simulare una scansione +dell'operatore: va preservata la protezione contro gli auto-submit accidentali. +Un errore o un esito incerto non deve avanzare il giro come se il movimento +fosse riuscito; restano necessarie le verifiche e il recupero gia' esistenti. + +## 10. Non scaffalate, salti, pausa e fine giro + +### R14. Non scaffalate + +Conservare il comportamento corrente: Non scaff. precede gli scaffali nel +default, salvo diversa posizione assegnata nella sequenza personalizzata. +La regola deve considerare le UDC di entrambe le liste. + +La cella virtuale e' condivisibile e non ha un livello fisico da confrontare. +Il criterio di parita' fra UDC non scaffalate delle due liste resta da +concordare; proposta: ordine stabile per ruolo lista e codice UDC. + +### R15. UDC saltate: approfondimento rinviato + +Il comando Salta UDC esiste gia'. L'utente ha chiesto di analizzarne in seguito +il comportamento nel nuovo giro abbinato, con l'intenzione di conservare +quanto possibile il funzionamento attuale. + +Restano da decidere il termine della singola lista con soli skip, la sua +sprenotazione, la prosecuzione dell'altra lista e le modalita' di ripresa. +Saltare una UDC non deve comunque equivalere a spedirla o cancellarla dalla lista. + +### Pausa e ripresa: comportamento da completare + +Oggi Pausa PL consente movimenti liberi e ESC riporta al picking. +Nel nuovo giro occorre conservare identita' dei due documenti e percorso. +Va deciso se al rientro mantenere la UDC gia' proposta o ricalcolare la +prossima posizione quando nel frattempo cambiano le ubicazioni. +I movimenti liberi devono mantenere le proprie regole di carico e scarico. + +### Fine di una lista: proposta ancora da confermare + +Quando una lista non ha piu' UDC residue, proseguire con l'altra mantenendone +ruolo e colore originali. Non promuovere automaticamente F2 a F1 e non +aggiungere un terzo documento. Distinguere esaurimento reale e soli skip. + +## 11. Esempi di percorso atteso + +### Esempio A: colonne presenti soltanto in F2 + +Area 6, stesso scaffale, default: colonne crescenti e livelli dal basso +verso l'alto. + +| Passo | Posizione | Lista proposta | +|---|---|---| +| 1 | Colonna 1, livello a | F2 | +| 2 | Colonna 2, livello a | F2 | +| 3 | Colonna 3, livello a | F1 | +| 4 | Colonna 3, livello b | F2 | +| 5 | Colonna 4, livello a | F2 | +| 6 | Colonna 5, livello a | F2 | +| 7 | Colonna 6, livello a | F1 | + +F1 ha UDC solo ogni tre colonne. Le colonne intermedie di F2 vengono visitate +al momento corretto, senza attendere il termine di F1. + +### Esempio B: alternanza verticale nella stessa colonna + +| Passo | Posizione | Lista proposta | +|---|---|---| +| 1 | Colonna 3, livello a | F2 | +| 2 | Colonna 3, livello b | F1 | +| 3 | Colonna 3, livello d | F2 | +| 4 | Colonna 4, livello a | F1 | + +Il passaggio F2 -> F1 -> F2 e' richiesto dal percorso; i documenti associati +ai tasti restano gli stessi. + +### Esempio C: area 5 con default storico + +| Ordinamento cella | Ubicazione | Lista proposta | +|---|---|---| +| 1001 | 1A.1.e | F2 | +| 1003 | 1A.1.c | F1 | +| 1007 | 2B.1.e | F1 | +| 1010 | 2B.1.b | F2 | +| 1013 | 2B.2.e | F2 | +| 1019 | 1A.2.e | F1 | + +Si uniscono le due liste rispettando i valori del percorso storico; non si +raggruppano automaticamente tutte le colonne 1A prima di quelle 2B. + +### Esempio D: ordine personalizzato della coppia + +Il responsabile imposta Non scaff. -> 4P -> 1M. Il terminalino lavora tutte +le UDC richieste dalle due liste in Non scaff., poi quelle in 4P, poi quelle +in 1M, applicando dentro ciascun gruppo l'ordine delle celle previsto. +Gli eventuali gruppi non configurati vengono accodati, mai esclusi. + +## 12. Garanzie e decisioni tecniche da tradurre in progetto + +La prenotazione attuale a documento unico deve evolvere per rappresentare +esplicitamente due documenti con ruoli stabili e una sequenza comune. +Il formato delle nuove tabelle e le modifiche alle stored non sono ancora +definiti da questo documento. + +La nuova prenotazione dovra' essere salvata in modo atomico, evitando che il +terminalino legga meta' coppia o una sequenza parzialmente aggiornata. +Le modifiche SQL dovranno avere script di installazione e di ripristino e +mantenere la separazione degli oggetti Python dagli oggetti legacy C#. + +Sono ancora da specificare persistenza della ripresa dopo riavvio, modifiche +alla coppia durante un giro, modifiche della sequenza a giro iniziato e +comportamento con piu' terminalini. Non assumere che la memoria locale del +singolo client sia una soluzione completa per questi casi. + +## 13. Dati locali predisposti per il collaudo + +Il 01/10/2026 sono state riattivate nella copia locale quattro testate SAM +cambiando solo D in P, senza movimentare UDC. Con filtro 30 giorni: + +| Lista | UDC residue | In celle fisiche | Non scaffalate nella vista | +|---|---:|---:|---:| +| 233 | 28 | 3 | 25 | +| 234 | 28 | 2 | 26 | +| 240 | 11 | 0 | 11 | +| 241 | 28 | 0 | 28 | +| 244, gia' attiva | 26 | 24 | 2 | + +Le celle fisiche candidate sono risultate prive di altre UDC, anche esterne +alle cinque liste. Le UDC residue non sono condivise fra questi documenti. +La condivisione della locazione virtuale Non scaff. e' invece prevista. + +Esempi utili in area 5: 234 in 1A.7.a e 244 in 1A.7.d; 233 in 1A.18.a e +244 in 1A.18.c. Sono celle distinte della stessa colonna. + +La voce Non scaff. puo' derivare anche dal fallback di una UDC senza giacenza +WMS: non prova da sola l'esistenza di un versamento registrato nella cella 1000. + +Questi dati non bastano a verificare tutte le alternanze verticali e le colonne +intermedie in area 6: occorrera' predisporre ulteriori casi controllati prima +del collaudo completo. Non creare documenti ERP fittizi o spostare UDC online +per ottenere tali scenari. + +Preparazione e ripristino sono descritti in [sql_tests/README.md](sql_tests/README.md). +Il ripristino delle testate non annulla le movimentazioni fatte durante i test. + +## 14. Criteri di accettazione + +| Test | Risultato atteso | +|---|---| +| Una sola lista | Ordinamento attuale conservato | +| Due liste entrambe Area 5 oppure entrambe Area 6 | Abbinamento consentito, applicando il percorso della relativa area | +| Una lista Area 5 e una Area 6 | Abbinamento rifiutato senza modificare le prenotazioni esistenti | +| F2 precede F1 nel percorso | All'avvio solo F2 utilizzabile | +| F1 presente ogni tre colonne | Nessuna colonna intermedia F2 omessa | +| Livelli alternati F2/F1/F2 | Tre proposte in ordine fisico, con documenti corretti | +| Default area 5 | Serpentina attuale e livelli dall'alto verso il basso, anche all'avvio | +| Area 5: F1 in 1A.7.a, F2 in 1A.7.d | Prima F2/d, poi F1/a, senza precedenza fissa a F1 | +| Default area 6 | Percorso corrente per scaffale, colonne crescenti e livelli dal basso verso l'alto | +| Personalizzazione della coppia | Stesso ordine applicato a entrambe | +| Gruppo presente solo in F2 | Visibile nell'editor e incluso nel percorso | +| Gruppo omesso dalla personalizzazione | Accodato secondo il comportamento attuale | +| Tasto fisico della lista disabilitata | Nessun cambio documento e nessun movimento | +| Cambio automatico | Numero lista, colore e UDC aggiornati coerentemente | +| UDC con due lotti | Un solo prelievo fisico | +| Terza lista disponibile | Mai inserita automaticamente nel giro | +| Aggiornamento programma del campo UDC | Non interpretato come nuova lettura | +| Movimento fallito o esito incerto | Nessun avanzamento ingiustificato | + +I casi relativi a skip, fine parziale, pausa, riavvio e modifiche concorrenti +andranno completati dopo la decisione delle relative regole. + +## 15. Decisioni aperte per la revisione + +L'avvio e' chiarito: si rispetta il percorso dell'area, dall'alto in area 5 +e dal basso in area 6. Restano aperti i punti seguenti. + +1. Assegnazione esplicita dei ruoli Lista 1 e Lista 2 sul desktop. +2. Gestione delle sequenze preesistenti quando si compone una nuova coppia. +3. Ordine fra UDC non scaffalate o con identico valore di percorso. +4. Salti, esaurimento con residui e riprenotazione della coppia o di una lista. +5. Ripresa dalla pausa e da un riavvio, con ubicazioni eventualmente cambiate. +6. Modifiche alla coppia/sequenza mentre il prelievo e' in corso. +7. Fonte autorevole dell'area di una lista e gestione di liste internamente + miste o con area indeterminata; le coppie Area 5/Area 6 sono vietate (R02a). +8. Condotta su dati anomali: stessa UDC in due liste o cella fisica con piu' UDC. + Il dataset di collaudo evita questi casi; la regola generale resta da definire. + +Le risposte possono essere annotate direttamente in questa sezione prima di +passare al progetto tecnico e alle modifiche applicative. diff --git a/sql_tests/README.md b/sql_tests/README.md new file mode 100644 index 0000000..a246274 --- /dev/null +++ b/sql_tests/README.md @@ -0,0 +1,38 @@ +# Dati per il collaudo a due picking list, 01/10/2026 + +Questi script riguardano esclusivamente la copia di test su HOMESERVER. +Non includerli nella patch cumulativa per la produzione. + +`riattiva_pickinglist_20261001.sql` cambia da D a P le testate SAM 233, 234, +240 e 241 dell'anno 2026. Mantiene date, righe, lotti e posizioni delle UDC. +Salva gli stati originali in Mediseawall.dbo.py_TestPickingListReactivation_20261001 +e controlla le precondizioni nella stessa transazione dell'aggiornamento. + +In Gestione Picking List selezionare **30 giorni** per vedere tutte le liste: + +| Lista | UDC residue | In celle fisiche | Non scaffalate nella vista | +|---|---:|---:|---:| +| 233 | 28 | 3 | 25 | +| 234 | 28 | 2 | 26 | +| 240 | 11 | 0 | 11 | +| 241 | 28 | 0 | 28 | +| 244 (gia attiva) | 26 | 24 | 2 | + +La 240 conserva altre 33 UDC gia spedite, escluse dalla vista operativa. +La qualifica non scaffalata deriva dalla vista: puo' comprendere UDC che non +hanno una giacenza WMS, mostrate mediante il fallback convenzionale 1000. + +Nessuna cella fisica candidata risulta contenere altre UDC. La condivisione +della locazione virtuale non scaffalata e' ammessa. Le liste 233/234 e 244 +contengono celle distinte nelle stesse colonne, utili per il futuro test del +cambio automatico fra documenti. Non sono stati creati movimenti di test. + +`ripristina_testate_pickinglist_20261001.sql` ripristina solo gli stati SAM. +Non ripristina UDC movimentate, skip o sequenze creati durante le prove. +Per ripetere identicamente un test che modifica le giacenze occorre un backup +coerente dei due database, oppure un ripristino specifico dei dati del test. +La piccola tabella di backup degli stati non sostituisce tale backup completo. + +Controllato anche il trigger SAM `StatoLottoRientrato`: il passaggio D -> P +non soddisfa la sua condizione e non modifica i lotti. Il ripristino P -> D +si ferma se esistono lotti marcati T, per evitare gli effetti di quel trigger. diff --git a/sql_tests/riattiva_pickinglist_20261001.sql b/sql_tests/riattiva_pickinglist_20261001.sql new file mode 100644 index 0000000..6952d26 --- /dev/null +++ b/sql_tests/riattiva_pickinglist_20261001.sql @@ -0,0 +1,82 @@ +/* Solo copia locale di test HOMESERVER. Non distribuire come patch produzione. + Riattiva 233,234,240,241 conservando date, righe, UDC e posizioni attuali. + Salva i valori di testata per un rollback mirato; non e' un backup dei DB. +*/ +USE Mediseawall; +SET NOCOUNT ON; +SET XACT_ABORT ON; + +IF CONVERT(nvarchar(128), SERVERPROPERTY('MachineName')) <> N'HOMESERVER' + THROW 51000, 'Script riservato alla copia di test su HOMESERVER.', 1; + +BEGIN TRY + BEGIN TRANSACTION; + + IF OBJECT_ID('dbo.py_TestPickingListReactivation_20261001', 'U') IS NOT NULL + THROW 51001, 'Preparazione gia eseguita: controllare il backup prima di ripetere.', 1; + + DECLARE @Targets TABLE (ID int PRIMARY KEY, Documento int NOT NULL); + INSERT INTO @Targets VALUES (11882,233),(11883,234),(11890,240),(11891,241); + + IF (SELECT COUNT(*) FROM SAMA1.dbo.BAMTES b WITH (UPDLOCK,HOLDLOCK) + JOIN @Targets t ON b.ID=t.ID AND b.NUMDOC=t.Documento + WHERE b.ANNDOC=2026 AND b.STATO='D' + AND b.DATDOC>CAST(GETDATE()-30 AS int)) <> 4 + THROW 51002, 'Le quattro testate non corrispondono allo scenario atteso.', 1; + + IF EXISTS ( + SELECT 1 FROM dbo.py_XMag_ViewPackingListStorico h + JOIN dbo.XMag_GiacenzaPallet g ON g.IDCella=h.Cella + WHERE h.Documento IN (233,234,240,241,244) AND h.Cella NOT IN (1000,9999) + AND LTRIM(RTRIM(g.BarcodePallet)) COLLATE Latin1_General_CI_AS <> + h.Pallet COLLATE Latin1_General_CI_AS + ) THROW 51003, 'Cella fisica con altre UDC: preparazione annullata.', 1; + + IF EXISTS ( + SELECT Pallet FROM dbo.py_XMag_ViewPackingListStorico + WHERE Documento IN (233,234,240,241,244) AND Cella<>9999 + GROUP BY Pallet HAVING COUNT(DISTINCT Documento)>1 + ) THROW 51004, 'Una UDC residua appartiene a piu liste candidate.', 1; + + IF EXISTS (SELECT 1 FROM dbo.py_BarcodePickingListSkip + WHERE Documento IN ('233','234','240','241') AND Risolto=0) + OR EXISTS (SELECT 1 FROM dbo.py_PickingListPrelievoSequenza + WHERE Documento IN ('233','234','240','241') AND DelDataOra IS NULL) + OR EXISTS (SELECT 1 FROM dbo.PyPickingListReservation + WHERE NULLIF(LTRIM(RTRIM(Documento)),'') IS NOT NULL) + THROW 51005, 'Prenotazioni, skip o sequenze attive: verificare prima del test.', 1; + + CREATE TABLE dbo.py_TestPickingListReactivation_20261001 ( + BamtesID int NOT NULL PRIMARY KEY, + Anno smallint NOT NULL, + Documento int NOT NULL, + StatoOriginale char(1) NOT NULL, + DataDocumentoOriginale float NOT NULL, + ApplicataIl datetime2 NOT NULL, + RipristinataIl datetime2 NULL + ); + INSERT INTO dbo.py_TestPickingListReactivation_20261001 + (BamtesID,Anno,Documento,StatoOriginale,DataDocumentoOriginale,ApplicataIl) + SELECT b.ID,b.ANNDOC,b.NUMDOC,b.STATO,b.DATDOC,SYSDATETIME() + FROM SAMA1.dbo.BAMTES b JOIN @Targets t ON t.ID=b.ID; + + UPDATE b SET STATO='P' + FROM SAMA1.dbo.BAMTES b JOIN @Targets t ON t.ID=b.ID; + IF @@ROWCOUNT<>4 THROW 51006, 'Numero di testate aggiornate inatteso.', 1; + + IF (SELECT COUNT(DISTINCT Documento) FROM dbo.py_ViewPackingListPrelievo + WHERE Documento IN (233,234,240,241,244) + AND DataDocumento>CAST(GETDATE()-30 AS int))<>5 + THROW 51007, 'Le cinque liste non risultano tutte visibili. Rollback.', 1; + + COMMIT TRANSACTION; +END TRY +BEGIN CATCH + IF @@TRANCOUNT>0 ROLLBACK TRANSACTION; + THROW; +END CATCH; + +SELECT Documento,COUNT(DISTINCT Pallet) AS UdcResidue +FROM dbo.py_ViewPackingListPrelievo +WHERE Documento IN (233,234,240,241,244) +GROUP BY Documento ORDER BY Documento; diff --git a/sql_tests/ripristina_testate_pickinglist_20261001.sql b/sql_tests/ripristina_testate_pickinglist_20261001.sql new file mode 100644 index 0000000..5f0ef61 --- /dev/null +++ b/sql_tests/ripristina_testate_pickinglist_20261001.sql @@ -0,0 +1,54 @@ +/* Ripristina SOLO lo stato delle quattro testate riattivate per i test. + Non annulla movimentazioni effettuate durante il collaudo. + Eseguire sullo stesso DB locale con applicazioni ferme e liste sprenotate. +*/ +USE Mediseawall; +SET NOCOUNT ON; +SET XACT_ABORT ON; +IF CONVERT(nvarchar(128),SERVERPROPERTY('MachineName'))<>N'HOMESERVER' + THROW 51000, 'Script riservato alla copia di test su HOMESERVER.', 1; +IF OBJECT_ID('dbo.py_TestPickingListReactivation_20261001','U') IS NULL + THROW 51001, 'Backup delle testate non trovato.', 1; + +BEGIN TRY + BEGIN TRANSACTION; + IF EXISTS ( + SELECT 1 FROM dbo.PyPickingListReservation r + JOIN dbo.py_TestPickingListReactivation_20261001 s + ON r.Documento=CAST(s.Documento AS varchar(8)) + WHERE s.RipristinataIl IS NULL + ) THROW 51002, 'Sprenotare le liste di test prima del ripristino.', 1; + + IF EXISTS ( + SELECT 1 FROM dbo.py_TestPickingListReactivation_20261001 s + LEFT JOIN SAMA1.dbo.BAMTES b WITH (UPDLOCK,HOLDLOCK) ON b.ID=s.BamtesID + WHERE s.RipristinataIl IS NULL AND + (b.ID IS NULL OR b.ANNDOC<>s.Anno OR b.NUMDOC<>s.Documento + OR b.DATDOC<>s.DataDocumentoOriginale OR b.STATO IS NULL + OR b.STATO NOT IN ('P','D')) + ) THROW 51003, 'Testate cambiate rispetto al backup: verificare manualmente.', 1; + + -- Il trigger SAM StatoLottoRientrato modifica LOTSER.DESCR tornando in D. + -- In presenza di lotti marcati T fermarsi prima di attivarlo. + IF EXISTS ( + SELECT 1 FROM dbo.py_TestPickingListReactivation_20261001 s + JOIN SAMA1.dbo.BAMTES b ON b.ID=s.BamtesID + JOIN SAMA1.dbo.FATRIG f ON f.IDBAM=b.ID + JOIN SAMA1.dbo.LOTTIBF lb ON lb.IDFATRIG=f.ID + JOIN SAMA1.dbo.LOTSER l ON l.ID=lb.IDLOTSER + WHERE s.RipristinataIl IS NULL AND b.STATO<>'D' AND l.DESCR='T' + ) THROW 51004, 'Lotti rientrati marcati T: ripristino da valutare per il trigger SAM.', 1; + + UPDATE b SET STATO=s.StatoOriginale + FROM SAMA1.dbo.BAMTES b + JOIN dbo.py_TestPickingListReactivation_20261001 s ON s.BamtesID=b.ID + WHERE s.RipristinataIl IS NULL; + + UPDATE dbo.py_TestPickingListReactivation_20261001 + SET RipristinataIl=SYSDATETIME() WHERE RipristinataIl IS NULL; + COMMIT TRANSACTION; +END TRY +BEGIN CATCH + IF @@TRANCOUNT>0 ROLLBACK TRANSACTION; + THROW; +END CATCH; diff --git a/tests/test_barcode_document_binding.py b/tests/test_barcode_document_binding.py new file mode 100644 index 0000000..4c46939 --- /dev/null +++ b/tests/test_barcode_document_binding.py @@ -0,0 +1,194 @@ +"""Document isolation regressions; no GUI, DB connection or real movements.""" + +import unittest +from unittest.mock import AsyncMock, patch + +from barcode_repository import BarcodeRepository, LegacyMoveResult +from barcode_service import BarcodeService + + +def row(doc, pallet, priority, order): + return dict(Documento=doc, Pallet=pallet, IDStato=priority, + Ordinamento=order, Cella=1101, Ubicazione="1A - 1 - a") + + +class PickingRepositoryFake: + def __init__(self): + # Without a document filter the sequence would be 172, 171, 172. + self.rows = [row("173", "600001", 1, 5), row("172", "600002", 0, 10), + row("171", "600003", 0, 20), row("172", "600004", 0, 30)] + self.skipped = set() + self.locations = {r["Pallet"]: r["Cella"] for r in self.rows} + self.released = [] + + async def fetch_next_picking(self, id_stato, documento=None): + candidates = [r for r in self.rows if r["IDStato"] == id_stato + and (documento is None or r["Documento"] == documento) + and r["Pallet"] not in self.skipped] + return min(candidates, key=lambda r: r["Ordinamento"]) if candidates else None + + async def fetch_active_skipped_document(self, id_stato, documento=None): + candidates = [r for r in self.rows if r["IDStato"] == id_stato + and (documento is None or r["Documento"] == documento) + and r["Pallet"] in self.skipped] + return dict(Documento=candidates[0]["Documento"], SkippedCount=len(candidates)) if candidates else None + + async def count_picking_document_remaining(self, documento): + return len({r["Pallet"] for r in self.rows if r["Documento"] == documento}) + + async def release_picking_document(self, *, documento, operator_id): + self.released.append(documento) + return 0 + + async def skip_picking_pallet(self, *, documento, pallet, **kwargs): + self.skipped.add(pallet) + + async def fetch_picking_by_pallet(self, pallet, documento=None): + return next((r for r in self.rows if r["Pallet"] == pallet + and (documento is None or r["Documento"] == documento)), None) + + async def fetch_trace_by_pallet(self, pallet): + return dict(Lotto="LOT", Prodotto="ART", Descrizione="Test") + + async def fetch_current_location_by_pallet(self, pallet): + return dict(IDCella=self.locations[pallet]) + + async def fetch_open_locations_by_pallet(self, pallet): + return [await self.fetch_current_location_by_pallet(pallet)] + + async def fetch_closed_picking_by_pallet(self, pallet): + return None + + async def execute_legacy_move(self, *, barcode_pallet, barcode_cella, **kwargs): + target = 9999 if barcode_cella == "9000000" else 1000 + self.locations[barcode_pallet] = target + if target == 9999: + self.rows = [r for r in self.rows if r["Pallet"] != barcode_pallet] + return LegacyMoveResult(0, barcode_cella, barcode_pallet, int(barcode_cella)) + + +class DocumentBindingTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + logger = patch("barcode_service.log_runtime_event") + logger.start() + self.addCleanup(logger.stop) + self.repo = PickingRepositoryFake() + self.service = BarcodeService(self.repo, operator_id=1) + + async def ship_current(self): + result = await self.service.submit( + scanned_pallet=self.service.state.expected_pallet, destination_barcode="9000000") + self.assertTrue(result.ok) + self.assertEqual(result.state.auto_advance_delay_ms, 3000) + + async def test_auto_advance_never_interleaves_low_priority_documents(self): + await self.service.start_priority_queue(0) + await self.ship_current() + result = await self.service.start_priority_queue(0) + self.assertEqual((result.state.document, result.state.expected_pallet), ("172", "600004")) + + async def test_f2_f1_f2_stays_bound_even_if_global_order_changes(self): + await self.service.start_priority_queue(0) + self.repo.rows[2]["Ordinamento"] = 1 + for _ in range(5): + high = await self.service.start_priority_queue(1) + self.assertEqual(high.state.document, "173") + low = await self.service.start_priority_queue(0) + self.assertEqual((low.state.document, low.state.expected_pallet), ("172", "600002")) + + async def test_pause_free_move_resume_and_switch_preserve_document(self): + await self.service.start_priority_queue(0) + paused = self.service.state + self.service.begin_priority_pause(0) + free = await self.service.submit(scanned_pallet="600003", destination_barcode="9001000") + self.assertTrue(free.ok) + self.service.resume_priority_state(paused) + await self.service.start_priority_queue(1) + low = await self.service.start_priority_queue(0) + self.assertEqual(low.state.document, "172") + self.assertEqual(low.state.expected_pallet, "600002") + + async def test_skip_advances_only_within_active_document(self): + await self.service.start_priority_queue(0) + await self.service.skip_current_picking_pallet() + low = await self.service.start_priority_queue(0) + self.assertEqual(low.state.expected_pallet, "600004") + + async def test_only_skipped_residuals_stop_even_with_other_lists_available(self): + await self.service.start_priority_queue(0) + await self.service.skip_current_picking_pallet() + await self.service.start_priority_queue(0) + await self.service.skip_current_picking_pallet() + result = await self.service.start_priority_queue(0) + self.assertEqual(result.state.mode, "manual_unload") + self.assertIn("PL 172 sospesa", result.state.status_text) + self.assertEqual(self.repo.released, ["172"]) + + async def test_completion_stops_before_next_document(self): + await self.service.start_priority_queue(0) + await self.ship_current() + await self.service.start_priority_queue(0) + await self.ship_current() + stopped = await self.service.start_priority_queue(0) + self.assertEqual(stopped.state.mode, "manual_unload") + self.assertIn("PL 172 terminata", stopped.state.status_text) + self.assertEqual(stopped.state.auto_advance_delay_ms, 0) + # A later explicit F2 can start another list, after the stop message. + next_list = await self.service.start_priority_queue(0) + self.assertEqual(next_list.state.document, "171") + + async def test_priority_change_blocks_without_forgetting_previous_document(self): + await self.service.start_priority_queue(0) + for r in self.repo.rows: + if r["Documento"] == "172": + r["IDStato"] = 1 + for _ in range(2): + stopped = await self.service.start_priority_queue(0) + self.assertIn("PL 172", stopped.state.status_text) + self.assertIn("Verifica la prenotazione", stopped.state.status_text) + for r in self.repo.rows: + if r["Documento"] == "172": + r["IDStato"] = 0 + resumed = await self.service.start_priority_queue(0) + self.assertEqual(resumed.state.document, "172") + + async def test_query_exception_keeps_document_for_retry(self): + await self.service.start_priority_queue(0) + with patch.object(self.repo, "fetch_next_picking", side_effect=TimeoutError): + with self.assertRaises(TimeoutError): + await self.service.start_priority_queue(0) + self.repo.rows[2]["Ordinamento"] = 1 + result = await self.service.start_priority_queue(0) + self.assertEqual(result.state.document, "172") + + async def test_wrong_document_from_repository_is_rejected(self): + await self.service.start_priority_queue(0) + with patch.object(self.repo, "fetch_next_picking", return_value=self.repo.rows[2]): + with self.assertRaisesRegex(RuntimeError, "Cambio inatteso documento"): + await self.service.start_priority_queue(0) + result = await self.service.start_priority_queue(0) + self.assertEqual(result.state.document, "172") + + async def test_confirmation_cannot_display_another_document_for_same_pallet(self): + await self.service.start_priority_queue(0) + self.repo.rows.append(row("171", "600002", 0, 1)) + self.repo.fetch_picking_by_pallet = AsyncMock(wraps=self.repo.fetch_picking_by_pallet) + state = await self.service._build_post_move_state( + barcode_pallet="600002", destination_barcode="9000000", + destination_display="7G.1.1", last_priority_state=0, auto_advance_delay_ms=3000) + self.assertEqual(state.document, "172") + self.repo.fetch_picking_by_pallet.assert_awaited_once_with("600002", documento="172") + + async def test_repository_passes_document_to_all_related_queries(self): + db = type("DB", (), {"query_json": AsyncMock(return_value={"rows": []})})() + repo = BarcodeRepository(db) + await repo.fetch_next_picking(0, documento="172") + await repo.fetch_active_skipped_document(0, documento="172") + await repo.fetch_picking_by_pallet("600002", documento="172") + for call in db.query_json.await_args_list: + self.assertEqual(call.args[1]["documento"], "172") + self.assertIn(":documento", call.args[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/version_info.py b/version_info.py index 52fa8b4..43e4b3b 100644 --- a/version_info.py +++ b/version_info.py @@ -13,9 +13,9 @@ MODULE_VERSIONS: dict[str, str] = { "async_msssql_query": "1.0.1", "audit_log": "1.0.0", "main": "1.0.2", - "barcode_client": "1.0.26", - "barcode_repository": "1.0.13", - "barcode_service": "1.0.24", + "barcode_client": "1.0.27", + "barcode_repository": "1.0.14", + "barcode_service": "1.0.25", "busy_overlay": "1.0.0", "db_config": "1.0.0", "diagnostica": "1.0.4",