brestok commited on
Commit
c2ea771
·
1 Parent(s): a9710f3

Refactor project structure and enhance batch processing capabilities

Browse files

- Updated `.gitignore` to exclude sensitive client material and system files.
- Removed unnecessary comments and documentation from `main.py` and `cbh/__init__.py`.
- Revised `README.md` to clarify batch processing routes and export formats.
- Added new manual shipment and row addition endpoints in `cbh/api/batch/views.py`.
- Enhanced batch model and schema definitions to support additional fields and metrics.
- Updated batch processing logic to handle new parameters for destination country and customs procedures.
- Introduced new utility functions for file handling and improved error management in batch processing.
- Removed deprecated account module files to streamline the codebase.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +6 -0
  2. README.md +89 -11
  3. cargoflow-codecontrole.html +23 -0
  4. cbh/__init__.py +3 -32
  5. cbh/api/account/__init__.py +0 -12
  6. cbh/api/account/dto.py +0 -38
  7. cbh/api/account/models.py +0 -124
  8. cbh/api/account/schemas.py +0 -11
  9. cbh/api/account/utils.py +0 -36
  10. cbh/api/account/views.py +0 -44
  11. cbh/api/batch/__init__.py +0 -8
  12. cbh/api/batch/db_requests.py +1 -18
  13. cbh/api/batch/dto.py +35 -28
  14. cbh/api/batch/models.py +29 -47
  15. cbh/api/batch/schemas.py +162 -22
  16. cbh/api/batch/services/excel/__init__.py +15 -3
  17. cbh/api/batch/services/excel/export.py +258 -0
  18. cbh/api/batch/services/excel/reader.py +80 -68
  19. cbh/api/batch/services/excel/writer.py +113 -93
  20. cbh/api/batch/services/metrics/__init__.py +13 -2
  21. cbh/api/batch/services/metrics/metrics.py +34 -78
  22. cbh/api/batch/services/metrics/recompute.py +66 -0
  23. cbh/api/batch/services/metrics/totals.py +151 -0
  24. cbh/api/batch/services/processor.py +145 -50
  25. cbh/api/batch/utils.py +7 -42
  26. cbh/api/batch/views.py +513 -112
  27. cbh/api/client/__init__.py +0 -7
  28. cbh/api/client/db_requests.py +26 -24
  29. cbh/api/client/dto.py +15 -30
  30. cbh/api/client/models.py +172 -66
  31. cbh/api/client/schemas.py +0 -16
  32. cbh/api/client/utils.py +0 -20
  33. cbh/api/client/views.py +5 -47
  34. cbh/api/common/db_requests.py +10 -163
  35. cbh/api/common/dto.py +67 -75
  36. cbh/api/common/models.py +0 -36
  37. cbh/api/common/schemas.py +1 -70
  38. cbh/api/common/services/cache/__init__.py +2 -18
  39. cbh/api/common/services/cache/cache.py +3 -54
  40. cbh/api/common/utils.py +3 -60
  41. cbh/api/importer/__init__.py +0 -7
  42. cbh/api/importer/db_requests.py +0 -26
  43. cbh/api/importer/dto.py +5 -38
  44. cbh/api/importer/models.py +0 -19
  45. cbh/api/importer/schemas.py +17 -23
  46. cbh/api/importer/services/base.py +36 -35
  47. cbh/api/importer/services/codeboek/codeboek.py +7 -45
  48. cbh/api/importer/services/demo/demo.py +54 -123
  49. cbh/api/importer/services/dtv/__init__.py +15 -0
  50. cbh/api/importer/services/dtv/dtv.py +368 -0
.gitignore CHANGED
@@ -8,3 +8,9 @@ __pycache__/
8
 
9
  # Uploaded packs, produced result files and reference-data files supplied by hand.
10
  storage/
 
 
 
 
 
 
 
8
 
9
  # Uploaded packs, produced result files and reference-data files supplied by hand.
10
  storage/
11
+
12
+ # Client material: real declarations, invoices and email threads. The parcel
13
+ # manifests carry consignee names, home addresses, telephone numbers and email
14
+ # addresses, so none of it belongs in the repository.
15
+ Attachments-*/
16
+ .DS_Store
README.md CHANGED
@@ -24,7 +24,8 @@ Storage is MongoDB, not Postgres. No queues, no vector database.
24
  | `cbh/api/linecheck` | The engine. Code resolution, document determination, warnings. The single entry point. |
25
  | `cbh/api/importer` | Loads the reference files. Idempotent, restartable, logged in `synclogs`. |
26
  | `cbh/api/client` | Per-client column mapping and export profile. |
27
- | `cbh/api/batch` | The Excel processor: a thin client over the line-check API. |
 
28
  | `cbh/api/common` | Shared DTOs, schemas, generic Mongo access and the TTL cache. |
29
 
30
  Each module follows the same layout: `__init__.py` (router) · `dto.py` (enums) ·
@@ -86,24 +87,94 @@ Two rules the code holds to:
86
  reported as a warning, because "unregulated" and "the data does not cover
87
  this" look identical from the inside.
88
 
89
- ## Processing a pack
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
  ```
92
  POST /api/client/suggest-mapping # guess the column mapping from a file, for confirmation
93
  POST /api/batch/upload # upload + process (clientProfileId, file)
94
- GET /api/batch/{id}/download # the same file back, with the added columns
95
- GET /api/batch/{id}/rows?needsReviewOnly=true
96
  ```
97
 
98
- The client's own file comes back: same sheet, same columns, same row order,
99
- with `resolved_code`, `documents_required`, `documents_exemption`, `warnings`
100
- and `status` appended in the order the export profile fixes, a totals row at the
101
- bottom (packages, gross weight, value) and `ambiguous`/`invalid` rows
102
- highlighted.
 
 
 
 
 
 
 
 
 
103
 
104
  Identical (code, country, direction, date) tuples are resolved once and reused
105
  across the pack — a 580-line pack typically collapses to a few dozen lookups.
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  ## Acceptance metrics
108
 
109
  ```
@@ -150,5 +221,12 @@ the first real files:
150
  document-code prefix (C/N/X = obligation, Y = exemption) instead. Verify
151
  against the published Measure-action list.
152
  - **The Softpak column order** — `default_export_columns()` in
153
- `client/models.py`, pending Judith's template specification.
154
- - **The Goenee column mapping** — `goenee_profile()` in `client/models.py`.
 
 
 
 
 
 
 
 
24
  | `cbh/api/linecheck` | The engine. Code resolution, document determination, warnings. The single entry point. |
25
  | `cbh/api/importer` | Loads the reference files. Idempotent, restartable, logged in `synclogs`. |
26
  | `cbh/api/client` | Per-client column mapping and export profile. |
27
+ | `cbh/api/batch` | Shipments: both input routes, the review table and the exports. |
28
+ | `cbh/api/rules` | The broker's own logic, written down once and replayed. |
29
  | `cbh/api/common` | Shared DTOs, schemas, generic Mongo access and the TTL cache. |
30
 
31
  Each module follows the same layout: `__init__.py` (router) · `dto.py` (enums) ·
 
87
  reported as a warning, because "unregulated" and "the data does not cover
88
  this" look identical from the inside.
89
 
90
+ ## Two ways in, one review table, one export
91
+
92
+ Both input routes build the same shipment, and the review table is read the
93
+ same way whichever route filled it.
94
+
95
+ **A — the form, one line at a time.**
96
+
97
+ ```
98
+ POST /api/batch/manual # open an empty shipment
99
+ POST /api/batch/{id}/rows # add one line; it is checked on arrival
100
+ DELETE /api/batch/{id}/rows/{rowIndex}
101
+ ```
102
+
103
+ **B — upload, the daily route.**
104
 
105
  ```
106
  POST /api/client/suggest-mapping # guess the column mapping from a file, for confirmation
107
  POST /api/batch/upload # upload + process (clientProfileId, file)
 
 
108
  ```
109
 
110
+ **The table, either way.**
111
+
112
+ ```
113
+ GET /api/batch/{id}/review?needsReviewOnly=true&confidence=…
114
+ PATCH /api/batch/{id}/rows/{rowIndex} # accept, correct, CBAM/EUDR/licence, note
115
+ POST /api/batch/{id}/rows/{rowIndex}/make-rule
116
+ POST /api/batch/{id}/recheck # replay every row against today's rules and data
117
+ ```
118
+
119
+ `GET /review` returns the header the screen is built from — reference, route,
120
+ countries, the totals (packages, weight, value) and the two scores, extraction
121
+ and document-codes, reported separately so neither can hide the other — plus
122
+ one row per line carrying its confidence, its reason, its document codes with
123
+ source and validity, its warnings and the rules that fired.
124
 
125
  Identical (code, country, direction, date) tuples are resolved once and reused
126
  across the pack — a 580-line pack typically collapses to a few dozen lookups.
127
 
128
+ ## Export, per tenant
129
+
130
+ ```
131
+ GET /api/batch/export-formats?clientProfileId=… # fills the dropdown
132
+ GET /api/batch/{id}/export?format=…
133
+ ```
134
+
135
+ | Format | What it is |
136
+ | --- | --- |
137
+ | `review` | The tool's own table: every column the reviewer saw, reasons included. |
138
+ | `source` | The uploaded file returned unchanged, with the checked columns appended. Upload route only. |
139
+ | `softpak` | The Goenee line list, rebuilt from the stored rows. |
140
+ | `scope`, `fiton` | Declared, listed, and refused with 501. |
141
+
142
+ `scope` and `fiton` are not implemented because their import specifications
143
+ have not been received. They are listed as unavailable with the reason rather
144
+ than quietly approximated, so the gap shows up in the UI instead of in a
145
+ rejected declaration. Adding one is a profile change, not a code change.
146
+
147
+ Totals are counted per physical package, not per row. A parcel manifest
148
+ carries one row per item and repeats the parcel's weight on each of them;
149
+ `TotalsConfig.parcelKeyColumn` names the column that identifies the parcel and
150
+ `parcelLevelFields` the fields to count once per parcel. For the G-Star
151
+ manifest of 04-09-2026 this is what reconciles the header with the Master
152
+ Invoice: 167 packages and GBP 11,376.42 across 252 rows.
153
+
154
+ ## Rules — the broker's own logic
155
+
156
+ ```
157
+ GET|POST /api/rules PUT|DELETE /api/rules/{id}
158
+ POST /api/batch/{id}/rows/{rowIndex}/make-rule
159
+ ```
160
+
161
+ A rule matches narrowly — any combination of code prefix, origin, destination,
162
+ direction and a description substring — and does one thing: pin a code, add
163
+ document codes, suppress document codes, or record a note. A rule with no
164
+ conditions is refused, because it would touch every line in every pack.
165
+
166
+ Two guarantees hold whatever a rule does:
167
+
168
+ - **It is never silent.** Every rule that fires is named on the line in
169
+ `appliedRules` and as an INFO warning saying what it changed.
170
+ - **It is never green.** A rule is the broker's logic, not TARIC's, so the
171
+ confidence it touched drops to amber and the source becomes `MANUAL`.
172
+
173
+ Pinning a code re-runs the check under the pinned code, so the documents belong
174
+ to the code actually being declared rather than to the one the reference
175
+ proposed. Rules are scoped to one client profile or to the tenant, and each
176
+ counts its own hits — which is the input the learning layer will need.
177
+
178
  ## Acceptance metrics
179
 
180
  ```
 
221
  document-code prefix (C/N/X = obligation, Y = exemption) instead. Verify
222
  against the published Measure-action list.
223
  - **The Softpak column order** — `default_export_columns()` in
224
+ `client/models.py`, pending Judith's template specification. The `softpak`
225
+ export currently reproduces the layout of the packs Goenee actually sends
226
+ (`PF 04-09-2026.xlsx`), which is evidence, not a specification.
227
+ - **The Goenee column mapping** — `goenee_softpak_profile()` in
228
+ `client/models.py`.
229
+ - **The G-Star gross weight.** Counting the manifest once per parcel gives
230
+ 175.86 kg; the Master Invoice for the same shipment says 148.69 kg. Packages
231
+ and value reconcile exactly, weight does not. Ask which basis the declared
232
+ weight uses before trusting that figure.
cargoflow-codecontrole.html ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="nl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>CargoFlow — Minder zoeken. Sneller klaar.</title><style>
3
+ *{box-sizing:border-box}body{margin:0;background:#f6f8fb;color:#192d40;font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,select,textarea{font:inherit}button{cursor:pointer;border:0}header{height:80px;padding:0 6%;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e4e9ef;background:white}.logo{font-size:24px;font-weight:750;letter-spacing:-1px}.logo span{color:#346fe3}.demo{font-size:12px;color:#64748b;background:#f1f4f8;padding:5px 11px;border-radius:20px}main{max-width:740px;margin:52px auto;padding:0 22px}h1{font-size:36px;line-height:1.2;letter-spacing:-1.3px;margin:0 0 12px}h2{font-size:22px;line-height:1.3;letter-spacing:-.5px;margin:0 0 9px}p{margin:0;color:#748094}.intro{text-align:center;margin-bottom:30px}.steps{display:flex;justify-content:center;gap:24px;font-size:12px;color:#8893a3;margin-bottom:25px}.steps span.active{color:#2865d9;font-weight:650}.card{background:white;border:1px solid #e1e7ef;box-shadow:0 8px 35px #19304d05;border-radius:16px;padding:30px}.row{display:grid;grid-template-columns:1fr 1fr;gap:18px}.field{margin-bottom:20px}label{display:block;font-size:13px;font-weight:600;margin-bottom:7px}input,select,textarea{width:100%;border:1px solid #dce3ec;border-radius:9px;padding:12px;background:#fff;color:#24394e}textarea{height:105px;resize:vertical}input:focus,select:focus,textarea:focus{outline:2px solid #b7d2ff;border-color:#6f9fee}button:focus-visible,summary:focus-visible{outline:3px solid #a4caff;outline-offset:3px}.primary{background:#2867e3;color:white;border-radius:9px;padding:14px 22px;font-weight:650;width:100%;margin-top:12px}.primary:hover{background:#1d57c7}.primary:disabled{background:#a9b8cf;cursor:not-allowed}.textbutton{background:none;color:#3970cb;padding:7px 0;font-size:12px}.under{text-align:center;font-size:11px;color:#8b96a6;margin-top:12px}summary{cursor:pointer;color:#728096;font-size:12px}details .content{padding-top:18px}details{margin:4px 0 15px}.context{font-size:12px;color:#748094;margin-bottom:12px}.context b{color:#42566d;font-weight:500}.roi{margin:23px 0 0;padding:21px 24px;background:#ebf3f0;border:1px solid #dce9e3;border-radius:13px}.roihead{display:flex;justify-content:space-between;gap:16px;align-items:center}.roi label{font-size:11px;color:#637b73;font-weight:500}.roi strong{font-size:25px;letter-spacing:-.8px;color:#236953}.roi p{font-size:11px;color:#71877e}.roi summary{color:#527969;margin-top:15px}.metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.roi .inputs{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.roi input{padding:8px;font-size:13px}.roi .formula{margin-top:12px;font-size:11px;color:#587567}.question{margin:23px 0}.choice{display:flex;gap:10px;flex-wrap:wrap}.choice button{flex:1;border:1px solid #dce3ec;border-radius:9px;padding:12px;background:white;color:#4a5c70;white-space:nowrap}.choice button.selected{border-color:#2d6ce5;color:#2867df;background:#eff5ff}.tag{display:inline-block;background:#edf4ff;color:#4974b1;border-radius:6px;padding:5px 9px;font-size:11px;margin-bottom:14px}.resultrow{border-bottom:1px solid #e9edf3;padding:16px 0;display:flex;gap:20px;justify-content:space-between;font-size:13px}.resultrow span{color:#718198}.resultrow b{text-align:right;max-width:65%;overflow-wrap:anywhere}.note{background:#f7f9fc;padding:14px;border-radius:8px;color:#778498;font-size:12px;margin:18px 0}.back{background:none;color:#76869a;font-size:12px;padding:0;margin-bottom:18px}.hidden{display:none!important}.error{color:#b45535;font-size:12px;margin-top:10px}.done{text-align:center}.check{margin:12px auto 18px;background:#e8f4ee;color:#2b8060;width:50px;height:50px;border-radius:50%;display:grid;place-items:center;font-size:26px}footer{text-align:center;font-size:11px;color:#94a0af;margin:24px 0}.small{font-size:12px}.secondary{border:1px solid #dce3ec;border-radius:9px;padding:12px 18px;background:white;color:#415972;width:100%;margin-top:10px}@media(max-width:560px){header{height:64px}main{margin:30px auto;padding:0 16px}h1{font-size:29px}.card{padding:23px 20px}.row{grid-template-columns:1fr;gap:0}.steps{gap:18px}.roi{padding:18px}.metrics{gap:9px}.roi strong{font-size:21px}.roihead{align-items:start}.choice button{padding:10px;font-size:12px}}
4
+ </style></head><body><header><div class="logo">Cargo<span>Flow</span></div><span class="demo">Interactieve demo</span></header><main><div class="intro"><h1>Minder zoeken. Sneller klaar.</h1><p>Van product naar een controleerbaar codevoorstel.</p></div><div class="steps" aria-label="Voortgang"><span id="step1" class="active">1. Product</span><span id="step2">2. Aanvullen</span><span id="step3">3. Resultaat</span></div><div class="card">
5
+ <section id="input"><form id="form"><div class="field"><label for="product">Wat wil je controleren?</label><textarea id="product" required placeholder="Omschrijving van de goederen, bijvoorbeeld: katoenen T-shirts, gebreid, 100% katoen."></textarea></div><div class="row"><div class="field"><label for="code">HS- of goederencode <span style="font-weight:400;color:#939ead">· indien bekend</span></label><input id="code" inputmode="numeric" placeholder="Bijv. 610910" pattern="([0-9]{6}|[0-9]{8}|[0-9]{10})" title="Gebruik 6, 8 of 10 cijfers"></div><div class="field"><label for="origin">Land van oorsprong</label><input id="origin" required placeholder="Bijv. India"></div></div><div class="context" id="contextSummary"></div><details><summary>Context aanpassen</summary><div class="content row"><div class="field"><label for="direction">Goederenstroom</label><select id="direction"><option>Invoer</option><option>Uitvoer</option></select></div><div class="field"><label for="country">Land van aangifte</label><select id="country"><option>Nederland</option><option>België</option><option>Duitsland</option></select></div><div class="field"><label for="date">Peildatum</label><input id="date" type="date" required></div><div class="field"><label for="procedure">Regeling</label><select id="procedure"><option>Vrij verkeer</option><option>Douane-entrepot</option><option>Tijdelijke invoer</option><option>Uitvoer</option></select></div><div class="field"><label for="dispatch">Verzending / bestemming</label><input id="dispatch" placeholder="Indien bekend"></div></div></details><button type="submit" class="primary">Controle starten →</button><p class="under">Alleen aanvullende vragen waar nodig.</p><button type="button" id="example" class="textbutton">Probeer een voorbeeld</button></form></section>
6
+ <section id="questions" class="hidden"><button class="back" data-goto="input">← Invoer aanpassen</button><br><span class="tag">Nog 1 vraag · voorbeeldflow</span><h2>Is de productsamenstelling bekend?</h2><p class="small">In productie bepaalt de broninformatie welke vraag nodig is. Deze demo laat zien hoe je alleen het ontbrekende aanvult.</p><div class="question"><label>Heb je de samenstelling gecontroleerd?</label><div class="choice"><button data-answer="Ja" aria-pressed="false">Ja, gecontroleerd</button><button data-answer="Nee" aria-pressed="false">Nee</button><button data-answer="Onbekend" aria-pressed="false">Weet ik niet</button></div></div><button id="toResult" class="primary" disabled>Bekijk resultaat →</button></section>
7
+ <section id="result" class="hidden"><button class="back" data-goto="questions">← Antwoord aanpassen</button><br><span class="tag">Concept · demo</span><h2 id="resultTitle">Dit is je controleoverzicht.</h2><p class="small" id="resultIntro"></p><div class="resultrow"><span>Product</span><b id="outProduct"></b></div><div class="resultrow"><span>Aangeleverde code</span><b id="outCode"></b></div><div class="resultrow"><span>Bescheidcodes</span><b>Broncontrole nodig</b></div><details style="margin-top:18px"><summary>Bekijk invoer en onderbouwing</summary><div class="content small" id="evidence"></div></details><div class="note">Live DTV/TARIC is niet aangesloten. Daarom worden hier geen definitieve codes of verplichtingen vastgesteld.</div><button class="primary" id="finish">Sla concept op</button><p class="under">Geen bevestiging van een douanebeoordeling.</p></section>
8
+ <section id="done" class="hidden done"><div class="check">✓</div><h2>Concept gereed.</h2><p class="small" id="savedMessage">Download je invoer en open punten om verder te beoordelen.</p><button class="primary" id="download">↓ Download controleoverzicht</button><button class="secondary" id="restart">Volgend product controleren</button></section></div>
9
+ <aside class="roi" aria-label="ROI calculator"><div class="metrics" aria-live="polite"><div><label>Potentiële tijdwinst / maand</label><strong id="hours">—</strong></div><div><label>Netto waarde / maand</label><strong id="net">—</strong></div><div><label>ROI per maand</label><strong id="roi">—</strong></div></div><details><summary>Jouw ROI berekenen · aannames aanpassen</summary><div class="content"><div class="inputs"><div><label for="volume">Controles per maand</label><input type="number" id="volume" value="400" min="0" step="1"></div><div><label for="rate">Uurkosten medewerker (€)</label><input type="number" id="rate" value="55" min="0"></div><div><label for="before">Minuten per controle, nu</label><input type="number" id="before" value="8" min="0" step="0.5"></div><div><label for="after">Minuten met CargoFlow, incl. review</label><input type="number" id="after" value="3" min="0" step="0.5"></div><div><label for="fee">Softwarekosten per maand (€)</label><input type="number" id="fee" value="299" min="0"></div><div><label for="other">Overige maandkosten (€)</label><input type="number" id="other" value="0" min="0"></div></div><div class="formula" id="formula"></div></div></details><p id="assumption">Rekenvoorbeeld, geen gemeten besparing of prijsaanbod.</p></aside><footer>Prototype · Geen live douaneadvies · De declarant houdt de controle.</footer></main>
10
+ <script>
11
+ const $=s=>document.querySelector(s);let answer='',record=null;const money=n=>new Intl.NumberFormat('nl-NL',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n);const fmt=n=>new Intl.NumberFormat('nl-NL',{maximumFractionDigits:1}).format(n);
12
+ function calculate(v,b,a,r,f,o){const hours=v*(b-a)/60,gross=hours*r,cost=f+o,net=gross-cost;return{hours,gross,cost,net,roi:cost>0?net/cost*100:null}}
13
+ function roi(){const ids=['volume','before','after','rate','fee','other'],values=ids.map(id=>Number($('#'+id).value));if(ids.some(id=>$('#'+id).value==='')||values.some(v=>!Number.isFinite(v)||v<0)){$('#hours').textContent=$('#net').textContent=$('#roi').textContent='—';$('#formula').textContent='Vul voor elke aanname een getal van minimaal 0 in.';return}const x=calculate(...values);$('#hours').textContent=fmt(x.hours)+' uur';$('#net').textContent=money(x.net);$('#roi').textContent=x.roi===null?'n.v.t.':fmt(x.roi)+'%';$('#formula').textContent=money(x.gross)+' capaciteitswaarde − '+money(x.cost)+' kosten = '+money(x.net)+' netto per maand. ROI = netto waarde ÷ kosten × 100%. Eenmalige implementatiekosten zijn niet meegerekend.';$('#assumption').textContent='Aannames, geen gemeten resultaat. Vrijgekomen capaciteit is niet automatisch een besparing op uitgaven. €299 is een rekenvoorbeeld, geen prijsaanbod.'}
14
+ function show(id){['input','questions','result','done'].forEach(s=>$('#'+s).classList.toggle('hidden',s!==id));[1,2,3].forEach(n=>$('#step'+n).classList.toggle('active',n===(id==='input'?1:id==='questions'?2:3)));window.scrollTo({top:0,behavior:'smooth'})}
15
+ function context(){$('#contextSummary').textContent=[$('#direction').value,$('#country').value,$('#procedure').value,$('#date').value].join(' · ')}
16
+ $('#date').value=new Date().toISOString().slice(0,10);['direction','country','procedure','date'].forEach(id=>$('#'+id).addEventListener('change',context));context();
17
+ $('#example').onclick=()=>{$('#product').value='Gebreide T-shirts, 100% katoen';$('#code').value='610910';$('#origin').value='India';$('#product').focus()};
18
+ $('#form').onsubmit=e=>{e.preventDefault();if(!$('#product').value.trim()||!$('#origin').value.trim())return;answer='';document.querySelectorAll('[data-answer]').forEach(b=>{b.classList.remove('selected');b.setAttribute('aria-pressed','false')});$('#toResult').disabled=true;show('questions')};
19
+ document.querySelectorAll('[data-answer]').forEach(b=>b.onclick=()=>{answer=b.dataset.answer;document.querySelectorAll('[data-answer]').forEach(c=>{c.classList.toggle('selected',c===b);c.setAttribute('aria-pressed',String(c===b))});$('#toResult').disabled=false});document.querySelectorAll('[data-goto]').forEach(b=>b.onclick=()=>show(b.dataset.goto));
20
+ $('#toResult').onclick=()=>{record={type:'CargoFlow concept — DEMO, geen douanebeoordeling',product:$('#product').value.trim(),aangeleverdeCode:$('#code').value||'Niet ingevuld',oorsprong:$('#origin').value.trim(),goederenstroom:$('#direction').value,landVanAangifte:$('#country').value,regeling:$('#procedure').value,peildatum:$('#date').value,verzendingOfBestemming:$('#dispatch').value||'Onbekend',samenstellingGecontroleerd:answer,bescheidcodes:'Niet vastgesteld — live broncontrole nodig',status:'Concept; beoordeling door declarant nodig'};$('#outProduct').textContent=record.product;$('#outCode').textContent=record.aangeleverdeCode;$('#resultTitle').textContent=answer==='Ja'?'Je invoer staat klaar.':'Er staat nog een vraag open.';$('#resultIntro').textContent=answer==='Ja'?'De volgende stap is inhoudelijke broncontrole.':'Vraag de productspecificatie op. Je kunt dit concept alvast bewaren.';$('#evidence').textContent=record.oorsprong+' · '+record.goederenstroom+' · '+record.landVanAangifte+' · '+record.regeling+' · '+record.peildatum+'. Samenstelling gecontroleerd: '+answer+'. Aangeleverde code niet gevalideerd. Geen bronmaatregelen geladen.';show('result')};
21
+ $('#finish').onclick=()=>{try{localStorage.setItem('cargoflow-simple-concept',JSON.stringify(record));$('#savedMessage').textContent='Lokaal opgeslagen in deze browser. Download het overzicht voor verdere beoordeling.'}catch{$('#savedMessage').textContent='Download het overzicht om dit concept te bewaren. Lokale opslag is hier niet beschikbaar.'}show('done')};
22
+ $('#download').onclick=()=>{const blob=new Blob([JSON.stringify(record,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='cargoflow-controleconcept.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)};$('#restart').onclick=()=>{$('#product').value='';$('#code').value='';answer='';record=null;show('input');$('#product').focus()};['volume','before','after','rate','fee','other'].forEach(id=>$('#'+id).addEventListener('input',roi));roi();
23
+ </script></body></html>
cbh/__init__.py CHANGED
@@ -1,8 +1,3 @@
1
- # pylint: disable=C0415
2
- """
3
- CargoFlow application package.
4
- """
5
-
6
  from fastapi import FastAPI
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from starlette.exceptions import HTTPException as StarletteHTTPException
@@ -11,14 +6,6 @@ from cbh.core.wrappers import CbhResponseWrapper, ErrorCbhResponse
11
 
12
 
13
  def register_routers(app: FastAPI) -> None:
14
- """
15
- Attach every module's router to the application.
16
-
17
- Each module defines its router in its ``__init__`` and its endpoints in
18
- ``views``; importing the view module is what binds the two together.
19
- """
20
- from cbh.api.account import account_router
21
- from cbh.api.account import views as account_views # noqa: F401
22
  from cbh.api.batch import batch_router
23
  from cbh.api.batch import views as batch_views # noqa: F401
24
  from cbh.api.client import client_router
@@ -27,24 +14,23 @@ def register_routers(app: FastAPI) -> None:
27
  from cbh.api.importer import views as importer_views # noqa: F401
28
  from cbh.api.linecheck import linecheck_router
29
  from cbh.api.linecheck import views as linecheck_views # noqa: F401
 
 
30
  from cbh.api.taric import taric_router
31
  from cbh.api.taric import views as taric_views # noqa: F401
32
 
33
  for router in (
34
- account_router,
35
  taric_router,
36
  linecheck_router,
37
  importer_router,
38
  client_router,
39
  batch_router,
 
40
  ):
41
  app.include_router(router)
42
 
43
 
44
  def create_app() -> FastAPI:
45
- """
46
- Create and configure the FastAPI application.
47
- """
48
  app = FastAPI(
49
  title="CargoFlow Regelcheck",
50
  description=(
@@ -70,18 +56,12 @@ def create_app() -> FastAPI:
70
 
71
  @app.exception_handler(StarletteHTTPException)
72
  async def http_exception_handler(_, exc):
73
- """
74
- Handle HTTP exceptions and convert them to standardized error responses.
75
- """
76
  return CbhResponseWrapper(
77
  data=None, successful=False, error=ErrorCbhResponse(message=str(exc.detail))
78
  ).response(exc.status_code)
79
 
80
  @app.on_event("startup")
81
  async def startup_event():
82
- """
83
- Execute startup tasks when the application starts.
84
- """
85
  from cbh.api.client.db_requests import ensure_default_profiles
86
  from cbh.api.common.db_requests import ensure_indexes
87
  from cbh.core.config import settings
@@ -96,9 +76,6 @@ def create_app() -> FastAPI:
96
 
97
  @app.on_event("shutdown")
98
  async def shutdown_event():
99
- """
100
- Stop background jobs when the application stops.
101
- """
102
  from cbh.core.config import settings
103
 
104
  if settings.SCHEDULER.running:
@@ -106,16 +83,10 @@ def create_app() -> FastAPI:
106
 
107
  @app.get("/api/health")
108
  async def health():
109
- """
110
- Health check endpoint for container orchestration and monitoring.
111
- """
112
  return {"status": "healthy"}
113
 
114
  @app.get("/")
115
  async def root():
116
- """
117
- Root endpoint for the application.
118
- """
119
  return {"service": "CargoFlow Regelcheck", "version": "0.3.0"}
120
 
121
  return app
 
 
 
 
 
 
1
  from fastapi import FastAPI
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from starlette.exceptions import HTTPException as StarletteHTTPException
 
6
 
7
 
8
  def register_routers(app: FastAPI) -> None:
 
 
 
 
 
 
 
 
9
  from cbh.api.batch import batch_router
10
  from cbh.api.batch import views as batch_views # noqa: F401
11
  from cbh.api.client import client_router
 
14
  from cbh.api.importer import views as importer_views # noqa: F401
15
  from cbh.api.linecheck import linecheck_router
16
  from cbh.api.linecheck import views as linecheck_views # noqa: F401
17
+ from cbh.api.rules import rules_router
18
+ from cbh.api.rules import views as rules_views # noqa: F401
19
  from cbh.api.taric import taric_router
20
  from cbh.api.taric import views as taric_views # noqa: F401
21
 
22
  for router in (
 
23
  taric_router,
24
  linecheck_router,
25
  importer_router,
26
  client_router,
27
  batch_router,
28
+ rules_router,
29
  ):
30
  app.include_router(router)
31
 
32
 
33
  def create_app() -> FastAPI:
 
 
 
34
  app = FastAPI(
35
  title="CargoFlow Regelcheck",
36
  description=(
 
56
 
57
  @app.exception_handler(StarletteHTTPException)
58
  async def http_exception_handler(_, exc):
 
 
 
59
  return CbhResponseWrapper(
60
  data=None, successful=False, error=ErrorCbhResponse(message=str(exc.detail))
61
  ).response(exc.status_code)
62
 
63
  @app.on_event("startup")
64
  async def startup_event():
 
 
 
65
  from cbh.api.client.db_requests import ensure_default_profiles
66
  from cbh.api.common.db_requests import ensure_indexes
67
  from cbh.core.config import settings
 
76
 
77
  @app.on_event("shutdown")
78
  async def shutdown_event():
 
 
 
79
  from cbh.core.config import settings
80
 
81
  if settings.SCHEDULER.running:
 
83
 
84
  @app.get("/api/health")
85
  async def health():
 
 
 
86
  return {"status": "healthy"}
87
 
88
  @app.get("/")
89
  async def root():
 
 
 
90
  return {"service": "CargoFlow Regelcheck", "version": "0.3.0"}
91
 
92
  return app
cbh/api/account/__init__.py DELETED
@@ -1,12 +0,0 @@
1
- """
2
- Account module initialization.
3
-
4
- This module defines the FastAPI router for account API endpoints
5
- and imports related views for account management.
6
- """
7
-
8
- from fastapi import APIRouter
9
-
10
- account_router = APIRouter(
11
- prefix="/api/account",
12
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
cbh/api/account/dto.py DELETED
@@ -1,38 +0,0 @@
1
- """
2
- Account DTOs.
3
- """
4
-
5
- from enum import Enum
6
-
7
-
8
- class AccountType(Enum):
9
- """
10
- Enum for account types.
11
- """
12
-
13
- USER = 1
14
- ADMIN = 2
15
- OWNER = 3
16
- SUPER_ADMIN = 4
17
-
18
-
19
- class AccountStatus(Enum):
20
- """
21
- Enum for account statuses.
22
- """
23
-
24
- ACTIVE = 1
25
- BLOCKED = 2
26
- PENDING_INVITATION = 3
27
- PENDING_COMPLETION = 4
28
-
29
-
30
- class RegistrationType(Enum):
31
- """
32
- Enum for registration types.
33
- """
34
-
35
- ORGANIC = 1
36
- GOOGLE = 2
37
- GITHUB = 3
38
- APPLE = 4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cbh/api/account/models.py DELETED
@@ -1,124 +0,0 @@
1
- """
2
- Account models.
3
- """
4
-
5
- from datetime import date, datetime
6
-
7
- from passlib.context import CryptContext
8
- from pydantic import Field, field_validator
9
-
10
- from cbh.api.account.dto import (
11
- AccountStatus,
12
- AccountType,
13
- RegistrationType
14
- )
15
- from cbh.core.config import settings
16
- from cbh.core.database import MongoBaseModel, MongoBaseShortenModel
17
-
18
-
19
- class OrganizationShorten(MongoBaseShortenModel):
20
- """
21
- Organization shorten model.
22
- """
23
-
24
- name: str
25
- slug: str | None = None
26
- pictureUrl: str | None = None
27
-
28
- @field_validator("pictureUrl", mode="before")
29
- @classmethod
30
- def serialize_picture_url(cls, v: str | None) -> str | None:
31
- """
32
- Serialize the scenario picture URL.
33
- """
34
- if v:
35
- if not v.startswith("https://"):
36
- return settings.S3_CLIENT.generate_presigned_url(v, expiration=3600)
37
- return v
38
- return None
39
-
40
-
41
- class AccountModel(MongoBaseModel):
42
- """
43
- Account model class.
44
- """
45
-
46
- name: str | None = None
47
- email: str
48
- pictureUrl: str | None = None
49
- password: str | None = Field(exclude=True, default=None)
50
-
51
- status: AccountStatus = AccountStatus.ACTIVE
52
- accountType: AccountType = Field(default=AccountType.USER)
53
- registrationType: RegistrationType | None = Field(
54
- default=RegistrationType.ORGANIC, exclude=True
55
- )
56
-
57
- organization: OrganizationShorten | None = None
58
- freeMinutesUsed: int = Field(default=0, exclude=True)
59
-
60
- lastLogin: date | None = Field(exclude=True, default=None)
61
- datetimeInserted: datetime = Field(default_factory=datetime.now)
62
- datetimeUpdated: datetime = Field(default_factory=datetime.now)
63
-
64
- @field_validator("password", mode="before", check_fields=False)
65
- @classmethod
66
- def set_password_hash(cls, v: str | None) -> str | None:
67
- """
68
- Set the password hash.
69
-
70
- Args:
71
- v (str): The password to hash.
72
-
73
- Returns:
74
- str: The hashed password.
75
- """
76
- if isinstance(v, str) and not v.startswith("$2b$"):
77
- return CryptContext(schemes=["bcrypt"], deprecated="auto").hash(v)
78
- return v
79
-
80
- @field_validator("pictureUrl", mode="before")
81
- @classmethod
82
- def serialize_picture_url(cls, v: str | None) -> str | None:
83
- """ """
84
- if v:
85
- if not v.startswith("https://"):
86
- return settings.S3_CLIENT.generate_presigned_url(v, expiration=3600)
87
- return v
88
- return None
89
-
90
- class Config: # pylint: disable=R0903
91
- """
92
- Config for the AccountModel class.
93
- """
94
-
95
- arbitrary_types_allowed = True
96
- populate_by_name = True
97
- json_encoders = {datetime: lambda dt: dt.isoformat()}
98
-
99
-
100
- class AccountShorten(MongoBaseShortenModel):
101
- """
102
- Account shorten model.
103
- """
104
-
105
- id: str
106
- name: str | None = None
107
- email: str
108
- pictureUrl: str | None = None
109
-
110
- accountType: AccountType
111
-
112
- organization: OrganizationShorten | None = None
113
-
114
- @field_validator("pictureUrl", mode="before")
115
- @classmethod
116
- def serialize_picture_url(cls, v: str | None) -> str | None:
117
- """
118
- Serialize the account picture URL
119
- """
120
- if v:
121
- if not v.startswith("https://"):
122
- return settings.S3_CLIENT.generate_presigned_url(v, expiration=3600)
123
- return v
124
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cbh/api/account/schemas.py DELETED
@@ -1,11 +0,0 @@
1
- from pydantic import BaseModel
2
-
3
-
4
- class AccountFilter(BaseModel):
5
- """
6
- Account filter.
7
- """
8
-
9
- accountTypes: list[int] | None = None
10
- statuses: list[int] | None = None
11
- searchTerm: str | None = None
 
 
 
 
 
 
 
 
 
 
 
 
cbh/api/account/utils.py DELETED
@@ -1,36 +0,0 @@
1
- from io import BytesIO
2
-
3
- from PIL import Image, ImageOps, UnidentifiedImageError
4
- from fastapi import HTTPException, UploadFile
5
-
6
- from cbh.api.account.models import AccountModel
7
-
8
-
9
- def compress_image(file: UploadFile) -> bytes:
10
- if not file.content_type or not file.content_type.startswith("image/"):
11
- raise HTTPException(status_code=400, detail="File must be an image")
12
- try:
13
- file.file.seek(0)
14
- original_bytes = file.file.read()
15
- image = Image.open(BytesIO(original_bytes))
16
- image = ImageOps.exif_transpose(image)
17
- max_size = 512
18
- image.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
19
- if image.mode not in ("RGB", "RGBA"):
20
- image = image.convert("RGBA")
21
- buffer = BytesIO()
22
- image.save(buffer, format="PNG", optimize=True, compress_level=9)
23
- return buffer.getvalue()
24
- except (UnidentifiedImageError, OSError):
25
- raise HTTPException(status_code=400, detail="Invalid image file")
26
- finally:
27
- try:
28
- file.file.close()
29
- except Exception:
30
- pass
31
-
32
-
33
- def prepare_additional_filter(account: AccountModel) -> dict:
34
- return {
35
- "organization.id": account.organization.id,
36
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cbh/api/account/views.py DELETED
@@ -1,44 +0,0 @@
1
- """
2
- Account views module.
3
- """
4
-
5
- from fastapi import Depends
6
-
7
- from cbh.api.account import account_router
8
- from cbh.api.account.dto import AccountType
9
- from cbh.api.account.models import AccountModel
10
- from cbh.api.common.db_requests import delete_obj
11
- from cbh.core.security import PermissionDependency
12
- from cbh.core.wrappers import CbhResponseWrapper
13
-
14
-
15
- @account_router.delete("/{accountId}")
16
- async def delete_account(
17
- accountId: str,
18
- account: AccountModel = Depends(
19
- PermissionDependency(
20
- [AccountType.OWNER],
21
- )
22
- ),
23
- ) -> CbhResponseWrapper[AccountModel]:
24
- """
25
- Delete an account.
26
- """
27
- removed_account = await delete_obj(
28
- AccountModel,
29
- accountId,
30
- additional_filter={
31
- "organization.id": account.organization.id,
32
- },
33
- )
34
- return CbhResponseWrapper()
35
-
36
-
37
- @account_router.get("")
38
- async def get_own_account(
39
- account: AccountModel = Depends(PermissionDependency()),
40
- ) -> CbhResponseWrapper[AccountModel]:
41
- """
42
- Get own account.
43
- """
44
- return CbhResponseWrapper(data=account)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cbh/api/batch/__init__.py CHANGED
@@ -1,11 +1,3 @@
1
- """
2
- Batch module initialization.
3
-
4
- This module defines the FastAPI router for the Excel processor: uploading a
5
- client's pack, running it through the line-check API and handing back the same
6
- file with the checker's columns added.
7
- """
8
-
9
  from fastapi import APIRouter
10
 
11
  batch_router = APIRouter(
 
 
 
 
 
 
 
 
 
1
  from fastapi import APIRouter
2
 
3
  batch_router = APIRouter(
cbh/api/batch/db_requests.py CHANGED
@@ -1,31 +1,20 @@
1
- """
2
- Batch database requests.
3
- """
4
-
5
  from cbh.api.batch.models import BatchModel, BatchRowModel
6
  from cbh.api.common.db_requests import find_objs, get_all_objs, get_obj_by_id
7
 
8
 
9
  async def get_batch(batch_id: str) -> BatchModel:
10
- """
11
- Fetch a pack, 404 when it does not exist.
12
- """
13
  return await get_obj_by_id(BatchModel, batch_id)
14
 
15
 
16
  async def get_batches(
17
  page_size: int, page_index: int, status: int | None = None
18
  ) -> tuple[list[BatchModel], int]:
19
- """
20
- Page through the packs, newest first.
21
- """
22
- filter_ = {"status": status} if status is not None else {}
23
  return await get_all_objs(
24
  BatchModel,
25
  page_size=page_size,
26
  page_index=page_index,
27
  sort=("datetimeInserted", -1),
28
- additional_filter=filter_,
29
  )
30
 
31
 
@@ -35,12 +24,6 @@ async def get_batch_rows(
35
  page_index: int = 0,
36
  needs_review_only: bool = False,
37
  ) -> tuple[list[BatchRowModel], int]:
38
- """
39
- Read the rows of a pack in spreadsheet order.
40
-
41
- ``needs_review_only`` is the working view for a broker: the lines the
42
- machine refused to answer for.
43
- """
44
  filter_: dict = {"batchId": batch_id}
45
  if needs_review_only:
46
  filter_["needsReview"] = True
 
 
 
 
 
1
  from cbh.api.batch.models import BatchModel, BatchRowModel
2
  from cbh.api.common.db_requests import find_objs, get_all_objs, get_obj_by_id
3
 
4
 
5
  async def get_batch(batch_id: str) -> BatchModel:
 
 
 
6
  return await get_obj_by_id(BatchModel, batch_id)
7
 
8
 
9
  async def get_batches(
10
  page_size: int, page_index: int, status: int | None = None
11
  ) -> tuple[list[BatchModel], int]:
 
 
 
 
12
  return await get_all_objs(
13
  BatchModel,
14
  page_size=page_size,
15
  page_index=page_index,
16
  sort=("datetimeInserted", -1),
17
+ additional_filter={"status": status} if status is not None else {},
18
  )
19
 
20
 
 
24
  page_index: int = 0,
25
  needs_review_only: bool = False,
26
  ) -> tuple[list[BatchRowModel], int]:
 
 
 
 
 
 
27
  filter_: dict = {"batchId": batch_id}
28
  if needs_review_only:
29
  filter_["needsReview"] = True
cbh/api/batch/dto.py CHANGED
@@ -1,18 +1,7 @@
1
- """
2
- Batch (Excel processing) DTOs.
3
- """
4
 
5
- from enum import Enum
6
-
7
-
8
- class BatchStatus(Enum):
9
- """
10
- Lifecycle of an uploaded pack.
11
-
12
- ``MAPPING_REQUIRED`` means the file was read but the profile does not map
13
- everything needed — the pack waits rather than being processed on guesses.
14
- """
15
 
 
16
  UPLOADED = 1
17
  MAPPING_REQUIRED = 2
18
  PROCESSING = 3
@@ -20,30 +9,48 @@ class BatchStatus(Enum):
20
  FAILED = 5
21
 
22
 
23
- class FileFormat(Enum):
24
- """
25
- Format a pack arrived in.
26
- """
27
-
28
  XLSX = 1
29
  CSV = 2
30
 
31
  @classmethod
32
  def from_name(cls, file_name: str) -> "FileFormat":
33
- """
34
- Derive the format from a file name.
35
- """
36
  return cls.CSV if file_name.lower().endswith((".csv", ".txt")) else cls.XLSX
37
 
38
 
39
- class ReviewStage(Enum):
 
 
 
 
 
 
 
 
 
 
40
  """
41
- Where a pack stands in human review.
42
 
43
- Review time is the metric that decides whether the product is worth having,
44
- so it is measured rather than estimated.
 
 
 
 
 
 
 
 
 
 
45
  """
46
 
47
- NOT_STARTED = 1
48
- IN_REVIEW = 2
49
- REVIEWED = 3
 
 
 
 
 
 
 
1
+ from enum import IntEnum
 
 
2
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ class BatchStatus(IntEnum):
5
  UPLOADED = 1
6
  MAPPING_REQUIRED = 2
7
  PROCESSING = 3
 
9
  FAILED = 5
10
 
11
 
12
+ class FileFormat(IntEnum):
 
 
 
 
13
  XLSX = 1
14
  CSV = 2
15
 
16
  @classmethod
17
  def from_name(cls, file_name: str) -> "FileFormat":
 
 
 
18
  return cls.CSV if file_name.lower().endswith((".csv", ".txt")) else cls.XLSX
19
 
20
 
21
+ class ReviewStage(IntEnum):
22
+ NOT_STARTED = 1
23
+ IN_REVIEW = 2
24
+ REVIEWED = 3
25
+
26
+
27
+ class BatchSource(IntEnum):
28
+ """How the lines got into the shipment.
29
+
30
+ Both routes end in the same review table; this only records which one was
31
+ taken, so a manually built shipment is not mistaken for a parsed file.
32
  """
 
33
 
34
+ UPLOAD = 1
35
+ MANUAL = 2
36
+
37
+
38
+ class ExportFormat(IntEnum):
39
+ """The client system the reviewed table is exported for.
40
+
41
+ REVIEW is the tool's own full table. SOURCE returns the client's uploaded
42
+ file with the checked columns appended, which is what the upload route has
43
+ always produced. SOFTPAK is the Goenee line list. SCOPE and FITON are
44
+ declared but not implemented: their import specifications have not been
45
+ received, and guessing at a layout is worse than refusing.
46
  """
47
 
48
+ REVIEW = 1
49
+ SOURCE = 2
50
+ SOFTPAK = 3
51
+ SCOPE = 4
52
+ FITON = 5
53
+
54
+ @property
55
+ def implemented(self) -> bool:
56
+ return self in (ExportFormat.REVIEW, ExportFormat.SOURCE, ExportFormat.SOFTPAK)
cbh/api/batch/models.py CHANGED
@@ -1,67 +1,58 @@
1
- """
2
- Batch models.
3
- """
4
-
5
  from datetime import date, datetime
6
 
7
  from pydantic import Field
8
 
9
- from cbh.api.batch.dto import BatchStatus, FileFormat, ReviewStage
10
- from cbh.api.common.dto import TradeDirection
11
  from cbh.api.common.models import FileRef
12
- from cbh.core.database import MongoBaseModel, MongoBaseShortenModel, MongoSerializableModel
 
13
 
14
 
15
  class ClientProfileRef(MongoSerializableModel):
16
- """
17
- The profile a pack was processed under.
18
-
19
- Stored by value as well as by id: a pack processed last month was processed
20
- under the mapping as it stood then, and re-reading it must not silently
21
- adopt a mapping edited since.
22
- """
23
-
24
  id: str
25
  name: str
26
 
27
 
28
  class BatchMetrics(MongoSerializableModel):
29
- """
30
- The numbers the acceptance criteria are stated in.
31
-
32
- ``reviewSeconds`` is the one that matters commercially: it is the time a
33
- human spent on the pack after the machine had finished.
34
- """
35
-
36
  totalLines: int = 0
37
  uniqueLookups: int = 0
38
  machineSeconds: float = 0.0
39
  reviewSeconds: float | None = None
40
  statusCounts: dict[str, int] = Field(default_factory=dict)
41
 
 
 
 
 
 
 
 
 
 
 
42
  @property
43
  def lines_needing_review(self) -> int:
44
- """
45
- Lines a broker has to look at before filing.
46
- """
47
  return self.statusCounts.get("AMBIGUOUS", 0) + self.statusCounts.get("INVALID", 0)
48
 
49
 
50
  class BatchModel(MongoBaseModel):
51
- """
52
- One uploaded pack of declaration lines.
53
- """
54
-
55
  name: str
56
  clientProfile: ClientProfileRef
57
  status: BatchStatus = BatchStatus.UPLOADED
 
58
  fileFormat: FileFormat = FileFormat.XLSX
59
 
60
- sourceFile: FileRef
 
61
  resultFile: FileRef | None = None
62
 
63
  checkDate: date | None = None
64
  defaultDirection: TradeDirection = TradeDirection.IMPORT
 
 
 
 
65
 
66
  metrics: BatchMetrics = Field(default_factory=BatchMetrics)
67
  errors: list[str] = Field(default_factory=list)
@@ -76,24 +67,7 @@ class BatchModel(MongoBaseModel):
76
  datetimeUpdated: datetime = Field(default_factory=datetime.now)
77
 
78
 
79
- class BatchShorten(MongoBaseShortenModel):
80
- """
81
- Batch projection for list endpoints.
82
- """
83
-
84
- name: str
85
- status: BatchStatus
86
- datetimeInserted: datetime
87
-
88
-
89
  class BatchRowModel(MongoBaseModel):
90
- """
91
- One row of a pack, with the answer it got.
92
-
93
- The raw input is kept alongside the result so a disputed line can be
94
- replayed exactly as it was submitted.
95
- """
96
-
97
  batchId: str
98
  rowIndex: int
99
 
@@ -104,4 +78,12 @@ class BatchRowModel(MongoBaseModel):
104
  status: str = "INVALID"
105
  needsReview: bool = False
106
 
 
 
 
 
 
 
 
 
107
  datetimeInserted: datetime = Field(default_factory=datetime.now)
 
 
 
 
 
1
  from datetime import date, datetime
2
 
3
  from pydantic import Field
4
 
5
+ from cbh.api.batch.dto import BatchSource, BatchStatus, FileFormat, ReviewStage
6
+ from cbh.api.common.dto import CustomsProcedure, TradeDirection
7
  from cbh.api.common.models import FileRef
8
+ from cbh.api.linecheck.dto import RowDecision
9
+ from cbh.core.database import MongoBaseModel, MongoSerializableModel
10
 
11
 
12
  class ClientProfileRef(MongoSerializableModel):
 
 
 
 
 
 
 
 
13
  id: str
14
  name: str
15
 
16
 
17
  class BatchMetrics(MongoSerializableModel):
 
 
 
 
 
 
 
18
  totalLines: int = 0
19
  uniqueLookups: int = 0
20
  machineSeconds: float = 0.0
21
  reviewSeconds: float | None = None
22
  statusCounts: dict[str, int] = Field(default_factory=dict)
23
 
24
+ extractionScore: float = 0.0
25
+ documentScore: float = 0.0
26
+ codeConfidenceCounts: dict[str, int] = Field(default_factory=dict)
27
+ documentConfidenceCounts: dict[str, int] = Field(default_factory=dict)
28
+
29
+ totalPackages: float = 0.0
30
+ totalGrossWeight: float = 0.0
31
+ totalNetWeight: float = 0.0
32
+ totalValue: float = 0.0
33
+
34
  @property
35
  def lines_needing_review(self) -> int:
 
 
 
36
  return self.statusCounts.get("AMBIGUOUS", 0) + self.statusCounts.get("INVALID", 0)
37
 
38
 
39
  class BatchModel(MongoBaseModel):
 
 
 
 
40
  name: str
41
  clientProfile: ClientProfileRef
42
  status: BatchStatus = BatchStatus.UPLOADED
43
+ source: BatchSource = BatchSource.UPLOAD
44
  fileFormat: FileFormat = FileFormat.XLSX
45
 
46
+ # A shipment built line by line through the form has no source file.
47
+ sourceFile: FileRef | None = None
48
  resultFile: FileRef | None = None
49
 
50
  checkDate: date | None = None
51
  defaultDirection: TradeDirection = TradeDirection.IMPORT
52
+ defaultProcedure: CustomsProcedure = CustomsProcedure.RELEASE_FOR_FREE_CIRCULATION
53
+ destinationCountry: str | None = None
54
+ shipmentReference: str | None = None
55
+ currency: str = "EUR"
56
 
57
  metrics: BatchMetrics = Field(default_factory=BatchMetrics)
58
  errors: list[str] = Field(default_factory=list)
 
67
  datetimeUpdated: datetime = Field(default_factory=datetime.now)
68
 
69
 
 
 
 
 
 
 
 
 
 
 
70
  class BatchRowModel(MongoBaseModel):
 
 
 
 
 
 
 
71
  batchId: str
72
  rowIndex: int
73
 
 
78
  status: str = "INVALID"
79
  needsReview: bool = False
80
 
81
+ decision: RowDecision = RowDecision.PENDING
82
+ correctedCode: str | None = None
83
+ cbamPermitNumber: str | None = None
84
+ eudrReference: str | None = None
85
+ licenceNumbers: list[str] = Field(default_factory=list)
86
+ reviewNote: str | None = None
87
+ datetimeReviewed: datetime | None = None
88
+
89
  datetimeInserted: datetime = Field(default_factory=datetime.now)
cbh/api/batch/schemas.py CHANGED
@@ -1,32 +1,36 @@
1
- """
2
- Batch schemas.
3
- """
4
-
5
  from datetime import date, datetime
6
  from typing import Any
7
 
8
  from pydantic import BaseModel
9
 
10
- from cbh.api.batch.dto import BatchStatus, ReviewStage
11
  from cbh.api.batch.models import BatchMetrics, ClientProfileRef
12
- from cbh.api.common.dto import TradeDirection
 
 
 
 
 
 
 
 
13
 
14
 
15
  class BatchResponse(BaseModel):
16
- """
17
- A pack as returned by the API.
18
- """
19
-
20
  id: str
21
  name: str
22
  clientProfile: ClientProfileRef
23
  status: BatchStatus
 
24
 
25
- sourceFileName: str
26
  resultFileName: str | None = None
27
 
28
  checkDate: date | None = None
29
  defaultDirection: TradeDirection
 
 
 
30
 
31
  metrics: BatchMetrics
32
  errors: list[str] = []
@@ -40,10 +44,6 @@ class BatchResponse(BaseModel):
40
 
41
 
42
  class BatchRowResponse(BaseModel):
43
- """
44
- One row of a pack, with the answer it received.
45
- """
46
-
47
  rowIndex: int
48
  status: str
49
  needsReview: bool
@@ -52,13 +52,6 @@ class BatchRowResponse(BaseModel):
52
 
53
 
54
  class GoldSetScoreResponse(BaseModel):
55
- """
56
- Accuracy of a pack against a confirmed gold file, plus its timings.
57
-
58
- Review time is the metric that sells the product, so it is reported
59
- alongside accuracy rather than separately.
60
- """
61
-
62
  goldRows: int
63
  matchedRows: int
64
  unmatchedGoldRows: list[int] = []
@@ -75,3 +68,150 @@ class GoldSetScoreResponse(BaseModel):
75
  reviewSeconds: float | None = None
76
  totalLines: int | None = None
77
  linesNeedingReview: int | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from datetime import date, datetime
2
  from typing import Any
3
 
4
  from pydantic import BaseModel
5
 
6
+ from cbh.api.batch.dto import BatchSource, BatchStatus, ExportFormat, ReviewStage
7
  from cbh.api.batch.models import BatchMetrics, ClientProfileRef
8
+ from cbh.api.common.dto import Paging
9
+ from cbh.api.common.dto import CustomsProcedure, TradeDirection
10
+ from cbh.api.linecheck.dto import ConfidenceLevel, ReferenceSource, RowDecision
11
+ from cbh.api.linecheck.schemas import (
12
+ AppliedRule,
13
+ DeclarationDate,
14
+ DocumentRequirement,
15
+ LineWarning,
16
+ )
17
 
18
 
19
  class BatchResponse(BaseModel):
 
 
 
 
20
  id: str
21
  name: str
22
  clientProfile: ClientProfileRef
23
  status: BatchStatus
24
+ source: BatchSource = BatchSource.UPLOAD
25
 
26
+ sourceFileName: str | None = None
27
  resultFileName: str | None = None
28
 
29
  checkDate: date | None = None
30
  defaultDirection: TradeDirection
31
+ destinationCountry: str | None = None
32
+ shipmentReference: str | None = None
33
+ currency: str = "EUR"
34
 
35
  metrics: BatchMetrics
36
  errors: list[str] = []
 
44
 
45
 
46
  class BatchRowResponse(BaseModel):
 
 
 
 
47
  rowIndex: int
48
  status: str
49
  needsReview: bool
 
52
 
53
 
54
  class GoldSetScoreResponse(BaseModel):
 
 
 
 
 
 
 
55
  goldRows: int
56
  matchedRows: int
57
  unmatchedGoldRows: list[int] = []
 
68
  reviewSeconds: float | None = None
69
  totalLines: int | None = None
70
  linesNeedingReview: int | None = None
71
+
72
+
73
+ class ReviewDocuments(BaseModel):
74
+ required: list[DocumentRequirement] = []
75
+ alternatives: list[list[DocumentRequirement]] = []
76
+ exemption: list[DocumentRequirement] = []
77
+ suspended: list[DocumentRequirement] = []
78
+ noneRequired: bool = False
79
+ label: str
80
+
81
+
82
+ class ReviewRow(BaseModel):
83
+ rowIndex: int
84
+ description: str | None = None
85
+
86
+ inputCode: str | None = None
87
+ proposedCode: str | None = None
88
+ formattedCode: str | None = None
89
+ codeDescription: str | None = None
90
+ candidates: list[str] = []
91
+ replacementCodes: list[str] = []
92
+
93
+ originCountry: str | None = None
94
+ destinationCountry: str | None = None
95
+ measureCountry: str | None = None
96
+ direction: TradeDirection | None = None
97
+ procedure: CustomsProcedure | None = None
98
+
99
+ codeConfidence: ConfidenceLevel
100
+ codeConfidenceReason: str = ""
101
+ documentConfidence: ConfidenceLevel
102
+ documentConfidenceReason: str = ""
103
+ source: ReferenceSource = ReferenceSource.NONE
104
+
105
+ documents: ReviewDocuments
106
+ warnings: list[LineWarning] = []
107
+ appliedRules: list[AppliedRule] = []
108
+
109
+ packages: float | None = None
110
+ grossWeight: float | None = None
111
+ netWeight: float | None = None
112
+ value: float | None = None
113
+
114
+ decision: RowDecision = RowDecision.PENDING
115
+ correctedCode: str | None = None
116
+ cbamPermitNumber: str | None = None
117
+ eudrReference: str | None = None
118
+ licenceNumbers: list[str] = []
119
+ reviewNote: str | None = None
120
+
121
+ status: str
122
+ needsReview: bool
123
+
124
+
125
+ class ShipmentTotals(BaseModel):
126
+ packages: float = 0.0
127
+ grossWeight: float = 0.0
128
+ netWeight: float = 0.0
129
+ value: float = 0.0
130
+ currency: str = "EUR"
131
+
132
+
133
+ class ReviewHeader(BaseModel):
134
+ batchId: str
135
+ shipmentReference: str | None = None
136
+ name: str
137
+ clientProfile: ClientProfileRef
138
+ status: BatchStatus
139
+ source: BatchSource = BatchSource.UPLOAD
140
+ direction: TradeDirection
141
+ procedure: CustomsProcedure = CustomsProcedure.RELEASE_FOR_FREE_CIRCULATION
142
+ originCountries: list[str] = []
143
+ destinationCountry: str | None = None
144
+ checkDate: date | None = None
145
+ referenceAsOf: str | None = None
146
+
147
+ totals: ShipmentTotals
148
+ totalLines: int = 0
149
+ extractionScore: float = 0.0
150
+ documentScore: float = 0.0
151
+ codeConfidenceCounts: dict[str, int] = {}
152
+ documentConfidenceCounts: dict[str, int] = {}
153
+ linesNeedingReview: int = 0
154
+
155
+
156
+ class ReviewTableResponse(BaseModel):
157
+ header: ReviewHeader
158
+ paging: Paging
159
+ rows: list[ReviewRow]
160
+
161
+
162
+ class RowReviewRequest(BaseModel):
163
+ correctedCode: str | None = None
164
+ decision: RowDecision | None = None
165
+ cbamPermitNumber: str | None = None
166
+ eudrReference: str | None = None
167
+ licenceNumbers: list[str] | None = None
168
+ reviewNote: str | None = None
169
+
170
+
171
+ class ManualShipmentRequest(BaseModel):
172
+ """Open an empty shipment that the form will fill line by line."""
173
+
174
+ clientProfileId: str
175
+ name: str | None = None
176
+ checkDate: date | None = None
177
+ direction: TradeDirection | None = None
178
+ procedure: CustomsProcedure | None = None
179
+ destinationCountry: str | None = None
180
+ shipmentReference: str | None = None
181
+ currency: str = "EUR"
182
+
183
+
184
+ class ManualLineRequest(BaseModel):
185
+ """One line from the single-item form, added to the review table.
186
+
187
+ These are the fields the UI specification lists for the form, plus the
188
+ quantities the totals row needs. A partial code is allowed: it resolves,
189
+ or it comes back ambiguous with its candidates, exactly as an uploaded
190
+ line does.
191
+ """
192
+
193
+ code: str
194
+ originCountry: str | None = None
195
+ destinationCountry: str | None = None
196
+ direction: TradeDirection | None = None
197
+ procedure: CustomsProcedure | None = None
198
+ date: DeclarationDate | None = None
199
+ additionalCodes: list[str] = []
200
+
201
+ description: str | None = None
202
+ quantity: float | None = None
203
+ packages: float | None = None
204
+ grossWeight: float | None = None
205
+ netWeight: float | None = None
206
+ value: float | None = None
207
+
208
+
209
+ class ExportFormatResponse(BaseModel):
210
+ """One entry of the export dropdown."""
211
+
212
+ format: ExportFormat
213
+ name: str
214
+ description: str
215
+ available: bool
216
+ unavailableReason: str | None = None
217
+ isProfileDefault: bool = False
cbh/api/batch/services/excel/__init__.py CHANGED
@@ -1,12 +1,24 @@
1
- from .reader import ParsedRow, ParsedSheet, parse_pack, read_headers
2
- from .writer import build_output_workbook, render_output_value, workbook_to_bytes
 
 
 
 
 
 
 
3
 
4
  __all__ = [
5
  "ParsedRow",
6
  "ParsedSheet",
 
7
  "build_output_workbook",
 
 
 
 
8
  "parse_pack",
9
  "read_headers",
10
- "render_output_value",
11
  "workbook_to_bytes",
12
  ]
 
1
+ from .export import (
2
+ build_export_workbook,
3
+ build_review_workbook,
4
+ build_softpak_workbook,
5
+ export_file_name,
6
+ unavailable_reason,
7
+ )
8
+ from .reader import ParsedRow, ParsedSheet, load_pack_workbook, parse_pack, read_headers
9
+ from .writer import build_output_workbook, workbook_to_bytes
10
 
11
  __all__ = [
12
  "ParsedRow",
13
  "ParsedSheet",
14
+ "build_export_workbook",
15
  "build_output_workbook",
16
+ "build_review_workbook",
17
+ "build_softpak_workbook",
18
+ "export_file_name",
19
+ "load_pack_workbook",
20
  "parse_pack",
21
  "read_headers",
22
+ "unavailable_reason",
23
  "workbook_to_bytes",
24
  ]
cbh/api/batch/services/excel/export.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from openpyxl.styles import Alignment, Font, PatternFill
2
+ from openpyxl.utils import get_column_letter
3
+ from openpyxl.workbook import Workbook
4
+
5
+ from cbh.api.batch.dto import ExportFormat
6
+ from cbh.api.batch.models import BatchModel, BatchRowModel
7
+ from cbh.api.batch.services.excel.writer import SOFTPAK_ORDER, render_output_value
8
+ from cbh.api.batch.services.metrics.totals import TotalsInput, input_value, sheet_totals
9
+ from cbh.api.client.dto import InputField, OutputField
10
+ from cbh.api.client.models import ClientProfileModel
11
+ from cbh.api.linecheck.dto import LineStatus, RowDecision
12
+ from cbh.api.linecheck.schemas import LineCheckRequest, LineCheckResponse
13
+ from cbh.api.linecheck.utils import render_required_documents, render_warnings
14
+
15
+ STATUS_FILLS = {
16
+ LineStatus.INVALID: PatternFill("solid", fgColor="FFF8CBCB"),
17
+ LineStatus.AMBIGUOUS: PatternFill("solid", fgColor="FFFDE9A9"),
18
+ LineStatus.EXPIRING: PatternFill("solid", fgColor="FFE2F0D9"),
19
+ }
20
+
21
+ REVIEW_COLUMNS: list[tuple[str, int]] = [
22
+ ("#", 6),
23
+ ("Description", 38),
24
+ ("Input code", 14),
25
+ ("Proposed code", 14),
26
+ ("Code description", 38),
27
+ ("Origin", 8),
28
+ ("Destination", 11),
29
+ ("Code confidence", 15),
30
+ ("Code confidence reason", 40),
31
+ ("Documents required", 26),
32
+ ("Document codes exempt", 22),
33
+ ("No document required", 20),
34
+ ("Document confidence", 18),
35
+ ("Document confidence reason", 40),
36
+ ("Warnings", 46),
37
+ ("Rules applied", 26),
38
+ ("CBAM permit", 18),
39
+ ("EUDR reference", 18),
40
+ ("Licences", 18),
41
+ ("Decision", 12),
42
+ ("Review note", 30),
43
+ ("Status", 12),
44
+ ("Packages", 10),
45
+ ("Gross weight", 12),
46
+ ("Net weight", 12),
47
+ ("Value", 12),
48
+ ]
49
+
50
+
51
+ def _unpack(row: BatchRowModel) -> tuple[LineCheckRequest | None, LineCheckResponse | None]:
52
+ request = LineCheckRequest(**row.request) if row.request else None
53
+ result = LineCheckResponse(**row.result) if row.result else None
54
+ return request, result
55
+
56
+
57
+ def _totals_lines(rows: list[BatchRowModel]) -> list[TotalsInput]:
58
+ return [
59
+ TotalsInput(raw=row.inputRow, request=_unpack(row)[0]) for row in rows
60
+ ]
61
+
62
+
63
+ def _review_values(row: BatchRowModel) -> list:
64
+ request, result = _unpack(row)
65
+ return [
66
+ row.rowIndex,
67
+ request.description if request else "",
68
+ result.inputCode if result else "",
69
+ row.correctedCode or (result.resolvedCode if result else "") or "",
70
+ result.codeDescription if result else "",
71
+ result.originCountry if result else "",
72
+ result.destinationCountry if result else "",
73
+ result.codeConfidence.name.lower() if result else "",
74
+ result.codeConfidenceReason if result else "",
75
+ render_required_documents(result) if result else "",
76
+ ", ".join(document.code for document in result.documentsExemption) if result else "",
77
+ "yes" if result and result.noneRequired else "",
78
+ result.documentConfidence.name.lower() if result else "",
79
+ result.documentConfidenceReason if result else "",
80
+ render_warnings(result) if result else "",
81
+ ", ".join(rule.name for rule in result.appliedRules) if result else "",
82
+ row.cbamPermitNumber or "",
83
+ row.eudrReference or "",
84
+ ", ".join(row.licenceNumbers),
85
+ RowDecision(row.decision).name.lower(),
86
+ row.reviewNote or "",
87
+ row.status.lower(),
88
+ request.packages if request else None,
89
+ request.grossWeight if request else None,
90
+ request.netWeight if request else None,
91
+ request.value if request else None,
92
+ ]
93
+
94
+
95
+ def _write_header(worksheet, headers: list[str], row_index: int) -> None:
96
+ for column_index, header in enumerate(headers, start=1):
97
+ cell = worksheet.cell(row=row_index, column=column_index)
98
+ cell.value = header
99
+ cell.font = Font(bold=True)
100
+ cell.fill = PatternFill("solid", fgColor="FFE8EEF7")
101
+ cell.alignment = Alignment(vertical="top")
102
+
103
+
104
+ def build_review_workbook(
105
+ batch: BatchModel, rows: list[BatchRowModel], highlight: bool = True
106
+ ) -> Workbook:
107
+ """The tool's own table, every column the reviewer saw, one row per line."""
108
+ workbook = Workbook()
109
+ worksheet = workbook.active
110
+ worksheet.title = "Review"
111
+
112
+ headers = [header for header, _ in REVIEW_COLUMNS]
113
+ _write_header(worksheet, headers, 1)
114
+ for index, (_, width) in enumerate(REVIEW_COLUMNS, start=1):
115
+ worksheet.column_dimensions[get_column_letter(index)].width = width
116
+
117
+ for offset, row in enumerate(rows, start=2):
118
+ _, result = _unpack(row)
119
+ fill = STATUS_FILLS.get(result.status) if result else None
120
+ for column_index, value in enumerate(_review_values(row), start=1):
121
+ cell = worksheet.cell(row=offset, column=column_index)
122
+ cell.value = value
123
+ cell.alignment = Alignment(vertical="top", wrap_text=True)
124
+ if fill is not None and highlight:
125
+ cell.fill = fill
126
+
127
+ totals_row = len(rows) + 2
128
+ worksheet.cell(row=totals_row, column=1).value = "TOTAL"
129
+ worksheet.cell(row=totals_row, column=1).font = Font(bold=True)
130
+ for offset, total in (
131
+ (23, batch.metrics.totalPackages),
132
+ (24, batch.metrics.totalGrossWeight),
133
+ (25, batch.metrics.totalNetWeight),
134
+ (26, batch.metrics.totalValue),
135
+ ):
136
+ cell = worksheet.cell(row=totals_row, column=offset)
137
+ cell.value = total
138
+ cell.font = Font(bold=True)
139
+
140
+ worksheet.freeze_panes = "A2"
141
+ return workbook
142
+
143
+
144
+ def build_softpak_workbook(
145
+ batch: BatchModel,
146
+ profile: ClientProfileModel,
147
+ rows: list[BatchRowModel],
148
+ ) -> Workbook:
149
+ """The Goenee line list: the client's own columns plus the codes we added.
150
+
151
+ Values come from the stored request rather than the source sheet, so a
152
+ shipment typed into the form exports exactly like an uploaded one.
153
+ """
154
+ workbook = Workbook()
155
+ worksheet = workbook.active
156
+ worksheet.title = "Blad1"
157
+
158
+ header_rows = profile.softpakHeaderRows or [
159
+ ["Government procedure", "10", " "],
160
+ ["Previous ", "00"],
161
+ ["Invoice N935", " "],
162
+ ["Currency", batch.currency],
163
+ ["Container"],
164
+ ]
165
+ for offset, header_row in enumerate(header_rows, start=1):
166
+ for column_index, value in enumerate(header_row, start=1):
167
+ worksheet.cell(row=offset, column=column_index).value = value
168
+
169
+ fields = [field for field in SOFTPAK_ORDER if field.value in profile.columnMapping]
170
+ if not fields:
171
+ fields = list(SOFTPAK_ORDER)
172
+
173
+ export_columns = profile.exportColumns or []
174
+ headers = [
175
+ profile.columnMapping.get(field.value, field.value) for field in fields
176
+ ] + [column.header for column in export_columns]
177
+
178
+ header_row_index = len(header_rows) + 2
179
+ _write_header(worksheet, headers, header_row_index)
180
+
181
+ data_row_index = header_row_index
182
+ for row in rows:
183
+ data_row_index += 1
184
+ request, result = _unpack(row)
185
+ values = [input_value(field, row.inputRow, request, profile) for field in fields]
186
+ if request is not None and InputField.CODE in fields:
187
+ # The code column carries what is actually being declared, not what
188
+ # the client sent: that is the whole point of the check.
189
+ values[fields.index(InputField.CODE)] = (
190
+ row.correctedCode or (result.resolvedCode if result else None) or request.code
191
+ )
192
+ values.extend(render_output_value(column.field, result) for column in export_columns)
193
+
194
+ fill = STATUS_FILLS.get(result.status) if result else None
195
+ for column_index, value in enumerate(values, start=1):
196
+ cell = worksheet.cell(row=data_row_index, column=column_index)
197
+ cell.value = value
198
+ if fill is not None and profile.highlightForReview:
199
+ cell.fill = fill
200
+
201
+ for index, header in enumerate(headers, start=1):
202
+ worksheet.column_dimensions[get_column_letter(index)].width = max(
203
+ 12, min(38, len(str(header)) + 6)
204
+ )
205
+
206
+ if profile.totals.enabled:
207
+ totals = sheet_totals(_totals_lines(rows), profile)
208
+ if totals:
209
+ totals_row = data_row_index + 1
210
+ label = worksheet.cell(row=totals_row, column=1)
211
+ label.value = profile.totals.label
212
+ label.font = Font(bold=True)
213
+ for canonical, total in totals.items():
214
+ try:
215
+ column_index = fields.index(InputField(canonical)) + 1
216
+ except ValueError:
217
+ continue
218
+ cell = worksheet.cell(row=totals_row, column=column_index)
219
+ cell.value = total
220
+ cell.font = Font(bold=True)
221
+
222
+ return workbook
223
+
224
+
225
+ def build_export_workbook(
226
+ export_format: ExportFormat,
227
+ batch: BatchModel,
228
+ profile: ClientProfileModel,
229
+ rows: list[BatchRowModel],
230
+ ) -> Workbook:
231
+ if export_format is ExportFormat.REVIEW:
232
+ return build_review_workbook(batch, rows, profile.highlightForReview)
233
+ if export_format is ExportFormat.SOFTPAK:
234
+ return build_softpak_workbook(batch, profile, rows)
235
+ raise ValueError(f"{export_format.name} cannot be built from stored rows.")
236
+
237
+
238
+ def export_file_name(batch: BatchModel, export_format: ExportFormat) -> str:
239
+ stem = "".join(
240
+ character if character.isalnum() or character in "-_" else "_"
241
+ for character in batch.name
242
+ ) or "shipment"
243
+ return f"{stem}_{export_format.name.lower()}.xlsx"
244
+
245
+
246
+ def unavailable_reason(export_format: ExportFormat) -> str | None:
247
+ """Why a declared format cannot be produced yet. None means it can."""
248
+ if export_format.implemented:
249
+ return None
250
+ return (
251
+ f"The {export_format.name.title()} import specification has not been received. "
252
+ "The layout is not guessed at: ask the client for their import template "
253
+ "and it becomes a profile change, not a code change."
254
+ )
255
+
256
+
257
+ def output_field_choices() -> list[str]:
258
+ return [field.value for field in OutputField]
cbh/api/batch/services/excel/reader.py CHANGED
@@ -1,11 +1,3 @@
1
- """
2
- Reading a client's pack.
3
-
4
- Client files are not a fixed schema: each broker sends their own layout, so the
5
- reader works off the profile's column mapping and carries every column it does
6
- not recognise straight through to the output.
7
- """
8
-
9
  import csv
10
  import io
11
  from dataclasses import dataclass, field
@@ -17,7 +9,7 @@ from openpyxl import Workbook, load_workbook
17
  from cbh.api.batch.dto import FileFormat
18
  from cbh.api.client.dto import InputField
19
  from cbh.api.client.models import ClientProfileModel
20
- from cbh.api.common.dto import TradeDirection
21
  from cbh.api.common.utils import normalize_ws, parse_date, parse_number
22
  from cbh.api.importer.utils import normalize_header
23
  from cbh.api.linecheck.schemas import LineCheckRequest
@@ -25,13 +17,6 @@ from cbh.api.linecheck.schemas import LineCheckRequest
25
 
26
  @dataclass
27
  class ParsedRow:
28
- """
29
- One data row of a pack.
30
-
31
- ``rowIndex`` is the worksheet row number, so results can be written back
32
- exactly where they came from and the client's row order is preserved.
33
- """
34
-
35
  rowIndex: int
36
  raw: dict[str, Any]
37
  request: LineCheckRequest | None = None
@@ -40,20 +25,48 @@ class ParsedRow:
40
 
41
  @dataclass
42
  class ParsedSheet:
43
- """
44
- A pack read into memory.
45
- """
46
-
47
  headers: list[str]
48
  rows: list[ParsedRow]
49
  headerRowIndex: int
50
  sheetName: str | None = None
51
 
52
 
53
- def workbook_from_csv(payload: bytes) -> Workbook:
54
- """
55
- Load a CSV pack into a workbook so one writer serves both formats.
 
 
56
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  text = payload.decode("utf-8-sig", errors="replace")
58
  try:
59
  dialect = csv.Sniffer().sniff(text[:4096], delimiters=",;\t|")
@@ -67,44 +80,15 @@ def workbook_from_csv(payload: bytes) -> Workbook:
67
  return workbook
68
 
69
 
70
- def load_pack_workbook(payload: bytes, file_name: str) -> Workbook:
71
- """
72
- Load a pack, whichever of the two formats it arrived in.
73
- """
74
- if FileFormat.from_name(file_name) is FileFormat.CSV:
75
- return workbook_from_csv(payload)
76
- return load_workbook(io.BytesIO(payload), data_only=True)
77
-
78
-
79
  def read_headers(payload: bytes, file_name: str, header_row: int = 1) -> list[Any]:
80
- """
81
- Read the header row of a pack, for mapping suggestions.
82
- """
83
- workbook = load_pack_workbook(payload, file_name)
84
- worksheet = workbook.active
85
  for index, row in enumerate(worksheet.iter_rows(values_only=True), start=1):
86
  if index == header_row:
87
  return list(row)
88
  return []
89
 
90
 
91
- def _header_positions(headers: list[Any], profile: ClientProfileModel) -> dict[str, int]:
92
- """
93
- Map canonical fields onto column positions using the profile's mapping.
94
- """
95
- normalized = {normalize_header(header): index for index, header in enumerate(headers)}
96
- positions: dict[str, int] = {}
97
- for canonical, client_header in profile.columnMapping.items():
98
- position = normalized.get(normalize_header(client_header))
99
- if position is not None:
100
- positions[canonical] = position
101
- return positions
102
-
103
-
104
  def _cell(row: tuple, position: int | None) -> Any:
105
- """
106
- Read a cell, tolerating short rows.
107
- """
108
  if position is None or position >= len(row):
109
  return None
110
  return row[position]
@@ -115,14 +99,10 @@ def parse_pack(
115
  file_name: str,
116
  profile: ClientProfileModel,
117
  check_date: date | None = None,
 
 
 
118
  ) -> ParsedSheet:
119
- """
120
- Read a pack into line-check requests.
121
-
122
- A row that cannot be read is kept with its errors rather than dropped: the
123
- output file must come back with the same rows, in the same order, as the
124
- one that went in.
125
- """
126
  workbook = load_pack_workbook(payload, file_name)
127
  worksheet = (
128
  workbook[profile.sheetName]
@@ -136,7 +116,22 @@ def parse_pack(
136
  return ParsedSheet(headers=[], rows=[], headerRowIndex=profile.headerRow)
137
 
138
  headers = [normalize_ws(header) or "" for header in all_rows[header_index]]
139
- positions = _header_positions(list(all_rows[header_index]), profile)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  defaults = profile.defaults
141
 
142
  rows: list[ParsedRow] = []
@@ -145,19 +140,34 @@ def parse_pack(
145
  if raw_row is None or all(cell is None or cell == "" for cell in raw_row):
146
  continue
147
 
148
- raw = {
149
- header: _cell(raw_row, index)
150
- for index, header in enumerate(headers)
151
- if header
152
- }
153
- parsed = ParsedRow(rowIndex=row_index, raw=raw)
 
 
154
 
155
  code = _cell(raw_row, positions.get(InputField.CODE.value))
156
  country = _cell(raw_row, positions.get(InputField.COUNTRY.value)) or defaults.country
 
 
 
 
 
157
  direction = (
158
  TradeDirection.parse(_cell(raw_row, positions.get(InputField.DIRECTION.value)))
 
159
  or defaults.direction
160
  )
 
 
 
 
 
 
 
161
  line_date = parse_date(_cell(raw_row, positions.get(InputField.DATE.value)))
162
  if line_date is None:
163
  line_date = check_date or (
@@ -173,8 +183,10 @@ def parse_pack(
173
 
174
  parsed.request = LineCheckRequest(
175
  code=str(code or "").strip(),
176
- country=str(country or "").strip(),
 
177
  direction=direction,
 
178
  date=line_date,
179
  additionalCodes=[str(additional).strip()] if additional else [],
180
  description=normalize_ws(
 
 
 
 
 
 
 
 
 
1
  import csv
2
  import io
3
  from dataclasses import dataclass, field
 
9
  from cbh.api.batch.dto import FileFormat
10
  from cbh.api.client.dto import InputField
11
  from cbh.api.client.models import ClientProfileModel
12
+ from cbh.api.common.dto import CustomsProcedure, TradeDirection
13
  from cbh.api.common.utils import normalize_ws, parse_date, parse_number
14
  from cbh.api.importer.utils import normalize_header
15
  from cbh.api.linecheck.schemas import LineCheckRequest
 
17
 
18
  @dataclass
19
  class ParsedRow:
 
 
 
 
 
 
 
20
  rowIndex: int
21
  raw: dict[str, Any]
22
  request: LineCheckRequest | None = None
 
25
 
26
  @dataclass
27
  class ParsedSheet:
 
 
 
 
28
  headers: list[str]
29
  rows: list[ParsedRow]
30
  headerRowIndex: int
31
  sheetName: str | None = None
32
 
33
 
34
+ def _workbook_from_legacy_xls(payload: bytes) -> Workbook:
35
+ """Read a BIFF `.xls` into the same Workbook the rest of the code expects.
36
+
37
+ Goenee's own line lists come out of JasperReports as legacy `.xls`, which
38
+ openpyxl cannot open at all.
39
  """
40
+ import xlrd
41
+
42
+ book = xlrd.open_workbook(file_contents=payload)
43
+ workbook = Workbook()
44
+ workbook.remove(workbook.active)
45
+
46
+ for sheet in book.sheets():
47
+ worksheet = workbook.create_sheet(title=(sheet.name or "Sheet1")[:31])
48
+ for index in range(sheet.nrows):
49
+ values = []
50
+ for position in range(sheet.ncols):
51
+ cell = sheet.cell(index, position)
52
+ if cell.ctype == xlrd.XL_CELL_DATE:
53
+ values.append(xlrd.xldate_as_datetime(cell.value, book.datemode))
54
+ elif cell.ctype == xlrd.XL_CELL_EMPTY:
55
+ values.append(None)
56
+ else:
57
+ values.append(cell.value)
58
+ worksheet.append(values)
59
+
60
+ return workbook
61
+
62
+
63
+ def load_pack_workbook(payload: bytes, file_name: str) -> Workbook:
64
+ if file_name.lower().endswith(".xls"):
65
+ return _workbook_from_legacy_xls(payload)
66
+
67
+ if FileFormat.from_name(file_name) is not FileFormat.CSV:
68
+ return load_workbook(io.BytesIO(payload), data_only=True)
69
+
70
  text = payload.decode("utf-8-sig", errors="replace")
71
  try:
72
  dialect = csv.Sniffer().sniff(text[:4096], delimiters=",;\t|")
 
80
  return workbook
81
 
82
 
 
 
 
 
 
 
 
 
 
83
  def read_headers(payload: bytes, file_name: str, header_row: int = 1) -> list[Any]:
84
+ worksheet = load_pack_workbook(payload, file_name).active
 
 
 
 
85
  for index, row in enumerate(worksheet.iter_rows(values_only=True), start=1):
86
  if index == header_row:
87
  return list(row)
88
  return []
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def _cell(row: tuple, position: int | None) -> Any:
 
 
 
92
  if position is None or position >= len(row):
93
  return None
94
  return row[position]
 
99
  file_name: str,
100
  profile: ClientProfileModel,
101
  check_date: date | None = None,
102
+ destination_country: str | None = None,
103
+ direction_default: TradeDirection | None = None,
104
+ procedure_default: CustomsProcedure | None = None,
105
  ) -> ParsedSheet:
 
 
 
 
 
 
 
106
  workbook = load_pack_workbook(payload, file_name)
107
  worksheet = (
108
  workbook[profile.sheetName]
 
116
  return ParsedSheet(headers=[], rows=[], headerRowIndex=profile.headerRow)
117
 
118
  headers = [normalize_ws(header) or "" for header in all_rows[header_index]]
119
+
120
+ normalized = {
121
+ normalize_header(header): index
122
+ for index, header in enumerate(all_rows[header_index])
123
+ }
124
+ positions: dict[str, int] = {}
125
+ for canonical, client_header in profile.columnMapping.items():
126
+ # One client's packs are not spelled identically from month to month —
127
+ # the same column is "Cn" in one export and "Case No." in the next — so
128
+ # a mapping may list the spellings it accepts, best first.
129
+ for spelling in str(client_header).split("|"):
130
+ position = normalized.get(normalize_header(spelling))
131
+ if position is not None:
132
+ positions[canonical] = position
133
+ break
134
+
135
  defaults = profile.defaults
136
 
137
  rows: list[ParsedRow] = []
 
140
  if raw_row is None or all(cell is None or cell == "" for cell in raw_row):
141
  continue
142
 
143
+ parsed = ParsedRow(
144
+ rowIndex=row_index,
145
+ raw={
146
+ header: _cell(raw_row, index)
147
+ for index, header in enumerate(headers)
148
+ if header
149
+ },
150
+ )
151
 
152
  code = _cell(raw_row, positions.get(InputField.CODE.value))
153
  country = _cell(raw_row, positions.get(InputField.COUNTRY.value)) or defaults.country
154
+ destination = (
155
+ _cell(raw_row, positions.get(InputField.DESTINATION_COUNTRY.value))
156
+ or destination_country
157
+ or defaults.destinationCountry
158
+ )
159
  direction = (
160
  TradeDirection.parse(_cell(raw_row, positions.get(InputField.DIRECTION.value)))
161
+ or direction_default
162
  or defaults.direction
163
  )
164
+ procedure = (
165
+ CustomsProcedure.parse(
166
+ _cell(raw_row, positions.get(InputField.PROCEDURE.value))
167
+ )
168
+ or procedure_default
169
+ or defaults.procedure
170
+ )
171
  line_date = parse_date(_cell(raw_row, positions.get(InputField.DATE.value)))
172
  if line_date is None:
173
  line_date = check_date or (
 
183
 
184
  parsed.request = LineCheckRequest(
185
  code=str(code or "").strip(),
186
+ originCountry=str(country or "").strip() or None,
187
+ destinationCountry=str(destination or "").strip() or None,
188
  direction=direction,
189
+ procedure=procedure,
190
  date=line_date,
191
  additionalCodes=[str(additional).strip()] if additional else [],
192
  description=normalize_ws(
cbh/api/batch/services/excel/writer.py CHANGED
@@ -1,13 +1,3 @@
1
- """
2
- Writing the output pack.
3
-
4
- The client gets their own file back: same sheet, same columns, same row order,
5
- with the checker's columns appended in the order their export profile fixes,
6
- and a totals row at the bottom — the standard check Judith runs on every pack.
7
-
8
- Rows that need a human are highlighted. They are never corrected.
9
- """
10
-
11
  import io
12
  from typing import Any
13
 
@@ -16,56 +6,45 @@ from openpyxl.utils import get_column_letter
16
  from openpyxl.workbook import Workbook
17
 
18
  from cbh.api.batch.services.excel.reader import ParsedSheet, load_pack_workbook
19
- from cbh.api.client.dto import InputField, OutputField
 
20
  from cbh.api.client.models import ClientProfileModel
 
21
  from cbh.api.linecheck.dto import LineStatus
22
  from cbh.api.linecheck.schemas import LineCheckResponse
23
- from cbh.api.linecheck.utils import (
24
- render_candidates,
25
- render_exemptions,
26
- render_required_documents,
27
- render_warnings,
28
- status_label,
29
- )
30
-
31
- HEADER_FILL = PatternFill("solid", fgColor="FFE8EEF7")
32
- INVALID_FILL = PatternFill("solid", fgColor="FFF8CBCB")
33
- AMBIGUOUS_FILL = PatternFill("solid", fgColor="FFFDE9A9")
34
- EXPIRING_FILL = PatternFill("solid", fgColor="FFE2F0D9")
35
 
36
- STATUS_FILLS = {
37
- LineStatus.INVALID: INVALID_FILL,
38
- LineStatus.AMBIGUOUS: AMBIGUOUS_FILL,
39
- LineStatus.EXPIRING: EXPIRING_FILL,
40
- }
41
 
42
- MAX_CELL_LENGTH = 32000
43
-
44
-
45
- def _truncate(value: str) -> str:
46
- """
47
- Keep a cell inside Excel's length limit.
48
- """
49
- if len(value) <= MAX_CELL_LENGTH:
50
- return value
51
- return value[: MAX_CELL_LENGTH - 3] + "..."
 
 
52
 
53
 
54
  def render_output_value(field: OutputField, result: LineCheckResponse | None) -> Any:
55
- """
56
- Render one appended column for one row.
57
- """
58
  if result is None:
59
  return ""
60
 
61
  renderers = {
62
  OutputField.RESOLVED_CODE: lambda: result.resolvedCode or "",
63
  OutputField.DOCUMENTS_REQUIRED: lambda: render_required_documents(result),
64
- OutputField.DOCUMENTS_EXEMPTION: lambda: render_exemptions(result),
 
 
65
  OutputField.WARNINGS: lambda: render_warnings(result),
66
- OutputField.STATUS: lambda: status_label(result.status),
67
  OutputField.CODE_DESCRIPTION: lambda: result.codeDescription or "",
68
- OutputField.CANDIDATES: lambda: render_candidates(result),
 
 
69
  OutputField.REPLACEMENT_CODES: lambda: ", ".join(result.replacementCodes),
70
  OutputField.EXPIRING_ON: lambda: (
71
  result.expiringOn.isoformat() if result.expiringOn else ""
@@ -76,34 +55,15 @@ def render_output_value(field: OutputField, result: LineCheckResponse | None) ->
76
  for measure in result.appliedMeasures
77
  ),
78
  OutputField.REFERENCE_AS_OF: lambda: result.referenceAsOf or "",
 
 
 
 
 
79
  }
80
- return _truncate(str(renderers[field]()))
81
-
82
-
83
- def _totals(
84
- sheet: ParsedSheet, profile: ClientProfileModel
85
- ) -> dict[str, float]:
86
- """
87
- Sum the quantity columns the profile asks to total.
88
- """
89
- totals: dict[str, float] = {}
90
- for canonical in profile.totals.sumFields:
91
- try:
92
- field = InputField(canonical)
93
- except ValueError:
94
- continue
95
- total = 0.0
96
- found = False
97
- for row in sheet.rows:
98
- if row.request is None:
99
- continue
100
- value = getattr(row.request, field.value, None)
101
- if isinstance(value, (int, float)):
102
- total += float(value)
103
- found = True
104
- if found:
105
- totals[canonical] = round(total, 2)
106
- return totals
107
 
108
 
109
  def build_output_workbook(
@@ -113,12 +73,9 @@ def build_output_workbook(
113
  sheet: ParsedSheet,
114
  results: dict[int, LineCheckResponse],
115
  ) -> Workbook:
116
- """
117
- Append the checker's columns to the client's own workbook.
118
 
119
- The original file is reopened and written into rather than rebuilt, so the
120
- client's formatting, extra columns and row order survive untouched.
121
- """
122
  workbook = load_pack_workbook(payload, file_name)
123
  worksheet = (
124
  workbook[sheet.sheetName]
@@ -133,12 +90,18 @@ def build_output_workbook(
133
  cell = worksheet.cell(row=header_row, column=first_new_column + offset)
134
  cell.value = column.header
135
  cell.font = Font(bold=True)
136
- cell.fill = HEADER_FILL
137
  cell.alignment = Alignment(vertical="top")
138
  worksheet.column_dimensions[
139
  get_column_letter(first_new_column + offset)
140
  ].width = 28
141
 
 
 
 
 
 
 
142
  last_data_row = header_row
143
  for row in sheet.rows:
144
  result = results.get(row.rowIndex)
@@ -151,14 +114,10 @@ def build_output_workbook(
151
 
152
  if not profile.highlightForReview or result is None:
153
  continue
154
- fill = STATUS_FILLS.get(result.status)
155
  if fill is None:
156
  continue
157
- # Highlight the whole row: a broker scanning the pack should see the
158
- # lines needing attention without reading the status column.
159
- for column_index in range(
160
- 1, first_new_column + len(profile.exportColumns)
161
- ):
162
  worksheet.cell(row=row.rowIndex, column=column_index).fill = fill
163
 
164
  if profile.totals.enabled:
@@ -167,22 +126,86 @@ def build_output_workbook(
167
  return workbook
168
 
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  def _append_totals_row(
171
  worksheet: Any,
172
  profile: ClientProfileModel,
173
  sheet: ParsedSheet,
174
  last_data_row: int,
 
175
  ) -> None:
176
- """
177
- Write the totals row under the last data row.
178
- """
179
- totals = _totals(sheet, profile)
180
  if not totals:
181
  return
182
 
183
  totals_row = last_data_row + 1
184
- normalized_headers = {
185
- header: index + 1 for index, header in enumerate(sheet.headers)
 
 
 
186
  }
187
 
188
  label_cell = worksheet.cell(row=totals_row, column=1)
@@ -191,7 +214,7 @@ def _append_totals_row(
191
 
192
  for canonical, total in totals.items():
193
  client_header = profile.columnMapping.get(canonical)
194
- column_index = normalized_headers.get(client_header) if client_header else None
195
  if column_index is None:
196
  continue
197
  cell = worksheet.cell(row=totals_row, column=column_index)
@@ -200,9 +223,6 @@ def _append_totals_row(
200
 
201
 
202
  def workbook_to_bytes(workbook: Workbook) -> bytes:
203
- """
204
- Serialize a workbook for storage or download.
205
- """
206
  buffer = io.BytesIO()
207
  workbook.save(buffer)
208
  return buffer.getvalue()
 
 
 
 
 
 
 
 
 
 
 
1
  import io
2
  from typing import Any
3
 
 
6
  from openpyxl.workbook import Workbook
7
 
8
  from cbh.api.batch.services.excel.reader import ParsedSheet, load_pack_workbook
9
+ from cbh.api.batch.services.metrics.totals import TotalsInput, sheet_totals
10
+ from cbh.api.client.dto import ExportLayout, InputField, OutputField
11
  from cbh.api.client.models import ClientProfileModel
12
+ from cbh.api.importer.utils import normalize_header
13
  from cbh.api.linecheck.dto import LineStatus
14
  from cbh.api.linecheck.schemas import LineCheckResponse
15
+ from cbh.api.linecheck.utils import render_required_documents, render_warnings
 
 
 
 
 
 
 
 
 
 
 
16
 
 
 
 
 
 
17
 
18
+ SOFTPAK_ORDER = [
19
+ InputField.CODE,
20
+ InputField.DESCRIPTION,
21
+ InputField.GROSS_WEIGHT,
22
+ InputField.NET_WEIGHT,
23
+ InputField.COUNTRY,
24
+ InputField.QUANTITY,
25
+ InputField.COLLI_TYPE,
26
+ InputField.PACKAGES,
27
+ InputField.REMARKS,
28
+ InputField.VALUE,
29
+ ]
30
 
31
 
32
  def render_output_value(field: OutputField, result: LineCheckResponse | None) -> Any:
 
 
 
33
  if result is None:
34
  return ""
35
 
36
  renderers = {
37
  OutputField.RESOLVED_CODE: lambda: result.resolvedCode or "",
38
  OutputField.DOCUMENTS_REQUIRED: lambda: render_required_documents(result),
39
+ OutputField.DOCUMENTS_EXEMPTION: lambda: "; ".join(
40
+ document.code for document in result.documentsExemption
41
+ ),
42
  OutputField.WARNINGS: lambda: render_warnings(result),
43
+ OutputField.STATUS: lambda: result.status.name.lower(),
44
  OutputField.CODE_DESCRIPTION: lambda: result.codeDescription or "",
45
+ OutputField.CANDIDATES: lambda: ", ".join(
46
+ candidate.code for candidate in result.candidates
47
+ ),
48
  OutputField.REPLACEMENT_CODES: lambda: ", ".join(result.replacementCodes),
49
  OutputField.EXPIRING_ON: lambda: (
50
  result.expiringOn.isoformat() if result.expiringOn else ""
 
55
  for measure in result.appliedMeasures
56
  ),
57
  OutputField.REFERENCE_AS_OF: lambda: result.referenceAsOf or "",
58
+ OutputField.YCODES: lambda: ",".join(
59
+ document.code for document in result.documentsExemption
60
+ ),
61
+ OutputField.CODE_CONFIDENCE: lambda: result.codeConfidence.name.lower(),
62
+ OutputField.DOCUMENT_CONFIDENCE: lambda: result.documentConfidence.name.lower(),
63
  }
64
+
65
+ value = str(renderers[field]())
66
+ return value if len(value) <= 32000 else value[:31997] + "..."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
 
69
  def build_output_workbook(
 
73
  sheet: ParsedSheet,
74
  results: dict[int, LineCheckResponse],
75
  ) -> Workbook:
76
+ if profile.exportLayout is ExportLayout.SOFTPAK_SHEET:
77
+ return _build_softpak_workbook(profile, sheet, results)
78
 
 
 
 
79
  workbook = load_pack_workbook(payload, file_name)
80
  worksheet = (
81
  workbook[sheet.sheetName]
 
90
  cell = worksheet.cell(row=header_row, column=first_new_column + offset)
91
  cell.value = column.header
92
  cell.font = Font(bold=True)
93
+ cell.fill = PatternFill("solid", fgColor="FFE8EEF7")
94
  cell.alignment = Alignment(vertical="top")
95
  worksheet.column_dimensions[
96
  get_column_letter(first_new_column + offset)
97
  ].width = 28
98
 
99
+ status_fills = {
100
+ LineStatus.INVALID: PatternFill("solid", fgColor="FFF8CBCB"),
101
+ LineStatus.AMBIGUOUS: PatternFill("solid", fgColor="FFFDE9A9"),
102
+ LineStatus.EXPIRING: PatternFill("solid", fgColor="FFE2F0D9"),
103
+ }
104
+
105
  last_data_row = header_row
106
  for row in sheet.rows:
107
  result = results.get(row.rowIndex)
 
114
 
115
  if not profile.highlightForReview or result is None:
116
  continue
117
+ fill = status_fills.get(result.status)
118
  if fill is None:
119
  continue
120
+ for column_index in range(1, first_new_column + len(profile.exportColumns)):
 
 
 
 
121
  worksheet.cell(row=row.rowIndex, column=column_index).fill = fill
122
 
123
  if profile.totals.enabled:
 
126
  return workbook
127
 
128
 
129
+ def _build_softpak_workbook(
130
+ profile: ClientProfileModel,
131
+ sheet: ParsedSheet,
132
+ results: dict[int, LineCheckResponse],
133
+ ) -> Workbook:
134
+ workbook = Workbook()
135
+ worksheet = workbook.active
136
+ worksheet.title = "Blad1"
137
+
138
+ for offset, header_row in enumerate(profile.softpakHeaderRows, start=1):
139
+ for column_index, value in enumerate(header_row, start=1):
140
+ worksheet.cell(row=offset, column=column_index).value = value
141
+
142
+ source_headers = [
143
+ profile.columnMapping[field.value]
144
+ for field in SOFTPAK_ORDER
145
+ if field.value in profile.columnMapping
146
+ ]
147
+ headers = source_headers + [column.header for column in profile.exportColumns]
148
+
149
+ header_row_index = max(profile.headerRow, len(profile.softpakHeaderRows) + 1)
150
+ for column_index, header in enumerate(headers, start=1):
151
+ cell = worksheet.cell(row=header_row_index, column=column_index)
152
+ cell.value = header
153
+ cell.font = Font(bold=True)
154
+ cell.fill = PatternFill("solid", fgColor="FFE8EEF7")
155
+
156
+ status_fills = {
157
+ LineStatus.INVALID: PatternFill("solid", fgColor="FFF8CBCB"),
158
+ LineStatus.AMBIGUOUS: PatternFill("solid", fgColor="FFFDE9A9"),
159
+ LineStatus.EXPIRING: PatternFill("solid", fgColor="FFE2F0D9"),
160
+ }
161
+
162
+ data_row_index = header_row_index
163
+ for row in sheet.rows:
164
+ data_row_index += 1
165
+ result = results.get(row.rowIndex)
166
+ by_header = {normalize_header(key): value for key, value in row.raw.items()}
167
+ values = [by_header.get(normalize_header(header)) for header in source_headers]
168
+ values.extend(
169
+ render_output_value(column.field, result) for column in profile.exportColumns
170
+ )
171
+ fill = status_fills.get(result.status) if result else None
172
+ for column_index, value in enumerate(values, start=1):
173
+ cell = worksheet.cell(row=data_row_index, column=column_index)
174
+ cell.value = value
175
+ if fill is not None and profile.highlightForReview:
176
+ cell.fill = fill
177
+
178
+ for index, header in enumerate(headers, start=1):
179
+ worksheet.column_dimensions[get_column_letter(index)].width = max(
180
+ 12, min(38, len(str(header)) + 6)
181
+ )
182
+
183
+ if profile.totals.enabled:
184
+ _append_totals_row(worksheet, profile, sheet, data_row_index, source_headers)
185
+
186
+ return workbook
187
+
188
+
189
  def _append_totals_row(
190
  worksheet: Any,
191
  profile: ClientProfileModel,
192
  sheet: ParsedSheet,
193
  last_data_row: int,
194
+ sheet_headers: list[str] | None = None,
195
  ) -> None:
196
+ totals = sheet_totals(
197
+ [TotalsInput(raw=row.raw, request=row.request) for row in sheet.rows], profile
198
+ )
199
+
200
  if not totals:
201
  return
202
 
203
  totals_row = last_data_row + 1
204
+ header_columns = {
205
+ header: index + 1
206
+ for index, header in enumerate(
207
+ sheet.headers if sheet_headers is None else sheet_headers
208
+ )
209
  }
210
 
211
  label_cell = worksheet.cell(row=totals_row, column=1)
 
214
 
215
  for canonical, total in totals.items():
216
  client_header = profile.columnMapping.get(canonical)
217
+ column_index = header_columns.get(client_header) if client_header else None
218
  if column_index is None:
219
  continue
220
  cell = worksheet.cell(row=totals_row, column=column_index)
 
223
 
224
 
225
  def workbook_to_bytes(workbook: Workbook) -> bytes:
 
 
 
226
  buffer = io.BytesIO()
227
  workbook.save(buffer)
228
  return buffer.getvalue()
cbh/api/batch/services/metrics/__init__.py CHANGED
@@ -1,3 +1,14 @@
1
- from .metrics import GoldSetScore, read_gold_rows, score_against_gold, split_codes
 
 
2
 
3
- __all__ = ["GoldSetScore", "read_gold_rows", "score_against_gold", "split_codes"]
 
 
 
 
 
 
 
 
 
 
1
+ from .metrics import GoldSetScore, read_gold_rows, score_against_gold
2
+ from .recompute import recompute_metrics
3
+ from .totals import TotalsInput, input_value, sheet_totals, shipment_totals
4
 
5
+ __all__ = [
6
+ "GoldSetScore",
7
+ "TotalsInput",
8
+ "input_value",
9
+ "read_gold_rows",
10
+ "recompute_metrics",
11
+ "score_against_gold",
12
+ "sheet_totals",
13
+ "shipment_totals",
14
+ ]
cbh/api/batch/services/metrics/metrics.py CHANGED
@@ -1,17 +1,3 @@
1
- """
2
- Gold-set scoring.
3
-
4
- The acceptance criteria are stated per pack: percentage of correct codes,
5
- percentage of correct document codes (required and exemption scored
6
- separately), machine time, and human review time. This module computes the
7
- first three by comparing a processed pack against a gold file whose answers
8
- Judith has confirmed; review time is measured on the pack itself.
9
-
10
- Document codes are compared as sets, because the order they appear in carries
11
- no meaning. Codes are compared after normalisation so that formatting
12
- differences between the two files do not read as errors.
13
- """
14
-
15
  from dataclasses import dataclass, field
16
  from typing import Any
17
 
@@ -21,10 +7,19 @@ from cbh.api.common.utils import normalize_ws
21
  from cbh.api.importer.utils import normalize_header
22
  from cbh.api.taric.utils import normalize_code
23
 
24
- # Header spellings accepted in a gold file for each column being scored.
25
  GOLD_COLUMNS: dict[str, tuple[str, ...]] = {
26
  "rowIndex": ("row", "rowindex", "regel", "line"),
27
- "resolvedCode": ("resolved_code", "resolvedcode", "code", "goederencode", "taric"),
 
 
 
 
 
 
 
 
 
 
28
  "documentsRequired": (
29
  "documents_required",
30
  "documentsrequired",
@@ -35,52 +30,37 @@ GOLD_COLUMNS: dict[str, tuple[str, ...]] = {
35
  "documentsExemption": (
36
  "documents_exemption",
37
  "documentsexemption",
 
 
 
38
  "vrijstellingen",
39
  "exemption",
40
  ),
41
  }
42
 
43
- _SEPARATORS = (";", ",", "|", " ")
44
-
45
 
46
  def split_codes(value: Any) -> set[str]:
47
- """
48
- Split a document-code cell into a comparable set.
49
-
50
- Either/or groups are written ``C400|Y900`` in the output; for scoring the
51
- alternatives are individual codes, since presenting either is correct.
52
- """
53
  text = normalize_ws(value)
54
  if not text:
55
  return set()
56
- for separator in _SEPARATORS:
57
  text = text.replace(separator, ";")
58
  return {part.strip().upper() for part in text.split(";") if part.strip()}
59
 
60
 
61
  @dataclass
62
  class FieldScore:
63
- """
64
- Score for one compared field.
65
- """
66
-
67
  compared: int = 0
68
  correct: int = 0
69
  mismatches: list[dict[str, Any]] = field(default_factory=list)
70
 
71
  @property
72
  def accuracy(self) -> float | None:
73
- """
74
- Share of compared rows that matched, as a percentage.
75
- """
76
  if not self.compared:
77
  return None
78
  return round(100.0 * self.correct / self.compared, 2)
79
 
80
  def record(self, row_index: int, expected: Any, actual: Any, matched: bool) -> None:
81
- """
82
- Record one comparison, keeping the first mismatches for inspection.
83
- """
84
  self.compared += 1
85
  if matched:
86
  self.correct += 1
@@ -96,10 +76,6 @@ class FieldScore:
96
 
97
  @dataclass
98
  class GoldSetScore:
99
- """
100
- The accuracy half of the acceptance metrics for one pack.
101
- """
102
-
103
  goldRows: int = 0
104
  matchedRows: int = 0
105
  unmatchedGoldRows: list[int] = field(default_factory=list)
@@ -109,9 +85,6 @@ class GoldSetScore:
109
  documentsExemption: FieldScore = field(default_factory=FieldScore)
110
 
111
  def to_dict(self) -> dict[str, Any]:
112
- """
113
- Render the score for the API.
114
- """
115
  return {
116
  "goldRows": self.goldRows,
117
  "matchedRows": self.matchedRows,
@@ -126,15 +99,7 @@ class GoldSetScore:
126
 
127
 
128
  def read_gold_rows(payload: bytes, file_name: str) -> list[dict[str, Any]]:
129
- """
130
- Read a gold file into comparable rows.
131
-
132
- Rows are matched on an explicit row number when the gold file carries one,
133
- which is the only reliable key when the same code appears many times in a
134
- pack.
135
- """
136
- workbook = load_pack_workbook(payload, file_name)
137
- worksheet = workbook.active
138
 
139
  rows = list(worksheet.iter_rows(values_only=True))
140
  if not rows:
@@ -160,7 +125,7 @@ def read_gold_rows(payload: bytes, file_name: str) -> list[dict[str, Any]]:
160
  if row is None or all(cell is None or cell == "" for cell in row):
161
  continue
162
 
163
- def cell(name: str) -> Any:
164
  position = positions.get(name)
165
  return row[position] if position is not None and position < len(row) else None
166
 
@@ -183,13 +148,6 @@ def read_gold_rows(payload: bytes, file_name: str) -> list[dict[str, Any]]:
183
  def score_against_gold(
184
  rows: list[BatchRowModel], gold_rows: list[dict[str, Any]]
185
  ) -> GoldSetScore:
186
- """
187
- Compare a processed pack against a gold file.
188
-
189
- Only rows present in both are scored; gold rows with no counterpart are
190
- reported rather than counted as failures, since a row-numbering mismatch is
191
- a problem with the comparison, not with the engine.
192
- """
193
  score = GoldSetScore(goldRows=len(gold_rows))
194
  by_index = {row.rowIndex: row for row in rows}
195
 
@@ -202,6 +160,12 @@ def score_against_gold(
202
 
203
  result = actual_row.result or {}
204
  actual_code = normalize_code(result.get("resolvedCode"))
 
 
 
 
 
 
205
  score.codes.record(
206
  gold["rowIndex"],
207
  gold["resolvedCode"],
@@ -210,7 +174,17 @@ def score_against_gold(
210
  )
211
 
212
  if gold["hasRequiredColumn"]:
213
- actual_required = _required_codes(result)
 
 
 
 
 
 
 
 
 
 
214
  score.documentsRequired.record(
215
  gold["rowIndex"],
216
  gold["documentsRequired"],
@@ -232,21 +206,3 @@ def score_against_gold(
232
  )
233
 
234
  return score
235
-
236
-
237
- def _required_codes(result: dict[str, Any]) -> set[str]:
238
- """
239
- Every obligation code on a result, alternatives flattened.
240
- """
241
- codes = {
242
- str(document.get("code", "")).upper()
243
- for document in result.get("documentsRequired", [])
244
- if document.get("code")
245
- }
246
- for group in result.get("documentsAlternatives", []):
247
- codes.update(
248
- str(document.get("code", "")).upper()
249
- for document in group
250
- if document.get("code")
251
- )
252
- return codes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from dataclasses import dataclass, field
2
  from typing import Any
3
 
 
7
  from cbh.api.importer.utils import normalize_header
8
  from cbh.api.taric.utils import normalize_code
9
 
 
10
  GOLD_COLUMNS: dict[str, tuple[str, ...]] = {
11
  "rowIndex": ("row", "rowindex", "regel", "line"),
12
+ # `hs code` and `y-code` are the spellings Goenee's own line list uses, so a
13
+ # finished pack can be handed back as the gold file without being retyped.
14
+ "resolvedCode": (
15
+ "resolved_code",
16
+ "resolvedcode",
17
+ "code",
18
+ "hs code",
19
+ "hscode",
20
+ "goederencode",
21
+ "taric",
22
+ ),
23
  "documentsRequired": (
24
  "documents_required",
25
  "documentsrequired",
 
30
  "documentsExemption": (
31
  "documents_exemption",
32
  "documentsexemption",
33
+ "y-code",
34
+ "ycode",
35
+ "ycodes",
36
  "vrijstellingen",
37
  "exemption",
38
  ),
39
  }
40
 
 
 
41
 
42
  def split_codes(value: Any) -> set[str]:
 
 
 
 
 
 
43
  text = normalize_ws(value)
44
  if not text:
45
  return set()
46
+ for separator in (";", ",", "|", " "):
47
  text = text.replace(separator, ";")
48
  return {part.strip().upper() for part in text.split(";") if part.strip()}
49
 
50
 
51
  @dataclass
52
  class FieldScore:
 
 
 
 
53
  compared: int = 0
54
  correct: int = 0
55
  mismatches: list[dict[str, Any]] = field(default_factory=list)
56
 
57
  @property
58
  def accuracy(self) -> float | None:
 
 
 
59
  if not self.compared:
60
  return None
61
  return round(100.0 * self.correct / self.compared, 2)
62
 
63
  def record(self, row_index: int, expected: Any, actual: Any, matched: bool) -> None:
 
 
 
64
  self.compared += 1
65
  if matched:
66
  self.correct += 1
 
76
 
77
  @dataclass
78
  class GoldSetScore:
 
 
 
 
79
  goldRows: int = 0
80
  matchedRows: int = 0
81
  unmatchedGoldRows: list[int] = field(default_factory=list)
 
85
  documentsExemption: FieldScore = field(default_factory=FieldScore)
86
 
87
  def to_dict(self) -> dict[str, Any]:
 
 
 
88
  return {
89
  "goldRows": self.goldRows,
90
  "matchedRows": self.matchedRows,
 
99
 
100
 
101
  def read_gold_rows(payload: bytes, file_name: str) -> list[dict[str, Any]]:
102
+ worksheet = load_pack_workbook(payload, file_name).active
 
 
 
 
 
 
 
 
103
 
104
  rows = list(worksheet.iter_rows(values_only=True))
105
  if not rows:
 
125
  if row is None or all(cell is None or cell == "" for cell in row):
126
  continue
127
 
128
+ def cell(name: str, row=row) -> Any:
129
  position = positions.get(name)
130
  return row[position] if position is not None and position < len(row) else None
131
 
 
148
  def score_against_gold(
149
  rows: list[BatchRowModel], gold_rows: list[dict[str, Any]]
150
  ) -> GoldSetScore:
 
 
 
 
 
 
 
151
  score = GoldSetScore(goldRows=len(gold_rows))
152
  by_index = {row.rowIndex: row for row in rows}
153
 
 
160
 
161
  result = actual_row.result or {}
162
  actual_code = normalize_code(result.get("resolvedCode"))
163
+ # A gold file written at 8 digits is confirming the subheading, not
164
+ # disputing the last two digits the engine resolved. Compare at the
165
+ # precision the gold file was written to, and no further.
166
+ gold_code = gold["resolvedCode"]
167
+ if gold_code and actual_code and len(gold_code) < len(actual_code):
168
+ actual_code = actual_code[: len(gold_code)]
169
  score.codes.record(
170
  gold["rowIndex"],
171
  gold["resolvedCode"],
 
174
  )
175
 
176
  if gold["hasRequiredColumn"]:
177
+ actual_required = {
178
+ str(document.get("code", "")).upper()
179
+ for document in result.get("documentsRequired", [])
180
+ if document.get("code")
181
+ }
182
+ for group in result.get("documentsAlternatives", []):
183
+ actual_required.update(
184
+ str(document.get("code", "")).upper()
185
+ for document in group
186
+ if document.get("code")
187
+ )
188
  score.documentsRequired.record(
189
  gold["rowIndex"],
190
  gold["documentsRequired"],
 
206
  )
207
 
208
  return score
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cbh/api/batch/services/metrics/recompute.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+
3
+ from cbh.api.batch.db_requests import get_batch_rows
4
+ from cbh.api.batch.models import BatchMetrics, BatchModel
5
+ from cbh.api.batch.services.metrics.totals import TotalsInput, shipment_totals
6
+ from cbh.api.client.db_requests import get_profile
7
+ from cbh.api.client.models import ClientProfileModel
8
+ from cbh.api.common.db_requests import update_obj
9
+ from cbh.api.linecheck.dto import ConfidenceLevel
10
+ from cbh.api.linecheck.schemas import LineCheckRequest, LineCheckResponse
11
+
12
+
13
+ async def recompute_metrics(
14
+ batch: BatchModel, profile: ClientProfileModel | None = None
15
+ ) -> BatchModel:
16
+ """Rebuild a shipment's header figures from the rows currently stored.
17
+
18
+ The upload route computes these once while it processes the pack. A
19
+ shipment built through the form changes one row at a time, so the same
20
+ figures have to be derived again from whatever is in the table now.
21
+ """
22
+ rows, _ = await get_batch_rows(batch.id)
23
+ profile = profile or await get_profile(batch.clientProfile.id)
24
+
25
+ status_counts: dict[str, int] = {}
26
+ code_counts: dict[str, int] = {}
27
+ document_counts: dict[str, int] = {}
28
+ lines: list[TotalsInput] = []
29
+
30
+ for row in rows:
31
+ status_counts[row.status] = status_counts.get(row.status, 0) + 1
32
+
33
+ if row.result:
34
+ result = LineCheckResponse(**row.result)
35
+ code_counts[result.codeConfidence.name] = (
36
+ code_counts.get(result.codeConfidence.name, 0) + 1
37
+ )
38
+ document_counts[result.documentConfidence.name] = (
39
+ document_counts.get(result.documentConfidence.name, 0) + 1
40
+ )
41
+
42
+ lines.append(
43
+ TotalsInput(
44
+ raw=row.inputRow,
45
+ request=LineCheckRequest(**row.request) if row.request else None,
46
+ )
47
+ )
48
+
49
+ total = len(rows) or 1
50
+ batch.metrics = BatchMetrics(
51
+ totalLines=len(rows),
52
+ uniqueLookups=batch.metrics.uniqueLookups,
53
+ machineSeconds=batch.metrics.machineSeconds,
54
+ reviewSeconds=batch.metrics.reviewSeconds,
55
+ statusCounts=status_counts,
56
+ extractionScore=round(100.0 * code_counts.get(ConfidenceLevel.GREEN.name, 0) / total, 1),
57
+ documentScore=round(
58
+ 100.0 * document_counts.get(ConfidenceLevel.GREEN.name, 0) / total, 1
59
+ ),
60
+ codeConfidenceCounts=code_counts,
61
+ documentConfidenceCounts=document_counts,
62
+ **shipment_totals(lines, profile),
63
+ )
64
+ batch.datetimeUpdated = datetime.now()
65
+ await update_obj(BatchModel, batch.id, batch.to_mongo())
66
+ return batch
cbh/api/batch/services/metrics/totals.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+
3
+ from cbh.api.client.dto import InputField
4
+ from cbh.api.client.models import ClientProfileModel
5
+ from cbh.api.common.utils import parse_number
6
+ from cbh.api.importer.utils import normalize_header
7
+ from cbh.api.linecheck.schemas import LineCheckRequest
8
+
9
+ REQUEST_ATTRIBUTES: dict[InputField, str] = {
10
+ InputField.CODE: "code",
11
+ InputField.COUNTRY: "originCountry",
12
+ InputField.DESTINATION_COUNTRY: "destinationCountry",
13
+ InputField.DESCRIPTION: "description",
14
+ InputField.QUANTITY: "quantity",
15
+ InputField.PACKAGES: "packages",
16
+ InputField.GROSS_WEIGHT: "grossWeight",
17
+ InputField.NET_WEIGHT: "netWeight",
18
+ InputField.VALUE: "value",
19
+ }
20
+
21
+
22
+ @dataclass
23
+ class TotalsInput:
24
+ """One line, as both the client wrote it and as the checker understood it."""
25
+
26
+ raw: dict
27
+ request: LineCheckRequest | None
28
+
29
+
30
+ def input_value(
31
+ field: InputField,
32
+ raw: dict,
33
+ request: LineCheckRequest | None,
34
+ profile: ClientProfileModel,
35
+ ):
36
+ """The client's own value for a canonical field.
37
+
38
+ The source row wins, because it is what the client actually wrote; the
39
+ parsed request is the fallback for a line that was typed into the form and
40
+ so has no source row.
41
+ """
42
+ header = profile.columnMapping.get(field.value)
43
+ if header and raw:
44
+ by_header = {normalize_header(key): value for key, value in raw.items()}
45
+ value = by_header.get(normalize_header(header))
46
+ if value not in (None, ""):
47
+ return value
48
+
49
+ attribute = REQUEST_ATTRIBUTES.get(field)
50
+ if attribute and request is not None:
51
+ return getattr(request, attribute, None)
52
+ return None
53
+
54
+
55
+ def _number(value) -> float | None:
56
+ """A source cell may be a number or the text of one; a CSV only ever has text."""
57
+ if isinstance(value, bool):
58
+ return None
59
+ if isinstance(value, (int, float)):
60
+ return float(value)
61
+ return parse_number(value)
62
+
63
+
64
+ def _parcel_key(line: TotalsInput, profile: ClientProfileModel) -> str | None:
65
+ column = profile.totals.parcelKeyColumn
66
+ if not column or not line.raw:
67
+ return None
68
+ by_header = {normalize_header(key): value for key, value in line.raw.items()}
69
+ value = by_header.get(normalize_header(column))
70
+ return str(value) if value not in (None, "") else None
71
+
72
+
73
+ def sum_field(
74
+ field: InputField, lines: list[TotalsInput], profile: ClientProfileModel
75
+ ) -> float | None:
76
+ """Add a field up over a pack, counting each physical package once.
77
+
78
+ A parcel manifest states weight per parcel and repeats it on every item
79
+ line of that parcel. Adding those rows up inflates the total, and the
80
+ total is exactly what the broker checks the shipment against, so a
81
+ parcel-level field is counted once per parcel key.
82
+ """
83
+ parcel_level = field.value in profile.totals.parcelLevelFields
84
+ total = 0.0
85
+ found = False
86
+ seen: set[str] = set()
87
+
88
+ for line in lines:
89
+ number = _number(input_value(field, line.raw, line.request, profile))
90
+ if number is None:
91
+ continue
92
+ if parcel_level:
93
+ key = _parcel_key(line, profile)
94
+ if key is not None:
95
+ if key in seen:
96
+ continue
97
+ seen.add(key)
98
+ total += number
99
+ found = True
100
+
101
+ return round(total, 2) if found else None
102
+
103
+
104
+ def count_parcels(lines: list[TotalsInput], profile: ClientProfileModel) -> int | None:
105
+ keys = {key for line in lines if (key := _parcel_key(line, profile)) is not None}
106
+ return len(keys) or None
107
+
108
+
109
+ def total_packages(lines: list[TotalsInput], profile: ClientProfileModel) -> float:
110
+ """How many physical packages the shipment holds.
111
+
112
+ For a parcel manifest that is the number of distinct parcels, not the sum
113
+ of the per-item piece counts, which counts a two-item parcel twice.
114
+ """
115
+ if profile.totals.packagesFromParcelCount:
116
+ parcels = count_parcels(lines, profile)
117
+ if parcels is not None:
118
+ return float(parcels)
119
+ return sum_field(InputField.PACKAGES, lines, profile) or 0.0
120
+
121
+
122
+ def shipment_totals(
123
+ lines: list[TotalsInput], profile: ClientProfileModel
124
+ ) -> dict[str, float]:
125
+ return {
126
+ "totalPackages": total_packages(lines, profile),
127
+ "totalGrossWeight": sum_field(InputField.GROSS_WEIGHT, lines, profile) or 0.0,
128
+ "totalNetWeight": sum_field(InputField.NET_WEIGHT, lines, profile) or 0.0,
129
+ "totalValue": sum_field(InputField.VALUE, lines, profile) or 0.0,
130
+ }
131
+
132
+
133
+ def sheet_totals(
134
+ lines: list[TotalsInput], profile: ClientProfileModel
135
+ ) -> dict[str, float]:
136
+ """The figures for the TOTAL row written into an exported sheet."""
137
+ totals: dict[str, float] = {}
138
+ for canonical in profile.totals.sumFields:
139
+ try:
140
+ field = InputField(canonical)
141
+ except ValueError:
142
+ continue
143
+ if field is InputField.PACKAGES and profile.totals.packagesFromParcelCount:
144
+ parcels = count_parcels(lines, profile)
145
+ if parcels is not None:
146
+ totals[canonical] = float(parcels)
147
+ continue
148
+ total = sum_field(field, lines, profile)
149
+ if total is not None:
150
+ totals[canonical] = total
151
+ return totals
cbh/api/batch/services/processor.py CHANGED
@@ -1,31 +1,32 @@
1
- """
2
- The pack processor: read the client's file, run it through the line-check API,
3
- write the file back out.
4
-
5
- This layer is deliberately thin. It owns no customs logic — every decision
6
- comes from the line-check engine, so the Excel path and any future declaration
7
- system cannot drift apart.
8
- """
9
-
10
  from datetime import date, datetime
 
11
 
12
- from cbh.api.batch.dto import BatchStatus, FileFormat
13
  from cbh.api.batch.models import BatchMetrics, BatchModel, BatchRowModel, ClientProfileRef
14
  from cbh.api.batch.services.excel import (
 
15
  build_output_workbook,
16
  parse_pack,
17
  workbook_to_bytes,
18
  )
19
- from cbh.api.batch.utils import read_file, result_file_name, store_file
 
20
  from cbh.api.client.models import ClientProfileModel
21
- from cbh.api.common.db_requests import delete_objs, insert_obj, insert_objs, update_obj
 
 
 
 
 
 
 
22
  from cbh.api.linecheck.dto import LineStatus
23
  from cbh.api.linecheck.schemas import LineCheckRequest, LineCheckResponse
24
- from cbh.api.linecheck.services.engine import check_lines
 
 
25
  from cbh.api.taric.db_requests import get_reference_as_of
26
 
27
- DB_WRITE_CHUNK = 500
28
-
29
 
30
  async def create_batch(
31
  name: str,
@@ -33,22 +34,23 @@ async def create_batch(
33
  file_name: str,
34
  profile: ClientProfileModel,
35
  check_date: date | None = None,
 
 
 
 
36
  ) -> BatchModel:
37
- """
38
- Store an uploaded pack and record it.
39
-
40
- The source file is kept: a disputed line is settled by replaying the file
41
- that was actually submitted.
42
- """
43
- stored = store_file(payload, file_name, subdirectory="source")
44
-
45
  batch = BatchModel(
46
  name=name,
47
  clientProfile=ClientProfileRef(id=profile.id, name=profile.name),
48
  fileFormat=FileFormat.from_name(file_name),
49
- sourceFile=stored,
50
  checkDate=check_date,
51
- defaultDirection=profile.defaults.direction,
 
 
 
 
 
52
  status=BatchStatus.UPLOADED,
53
  )
54
 
@@ -65,14 +67,16 @@ async def create_batch(
65
 
66
 
67
  async def process_batch(batch: BatchModel, profile: ClientProfileModel) -> BatchModel:
68
- """
69
- Run a pack end to end and produce the output file.
70
-
71
- Safe to repeat: the rows of a previous attempt are cleared first, so a
72
- reprocessed pack is replaced rather than doubled.
73
- """
74
- payload = read_file(batch.sourceFile)
75
- sheet = parse_pack(payload, batch.sourceFile.fileName, profile, batch.checkDate)
 
 
76
 
77
  await delete_objs(BatchRowModel, {"batchId": batch.id})
78
 
@@ -81,6 +85,12 @@ async def process_batch(batch: BatchModel, profile: ClientProfileModel) -> Batch
81
  ]
82
  batch_result = await check_lines(requests, batch.checkDate)
83
 
 
 
 
 
 
 
84
  results: dict[int, LineCheckResponse] = {
85
  result.rowIndex: result
86
  for result in batch_result.results
@@ -100,13 +110,21 @@ async def process_batch(batch: BatchModel, profile: ClientProfileModel) -> Batch
100
 
101
  batch.resultFile = result_file
102
  batch.status = BatchStatus.COMPLETED
103
- batch.referenceAsOf = await get_reference_as_of()
104
  batch.metrics = BatchMetrics(
105
  totalLines=batch_result.totalLines,
106
  uniqueLookups=batch_result.uniqueLookups,
107
  machineSeconds=batch_result.machineSeconds,
108
  statusCounts=batch_result.statusCounts,
109
  reviewSeconds=batch.metrics.reviewSeconds,
 
 
 
 
 
 
 
 
110
  )
111
  batch.datetimeUpdated = datetime.now()
112
 
@@ -114,14 +132,95 @@ async def process_batch(batch: BatchModel, profile: ClientProfileModel) -> Batch
114
  return batch
115
 
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  async def _persist_rows(
118
  batch: BatchModel,
119
- sheet,
120
  results: dict[int, LineCheckResponse],
121
  ) -> None:
122
- """
123
- Store every row with the answer it received.
124
- """
125
  rows: list[BatchRowModel] = []
126
  for row in sheet.rows:
127
  result = results.get(row.rowIndex)
@@ -130,7 +229,12 @@ async def _persist_rows(
130
  BatchRowModel(
131
  batchId=batch.id,
132
  rowIndex=row.rowIndex,
133
- inputRow={key: _serializable(value) for key, value in row.raw.items()},
 
 
 
 
 
134
  request=row.request.model_dump(mode="json") if row.request else {},
135
  result=result.model_dump(mode="json") if result else {},
136
  status=status.name,
@@ -138,14 +242,5 @@ async def _persist_rows(
138
  )
139
  )
140
 
141
- for start in range(0, len(rows), DB_WRITE_CHUNK):
142
- await insert_objs(rows[start : start + DB_WRITE_CHUNK])
143
-
144
-
145
- def _serializable(value):
146
- """
147
- Render a raw spreadsheet cell as something BSON can store.
148
- """
149
- if value is None or isinstance(value, (str, int, float, bool)):
150
- return value
151
- return str(value)
 
 
 
 
 
 
 
 
 
 
1
  from datetime import date, datetime
2
+ from pathlib import Path
3
 
4
+ from cbh.api.batch.dto import BatchSource, BatchStatus, FileFormat
5
  from cbh.api.batch.models import BatchMetrics, BatchModel, BatchRowModel, ClientProfileRef
6
  from cbh.api.batch.services.excel import (
7
+ ParsedSheet,
8
  build_output_workbook,
9
  parse_pack,
10
  workbook_to_bytes,
11
  )
12
+ from cbh.api.batch.services.metrics.totals import TotalsInput, shipment_totals
13
+ from cbh.api.batch.utils import result_file_name, store_file
14
  from cbh.api.client.models import ClientProfileModel
15
+ from cbh.api.common.db_requests import (
16
+ delete_objs,
17
+ find_objs,
18
+ insert_obj,
19
+ insert_objs,
20
+ update_obj,
21
+ )
22
+ from cbh.api.common.dto import CustomsProcedure, TradeDirection
23
  from cbh.api.linecheck.dto import LineStatus
24
  from cbh.api.linecheck.schemas import LineCheckRequest, LineCheckResponse
25
+ from cbh.api.linecheck.services.engine import check_line_cached, check_lines, recount_confidence
26
+ from cbh.api.rules.db_requests import get_active_rules, record_rule_hits
27
+ from cbh.api.rules.services.apply import apply_rules, apply_rules_to_lines
28
  from cbh.api.taric.db_requests import get_reference_as_of
29
 
 
 
30
 
31
  async def create_batch(
32
  name: str,
 
34
  file_name: str,
35
  profile: ClientProfileModel,
36
  check_date: date | None = None,
37
+ destination_country: str | None = None,
38
+ direction: TradeDirection | None = None,
39
+ procedure: CustomsProcedure | None = None,
40
+ shipment_reference: str | None = None,
41
  ) -> BatchModel:
 
 
 
 
 
 
 
 
42
  batch = BatchModel(
43
  name=name,
44
  clientProfile=ClientProfileRef(id=profile.id, name=profile.name),
45
  fileFormat=FileFormat.from_name(file_name),
46
+ sourceFile=store_file(payload, file_name, subdirectory="source"),
47
  checkDate=check_date,
48
+ defaultDirection=direction or profile.defaults.direction,
49
+ defaultProcedure=procedure or profile.defaults.procedure,
50
+ destinationCountry=(
51
+ (destination_country or profile.defaults.destinationCountry or "").upper() or None
52
+ ),
53
+ shipmentReference=shipment_reference,
54
  status=BatchStatus.UPLOADED,
55
  )
56
 
 
67
 
68
 
69
  async def process_batch(batch: BatchModel, profile: ClientProfileModel) -> BatchModel:
70
+ payload = Path(batch.sourceFile.storagePath).read_bytes()
71
+ sheet = parse_pack(
72
+ payload,
73
+ batch.sourceFile.fileName,
74
+ profile,
75
+ batch.checkDate,
76
+ batch.destinationCountry,
77
+ batch.defaultDirection,
78
+ batch.defaultProcedure,
79
+ )
80
 
81
  await delete_objs(BatchRowModel, {"batchId": batch.id})
82
 
 
85
  ]
86
  batch_result = await check_lines(requests, batch.checkDate)
87
 
88
+ reference_as_of = await get_reference_as_of()
89
+ batch_result.results = await apply_rules_to_lines(
90
+ requests, batch_result.results, batch.clientProfile.id, reference_as_of
91
+ )
92
+ recount_confidence(batch_result)
93
+
94
  results: dict[int, LineCheckResponse] = {
95
  result.rowIndex: result
96
  for result in batch_result.results
 
110
 
111
  batch.resultFile = result_file
112
  batch.status = BatchStatus.COMPLETED
113
+ batch.referenceAsOf = reference_as_of
114
  batch.metrics = BatchMetrics(
115
  totalLines=batch_result.totalLines,
116
  uniqueLookups=batch_result.uniqueLookups,
117
  machineSeconds=batch_result.machineSeconds,
118
  statusCounts=batch_result.statusCounts,
119
  reviewSeconds=batch.metrics.reviewSeconds,
120
+ extractionScore=batch_result.extractionScore,
121
+ documentScore=batch_result.documentScore,
122
+ codeConfidenceCounts=batch_result.codeConfidenceCounts,
123
+ documentConfidenceCounts=batch_result.documentConfidenceCounts,
124
+ **shipment_totals(
125
+ [TotalsInput(raw=row.raw, request=row.request) for row in sheet.rows],
126
+ profile,
127
+ ),
128
  )
129
  batch.datetimeUpdated = datetime.now()
130
 
 
132
  return batch
133
 
134
 
135
+ async def create_manual_batch(
136
+ name: str,
137
+ profile: ClientProfileModel,
138
+ check_date: date | None = None,
139
+ destination_country: str | None = None,
140
+ direction: TradeDirection | None = None,
141
+ procedure: CustomsProcedure | None = None,
142
+ shipment_reference: str | None = None,
143
+ currency: str = "EUR",
144
+ ) -> BatchModel:
145
+ """Open an empty shipment for the form to fill.
146
+
147
+ It is COMPLETED from the start: there is no file to parse, and every line
148
+ is checked as it arrives, so the table is always current.
149
+ """
150
+ batch = BatchModel(
151
+ name=name,
152
+ clientProfile=ClientProfileRef(id=profile.id, name=profile.name),
153
+ source=BatchSource.MANUAL,
154
+ status=BatchStatus.COMPLETED,
155
+ sourceFile=None,
156
+ checkDate=check_date,
157
+ defaultDirection=direction or profile.defaults.direction,
158
+ defaultProcedure=procedure or profile.defaults.procedure,
159
+ destinationCountry=(
160
+ (destination_country or profile.defaults.destinationCountry or "").upper() or None
161
+ ),
162
+ shipmentReference=shipment_reference,
163
+ currency=currency,
164
+ referenceAsOf=await get_reference_as_of(),
165
+ )
166
+ await insert_obj(batch)
167
+ return batch
168
+
169
+
170
+ async def check_and_store_row(
171
+ batch: BatchModel,
172
+ request: LineCheckRequest,
173
+ row_index: int,
174
+ existing: BatchRowModel | None = None,
175
+ ) -> BatchRowModel:
176
+ """Check one line and write it into the shipment's table.
177
+
178
+ The same path serves a new form line, a corrected code and a re-check, so
179
+ a manually added row is indistinguishable from an uploaded one downstream.
180
+ """
181
+ reference_as_of = await get_reference_as_of()
182
+ request.rowIndex = row_index
183
+ if request.date is None:
184
+ request.date = batch.checkDate
185
+
186
+ result = await check_line_cached(request, reference_as_of)
187
+ result.rowIndex = row_index
188
+
189
+ rules = await get_active_rules(batch.clientProfile.id)
190
+ if rules:
191
+ result = await apply_rules(request, result, rules, reference_as_of)
192
+ await record_rule_hits([rule.ruleId for rule in result.appliedRules])
193
+
194
+ row = existing or BatchRowModel(batchId=batch.id, rowIndex=row_index)
195
+ row.inputRow = {
196
+ key: value
197
+ for key, value in request.model_dump(mode="json").items()
198
+ if value is not None
199
+ }
200
+ row.request = request.model_dump(mode="json")
201
+ row.result = result.model_dump(mode="json")
202
+ row.status = result.status.name
203
+ row.needsReview = result.status.needs_review
204
+
205
+ if existing is None:
206
+ await insert_obj(row)
207
+ else:
208
+ await update_obj(BatchRowModel, row.id, row.to_mongo())
209
+ return row
210
+
211
+
212
+ async def next_row_index(batch_id: str) -> int:
213
+ rows = await find_objs(
214
+ BatchRowModel, {"batchId": batch_id}, limit=1, sort=("rowIndex", -1)
215
+ )
216
+ return (rows[0].rowIndex + 1) if rows else 1
217
+
218
+
219
  async def _persist_rows(
220
  batch: BatchModel,
221
+ sheet: ParsedSheet,
222
  results: dict[int, LineCheckResponse],
223
  ) -> None:
 
 
 
224
  rows: list[BatchRowModel] = []
225
  for row in sheet.rows:
226
  result = results.get(row.rowIndex)
 
229
  BatchRowModel(
230
  batchId=batch.id,
231
  rowIndex=row.rowIndex,
232
+ inputRow={
233
+ key: value
234
+ if value is None or isinstance(value, (str, int, float, bool))
235
+ else str(value)
236
+ for key, value in row.raw.items()
237
+ },
238
  request=row.request.model_dump(mode="json") if row.request else {},
239
  result=result.model_dump(mode="json") if result else {},
240
  status=status.name,
 
242
  )
243
  )
244
 
245
+ for start in range(0, len(rows), 500):
246
+ await insert_objs(rows[start : start + 500])
 
 
 
 
 
 
 
 
 
cbh/api/batch/utils.py CHANGED
@@ -1,7 +1,3 @@
1
- """
2
- Batch helpers: file storage and naming.
3
- """
4
-
5
  import re
6
  import uuid
7
  from datetime import datetime
@@ -10,35 +6,13 @@ from pathlib import Path
10
  from cbh.api.common.models import FileRef
11
  from cbh.core.config import settings
12
 
13
- CONTENT_TYPES = {
14
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
15
- ".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
16
- ".csv": "text/csv",
17
- ".txt": "text/plain",
18
- }
19
-
20
 
21
  def safe_file_name(file_name: str) -> str:
22
- """
23
- Strip anything that could escape the storage directory.
24
- """
25
  cleaned = re.sub(r"[^A-Za-z0-9._-]", "_", Path(file_name).name)
26
  return cleaned or "upload.xlsx"
27
 
28
 
29
- def content_type_for(file_name: str) -> str:
30
- """
31
- Content type for a stored pack.
32
- """
33
- return CONTENT_TYPES.get(Path(file_name).suffix.lower(), "application/octet-stream")
34
-
35
-
36
  def store_file(payload: bytes, file_name: str, subdirectory: str = "source") -> FileRef:
37
- """
38
- Write a pack to local storage and describe where it went.
39
-
40
- Files are prefixed with a UUID so two packs of the same name never collide.
41
- """
42
  directory = settings.BATCH_STORAGE_DIR / subdirectory
43
  directory.mkdir(parents=True, exist_ok=True)
44
 
@@ -49,25 +23,16 @@ def store_file(payload: bytes, file_name: str, subdirectory: str = "source") ->
49
  return FileRef(
50
  fileName=clean_name,
51
  storagePath=str(path),
52
- contentType=content_type_for(clean_name),
 
 
 
 
 
53
  sizeBytes=len(payload),
54
  )
55
 
56
 
57
- def read_file(file_ref: FileRef) -> bytes:
58
- """
59
- Read a stored pack back off disk.
60
- """
61
- return Path(file_ref.storagePath).read_bytes()
62
-
63
-
64
  def result_file_name(source_name: str) -> str:
65
- """
66
- Name of the output file for a pack.
67
-
68
- Always .xlsx: the output carries highlighting and a totals row, which a CSV
69
- cannot represent.
70
- """
71
  stem = Path(safe_file_name(source_name)).stem
72
- stamp = datetime.now().strftime("%Y%m%d")
73
- return f"{stem}_checked_{stamp}.xlsx"
 
 
 
 
 
1
  import re
2
  import uuid
3
  from datetime import datetime
 
6
  from cbh.api.common.models import FileRef
7
  from cbh.core.config import settings
8
 
 
 
 
 
 
 
 
9
 
10
  def safe_file_name(file_name: str) -> str:
 
 
 
11
  cleaned = re.sub(r"[^A-Za-z0-9._-]", "_", Path(file_name).name)
12
  return cleaned or "upload.xlsx"
13
 
14
 
 
 
 
 
 
 
 
15
  def store_file(payload: bytes, file_name: str, subdirectory: str = "source") -> FileRef:
 
 
 
 
 
16
  directory = settings.BATCH_STORAGE_DIR / subdirectory
17
  directory.mkdir(parents=True, exist_ok=True)
18
 
 
23
  return FileRef(
24
  fileName=clean_name,
25
  storagePath=str(path),
26
+ contentType={
27
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
28
+ ".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
29
+ ".csv": "text/csv",
30
+ ".txt": "text/plain",
31
+ }.get(path.suffix.lower(), "application/octet-stream"),
32
  sizeBytes=len(payload),
33
  )
34
 
35
 
 
 
 
 
 
 
 
36
  def result_file_name(source_name: str) -> str:
 
 
 
 
 
 
37
  stem = Path(safe_file_name(source_name)).stem
38
+ return f"{stem}_checked_{datetime.now().strftime('%Y%m%d')}.xlsx"
 
cbh/api/batch/views.py CHANGED
@@ -1,43 +1,78 @@
1
- """
2
- Batch views module.
3
- """
4
-
5
  from datetime import date, datetime
6
  from pathlib import Path
7
 
8
- from fastapi import Depends, File, Form, HTTPException, Query, UploadFile
9
  from fastapi.responses import FileResponse
 
10
 
11
- from cbh.api.account.dto import AccountType
12
- from cbh.api.account.models import AccountModel
13
  from cbh.api.batch import batch_router
14
  from cbh.api.batch.db_requests import get_batch, get_batch_rows, get_batches
15
- from cbh.api.batch.dto import BatchStatus, ReviewStage
16
- from cbh.api.batch.models import BatchModel
17
- from cbh.api.batch.schemas import BatchResponse, BatchRowResponse, GoldSetScoreResponse
18
- from cbh.api.batch.services.metrics import read_gold_rows, score_against_gold
19
- from cbh.api.batch.services.processor import create_batch, process_batch
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  from cbh.api.client.db_requests import get_profile
21
- from cbh.api.common.db_requests import delete_obj, delete_objs, update_obj
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  from cbh.api.common.schemas import AllObjectsResponse
23
- from cbh.api.common.utils import to_paging
24
- from cbh.core.security import PermissionDependency
25
  from cbh.core.wrappers import CbhResponseWrapper
26
 
27
- ANY_ACCOUNT = [AccountType.USER, AccountType.ADMIN, AccountType.OWNER, AccountType.SUPER_ADMIN]
28
- ADMIN_ONLY = [AccountType.ADMIN, AccountType.OWNER, AccountType.SUPER_ADMIN]
29
-
30
 
31
  def _to_response(batch: BatchModel) -> BatchResponse:
32
- """
33
- Map a pack onto its API representation.
34
- """
35
  return BatchResponse(
36
  id=batch.id,
37
  name=batch.name,
38
  clientProfile=batch.clientProfile,
39
  status=batch.status,
40
- sourceFileName=batch.sourceFile.fileName,
 
41
  resultFileName=batch.resultFile.fileName if batch.resultFile else None,
42
  checkDate=batch.checkDate,
43
  defaultDirection=batch.defaultDirection,
@@ -51,18 +86,31 @@ def _to_response(batch: BatchModel) -> BatchResponse:
51
  )
52
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  @batch_router.post("/upload")
55
  async def upload_batch(
56
  clientProfileId: str = Form(...),
57
  name: str | None = Form(default=None),
58
  checkDate: date | None = Form(default=None),
 
 
 
 
59
  process: bool = Form(default=True),
60
  file: UploadFile = File(...),
61
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
62
  ) -> CbhResponseWrapper[BatchResponse]:
63
- """
64
- Upload a client's pack and, by default, process it straight away.
65
- """
66
  payload = await file.read()
67
  if not payload:
68
  raise HTTPException(status_code=400, detail="Empty file.")
@@ -76,6 +124,10 @@ async def upload_batch(
76
  file_name=file_name,
77
  profile=profile,
78
  check_date=checkDate,
 
 
 
 
79
  )
80
 
81
  if process and batch.status is not BatchStatus.MAPPING_REQUIRED:
@@ -84,17 +136,95 @@ async def upload_batch(
84
  return CbhResponseWrapper(data=_to_response(batch))
85
 
86
 
87
- @batch_router.post("/{batchId}/process")
88
- async def process_existing_batch(
89
- batchId: str,
90
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
91
  ) -> CbhResponseWrapper[BatchResponse]:
 
 
 
 
92
  """
93
- Process, or reprocess, an uploaded pack.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
- Reprocessing is how a pack is brought up to date after a reference-data
96
- import: the answers are recomputed against the rules now loaded.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  batch = await get_batch(batchId)
99
  profile = await get_profile(batch.clientProfile.id)
100
 
@@ -108,48 +238,71 @@ async def process_existing_batch(
108
  return CbhResponseWrapper(data=_to_response(await _run(batch, profile)))
109
 
110
 
111
- async def _run(batch: BatchModel, profile) -> BatchModel:
112
- """
113
- Run a pack, recording a failure on the pack rather than losing it.
114
- """
115
- try:
116
- return await process_batch(batch, profile)
117
- except Exception as error: # pylint: disable=W0718
118
- batch.status = BatchStatus.FAILED
119
- batch.errors.append(f"{type(error).__name__}: {error}")
120
- batch.datetimeUpdated = datetime.now()
121
- await update_obj(BatchModel, batch.id, batch.to_mongo())
122
- raise HTTPException(
123
- status_code=400, detail=f"Could not process the pack: {error}"
124
- ) from error
125
-
126
-
127
  @batch_router.get("")
128
  async def list_batches(
129
  pageSize: int = Query(default=25, le=100),
130
  pageIndex: int = Query(default=0, ge=0),
131
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
132
  ) -> CbhResponseWrapper[AllObjectsResponse[BatchResponse]]:
133
- """
134
- List the processed packs, newest first.
135
- """
136
  batches, total = await get_batches(pageSize, pageIndex)
137
  return CbhResponseWrapper(
138
  data=AllObjectsResponse(
139
- paging=to_paging(pageSize, pageIndex, total),
140
  data=[_to_response(batch) for batch in batches],
141
  )
142
  )
143
 
144
 
145
- @batch_router.get("/{batchId}")
146
- async def get_batch_details(
147
- batchId: str,
148
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
149
- ) -> CbhResponseWrapper[BatchResponse]:
150
- """
151
- Fetch one pack.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  return CbhResponseWrapper(data=_to_response(await get_batch(batchId)))
154
 
155
 
@@ -159,18 +312,11 @@ async def list_batch_rows(
159
  needsReviewOnly: bool = Query(default=False),
160
  pageSize: int = Query(default=100, le=1000),
161
  pageIndex: int = Query(default=0, ge=0),
162
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
163
  ) -> CbhResponseWrapper[AllObjectsResponse[BatchRowResponse]]:
164
- """
165
- Read the rows of a pack.
166
-
167
- ``needsReviewOnly`` gives the broker's working list: the lines the machine
168
- would not answer for.
169
- """
170
  rows, total = await get_batch_rows(batchId, pageSize, pageIndex, needsReviewOnly)
171
  return CbhResponseWrapper(
172
  data=AllObjectsResponse(
173
- paging=to_paging(pageSize, pageIndex, total),
174
  data=[
175
  BatchRowResponse(
176
  rowIndex=row.rowIndex,
@@ -185,17 +331,306 @@ async def list_batch_rows(
185
  )
186
 
187
 
188
- @batch_router.get("/{batchId}/download")
189
- async def download_batch_result(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  batchId: str,
191
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
192
- ) -> FileResponse:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  """
194
- Download the checked pack.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  batch = await get_batch(batchId)
197
  if not batch.resultFile:
198
- raise HTTPException(status_code=404, detail="This pack has not been processed yet.")
 
 
 
 
 
 
 
 
199
 
200
  path = Path(batch.resultFile.storagePath)
201
  if not path.exists():
@@ -209,16 +644,7 @@ async def download_batch_result(
209
 
210
 
211
  @batch_router.post("/{batchId}/review/start")
212
- async def start_review(
213
- batchId: str,
214
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
215
- ) -> CbhResponseWrapper[BatchResponse]:
216
- """
217
- Mark the start of human review.
218
-
219
- Review time is the metric the pilot is judged on, so it is measured here
220
- rather than estimated afterwards.
221
- """
222
  batch = await get_batch(batchId)
223
  batch.reviewStage = ReviewStage.IN_REVIEW
224
  batch.reviewStartedAt = datetime.now()
@@ -230,13 +656,7 @@ async def start_review(
230
 
231
 
232
  @batch_router.post("/{batchId}/review/finish")
233
- async def finish_review(
234
- batchId: str,
235
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
236
- ) -> CbhResponseWrapper[BatchResponse]:
237
- """
238
- Mark the end of human review and record how long it took.
239
- """
240
  batch = await get_batch(batchId)
241
  if not batch.reviewStartedAt:
242
  raise HTTPException(status_code=400, detail="Review was never started.")
@@ -255,15 +675,7 @@ async def finish_review(
255
  async def score_batch_against_gold(
256
  batchId: str,
257
  file: UploadFile = File(...),
258
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
259
  ) -> CbhResponseWrapper[GoldSetScoreResponse]:
260
- """
261
- Score a processed pack against a gold file confirmed by the client.
262
-
263
- Returns the acceptance metrics for that pack: correct codes, correct
264
- document codes (required and exemption scored separately), machine time and
265
- review time.
266
- """
267
  batch = await get_batch(batchId)
268
  if batch.status is not BatchStatus.COMPLETED:
269
  raise HTTPException(status_code=400, detail="This pack has not been processed yet.")
@@ -291,18 +703,7 @@ async def score_batch_against_gold(
291
 
292
 
293
  @batch_router.delete("/{batchId}")
294
- async def delete_batch(
295
- batchId: str,
296
- account: AccountModel = Depends(PermissionDependency(ADMIN_ONLY)),
297
- ) -> CbhResponseWrapper[BatchResponse]:
298
- """
299
- Delete a pack and its rows.
300
-
301
- The stored files are left on disk: they are the evidence behind answers
302
- already given to the client.
303
- """
304
- from cbh.api.batch.models import BatchRowModel
305
-
306
  batch = await delete_obj(BatchModel, batchId)
307
  await delete_objs(BatchRowModel, {"batchId": batchId})
308
  return CbhResponseWrapper(data=_to_response(batch))
 
 
 
 
 
1
  from datetime import date, datetime
2
  from pathlib import Path
3
 
4
+ from fastapi import File, Form, HTTPException, Query, UploadFile
5
  from fastapi.responses import FileResponse
6
+ from pydantic import ValidationError
7
 
 
 
8
  from cbh.api.batch import batch_router
9
  from cbh.api.batch.db_requests import get_batch, get_batch_rows, get_batches
10
+ from cbh.api.batch.dto import BatchSource, BatchStatus, ExportFormat, ReviewStage
11
+ from cbh.api.batch.models import BatchModel, BatchRowModel
12
+ from cbh.api.batch.schemas import (
13
+ BatchResponse,
14
+ BatchRowResponse,
15
+ ExportFormatResponse,
16
+ GoldSetScoreResponse,
17
+ ManualLineRequest,
18
+ ManualShipmentRequest,
19
+ ReviewDocuments,
20
+ ReviewHeader,
21
+ ReviewRow,
22
+ ReviewTableResponse,
23
+ RowReviewRequest,
24
+ ShipmentTotals,
25
+ )
26
+ from cbh.api.batch.services.excel import (
27
+ build_export_workbook,
28
+ export_file_name,
29
+ unavailable_reason,
30
+ workbook_to_bytes,
31
+ )
32
+ from cbh.api.batch.services.metrics import (
33
+ read_gold_rows,
34
+ recompute_metrics,
35
+ score_against_gold,
36
+ )
37
+ from cbh.api.batch.services.processor import (
38
+ check_and_store_row,
39
+ create_batch,
40
+ create_manual_batch,
41
+ next_row_index,
42
+ process_batch,
43
+ )
44
+ from cbh.api.batch.utils import store_file
45
  from cbh.api.client.db_requests import get_profile
46
+ from cbh.api.client.dto import ExportLayout
47
+ from cbh.api.client.models import ClientProfileModel
48
+ from cbh.api.common.db_requests import (
49
+ delete_obj,
50
+ delete_objs,
51
+ find_objs,
52
+ insert_obj,
53
+ update_obj,
54
+ )
55
+ from cbh.api.common.dto import CustomsProcedure, Paging, TradeDirection
56
+ from cbh.api.linecheck.dto import ConfidenceLevel, ReferenceSource, RowDecision
57
+ from cbh.api.linecheck.schemas import LineCheckRequest, LineCheckResponse
58
+ from cbh.api.rules.models import RuleModel
59
+ from cbh.api.rules.schemas import MakeRuleRequest, RuleRequest, RuleResponse
60
+ from cbh.api.rules.views import to_rule_response
61
+ from cbh.api.taric.db_requests import get_reference_as_of
62
+ from cbh.api.taric.utils import normalize_code
63
  from cbh.api.common.schemas import AllObjectsResponse
64
+ from cbh.core.config import settings
 
65
  from cbh.core.wrappers import CbhResponseWrapper
66
 
 
 
 
67
 
68
  def _to_response(batch: BatchModel) -> BatchResponse:
 
 
 
69
  return BatchResponse(
70
  id=batch.id,
71
  name=batch.name,
72
  clientProfile=batch.clientProfile,
73
  status=batch.status,
74
+ source=batch.source,
75
+ sourceFileName=batch.sourceFile.fileName if batch.sourceFile else None,
76
  resultFileName=batch.resultFile.fileName if batch.resultFile else None,
77
  checkDate=batch.checkDate,
78
  defaultDirection=batch.defaultDirection,
 
86
  )
87
 
88
 
89
+ async def _run(batch: BatchModel, profile: ClientProfileModel) -> BatchModel:
90
+ try:
91
+ return await process_batch(batch, profile)
92
+ except Exception as error:
93
+ batch.status = BatchStatus.FAILED
94
+ batch.errors.append(f"{type(error).__name__}: {error}")
95
+ batch.datetimeUpdated = datetime.now()
96
+ await update_obj(BatchModel, batch.id, batch.to_mongo())
97
+ raise HTTPException(
98
+ status_code=400, detail=f"Could not process the pack: {error}"
99
+ ) from error
100
+
101
+
102
  @batch_router.post("/upload")
103
  async def upload_batch(
104
  clientProfileId: str = Form(...),
105
  name: str | None = Form(default=None),
106
  checkDate: date | None = Form(default=None),
107
+ destinationCountry: str | None = Form(default=None),
108
+ direction: TradeDirection | None = Form(default=None),
109
+ procedure: CustomsProcedure | None = Form(default=None),
110
+ shipmentReference: str | None = Form(default=None),
111
  process: bool = Form(default=True),
112
  file: UploadFile = File(...),
 
113
  ) -> CbhResponseWrapper[BatchResponse]:
 
 
 
114
  payload = await file.read()
115
  if not payload:
116
  raise HTTPException(status_code=400, detail="Empty file.")
 
124
  file_name=file_name,
125
  profile=profile,
126
  check_date=checkDate,
127
+ destination_country=destinationCountry,
128
+ direction=direction,
129
+ procedure=procedure,
130
+ shipment_reference=shipmentReference,
131
  )
132
 
133
  if process and batch.status is not BatchStatus.MAPPING_REQUIRED:
 
136
  return CbhResponseWrapper(data=_to_response(batch))
137
 
138
 
139
+ @batch_router.post("/manual")
140
+ async def create_manual_shipment(
141
+ data: ManualShipmentRequest,
 
142
  ) -> CbhResponseWrapper[BatchResponse]:
143
+ """Route A of the specification: start a shipment the form will fill.
144
+
145
+ It produces the same review table as an upload, so the two input routes
146
+ converge on one product rather than two.
147
  """
148
+ profile = await get_profile(data.clientProfileId)
149
+ batch = await create_manual_batch(
150
+ name=data.name or f"Manual shipment {datetime.now():%Y-%m-%d %H:%M}",
151
+ profile=profile,
152
+ check_date=data.checkDate,
153
+ destination_country=data.destinationCountry,
154
+ direction=data.direction,
155
+ procedure=data.procedure,
156
+ shipment_reference=data.shipmentReference,
157
+ currency=data.currency,
158
+ )
159
+ return CbhResponseWrapper(data=_to_response(batch))
160
+
161
+
162
+ @batch_router.post("/{batchId}/rows")
163
+ async def add_manual_row(
164
+ batchId: str,
165
+ data: ManualLineRequest,
166
+ ) -> CbhResponseWrapper[ReviewRow]:
167
+ """Add one form line to the table, checked on arrival."""
168
+ batch = await get_batch(batchId)
169
+
170
+ request = LineCheckRequest(
171
+ code=data.code,
172
+ originCountry=data.originCountry,
173
+ destinationCountry=data.destinationCountry or batch.destinationCountry,
174
+ direction=data.direction or batch.defaultDirection,
175
+ procedure=data.procedure or batch.defaultProcedure,
176
+ date=data.date or batch.checkDate,
177
+ additionalCodes=data.additionalCodes,
178
+ description=data.description,
179
+ quantity=data.quantity,
180
+ packages=data.packages,
181
+ grossWeight=data.grossWeight,
182
+ netWeight=data.netWeight,
183
+ value=data.value,
184
+ clientProfileId=batch.clientProfile.id,
185
+ )
186
 
187
+ row = await check_and_store_row(batch, request, await next_row_index(batchId))
188
+ await recompute_metrics(batch)
189
+ return CbhResponseWrapper(data=_to_review_row(row))
190
+
191
+
192
+ @batch_router.delete("/{batchId}/rows/{rowIndex}")
193
+ async def delete_manual_row(
194
+ batchId: str,
195
+ rowIndex: int,
196
+ ) -> CbhResponseWrapper[BatchResponse]:
197
+ batch = await get_batch(batchId)
198
+ removed = await delete_objs(BatchRowModel, {"batchId": batchId, "rowIndex": rowIndex})
199
+ if not removed:
200
+ raise HTTPException(status_code=404, detail="Object not found.")
201
+ return CbhResponseWrapper(data=_to_response(await recompute_metrics(batch)))
202
+
203
+
204
+ @batch_router.post("/{batchId}/recheck")
205
+ async def recheck_shipment(batchId: str) -> CbhResponseWrapper[BatchResponse]:
206
+ """Re-run every row against the reference data and rules in force now.
207
+
208
+ This is what makes a rule retroactive: write the rule, press re-check, and
209
+ the lines it covers are answered again rather than edited by hand.
210
  """
211
+ batch = await get_batch(batchId)
212
+ rows, _ = await get_batch_rows(batchId)
213
+
214
+ for row in rows:
215
+ if not row.request:
216
+ continue
217
+ request = LineCheckRequest(**row.request)
218
+ if row.correctedCode:
219
+ request.code = row.correctedCode
220
+ await check_and_store_row(batch, request, row.rowIndex, existing=row)
221
+
222
+ batch.referenceAsOf = await get_reference_as_of()
223
+ return CbhResponseWrapper(data=_to_response(await recompute_metrics(batch)))
224
+
225
+
226
+ @batch_router.post("/{batchId}/process")
227
+ async def process_existing_batch(batchId: str) -> CbhResponseWrapper[BatchResponse]:
228
  batch = await get_batch(batchId)
229
  profile = await get_profile(batch.clientProfile.id)
230
 
 
238
  return CbhResponseWrapper(data=_to_response(await _run(batch, profile)))
239
 
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  @batch_router.get("")
242
  async def list_batches(
243
  pageSize: int = Query(default=25, le=100),
244
  pageIndex: int = Query(default=0, ge=0),
 
245
  ) -> CbhResponseWrapper[AllObjectsResponse[BatchResponse]]:
 
 
 
246
  batches, total = await get_batches(pageSize, pageIndex)
247
  return CbhResponseWrapper(
248
  data=AllObjectsResponse(
249
+ paging=Paging(pageSize=pageSize, pageIndex=pageIndex, totalCount=total),
250
  data=[_to_response(batch) for batch in batches],
251
  )
252
  )
253
 
254
 
255
+ EXPORT_FORMAT_DESCRIPTIONS: dict[ExportFormat, str] = {
256
+ ExportFormat.REVIEW: (
257
+ "The tool's own table: every column the reviewer saw, including "
258
+ "confidence reasons, warnings and the rules that fired."
259
+ ),
260
+ ExportFormat.SOURCE: (
261
+ "The client's uploaded file returned unchanged, with the checked "
262
+ "columns appended in the order the profile fixes."
263
+ ),
264
+ ExportFormat.SOFTPAK: "Goenee line list: the client's own columns plus the document codes.",
265
+ ExportFormat.SCOPE: "Scope line list (Fregat, Spare Cargo).",
266
+ ExportFormat.FITON: "Fiton line list (IP Handlers type).",
267
+ }
268
+
269
+
270
+ @batch_router.get("/export-formats")
271
+ async def list_export_formats(
272
+ clientProfileId: str | None = Query(default=None),
273
+ ) -> CbhResponseWrapper[list[ExportFormatResponse]]:
274
+ """Fills the `Export: … ▾` dropdown.
275
+
276
+ Formats whose import specification has not been received are listed but
277
+ marked unavailable, so the gap is visible in the UI instead of being
278
+ papered over with a guessed layout.
279
  """
280
+ default_format = None
281
+ if clientProfileId:
282
+ profile = await get_profile(clientProfileId)
283
+ default_format = (
284
+ ExportFormat.SOFTPAK
285
+ if profile.exportLayout is ExportLayout.SOFTPAK_SHEET
286
+ else ExportFormat.SOURCE
287
+ )
288
+
289
+ return CbhResponseWrapper(
290
+ data=[
291
+ ExportFormatResponse(
292
+ format=export_format,
293
+ name=export_format.name.title(),
294
+ description=EXPORT_FORMAT_DESCRIPTIONS[export_format],
295
+ available=export_format.implemented,
296
+ unavailableReason=unavailable_reason(export_format),
297
+ isProfileDefault=export_format is default_format,
298
+ )
299
+ for export_format in ExportFormat
300
+ ]
301
+ )
302
+
303
+
304
+ @batch_router.get("/{batchId}")
305
+ async def get_batch_details(batchId: str) -> CbhResponseWrapper[BatchResponse]:
306
  return CbhResponseWrapper(data=_to_response(await get_batch(batchId)))
307
 
308
 
 
312
  needsReviewOnly: bool = Query(default=False),
313
  pageSize: int = Query(default=100, le=1000),
314
  pageIndex: int = Query(default=0, ge=0),
 
315
  ) -> CbhResponseWrapper[AllObjectsResponse[BatchRowResponse]]:
 
 
 
 
 
 
316
  rows, total = await get_batch_rows(batchId, pageSize, pageIndex, needsReviewOnly)
317
  return CbhResponseWrapper(
318
  data=AllObjectsResponse(
319
+ paging=Paging(pageSize=pageSize, pageIndex=pageIndex, totalCount=total),
320
  data=[
321
  BatchRowResponse(
322
  rowIndex=row.rowIndex,
 
331
  )
332
 
333
 
334
+ def _to_review_row(row: BatchRowModel) -> ReviewRow:
335
+ result = LineCheckResponse(**row.result) if row.result else None
336
+ request = LineCheckRequest(**row.request) if row.request else None
337
+
338
+ if result is None:
339
+ documents = ReviewDocuments(label="Not checked")
340
+ elif result.noneRequired:
341
+ documents = ReviewDocuments(noneRequired=True, label="No document required")
342
+ elif result.documentsRequired or result.documentsAlternatives:
343
+ documents = ReviewDocuments(
344
+ required=result.documentsRequired,
345
+ alternatives=result.documentsAlternatives,
346
+ exemption=result.documentsExemption,
347
+ suspended=result.documentsSuspended,
348
+ label="; ".join(
349
+ [document.code for document in result.documentsRequired]
350
+ + [
351
+ "|".join(document.code for document in group)
352
+ for group in result.documentsAlternatives
353
+ ]
354
+ ),
355
+ )
356
+ elif result.documentsExemption:
357
+ documents = ReviewDocuments(
358
+ exemption=result.documentsExemption,
359
+ suspended=result.documentsSuspended,
360
+ label=", ".join(document.code for document in result.documentsExemption),
361
+ )
362
+ elif result.documentsSuspended:
363
+ # Nothing to write in Box 44 here, but the line is not settled: the
364
+ # label says which obligations the procedure carried forward, so the
365
+ # empty cell cannot be read as "nothing was owed".
366
+ documents = ReviewDocuments(
367
+ suspended=result.documentsSuspended,
368
+ label=(
369
+ "Suspended: "
370
+ + ", ".join(document.code for document in result.documentsSuspended)
371
+ ),
372
+ )
373
+ else:
374
+ documents = ReviewDocuments(label="Not established")
375
+
376
+ return ReviewRow(
377
+ rowIndex=row.rowIndex,
378
+ description=request.description if request else None,
379
+ inputCode=result.inputCode if result else None,
380
+ proposedCode=row.correctedCode or (result.resolvedCode if result else None),
381
+ formattedCode=result.formattedCode if result else None,
382
+ codeDescription=result.codeDescription if result else None,
383
+ candidates=[candidate.code for candidate in result.candidates] if result else [],
384
+ replacementCodes=result.replacementCodes if result else [],
385
+ originCountry=result.originCountry if result else None,
386
+ destinationCountry=result.destinationCountry if result else None,
387
+ measureCountry=result.measureCountry if result else None,
388
+ direction=result.direction if result else None,
389
+ procedure=result.procedure if result else None,
390
+ codeConfidence=result.codeConfidence if result else ConfidenceLevel.RED,
391
+ codeConfidenceReason=result.codeConfidenceReason if result else "Row was not checked.",
392
+ documentConfidence=result.documentConfidence if result else ConfidenceLevel.RED,
393
+ documentConfidenceReason=(
394
+ result.documentConfidenceReason if result else "Row was not checked."
395
+ ),
396
+ source=result.source if result else ReferenceSource.NONE,
397
+ documents=documents,
398
+ warnings=result.warnings if result else [],
399
+ appliedRules=result.appliedRules if result else [],
400
+ packages=request.packages if request else None,
401
+ grossWeight=request.grossWeight if request else None,
402
+ netWeight=request.netWeight if request else None,
403
+ value=request.value if request else None,
404
+ decision=row.decision,
405
+ correctedCode=row.correctedCode,
406
+ cbamPermitNumber=row.cbamPermitNumber,
407
+ eudrReference=row.eudrReference,
408
+ licenceNumbers=row.licenceNumbers,
409
+ reviewNote=row.reviewNote,
410
+ status=row.status,
411
+ needsReview=row.needsReview,
412
+ )
413
+
414
+
415
+ @batch_router.get("/{batchId}/review")
416
+ async def get_review_table(
417
  batchId: str,
418
+ needsReviewOnly: bool = Query(default=False),
419
+ confidence: ConfidenceLevel | None = Query(default=None),
420
+ pageSize: int = Query(default=100, le=1000),
421
+ pageIndex: int = Query(default=0, ge=0),
422
+ ) -> CbhResponseWrapper[ReviewTableResponse]:
423
+ batch = await get_batch(batchId)
424
+ rows, total = await get_batch_rows(batchId, pageSize, pageIndex, needsReviewOnly)
425
+ review_rows = [_to_review_row(row) for row in rows]
426
+ if confidence is not None:
427
+ review_rows = [
428
+ row
429
+ for row in review_rows
430
+ if confidence in (row.codeConfidence, row.documentConfidence)
431
+ ]
432
+
433
+ origins = await settings.DB_CLIENT.batchrows.distinct(
434
+ "request.originCountry", {"batchId": batchId}
435
+ )
436
+
437
+ return CbhResponseWrapper(
438
+ data=ReviewTableResponse(
439
+ header=ReviewHeader(
440
+ batchId=batch.id,
441
+ shipmentReference=batch.shipmentReference,
442
+ name=batch.name,
443
+ clientProfile=batch.clientProfile,
444
+ status=batch.status,
445
+ source=batch.source,
446
+ direction=batch.defaultDirection,
447
+ procedure=batch.defaultProcedure,
448
+ originCountries=sorted(origin for origin in origins if origin),
449
+ destinationCountry=batch.destinationCountry,
450
+ checkDate=batch.checkDate,
451
+ referenceAsOf=batch.referenceAsOf,
452
+ totals=ShipmentTotals(
453
+ packages=batch.metrics.totalPackages,
454
+ grossWeight=batch.metrics.totalGrossWeight,
455
+ netWeight=batch.metrics.totalNetWeight,
456
+ value=batch.metrics.totalValue,
457
+ currency=batch.currency,
458
+ ),
459
+ totalLines=batch.metrics.totalLines,
460
+ extractionScore=batch.metrics.extractionScore,
461
+ documentScore=batch.metrics.documentScore,
462
+ codeConfidenceCounts=batch.metrics.codeConfidenceCounts,
463
+ documentConfidenceCounts=batch.metrics.documentConfidenceCounts,
464
+ linesNeedingReview=batch.metrics.lines_needing_review,
465
+ ),
466
+ paging=Paging(pageSize=pageSize, pageIndex=pageIndex, totalCount=total),
467
+ rows=review_rows,
468
+ )
469
+ )
470
+
471
+
472
+ @batch_router.patch("/{batchId}/rows/{rowIndex}")
473
+ async def review_row(
474
+ batchId: str,
475
+ rowIndex: int,
476
+ data: RowReviewRequest,
477
+ ) -> CbhResponseWrapper[ReviewRow]:
478
+ batch = await get_batch(batchId)
479
+ matches = await find_objs(
480
+ BatchRowModel, {"batchId": batchId, "rowIndex": rowIndex}, limit=1
481
+ )
482
+ if not matches:
483
+ raise HTTPException(status_code=404, detail="Object not found.")
484
+ row = matches[0]
485
+
486
+ if data.correctedCode and data.correctedCode != row.correctedCode:
487
+ request = LineCheckRequest(**row.request) if row.request else None
488
+ if request is None:
489
+ raise HTTPException(
490
+ status_code=400, detail="This row has no request to re-check."
491
+ )
492
+ request.code = data.correctedCode
493
+ row = await check_and_store_row(batch, request, rowIndex, existing=row)
494
+ row.correctedCode = data.correctedCode
495
+ row.decision = RowDecision.CORRECTED
496
+
497
+ if data.decision is not None:
498
+ row.decision = data.decision
499
+ if data.cbamPermitNumber is not None:
500
+ row.cbamPermitNumber = data.cbamPermitNumber
501
+ if data.eudrReference is not None:
502
+ row.eudrReference = data.eudrReference
503
+ if data.licenceNumbers is not None:
504
+ row.licenceNumbers = data.licenceNumbers
505
+ if data.reviewNote is not None:
506
+ row.reviewNote = data.reviewNote
507
+ row.datetimeReviewed = datetime.now()
508
+
509
+ await update_obj(BatchRowModel, row.id, row.to_mongo())
510
+ await recompute_metrics(batch)
511
+ return CbhResponseWrapper(data=_to_review_row(row))
512
+
513
+
514
+ @batch_router.post("/{batchId}/rows/{rowIndex}/make-rule")
515
+ async def make_rule_from_row(
516
+ batchId: str,
517
+ rowIndex: int,
518
+ data: MakeRuleRequest,
519
+ ) -> CbhResponseWrapper[RuleResponse]:
520
+ """The `Make a rule` action on a review row.
521
+
522
+ Conditions the broker does not spell out are taken from the row in front
523
+ of them, so pressing the button on a line produces a rule that matches
524
+ that line and the ones like it.
525
  """
526
+ batch = await get_batch(batchId)
527
+ matches = await find_objs(
528
+ BatchRowModel, {"batchId": batchId, "rowIndex": rowIndex}, limit=1
529
+ )
530
+ if not matches:
531
+ raise HTTPException(status_code=404, detail="Object not found.")
532
+
533
+ row = matches[0]
534
+ if not row.request:
535
+ raise HTTPException(
536
+ status_code=400, detail="This row has no request to build a rule from."
537
+ )
538
+
539
+ request = LineCheckRequest(**row.request)
540
+ result = LineCheckResponse(**row.result) if row.result else None
541
+ code = row.correctedCode or (result.resolvedCode if result else None) or str(request.code)
542
+ normalized = normalize_code(code) or code
543
+
544
+ prefix = None
545
+ if data.useCodePrefix:
546
+ length = max(2, min(data.codePrefixLength, len(normalized)))
547
+ prefix = normalized[:length]
548
+
549
+ try:
550
+ rule_request = RuleRequest(
551
+ name=data.name,
552
+ description=data.description,
553
+ scope=data.scope,
554
+ clientProfileId=batch.clientProfile.id,
555
+ codePrefix=prefix,
556
+ originCountry=request.originCountry if data.useOriginCountry else None,
557
+ destinationCountry=(
558
+ request.destinationCountry if data.useDestinationCountry else None
559
+ ),
560
+ direction=request.direction if data.useDirection else None,
561
+ descriptionContains=data.descriptionContains,
562
+ action=data.action,
563
+ pinnedCode=data.pinnedCode,
564
+ documentCodes=data.documentCodes,
565
+ note=data.note,
566
+ createdFromBatchId=batchId,
567
+ createdFromRowIndex=rowIndex,
568
+ createdBy=data.createdBy,
569
+ )
570
+ except ValidationError as error:
571
+ raise HTTPException(status_code=400, detail=str(error)) from error
572
+
573
+ rule = RuleModel(**rule_request.model_dump())
574
+ await insert_obj(rule)
575
+ return CbhResponseWrapper(data=to_rule_response(rule))
576
+
577
+
578
+ @batch_router.get("/{batchId}/export")
579
+ async def export_batch(
580
+ batchId: str,
581
+ format: ExportFormat | None = Query(default=None),
582
+ ) -> FileResponse:
583
+ """One export, per tenant, chosen at download time.
584
+
585
+ Without a format the client profile's own layout is used. REVIEW and
586
+ SOFTPAK are built from the stored rows, so a shipment typed into the form
587
+ exports exactly like an uploaded one.
588
  """
589
+ batch = await get_batch(batchId)
590
+ profile = await get_profile(batch.clientProfile.id)
591
+
592
+ if format is None:
593
+ format = (
594
+ ExportFormat.SOFTPAK
595
+ if profile.exportLayout is ExportLayout.SOFTPAK_SHEET
596
+ else ExportFormat.SOURCE
597
+ )
598
+
599
+ reason = unavailable_reason(format)
600
+ if reason:
601
+ raise HTTPException(status_code=501, detail=reason)
602
+
603
+ if format is ExportFormat.SOURCE:
604
+ return await download_batch_result(batchId)
605
+
606
+ rows, _ = await get_batch_rows(batchId)
607
+ if not rows:
608
+ raise HTTPException(status_code=400, detail="This shipment has no lines yet.")
609
+
610
+ workbook = build_export_workbook(format, batch, profile, rows)
611
+ file_ref = store_file(
612
+ workbook_to_bytes(workbook), export_file_name(batch, format), subdirectory="export"
613
+ )
614
+ return FileResponse(
615
+ path=file_ref.storagePath,
616
+ filename=file_ref.fileName,
617
+ media_type=file_ref.contentType,
618
+ )
619
+
620
+
621
+ @batch_router.get("/{batchId}/download")
622
+ async def download_batch_result(batchId: str) -> FileResponse:
623
  batch = await get_batch(batchId)
624
  if not batch.resultFile:
625
+ raise HTTPException(
626
+ status_code=404,
627
+ detail=(
628
+ "This shipment has no source-file result. It was built through the "
629
+ "form, or has not been processed yet. Use /export?format=review."
630
+ )
631
+ if batch.source is BatchSource.MANUAL
632
+ else "This pack has not been processed yet.",
633
+ )
634
 
635
  path = Path(batch.resultFile.storagePath)
636
  if not path.exists():
 
644
 
645
 
646
  @batch_router.post("/{batchId}/review/start")
647
+ async def start_review(batchId: str) -> CbhResponseWrapper[BatchResponse]:
 
 
 
 
 
 
 
 
 
648
  batch = await get_batch(batchId)
649
  batch.reviewStage = ReviewStage.IN_REVIEW
650
  batch.reviewStartedAt = datetime.now()
 
656
 
657
 
658
  @batch_router.post("/{batchId}/review/finish")
659
+ async def finish_review(batchId: str) -> CbhResponseWrapper[BatchResponse]:
 
 
 
 
 
 
660
  batch = await get_batch(batchId)
661
  if not batch.reviewStartedAt:
662
  raise HTTPException(status_code=400, detail="Review was never started.")
 
675
  async def score_batch_against_gold(
676
  batchId: str,
677
  file: UploadFile = File(...),
 
678
  ) -> CbhResponseWrapper[GoldSetScoreResponse]:
 
 
 
 
 
 
 
679
  batch = await get_batch(batchId)
680
  if batch.status is not BatchStatus.COMPLETED:
681
  raise HTTPException(status_code=400, detail="This pack has not been processed yet.")
 
703
 
704
 
705
  @batch_router.delete("/{batchId}")
706
+ async def delete_batch(batchId: str) -> CbhResponseWrapper[BatchResponse]:
 
 
 
 
 
 
 
 
 
 
 
707
  batch = await delete_obj(BatchModel, batchId)
708
  await delete_objs(BatchRowModel, {"batchId": batchId})
709
  return CbhResponseWrapper(data=_to_response(batch))
cbh/api/client/__init__.py CHANGED
@@ -1,10 +1,3 @@
1
- """
2
- Client module initialization.
3
-
4
- This module defines the FastAPI router for client profiles: the per-client
5
- column mapping and export profile used to read and write their files.
6
- """
7
-
8
  from fastapi import APIRouter
9
 
10
  client_router = APIRouter(
 
 
 
 
 
 
 
 
1
  from fastapi import APIRouter
2
 
3
  client_router = APIRouter(
cbh/api/client/db_requests.py CHANGED
@@ -1,33 +1,35 @@
1
- """
2
- Client profile database requests.
3
- """
4
-
5
- from cbh.api.client.models import ClientProfileModel, goenee_profile
6
- from cbh.api.common.db_requests import count_objs, find_objs, get_obj_by_id, insert_obj
 
 
7
 
8
 
9
  async def get_profile(profile_id: str) -> ClientProfileModel:
10
- """
11
- Fetch a client profile, 404 when it does not exist.
12
- """
13
  return await get_obj_by_id(ClientProfileModel, profile_id)
14
 
15
 
16
- async def get_profile_by_name(name: str) -> ClientProfileModel | None:
17
- """
18
- Fetch a client profile by name.
19
- """
20
- profiles = await find_objs(ClientProfileModel, {"name": name}, limit=1)
21
- return profiles[0] if profiles else None
22
-
23
-
24
  async def ensure_default_profiles() -> None:
25
- """
26
- Seed the Goenee profile on an empty database.
27
 
28
- Goenee is the first client, so their profile ships with the application
29
- rather than having to be typed in before the first pack can be processed.
30
  """
31
- if await count_objs(ClientProfileModel) > 0:
32
- return
33
- await insert_obj(goenee_profile())
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from cbh.api.client.models import (
2
+ ClientProfileModel,
3
+ efg_henx_transit_profile,
4
+ goenee_export_line_list_profile,
5
+ goenee_softpak_profile,
6
+ gstar_ecom_profile,
7
+ )
8
+ from cbh.api.common.db_requests import find_objs, get_obj_by_id, insert_objs
9
 
10
 
11
  async def get_profile(profile_id: str) -> ClientProfileModel:
 
 
 
12
  return await get_obj_by_id(ClientProfileModel, profile_id)
13
 
14
 
 
 
 
 
 
 
 
 
15
  async def ensure_default_profiles() -> None:
16
+ """Add any built-in profile that is not there yet, by name.
 
17
 
18
+ Bailing out as soon as one profile exists meant a profile added later never
19
+ reached a database that had already been started once.
20
  """
21
+ defaults = [
22
+ goenee_softpak_profile(),
23
+ gstar_ecom_profile(),
24
+ goenee_export_line_list_profile(),
25
+ efg_henx_transit_profile(),
26
+ ]
27
+ existing = {
28
+ profile.name
29
+ for profile in await find_objs(
30
+ ClientProfileModel, {"name": {"$in": [default.name for default in defaults]}}
31
+ )
32
+ }
33
+ missing = [default for default in defaults if default.name not in existing]
34
+ if missing:
35
+ await insert_objs(missing)
cbh/api/client/dto.py CHANGED
@@ -1,58 +1,34 @@
1
- """
2
- Client profile DTOs.
3
- """
4
 
5
- from enum import Enum
6
-
7
-
8
- class ExportProfileType(Enum):
9
- """
10
- Which declaration system the output file is shaped for.
11
- """
12
 
 
13
  SOFTPAK = 1
14
  GENERIC = 2
15
 
16
 
17
  class InputField(Enum):
18
- """
19
- The canonical fields a client column can be mapped onto.
20
-
21
- Client files arrive in the client's own layout, so the mapping is
22
- configured once per client and reused for every pack.
23
- """
24
-
25
  CODE = "code"
26
  COUNTRY = "country"
 
27
  DIRECTION = "direction"
 
28
  DATE = "date"
29
  ADDITIONAL_CODE = "additionalCode"
30
  DESCRIPTION = "description"
31
  QUANTITY = "quantity"
32
  PACKAGES = "packages"
 
33
  GROSS_WEIGHT = "grossWeight"
34
  NET_WEIGHT = "netWeight"
35
  VALUE = "value"
 
36
 
37
  @property
38
  def required(self) -> bool:
39
- """
40
- Whether a pack cannot be processed without this field.
41
-
42
- Direction and date have profile-level defaults, so only the code and
43
- the country have to come from the file.
44
- """
45
  return self in (InputField.CODE, InputField.COUNTRY)
46
 
47
 
48
  class OutputField(Enum):
49
- """
50
- The columns the checker appends to the client's file.
51
-
52
- The first five are the contract agreed for the Softpak template; the rest
53
- are optional detail a reviewer can switch on.
54
- """
55
-
56
  RESOLVED_CODE = "resolved_code"
57
  DOCUMENTS_REQUIRED = "documents_required"
58
  DOCUMENTS_EXEMPTION = "documents_exemption"
@@ -66,3 +42,12 @@ class OutputField(Enum):
66
  NONE_REQUIRED = "none_required"
67
  APPLIED_MEASURES = "applied_measures"
68
  REFERENCE_AS_OF = "reference_as_of"
 
 
 
 
 
 
 
 
 
 
1
+ from enum import Enum, IntEnum
 
 
2
 
 
 
 
 
 
 
 
3
 
4
+ class ExportProfileType(IntEnum):
5
  SOFTPAK = 1
6
  GENERIC = 2
7
 
8
 
9
  class InputField(Enum):
 
 
 
 
 
 
 
10
  CODE = "code"
11
  COUNTRY = "country"
12
+ DESTINATION_COUNTRY = "destinationCountry"
13
  DIRECTION = "direction"
14
+ PROCEDURE = "procedure"
15
  DATE = "date"
16
  ADDITIONAL_CODE = "additionalCode"
17
  DESCRIPTION = "description"
18
  QUANTITY = "quantity"
19
  PACKAGES = "packages"
20
+ COLLI_TYPE = "colliType"
21
  GROSS_WEIGHT = "grossWeight"
22
  NET_WEIGHT = "netWeight"
23
  VALUE = "value"
24
+ REMARKS = "remarks"
25
 
26
  @property
27
  def required(self) -> bool:
 
 
 
 
 
 
28
  return self in (InputField.CODE, InputField.COUNTRY)
29
 
30
 
31
  class OutputField(Enum):
 
 
 
 
 
 
 
32
  RESOLVED_CODE = "resolved_code"
33
  DOCUMENTS_REQUIRED = "documents_required"
34
  DOCUMENTS_EXEMPTION = "documents_exemption"
 
42
  NONE_REQUIRED = "none_required"
43
  APPLIED_MEASURES = "applied_measures"
44
  REFERENCE_AS_OF = "reference_as_of"
45
+
46
+ YCODES = "ycodes"
47
+ CODE_CONFIDENCE = "code_confidence"
48
+ DOCUMENT_CONFIDENCE = "document_confidence"
49
+
50
+
51
+ class ExportLayout(Enum):
52
+ APPEND_COLUMNS = 1
53
+ SOFTPAK_SHEET = 2
cbh/api/client/models.py CHANGED
@@ -1,43 +1,26 @@
1
- """
2
- Client profile models.
3
- """
4
-
5
  from datetime import datetime
6
 
7
  from pydantic import Field
8
 
9
- from cbh.api.client.dto import ExportProfileType, InputField, OutputField
10
- from cbh.api.common.dto import TradeDirection
11
- from cbh.core.database import MongoBaseModel, MongoBaseShortenModel, MongoSerializableModel
12
 
13
 
14
  class ExportColumn(MongoSerializableModel):
15
- """
16
- One appended output column: what to write and what to call it.
17
- """
18
-
19
  field: OutputField
20
  header: str
21
 
22
 
23
  class ProfileDefaults(MongoSerializableModel):
24
- """
25
- What to assume when the client's file does not say.
26
-
27
- A pack with no date column is checked against today, which is what a broker
28
- filing today means by an undated line.
29
- """
30
-
31
  direction: TradeDirection = TradeDirection.IMPORT
 
32
  country: str | None = None
 
33
  useTodayWhenDateMissing: bool = True
34
 
35
 
36
  class TotalsConfig(MongoSerializableModel):
37
- """
38
- The totals row Judith checks at the bottom of every pack.
39
- """
40
-
41
  enabled: bool = True
42
  label: str = "TOTAL"
43
  sumFields: list[str] = Field(
@@ -48,11 +31,16 @@ class TotalsConfig(MongoSerializableModel):
48
  ]
49
  )
50
 
 
 
 
 
 
 
 
 
51
 
52
  def default_export_columns() -> list[ExportColumn]:
53
- """
54
- The agreed set of appended columns, in order.
55
- """
56
  return [
57
  ExportColumn(field=OutputField.RESOLVED_CODE, header="resolved_code"),
58
  ExportColumn(field=OutputField.DOCUMENTS_REQUIRED, header="documents_required"),
@@ -63,14 +51,6 @@ def default_export_columns() -> list[ExportColumn]:
63
 
64
 
65
  class ClientProfileModel(MongoBaseModel):
66
- """
67
- How one client's files are read and written.
68
-
69
- ``columnMapping`` maps a canonical field onto the header used in that
70
- client's spreadsheets; ``exportColumns`` fixes the order of the columns
71
- appended for their declaration system.
72
- """
73
-
74
  name: str
75
  description: str | None = None
76
  exportProfile: ExportProfileType = ExportProfileType.SOFTPAK
@@ -83,20 +63,13 @@ class ClientProfileModel(MongoBaseModel):
83
  headerRow: int = 1
84
  sheetName: str | None = None
85
  highlightForReview: bool = True
 
 
86
 
87
  datetimeInserted: datetime = Field(default_factory=datetime.now)
88
  datetimeUpdated: datetime = Field(default_factory=datetime.now)
89
 
90
- def header_for(self, field: InputField) -> str | None:
91
- """
92
- The client's header for a canonical field, if mapped.
93
- """
94
- return self.columnMapping.get(field.value)
95
-
96
  def missing_required_fields(self) -> list[str]:
97
- """
98
- Required canonical fields this profile does not map.
99
- """
100
  return [
101
  field.value
102
  for field in InputField
@@ -104,38 +77,171 @@ class ClientProfileModel(MongoBaseModel):
104
  ]
105
 
106
 
107
- class ClientProfileShorten(MongoBaseShortenModel):
108
- """
109
- Client profile projection for list endpoints.
110
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
- name: str
113
- exportProfile: ExportProfileType
114
 
 
 
115
 
116
- def goenee_profile() -> ClientProfileModel:
 
 
117
  """
118
- The starting profile for Goenee (Softpak).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
- The column names are the ones their files have used so far; the export
121
- order follows the five agreed columns until Judith's Softpak template
122
- specification lands, at which point only this function changes.
 
 
 
 
 
 
 
 
123
  """
124
  return ClientProfileModel(
125
- name="Goenee",
126
- description="Customs broker, Vianen. Files Softpak declarations.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  exportProfile=ExportProfileType.SOFTPAK,
128
  columnMapping={
129
- InputField.CODE.value: "Goederencode",
130
- InputField.COUNTRY.value: "Land",
131
- InputField.DIRECTION.value: "Richting",
132
- InputField.DATE.value: "Datum",
133
- InputField.DESCRIPTION.value: "Omschrijving",
134
- InputField.QUANTITY.value: "Aantal",
135
- InputField.PACKAGES.value: "Colli",
136
- InputField.GROSS_WEIGHT.value: "Brutogewicht",
137
- InputField.NET_WEIGHT.value: "Nettogewicht",
138
- InputField.VALUE.value: "Waarde",
139
  },
140
- defaults=ProfileDefaults(direction=TradeDirection.EXPORT),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  )
 
 
 
 
 
1
  from datetime import datetime
2
 
3
  from pydantic import Field
4
 
5
+ from cbh.api.client.dto import ExportLayout, ExportProfileType, InputField, OutputField
6
+ from cbh.api.common.dto import CustomsProcedure, TradeDirection
7
+ from cbh.core.database import MongoBaseModel, MongoSerializableModel
8
 
9
 
10
  class ExportColumn(MongoSerializableModel):
 
 
 
 
11
  field: OutputField
12
  header: str
13
 
14
 
15
  class ProfileDefaults(MongoSerializableModel):
 
 
 
 
 
 
 
16
  direction: TradeDirection = TradeDirection.IMPORT
17
+ procedure: CustomsProcedure = CustomsProcedure.RELEASE_FOR_FREE_CIRCULATION
18
  country: str | None = None
19
+ destinationCountry: str | None = None
20
  useTodayWhenDateMissing: bool = True
21
 
22
 
23
  class TotalsConfig(MongoSerializableModel):
 
 
 
 
24
  enabled: bool = True
25
  label: str = "TOTAL"
26
  sumFields: list[str] = Field(
 
31
  ]
32
  )
33
 
34
+ # A parcel manifest carries one row per item but states weight and piece
35
+ # count per parcel, repeated on every row of that parcel. Adding the rows
36
+ # up inflates the shipment totals, which is the figure the broker checks
37
+ # the consignment against, so those fields are counted once per parcel.
38
+ parcelKeyColumn: str | None = None
39
+ parcelLevelFields: list[str] = Field(default_factory=list)
40
+ packagesFromParcelCount: bool = False
41
+
42
 
43
  def default_export_columns() -> list[ExportColumn]:
 
 
 
44
  return [
45
  ExportColumn(field=OutputField.RESOLVED_CODE, header="resolved_code"),
46
  ExportColumn(field=OutputField.DOCUMENTS_REQUIRED, header="documents_required"),
 
51
 
52
 
53
  class ClientProfileModel(MongoBaseModel):
 
 
 
 
 
 
 
 
54
  name: str
55
  description: str | None = None
56
  exportProfile: ExportProfileType = ExportProfileType.SOFTPAK
 
63
  headerRow: int = 1
64
  sheetName: str | None = None
65
  highlightForReview: bool = True
66
+ exportLayout: ExportLayout = ExportLayout.APPEND_COLUMNS
67
+ softpakHeaderRows: list[list[str]] = Field(default_factory=list)
68
 
69
  datetimeInserted: datetime = Field(default_factory=datetime.now)
70
  datetimeUpdated: datetime = Field(default_factory=datetime.now)
71
 
 
 
 
 
 
 
72
  def missing_required_fields(self) -> list[str]:
 
 
 
73
  return [
74
  field.value
75
  for field in InputField
 
77
  ]
78
 
79
 
80
+ def goenee_softpak_profile() -> ClientProfileModel:
81
+ return ClientProfileModel(
82
+ name="Goenee Softpak",
83
+ description="Jac. Goenee B.V., Vianen. Softpak line list, Excel in and out.",
84
+ exportProfile=ExportProfileType.SOFTPAK,
85
+ exportLayout=ExportLayout.SOFTPAK_SHEET,
86
+ headerRow=7,
87
+ columnMapping={
88
+ InputField.CODE.value: "hs code",
89
+ InputField.DESCRIPTION.value: "description",
90
+ InputField.GROSS_WEIGHT.value: "WEIGHT GROSS",
91
+ InputField.NET_WEIGHT.value: "WEIGHT nett",
92
+ InputField.COUNTRY.value: "origin",
93
+ InputField.QUANTITY.value: "sup_quantity",
94
+ InputField.COLLI_TYPE.value: "type colli",
95
+ InputField.PACKAGES.value: "nr colli",
96
+ InputField.REMARKS.value: "remarks",
97
+ InputField.VALUE.value: "value",
98
+ },
99
+ exportColumns=[ExportColumn(field=OutputField.YCODES, header="Ycodes")],
100
+ softpakHeaderRows=[
101
+ ["Government procedure", "10", " "],
102
+ ["Previous ", "00"],
103
+ ["Invoice N935", " "],
104
+ ["Currency", "EUR"],
105
+ ["Container"],
106
+ ],
107
+ defaults=ProfileDefaults(
108
+ direction=TradeDirection.EXPORT, useTodayWhenDateMissing=True
109
+ ),
110
+ totals=TotalsConfig(
111
+ sumFields=[
112
+ InputField.GROSS_WEIGHT.value,
113
+ InputField.NET_WEIGHT.value,
114
+ InputField.PACKAGES.value,
115
+ InputField.VALUE.value,
116
+ ]
117
+ ),
118
+ )
119
 
 
 
120
 
121
+ def goenee_export_line_list_profile() -> ClientProfileModel:
122
+ """The line list Goenee produces from a G-Star manifest — `Export Goenee.xls`.
123
 
124
+ It is the same shipment as the ecom manifest but already worked: the Y-code
125
+ column holds the exemptions a declarant assigned by hand, which is what the
126
+ gold-set scoring is measured against.
127
  """
128
+ return ClientProfileModel(
129
+ name="Goenee export line list",
130
+ description="Jac. Goenee B.V. line list for a G-Star export, legacy .xls.",
131
+ exportProfile=ExportProfileType.SOFTPAK,
132
+ columnMapping={
133
+ InputField.CODE.value: "HS Code",
134
+ InputField.DESCRIPTION.value: "Item Descr",
135
+ InputField.GROSS_WEIGHT.value: "Total Item Weight",
136
+ InputField.COUNTRY.value: "Item COO",
137
+ InputField.PACKAGES.value: "Pieces",
138
+ InputField.COLLI_TYPE.value: "Colli type",
139
+ # The header is truncated in the file itself; it has no closing
140
+ # bracket, and the mapping matches what is actually written.
141
+ InputField.REMARKS.value: "Remarks (EAN",
142
+ InputField.VALUE.value: "Total Value (GBP)",
143
+ },
144
+ exportColumns=[ExportColumn(field=OutputField.YCODES, header="Y-code")],
145
+ defaults=ProfileDefaults(
146
+ direction=TradeDirection.EXPORT, destinationCountry="GB"
147
+ ),
148
+ totals=TotalsConfig(
149
+ sumFields=[
150
+ InputField.PACKAGES.value,
151
+ InputField.GROSS_WEIGHT.value,
152
+ InputField.VALUE.value,
153
+ ],
154
+ parcelKeyColumn="Tracking no",
155
+ parcelLevelFields=[
156
+ InputField.PACKAGES.value,
157
+ InputField.GROSS_WEIGHT.value,
158
+ ],
159
+ packagesFromParcelCount=True,
160
+ ),
161
+ )
162
 
163
+
164
+ def efg_henx_transit_profile() -> ClientProfileModel:
165
+ """EFG Arabia to Henx Europe — the packs Goenee files under external transit.
166
+
167
+ The goods are not released on arrival: they move under T1 to Henx and are
168
+ declared there, so duty, VAT and excise are secured rather than paid. The
169
+ prohibitions and restrictions still bind, which is what this profile is for.
170
+
171
+ The codes in these packs are eight-digit CN. An import declaration is filed
172
+ on ten, so most lines come back with their declarable candidates rather than
173
+ an answer — that is the pack's gap, not the engine's, and it is left visible.
174
  """
175
  return ClientProfileModel(
176
+ name="EFG Arabia to Henx (T1)",
177
+ description="EFG Arabia commercial invoice / packing list, external transit to Henx Europe BV.",
178
+ exportProfile=ExportProfileType.SOFTPAK,
179
+ # The pack opens with the invoice head — shipper, receiver, totals — and
180
+ # the line table starts at row 20. The workbook's other sheet is a pivot
181
+ # of the same lines and holds no declarable detail.
182
+ sheetName="1. SUMMARY (EQ LIST FOR SHIPPIN",
183
+ headerRow=20,
184
+ columnMapping={
185
+ InputField.CODE.value: "HS CODE",
186
+ InputField.COUNTRY.value: "(country)",
187
+ InputField.DESCRIPTION.value: "Description",
188
+ InputField.QUANTITY.value: "Quantity",
189
+ InputField.GROSS_WEIGHT.value: "Case weight",
190
+ InputField.VALUE.value: "Total Value (EUR)|Total Value",
191
+ InputField.REMARKS.value: "Serial No.",
192
+ },
193
+ exportColumns=[ExportColumn(field=OutputField.YCODES, header="Y-code")],
194
+ defaults=ProfileDefaults(
195
+ direction=TradeDirection.IMPORT,
196
+ procedure=CustomsProcedure.EXTERNAL_TRANSIT_T1,
197
+ destinationCountry="NL",
198
+ useTodayWhenDateMissing=True,
199
+ ),
200
+ totals=TotalsConfig(
201
+ sumFields=[
202
+ InputField.GROSS_WEIGHT.value,
203
+ InputField.QUANTITY.value,
204
+ InputField.VALUE.value,
205
+ ]
206
+ ),
207
+ )
208
+
209
+
210
+ def gstar_ecom_profile() -> ClientProfileModel:
211
+ return ClientProfileModel(
212
+ name="G-Star ecom GB",
213
+ description="Group eStore BV parcel manifest, NL to GB. One row per item.",
214
  exportProfile=ExportProfileType.SOFTPAK,
215
  columnMapping={
216
+ InputField.CODE.value: "HS Code",
217
+ InputField.DESCRIPTION.value: "Item Descr",
218
+ InputField.GROSS_WEIGHT.value: "Total Weight",
219
+ InputField.COUNTRY.value: "Item COO",
220
+ InputField.DESTINATION_COUNTRY.value: "Cust Country",
221
+ InputField.PACKAGES.value: "Pieces",
222
+ InputField.QUANTITY.value: "Item Qty",
223
+ InputField.VALUE.value: "Item Value",
224
+ InputField.REMARKS.value: "SKU",
 
225
  },
226
+ exportColumns=[ExportColumn(field=OutputField.YCODES, header="Y-code")],
227
+ defaults=ProfileDefaults(
228
+ direction=TradeDirection.EXPORT, destinationCountry="GB"
229
+ ),
230
+ # One row per item, many rows per parcel. "Total Weight" and the piece
231
+ # count belong to the parcel, so both are counted once per tracking
232
+ # number. Checked against the Master Invoice for 04-09-2026: 167
233
+ # packages and GBP 11,376.42, which only reconcile this way.
234
+ totals=TotalsConfig(
235
+ sumFields=[
236
+ InputField.PACKAGES.value,
237
+ InputField.GROSS_WEIGHT.value,
238
+ InputField.VALUE.value,
239
+ ],
240
+ parcelKeyColumn="Tracking no",
241
+ parcelLevelFields=[
242
+ InputField.PACKAGES.value,
243
+ InputField.GROSS_WEIGHT.value,
244
+ ],
245
+ packagesFromParcelCount=True,
246
+ ),
247
  )
cbh/api/client/schemas.py CHANGED
@@ -1,7 +1,3 @@
1
- """
2
- Client profile schemas.
3
- """
4
-
5
  from pydantic import BaseModel
6
 
7
  from cbh.api.client.dto import ExportProfileType
@@ -9,10 +5,6 @@ from cbh.api.client.models import ExportColumn, ProfileDefaults, TotalsConfig
9
 
10
 
11
  class ClientProfileRequest(BaseModel):
12
- """
13
- Create or replace a client profile.
14
- """
15
-
16
  name: str
17
  description: str | None = None
18
  exportProfile: ExportProfileType = ExportProfileType.SOFTPAK
@@ -28,10 +20,6 @@ class ClientProfileRequest(BaseModel):
28
 
29
 
30
  class ClientProfileResponse(BaseModel):
31
- """
32
- A client profile as returned by the API.
33
- """
34
-
35
  id: str
36
  name: str
37
  description: str | None = None
@@ -49,10 +37,6 @@ class ClientProfileResponse(BaseModel):
49
 
50
 
51
  class ColumnMappingSuggestion(BaseModel):
52
- """
53
- A guessed mapping for a newly uploaded file, for confirmation.
54
- """
55
-
56
  headers: list[str]
57
  suggestedMapping: dict[str, str]
58
  unmappedHeaders: list[str]
 
 
 
 
 
1
  from pydantic import BaseModel
2
 
3
  from cbh.api.client.dto import ExportProfileType
 
5
 
6
 
7
  class ClientProfileRequest(BaseModel):
 
 
 
 
8
  name: str
9
  description: str | None = None
10
  exportProfile: ExportProfileType = ExportProfileType.SOFTPAK
 
20
 
21
 
22
  class ClientProfileResponse(BaseModel):
 
 
 
 
23
  id: str
24
  name: str
25
  description: str | None = None
 
37
 
38
 
39
  class ColumnMappingSuggestion(BaseModel):
 
 
 
 
40
  headers: list[str]
41
  suggestedMapping: dict[str, str]
42
  unmappedHeaders: list[str]
cbh/api/client/utils.py CHANGED
@@ -1,14 +1,8 @@
1
- """
2
- Client profile helpers: guessing a column mapping from a file's headers.
3
- """
4
-
5
  from typing import Any
6
 
7
  from cbh.api.client.dto import InputField
8
  from cbh.api.importer.utils import normalize_header
9
 
10
- # Header spellings seen in broker files, per canonical field. Dutch first:
11
- # that is what the files in scope are written in.
12
  FIELD_SYNONYMS: dict[InputField, tuple[str, ...]] = {
13
  InputField.CODE: (
14
  "goederencode",
@@ -86,14 +80,6 @@ FIELD_SYNONYMS: dict[InputField, tuple[str, ...]] = {
86
 
87
 
88
  def suggest_column_mapping(headers: list[Any]) -> dict[str, str]:
89
- """
90
- Guess a column mapping from a file's header row.
91
-
92
- Exact matches on a known spelling win; otherwise a header that contains a
93
- known spelling is accepted. A guess is a starting point for the one-time
94
- per-client configuration, not a substitute for it — which is why the API
95
- returns it for confirmation rather than saving it.
96
- """
97
  normalized = [(normalize_header(header), str(header)) for header in headers if header]
98
  mapping: dict[str, str] = {}
99
  taken: set[str] = set()
@@ -135,11 +121,5 @@ def suggest_column_mapping(headers: list[Any]) -> dict[str, str]:
135
 
136
 
137
  def unmapped_headers(headers: list[Any], mapping: dict[str, str]) -> list[str]:
138
- """
139
- Headers the mapping does not claim.
140
-
141
- These are carried through to the output untouched, so the client's file
142
- comes back with everything it arrived with.
143
- """
144
  claimed = set(mapping.values())
145
  return [str(header) for header in headers if header and str(header) not in claimed]
 
 
 
 
 
1
  from typing import Any
2
 
3
  from cbh.api.client.dto import InputField
4
  from cbh.api.importer.utils import normalize_header
5
 
 
 
6
  FIELD_SYNONYMS: dict[InputField, tuple[str, ...]] = {
7
  InputField.CODE: (
8
  "goederencode",
 
80
 
81
 
82
  def suggest_column_mapping(headers: list[Any]) -> dict[str, str]:
 
 
 
 
 
 
 
 
83
  normalized = [(normalize_header(header), str(header)) for header in headers if header]
84
  mapping: dict[str, str] = {}
85
  taken: set[str] = set()
 
121
 
122
 
123
  def unmapped_headers(headers: list[Any], mapping: dict[str, str]) -> list[str]:
 
 
 
 
 
 
124
  claimed = set(mapping.values())
125
  return [str(header) for header in headers if header and str(header) not in claimed]
cbh/api/client/views.py CHANGED
@@ -1,15 +1,11 @@
1
- """
2
- Client profile views module.
3
- """
4
-
5
  from datetime import datetime
6
 
7
- from fastapi import Depends, File, Query, UploadFile
8
 
9
- from cbh.api.account.dto import AccountType
10
- from cbh.api.account.models import AccountModel
11
  from cbh.api.client import client_router
12
  from cbh.api.client.db_requests import get_profile
 
13
  from cbh.api.client.models import ClientProfileModel, default_export_columns
14
  from cbh.api.client.schemas import (
15
  ClientProfileRequest,
@@ -18,19 +14,12 @@ from cbh.api.client.schemas import (
18
  )
19
  from cbh.api.client.utils import suggest_column_mapping, unmapped_headers
20
  from cbh.api.common.db_requests import delete_obj, get_all_objs, insert_obj, update_obj
 
21
  from cbh.api.common.schemas import AllObjectsResponse
22
- from cbh.api.common.utils import to_paging
23
- from cbh.core.security import PermissionDependency
24
  from cbh.core.wrappers import CbhResponseWrapper
25
 
26
- ANY_ACCOUNT = [AccountType.USER, AccountType.ADMIN, AccountType.OWNER, AccountType.SUPER_ADMIN]
27
- ADMIN_ONLY = [AccountType.ADMIN, AccountType.OWNER, AccountType.SUPER_ADMIN]
28
-
29
 
30
  def _to_response(profile: ClientProfileModel) -> ClientProfileResponse:
31
- """
32
- Map a client profile onto its API representation.
33
- """
34
  return ClientProfileResponse(
35
  id=profile.id,
36
  name=profile.name,
@@ -51,17 +40,13 @@ def _to_response(profile: ClientProfileModel) -> ClientProfileResponse:
51
  async def list_profiles(
52
  pageSize: int = Query(default=25, le=100),
53
  pageIndex: int = Query(default=0, ge=0),
54
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
55
  ) -> CbhResponseWrapper[AllObjectsResponse[ClientProfileResponse]]:
56
- """
57
- List the configured client profiles.
58
- """
59
  profiles, total = await get_all_objs(
60
  ClientProfileModel, page_size=pageSize, page_index=pageIndex, sort=("name", 1)
61
  )
62
  return CbhResponseWrapper(
63
  data=AllObjectsResponse(
64
- paging=to_paging(pageSize, pageIndex, total),
65
  data=[_to_response(profile) for profile in profiles],
66
  )
67
  )
@@ -70,22 +55,14 @@ async def list_profiles(
70
  @client_router.get("/{profileId}")
71
  async def get_client_profile(
72
  profileId: str,
73
- account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
74
  ) -> CbhResponseWrapper[ClientProfileResponse]:
75
- """
76
- Fetch one client profile.
77
- """
78
  return CbhResponseWrapper(data=_to_response(await get_profile(profileId)))
79
 
80
 
81
  @client_router.post("")
82
  async def create_profile(
83
  data: ClientProfileRequest,
84
- account: AccountModel = Depends(PermissionDependency(ADMIN_ONLY)),
85
  ) -> CbhResponseWrapper[ClientProfileResponse]:
86
- """
87
- Create a client profile.
88
- """
89
  profile = ClientProfileModel(
90
  **data.model_dump(exclude_none=True),
91
  )
@@ -99,11 +76,7 @@ async def create_profile(
99
  async def replace_profile(
100
  profileId: str,
101
  data: ClientProfileRequest,
102
- account: AccountModel = Depends(PermissionDependency(ADMIN_ONLY)),
103
  ) -> CbhResponseWrapper[ClientProfileResponse]:
104
- """
105
- Replace a client profile.
106
- """
107
  existing = await get_profile(profileId)
108
  updated = ClientProfileModel(
109
  **{
@@ -120,11 +93,7 @@ async def replace_profile(
120
  @client_router.delete("/{profileId}")
121
  async def delete_profile(
122
  profileId: str,
123
- account: AccountModel = Depends(PermissionDependency(ADMIN_ONLY)),
124
  ) -> CbhResponseWrapper[ClientProfileResponse]:
125
- """
126
- Delete a client profile.
127
- """
128
  removed = await delete_obj(ClientProfileModel, profileId)
129
  return CbhResponseWrapper(data=_to_response(removed))
130
 
@@ -133,18 +102,7 @@ async def delete_profile(
133
  async def suggest_mapping(
134
  file: UploadFile = File(...),
135
  headerRow: int = Query(default=1, ge=1),
136
- account: AccountModel = Depends(PermissionDependency(ADMIN_ONLY)),
137
  ) -> CbhResponseWrapper[ColumnMappingSuggestion]:
138
- """
139
- Guess a column mapping from an uploaded client file.
140
-
141
- The guess is returned for confirmation, never saved: the mapping is
142
- configured once per client and a wrong guess would quietly mis-read every
143
- pack that followed.
144
- """
145
- from cbh.api.batch.services.excel import read_headers
146
- from cbh.api.client.dto import InputField
147
-
148
  payload = await file.read()
149
  headers = read_headers(payload, file.filename or "upload.xlsx", headerRow)
150
  mapping = suggest_column_mapping(headers)
 
 
 
 
 
1
  from datetime import datetime
2
 
3
+ from fastapi import File, Query, UploadFile
4
 
5
+ from cbh.api.batch.services.excel import read_headers
 
6
  from cbh.api.client import client_router
7
  from cbh.api.client.db_requests import get_profile
8
+ from cbh.api.client.dto import InputField
9
  from cbh.api.client.models import ClientProfileModel, default_export_columns
10
  from cbh.api.client.schemas import (
11
  ClientProfileRequest,
 
14
  )
15
  from cbh.api.client.utils import suggest_column_mapping, unmapped_headers
16
  from cbh.api.common.db_requests import delete_obj, get_all_objs, insert_obj, update_obj
17
+ from cbh.api.common.dto import Paging
18
  from cbh.api.common.schemas import AllObjectsResponse
 
 
19
  from cbh.core.wrappers import CbhResponseWrapper
20
 
 
 
 
21
 
22
  def _to_response(profile: ClientProfileModel) -> ClientProfileResponse:
 
 
 
23
  return ClientProfileResponse(
24
  id=profile.id,
25
  name=profile.name,
 
40
  async def list_profiles(
41
  pageSize: int = Query(default=25, le=100),
42
  pageIndex: int = Query(default=0, ge=0),
 
43
  ) -> CbhResponseWrapper[AllObjectsResponse[ClientProfileResponse]]:
 
 
 
44
  profiles, total = await get_all_objs(
45
  ClientProfileModel, page_size=pageSize, page_index=pageIndex, sort=("name", 1)
46
  )
47
  return CbhResponseWrapper(
48
  data=AllObjectsResponse(
49
+ paging=Paging(pageSize=pageSize, pageIndex=pageIndex, totalCount=total),
50
  data=[_to_response(profile) for profile in profiles],
51
  )
52
  )
 
55
  @client_router.get("/{profileId}")
56
  async def get_client_profile(
57
  profileId: str,
 
58
  ) -> CbhResponseWrapper[ClientProfileResponse]:
 
 
 
59
  return CbhResponseWrapper(data=_to_response(await get_profile(profileId)))
60
 
61
 
62
  @client_router.post("")
63
  async def create_profile(
64
  data: ClientProfileRequest,
 
65
  ) -> CbhResponseWrapper[ClientProfileResponse]:
 
 
 
66
  profile = ClientProfileModel(
67
  **data.model_dump(exclude_none=True),
68
  )
 
76
  async def replace_profile(
77
  profileId: str,
78
  data: ClientProfileRequest,
 
79
  ) -> CbhResponseWrapper[ClientProfileResponse]:
 
 
 
80
  existing = await get_profile(profileId)
81
  updated = ClientProfileModel(
82
  **{
 
93
  @client_router.delete("/{profileId}")
94
  async def delete_profile(
95
  profileId: str,
 
96
  ) -> CbhResponseWrapper[ClientProfileResponse]:
 
 
 
97
  removed = await delete_obj(ClientProfileModel, profileId)
98
  return CbhResponseWrapper(data=_to_response(removed))
99
 
 
102
  async def suggest_mapping(
103
  file: UploadFile = File(...),
104
  headerRow: int = Query(default=1, ge=1),
 
105
  ) -> CbhResponseWrapper[ColumnMappingSuggestion]:
 
 
 
 
 
 
 
 
 
 
106
  payload = await file.read()
107
  headers = read_headers(payload, file.filename or "upload.xlsx", headerRow)
108
  mapping = suggest_column_mapping(headers)
cbh/api/common/db_requests.py CHANGED
@@ -1,27 +1,16 @@
1
- """
2
- Common database requests.
3
- """
4
-
5
- import re
6
- from datetime import datetime, timedelta
7
  from typing import Any, TypeVar
8
 
9
  from fastapi import HTTPException
10
  from pydantic import BaseModel
11
  from pymongo import ASCENDING, DESCENDING, UpdateOne
12
 
13
- from cbh.api.common.schemas import SearchRequest
14
  from cbh.core.config import settings
15
 
16
  T = TypeVar("T", bound=BaseModel)
17
 
18
  collection_map = {
19
- "AccountModel": "accounts",
20
- "AccountShorten": "accounts",
21
  "CacheEntryModel": "caches",
22
- # --- TARIC / national reference data ---
23
  "NomenclatureModel": "nomenclature",
24
- "NomenclatureShorten": "nomenclature",
25
  "MeasureModel": "measures",
26
  "MeasureTypeModel": "measuretypes",
27
  "GeoAreaModel": "geoareas",
@@ -29,19 +18,14 @@ collection_map = {
29
  "DocumentCodeModel": "documentcodes",
30
  "CodeChangeModel": "codechanges",
31
  "CodeboekEntryModel": "codeboekentries",
32
- # --- importer ---
33
  "SyncLogModel": "synclogs",
34
- # --- client configuration ---
35
  "ClientProfileModel": "clientprofiles",
36
- "ClientProfileShorten": "clientprofiles",
37
- # --- excel batches ---
38
  "BatchModel": "batches",
39
- "BatchShorten": "batches",
40
  "BatchRowModel": "batchrows",
 
41
  }
42
 
43
  INDEXES: dict[str, list[tuple[list[tuple[str, int]], dict]]] = {
44
- "accounts": [([("id", ASCENDING)], {"unique": True})],
45
  "caches": [
46
  ([("namespace", ASCENDING), ("key", ASCENDING)], {"unique": True}),
47
  ([("deletedAt", ASCENDING)], {"expireAfterSeconds": 0}),
@@ -77,25 +61,20 @@ INDEXES: dict[str, list[tuple[list[tuple[str, int]], dict]]] = {
77
  ([("datetimeInserted", DESCENDING)], {}),
78
  ],
79
  "batchrows": [([("batchId", ASCENDING), ("rowIndex", ASCENDING)], {"unique": True})],
 
 
 
 
80
  }
81
 
82
 
83
  async def ensure_indexes() -> None:
84
- """
85
- Create the indexes the application relies on.
86
-
87
- Idempotent: MongoDB ignores a create_index call for an index that already
88
- exists with the same definition.
89
- """
90
  for collection, definitions in INDEXES.items():
91
  for keys, options in definitions:
92
  await settings.DB_CLIENT[collection].create_index(keys, **options)
93
 
94
 
95
  def resolve_collection(model: T, collection: str | None = None) -> str:
96
- """
97
- Resolve the MongoDB collection backing a model.
98
- """
99
  if collection:
100
  return collection
101
  name = model if isinstance(model, str) else model.__name__
@@ -112,9 +91,6 @@ async def get_obj_by_id(
112
  exception: bool = True,
113
  collection: str | None = None,
114
  ) -> T | None:
115
- """
116
- Get an object by ID.
117
- """
118
  filter_ = {"id": obj_id} if obj_id else {}
119
  if additional_filter:
120
  filter_.update(additional_filter)
@@ -128,18 +104,15 @@ async def get_obj_by_id(
128
  return model.from_mongo(obj)
129
 
130
 
131
- async def _paginate_objs(
132
  model: T,
133
- filter_: dict,
134
  page_size: int,
135
  page_index: int,
136
- sort: tuple[str, int],
137
- projection: dict | None,
 
138
  collection: str | None = None,
139
  ) -> tuple[list[T], int]:
140
- """
141
- Fetch one page plus the total count in a single $facet aggregate.
142
- """
143
  if page_size > 0:
144
  page_stages = [{"$skip": page_index * page_size}, {"$limit": page_size}]
145
  else:
@@ -147,7 +120,7 @@ async def _paginate_objs(
147
  if projection:
148
  page_stages.append({"$project": projection})
149
  pipeline = [
150
- {"$match": filter_},
151
  {"$sort": {sort[0]: sort[1]}},
152
  {
153
  "$facet": {
@@ -163,22 +136,6 @@ async def _paginate_objs(
163
  return objs, total_count
164
 
165
 
166
- async def get_all_objs(
167
- model: T,
168
- page_size: int,
169
- page_index: int,
170
- sort: tuple[str, int] = ("id", -1),
171
- additional_filter: dict | None = None,
172
- projection: dict | None = None,
173
- collection: str | None = None,
174
- ) -> tuple[list[T], int]:
175
- """
176
- Get all objects.
177
- """
178
- filter_ = additional_filter if additional_filter else {}
179
- return await _paginate_objs(model, filter_, page_size, page_index, sort, projection, collection)
180
-
181
-
182
  async def find_objs(
183
  model: T,
184
  filter_: dict,
@@ -187,9 +144,6 @@ async def find_objs(
187
  projection: dict | None = None,
188
  collection: str | None = None,
189
  ) -> list[T]:
190
- """
191
- Find objects without pagination metadata.
192
- """
193
  cursor = settings.DB_CLIENT[resolve_collection(model, collection)].find(filter_, projection)
194
  if sort:
195
  cursor = cursor.sort(sort[0], sort[1])
@@ -201,27 +155,18 @@ async def find_objs(
201
  async def count_objs(
202
  model: T, additional_filter: dict | None = None, collection: str | None = None
203
  ) -> int:
204
- """
205
- Count objects.
206
- """
207
  return await settings.DB_CLIENT[resolve_collection(model, collection)].count_documents(
208
  additional_filter or {}
209
  )
210
 
211
 
212
  async def insert_obj(model_instance: BaseModel, collection: str | None = None) -> BaseModel:
213
- """
214
- Insert a single object.
215
- """
216
  collection = resolve_collection(type(model_instance), collection)
217
  await settings.DB_CLIENT[collection].insert_one(model_instance.to_mongo())
218
  return model_instance
219
 
220
 
221
  async def insert_objs(model_instances: list[BaseModel], collection: str | None = None) -> int:
222
- """
223
- Insert many objects at once.
224
- """
225
  if not model_instances:
226
  return 0
227
  collection = resolve_collection(type(model_instances[0]), collection)
@@ -239,9 +184,6 @@ async def update_obj(
239
  collection: str | None = None,
240
  exception: bool = True,
241
  ) -> T | None:
242
- """
243
- Update an object by ID and return the updated document.
244
- """
245
  filter_ = {"id": obj_id}
246
  if additional_filter:
247
  filter_.update(additional_filter)
@@ -261,16 +203,6 @@ async def upsert_objs(
261
  key_fields: list[str],
262
  collection: str | None = None,
263
  ) -> tuple[int, int]:
264
- """
265
- Idempotently upsert reference-data documents.
266
-
267
- The natural key (``key_fields``) decides identity, not the generated ``id``:
268
- re-running an import must update in place rather than duplicate. ``id`` and
269
- ``datetimeInserted`` are only written when the document is first created, so
270
- existing references stay stable across re-imports.
271
-
272
- Returns a (matched, upserted) tuple.
273
- """
274
  if not documents:
275
  return 0, 0
276
  collection = resolve_collection(model, collection)
@@ -293,9 +225,6 @@ async def delete_obj(
293
  additional_filter: dict | None = None,
294
  collection: str | None = None,
295
  ) -> T:
296
- """
297
- Delete an object.
298
- """
299
  filter_ = {"id": obj_id} if obj_id else {}
300
  if additional_filter:
301
  filter_.update(additional_filter)
@@ -310,89 +239,7 @@ async def delete_obj(
310
  async def delete_objs(
311
  model: T, additional_filter: dict | None = None, collection: str | None = None
312
  ) -> int:
313
- """
314
- Delete every object matching a filter. Used by full-replacement imports.
315
- """
316
  result = await settings.DB_CLIENT[resolve_collection(model, collection)].delete_many(
317
  additional_filter or {}
318
  )
319
  return result.deleted_count
320
-
321
-
322
- async def search_objs(
323
- model: T,
324
- data: SearchRequest,
325
- additional_filter: dict | None = None,
326
- projection: dict | None = None,
327
- collection: str | None = None,
328
- ) -> tuple[list[T], int]:
329
- """
330
- Search for objects in a specified collection based on search filters.
331
- """
332
- filters = []
333
- date_filters: dict[str, list[dict]] = {}
334
-
335
- for search_filter in data.filter:
336
- if isinstance(search_filter.value, str):
337
- date_match = re.fullmatch(r"^(\d{4}-\d{2}-\d{2});([+-]\d{1,2})$", search_filter.value)
338
-
339
- if date_match:
340
- date_filters.setdefault(search_filter.name, []).append(
341
- {
342
- "date": datetime.strptime(date_match.group(1), "%Y-%m-%d"),
343
- "timezone_offset": int(date_match.group(2)),
344
- }
345
- )
346
- else:
347
- filters.append(
348
- {
349
- search_filter.name: {
350
- "$regex": re.escape(search_filter.value),
351
- "$options": "i",
352
- }
353
- }
354
- )
355
- else:
356
- filters.append({search_filter.name: search_filter.value})
357
-
358
- for field_name, dates in date_filters.items():
359
- if len(dates) == 1:
360
- date_info = dates[0]
361
- user_local_day_start = date_info["date"]
362
- user_local_day_end = user_local_day_start + timedelta(days=1)
363
- filters.append(
364
- {
365
- field_name: {
366
- "$gte": (
367
- user_local_day_start - timedelta(hours=date_info["timezone_offset"])
368
- ).isoformat(),
369
- "$lt": (
370
- user_local_day_end - timedelta(hours=date_info["timezone_offset"])
371
- ).isoformat(),
372
- }
373
- }
374
- )
375
- elif len(dates) >= 2:
376
- start_date = min(dates, key=lambda x: x["date"])
377
- end_date = max(dates, key=lambda x: x["date"])
378
-
379
- start_datetime = start_date["date"] - timedelta(hours=start_date["timezone_offset"])
380
- end_datetime = (
381
- end_date["date"] + timedelta(days=1) - timedelta(hours=end_date["timezone_offset"])
382
- )
383
-
384
- filters.append(
385
- {
386
- field_name: {
387
- "$gte": start_datetime.isoformat(),
388
- "$lt": end_datetime.isoformat(),
389
- }
390
- }
391
- )
392
-
393
- if additional_filter:
394
- filters.append(additional_filter)
395
- regex_filter = {"$and": filters} if filters else {}
396
- return await _paginate_objs(
397
- model, regex_filter, data.pageSize, data.pageIndex, ("id", -1), projection, collection
398
- )
 
 
 
 
 
 
 
1
  from typing import Any, TypeVar
2
 
3
  from fastapi import HTTPException
4
  from pydantic import BaseModel
5
  from pymongo import ASCENDING, DESCENDING, UpdateOne
6
 
 
7
  from cbh.core.config import settings
8
 
9
  T = TypeVar("T", bound=BaseModel)
10
 
11
  collection_map = {
 
 
12
  "CacheEntryModel": "caches",
 
13
  "NomenclatureModel": "nomenclature",
 
14
  "MeasureModel": "measures",
15
  "MeasureTypeModel": "measuretypes",
16
  "GeoAreaModel": "geoareas",
 
18
  "DocumentCodeModel": "documentcodes",
19
  "CodeChangeModel": "codechanges",
20
  "CodeboekEntryModel": "codeboekentries",
 
21
  "SyncLogModel": "synclogs",
 
22
  "ClientProfileModel": "clientprofiles",
 
 
23
  "BatchModel": "batches",
 
24
  "BatchRowModel": "batchrows",
25
+ "RuleModel": "rules",
26
  }
27
 
28
  INDEXES: dict[str, list[tuple[list[tuple[str, int]], dict]]] = {
 
29
  "caches": [
30
  ([("namespace", ASCENDING), ("key", ASCENDING)], {"unique": True}),
31
  ([("deletedAt", ASCENDING)], {"expireAfterSeconds": 0}),
 
61
  ([("datetimeInserted", DESCENDING)], {}),
62
  ],
63
  "batchrows": [([("batchId", ASCENDING), ("rowIndex", ASCENDING)], {"unique": True})],
64
+ "rules": [
65
+ ([("id", ASCENDING)], {"unique": True}),
66
+ ([("active", ASCENDING), ("scope", ASCENDING), ("clientProfileId", ASCENDING)], {}),
67
+ ],
68
  }
69
 
70
 
71
  async def ensure_indexes() -> None:
 
 
 
 
 
 
72
  for collection, definitions in INDEXES.items():
73
  for keys, options in definitions:
74
  await settings.DB_CLIENT[collection].create_index(keys, **options)
75
 
76
 
77
  def resolve_collection(model: T, collection: str | None = None) -> str:
 
 
 
78
  if collection:
79
  return collection
80
  name = model if isinstance(model, str) else model.__name__
 
91
  exception: bool = True,
92
  collection: str | None = None,
93
  ) -> T | None:
 
 
 
94
  filter_ = {"id": obj_id} if obj_id else {}
95
  if additional_filter:
96
  filter_.update(additional_filter)
 
104
  return model.from_mongo(obj)
105
 
106
 
107
+ async def get_all_objs(
108
  model: T,
 
109
  page_size: int,
110
  page_index: int,
111
+ sort: tuple[str, int] = ("id", -1),
112
+ additional_filter: dict | None = None,
113
+ projection: dict | None = None,
114
  collection: str | None = None,
115
  ) -> tuple[list[T], int]:
 
 
 
116
  if page_size > 0:
117
  page_stages = [{"$skip": page_index * page_size}, {"$limit": page_size}]
118
  else:
 
120
  if projection:
121
  page_stages.append({"$project": projection})
122
  pipeline = [
123
+ {"$match": additional_filter or {}},
124
  {"$sort": {sort[0]: sort[1]}},
125
  {
126
  "$facet": {
 
136
  return objs, total_count
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  async def find_objs(
140
  model: T,
141
  filter_: dict,
 
144
  projection: dict | None = None,
145
  collection: str | None = None,
146
  ) -> list[T]:
 
 
 
147
  cursor = settings.DB_CLIENT[resolve_collection(model, collection)].find(filter_, projection)
148
  if sort:
149
  cursor = cursor.sort(sort[0], sort[1])
 
155
  async def count_objs(
156
  model: T, additional_filter: dict | None = None, collection: str | None = None
157
  ) -> int:
 
 
 
158
  return await settings.DB_CLIENT[resolve_collection(model, collection)].count_documents(
159
  additional_filter or {}
160
  )
161
 
162
 
163
  async def insert_obj(model_instance: BaseModel, collection: str | None = None) -> BaseModel:
 
 
 
164
  collection = resolve_collection(type(model_instance), collection)
165
  await settings.DB_CLIENT[collection].insert_one(model_instance.to_mongo())
166
  return model_instance
167
 
168
 
169
  async def insert_objs(model_instances: list[BaseModel], collection: str | None = None) -> int:
 
 
 
170
  if not model_instances:
171
  return 0
172
  collection = resolve_collection(type(model_instances[0]), collection)
 
184
  collection: str | None = None,
185
  exception: bool = True,
186
  ) -> T | None:
 
 
 
187
  filter_ = {"id": obj_id}
188
  if additional_filter:
189
  filter_.update(additional_filter)
 
203
  key_fields: list[str],
204
  collection: str | None = None,
205
  ) -> tuple[int, int]:
 
 
 
 
 
 
 
 
 
 
206
  if not documents:
207
  return 0, 0
208
  collection = resolve_collection(model, collection)
 
225
  additional_filter: dict | None = None,
226
  collection: str | None = None,
227
  ) -> T:
 
 
 
228
  filter_ = {"id": obj_id} if obj_id else {}
229
  if additional_filter:
230
  filter_.update(additional_filter)
 
239
  async def delete_objs(
240
  model: T, additional_filter: dict | None = None, collection: str | None = None
241
  ) -> int:
 
 
 
242
  result = await settings.DB_CLIENT[resolve_collection(model, collection)].delete_many(
243
  additional_filter or {}
244
  )
245
  return result.deleted_count
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cbh/api/common/dto.py CHANGED
@@ -1,113 +1,105 @@
1
- """
2
- Common DTOs shared across CargoFlow modules.
3
- """
4
-
5
- from datetime import datetime
6
- from enum import Enum
7
 
8
  from pydantic import BaseModel
9
 
10
 
11
- class OrderType(Enum):
12
- """
13
- Sort order.
14
- """
15
-
16
- ASC = 1
17
- DESC = 2
18
-
19
-
20
- class SortBy(BaseModel):
21
- """
22
- Sorting instruction for list endpoints.
23
- """
24
-
25
- name: str
26
- order: OrderType = OrderType.DESC
27
-
28
- def to_mongo_sort(self) -> tuple[str, int]:
29
- """
30
- Convert to a MongoDB sort tuple.
31
- """
32
- return self.name, 1 if self.order == OrderType.ASC else -1
33
-
34
-
35
  class Paging(BaseModel):
36
- """
37
- Pagination model for API responses.
38
- """
39
-
40
  pageSize: int
41
  pageIndex: int
42
  totalCount: int
43
 
44
 
45
- class SearchFilter(BaseModel):
46
- """
47
- Search filter model for constructing database queries.
48
- """
49
 
50
- name: str
51
- value: str | int
52
-
53
-
54
- class IDName(BaseModel):
55
- """
56
- Generic id/name pair.
57
- """
58
-
59
- id: str | int
60
- name: str
61
-
62
-
63
- class DateValue(BaseModel):
64
- """
65
- Date/value pair for time series responses.
66
  """
67
 
68
- date: datetime
69
- value: int
 
 
70
 
 
 
 
 
 
 
71
 
72
- class TradeDirection(Enum):
73
- """
74
- Direction of the declaration line.
75
-
76
- A measure may apply to imports only, exports only, or both.
77
- """
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  IMPORT = 1
80
  EXPORT = 2
81
  BOTH = 3
 
82
 
83
  @classmethod
84
  def parse(cls, value: "str | int | TradeDirection | None") -> "TradeDirection | None":
85
- """
86
- Parse a direction from the loose representations found in client files.
87
- """
88
  if value is None or isinstance(value, TradeDirection):
89
  return value
90
  if isinstance(value, int):
91
  return cls(value)
92
- normalized = str(value).strip().lower()
93
- mapping = {
94
  "import": cls.IMPORT,
95
  "invoer": cls.IMPORT,
96
  "in": cls.IMPORT,
97
  "i": cls.IMPORT,
98
  "1": cls.IMPORT,
 
 
99
  "export": cls.EXPORT,
100
  "uitvoer": cls.EXPORT,
101
  "out": cls.EXPORT,
102
  "e": cls.EXPORT,
103
  "2": cls.EXPORT,
 
 
104
  "both": cls.BOTH,
105
  "3": cls.BOTH,
106
- }
107
- return mapping.get(normalized)
108
-
109
- def matches(self, other: "TradeDirection") -> bool:
110
- """
111
- Check whether a measure direction applies to a requested direction.
112
- """
113
- return self is TradeDirection.BOTH or other is TradeDirection.BOTH or self is other
 
1
+ from enum import IntEnum
 
 
 
 
 
2
 
3
  from pydantic import BaseModel
4
 
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  class Paging(BaseModel):
 
 
 
 
7
  pageSize: int
8
  pageIndex: int
9
  totalCount: int
10
 
11
 
12
+ class CustomsProcedure(IntEnum):
13
+ """The customs procedure the declaration is filed under.
 
 
14
 
15
+ Direction says which way the goods move; this says what is being asked of
16
+ customs when they move. The difference matters because under transit the
17
+ goods are not released, so duty and tax are secured rather than paid, while
18
+ prohibitions and restrictions bind exactly as they would on release.
 
 
 
 
 
 
 
 
 
 
 
 
19
  """
20
 
21
+ RELEASE_FOR_FREE_CIRCULATION = 1
22
+ EXTERNAL_TRANSIT_T1 = 2
23
+ INTERNAL_TRANSIT_T2 = 3
24
+ CUSTOMS_WAREHOUSE = 4
25
 
26
+ @property
27
+ def is_transit(self) -> bool:
28
+ return self in (
29
+ CustomsProcedure.EXTERNAL_TRANSIT_T1,
30
+ CustomsProcedure.INTERNAL_TRANSIT_T2,
31
+ )
32
 
33
+ @property
34
+ def suspends_duty(self) -> bool:
35
+ """Whether duty, tax and quota are secured now and settled elsewhere."""
36
+ return self is not CustomsProcedure.RELEASE_FOR_FREE_CIRCULATION
 
 
37
 
38
+ @classmethod
39
+ def parse(
40
+ cls, value: "str | int | CustomsProcedure | None"
41
+ ) -> "CustomsProcedure | None":
42
+ if value is None or isinstance(value, CustomsProcedure):
43
+ return value
44
+ if isinstance(value, int):
45
+ return cls(value)
46
+ return {
47
+ "t1": cls.EXTERNAL_TRANSIT_T1,
48
+ "t-1": cls.EXTERNAL_TRANSIT_T1,
49
+ "extern douanevervoer": cls.EXTERNAL_TRANSIT_T1,
50
+ "external transit": cls.EXTERNAL_TRANSIT_T1,
51
+ "t2": cls.INTERNAL_TRANSIT_T2,
52
+ "t-2": cls.INTERNAL_TRANSIT_T2,
53
+ "intern douanevervoer": cls.INTERNAL_TRANSIT_T2,
54
+ "internal transit": cls.INTERNAL_TRANSIT_T2,
55
+ "free circulation": cls.RELEASE_FOR_FREE_CIRCULATION,
56
+ "release for free circulation": cls.RELEASE_FOR_FREE_CIRCULATION,
57
+ "in het vrije verkeer brengen": cls.RELEASE_FOR_FREE_CIRCULATION,
58
+ "40": cls.RELEASE_FOR_FREE_CIRCULATION,
59
+ "4000": cls.RELEASE_FOR_FREE_CIRCULATION,
60
+ "42": cls.RELEASE_FOR_FREE_CIRCULATION,
61
+ "4200": cls.RELEASE_FOR_FREE_CIRCULATION,
62
+ "warehouse": cls.CUSTOMS_WAREHOUSE,
63
+ "entrepot": cls.CUSTOMS_WAREHOUSE,
64
+ "douane-entrepot": cls.CUSTOMS_WAREHOUSE,
65
+ "71": cls.CUSTOMS_WAREHOUSE,
66
+ "7100": cls.CUSTOMS_WAREHOUSE,
67
+ }.get(str(value).strip().lower())
68
+
69
+
70
+ class TradeDirection(IntEnum):
71
  IMPORT = 1
72
  EXPORT = 2
73
  BOTH = 3
74
+ WAREHOUSE = 4
75
 
76
  @classmethod
77
  def parse(cls, value: "str | int | TradeDirection | None") -> "TradeDirection | None":
 
 
 
78
  if value is None or isinstance(value, TradeDirection):
79
  return value
80
  if isinstance(value, int):
81
  return cls(value)
82
+ return {
 
83
  "import": cls.IMPORT,
84
  "invoer": cls.IMPORT,
85
  "in": cls.IMPORT,
86
  "i": cls.IMPORT,
87
  "1": cls.IMPORT,
88
+ "40": cls.IMPORT,
89
+ "4000": cls.IMPORT,
90
  "export": cls.EXPORT,
91
  "uitvoer": cls.EXPORT,
92
  "out": cls.EXPORT,
93
  "e": cls.EXPORT,
94
  "2": cls.EXPORT,
95
+ "10": cls.EXPORT,
96
+ "1000": cls.EXPORT,
97
  "both": cls.BOTH,
98
  "3": cls.BOTH,
99
+ "warehouse": cls.WAREHOUSE,
100
+ "entrepot": cls.WAREHOUSE,
101
+ "douane-entrepot": cls.WAREHOUSE,
102
+ "douane entrepot": cls.WAREHOUSE,
103
+ "71": cls.WAREHOUSE,
104
+ "7100": cls.WAREHOUSE,
105
+ }.get(str(value).strip().lower())
 
cbh/api/common/models.py CHANGED
@@ -1,7 +1,3 @@
1
- """
2
- Common models shared across CargoFlow modules.
3
- """
4
-
5
  from datetime import date, datetime, timedelta
6
 
7
  from pydantic import BaseModel, Field
@@ -10,41 +6,16 @@ from cbh.core.database import MongoBaseModel, MongoSerializableModel
10
 
11
 
12
  class ValidityMixin(BaseModel):
13
- """
14
- Validity period carried by every piece of reference data.
15
-
16
- History is never deleted: each record keeps the window it was in force for,
17
- so a check can always be replayed with the rules of the day a declaration
18
- was filed. ``validTo = None`` means "open ended".
19
- """
20
-
21
  validFrom: date | None = None
22
  validTo: date | None = None
23
 
24
- def is_valid_on(self, on_date: date) -> bool:
25
- """
26
- Check whether this record is in force on a given date.
27
- """
28
- if self.validFrom and on_date < self.validFrom:
29
- return False
30
- if self.validTo and on_date > self.validTo:
31
- return False
32
- return True
33
-
34
  def days_until_expiry(self, on_date: date) -> int | None:
35
- """
36
- Days left until this record expires, or None when open ended.
37
- """
38
  if self.validTo is None:
39
  return None
40
  return (self.validTo - on_date).days
41
 
42
 
43
  class FileRef(MongoSerializableModel):
44
- """
45
- Reference to a file kept in local storage.
46
- """
47
-
48
  fileName: str
49
  storagePath: str
50
  contentType: str | None = None
@@ -53,13 +24,6 @@ class FileRef(MongoSerializableModel):
53
 
54
 
55
  class CacheEntryModel(MongoBaseModel):
56
- """
57
- Generic TTL cache entry.
58
-
59
- Used by the line-check engine so that an identical (code, country,
60
- direction, date) tuple is resolved once and reused across a batch.
61
- """
62
-
63
  namespace: str
64
  key: str
65
  value: dict | list | str | int | float | bool | None = None
 
 
 
 
 
1
  from datetime import date, datetime, timedelta
2
 
3
  from pydantic import BaseModel, Field
 
6
 
7
 
8
  class ValidityMixin(BaseModel):
 
 
 
 
 
 
 
 
9
  validFrom: date | None = None
10
  validTo: date | None = None
11
 
 
 
 
 
 
 
 
 
 
 
12
  def days_until_expiry(self, on_date: date) -> int | None:
 
 
 
13
  if self.validTo is None:
14
  return None
15
  return (self.validTo - on_date).days
16
 
17
 
18
  class FileRef(MongoSerializableModel):
 
 
 
 
19
  fileName: str
20
  storagePath: str
21
  contentType: str | None = None
 
24
 
25
 
26
  class CacheEntryModel(MongoBaseModel):
 
 
 
 
 
 
 
27
  namespace: str
28
  key: str
29
  value: dict | list | str | int | float | bool | None = None
cbh/api/common/schemas.py CHANGED
@@ -1,81 +1,12 @@
1
- """
2
- Common schemas.
3
- """
4
-
5
  from typing import Generic, TypeVar
6
 
7
  from pydantic import BaseModel
8
 
9
- from cbh.api.common.dto import Paging, SearchFilter, SortBy
10
 
11
  T = TypeVar("T", bound=BaseModel)
12
 
13
 
14
  class AllObjectsResponse(BaseModel, Generic[T]):
15
- """
16
- Response model for paginated collections.
17
- """
18
-
19
  paging: Paging
20
  data: list[T]
21
-
22
-
23
- class SearchRequest(BaseModel):
24
- """
25
- Request schema for searching objects.
26
- """
27
-
28
- filter: list[SearchFilter]
29
- pageSize: int = 10
30
- pageIndex: int = 0
31
-
32
-
33
- class FilterRequest(BaseModel, Generic[T]):
34
- """
35
- Filter request.
36
- """
37
-
38
- filter: T
39
- sortBy: SortBy | None = None
40
- pageSize: int = 10
41
- pageIndex: int = 0
42
-
43
-
44
- class PlainTextResponse(BaseModel):
45
- """
46
- Response model for plain text.
47
- """
48
-
49
- text: str
50
-
51
-
52
- class ContentRequest(BaseModel):
53
- """
54
- Content request.
55
- """
56
-
57
- content: str | None = None
58
-
59
-
60
- class BatchIdsRequest(BaseModel):
61
- """
62
- Batch ids request.
63
- """
64
-
65
- ids: list[str]
66
-
67
-
68
- class EmailRequest(BaseModel):
69
- """
70
- Email request.
71
- """
72
-
73
- email: str
74
-
75
-
76
- class CountResponse(BaseModel):
77
- """
78
- Generic count response, used by importer and reference data endpoints.
79
- """
80
-
81
- count: int
 
 
 
 
 
1
  from typing import Generic, TypeVar
2
 
3
  from pydantic import BaseModel
4
 
5
+ from cbh.api.common.dto import Paging
6
 
7
  T = TypeVar("T", bound=BaseModel)
8
 
9
 
10
  class AllObjectsResponse(BaseModel, Generic[T]):
 
 
 
 
11
  paging: Paging
12
  data: list[T]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cbh/api/common/services/cache/__init__.py CHANGED
@@ -1,19 +1,3 @@
1
- from .cache import (
2
- LINE_CHECK_NAMESPACE,
3
- build_cache_key,
4
- cached_payload,
5
- get_cache,
6
- invalidate_cache,
7
- invalidate_line_check_cache,
8
- set_cache,
9
- )
10
 
11
- __all__ = [
12
- "LINE_CHECK_NAMESPACE",
13
- "build_cache_key",
14
- "cached_payload",
15
- "get_cache",
16
- "invalidate_cache",
17
- "invalidate_line_check_cache",
18
- "set_cache",
19
- ]
 
1
+ from .cache import build_cache_key, get_cache, invalidate_line_check_cache, set_cache
 
 
 
 
 
 
 
 
2
 
3
+ __all__ = ["build_cache_key", "get_cache", "invalidate_line_check_cache", "set_cache"]
 
 
 
 
 
 
 
 
cbh/api/common/services/cache/cache.py CHANGED
@@ -1,16 +1,4 @@
1
- """
2
- Generic TTL cache backed by MongoDB.
3
-
4
- The line-check engine resolves each unique (code, country, direction, date)
5
- tuple once; a pack of ~580 declaration lines typically collapses to a few dozen
6
- distinct tuples, which is where most of the runtime saving comes from.
7
-
8
- The cache is keyed on the reference-data state as well, so an importer run
9
- invalidates it rather than serving answers built on superseded rules.
10
- """
11
-
12
  import hashlib
13
- import json
14
  from datetime import datetime, timedelta
15
  from typing import Any
16
 
@@ -20,21 +8,13 @@ from cbh.api.common.models import CacheEntryModel
20
  from cbh.core.config import settings
21
  from cbh.core.wrappers import background_task
22
 
23
- LINE_CHECK_NAMESPACE = "linecheck"
24
-
25
 
26
  def build_cache_key(*parts: Any) -> str:
27
- """
28
- Build a stable cache key from its parts.
29
- """
30
  raw = "|".join("" if part is None else str(part) for part in parts)
31
  return hashlib.sha256(raw.encode("utf-8")).hexdigest()
32
 
33
 
34
  def _serialize(value: Any) -> Any:
35
- """
36
- Serialize a value into something BSON can store.
37
- """
38
  if isinstance(value, BaseModel):
39
  return value.model_dump(mode="json")
40
  if isinstance(value, list):
@@ -45,9 +25,6 @@ def _serialize(value: Any) -> Any:
45
 
46
 
47
  async def get_cache(namespace: str, key: str) -> Any | None:
48
- """
49
- Read a cache entry, or None when it is absent or expired.
50
- """
51
  entry = await settings.DB_CLIENT.caches.find_one({"namespace": namespace, "key": key})
52
  if not entry:
53
  return None
@@ -63,16 +40,12 @@ async def set_cache(
63
  value: Any,
64
  ttl_seconds: int | None = None,
65
  ) -> None:
66
- """
67
- Write a cache entry, replacing any previous value for the same key.
68
- """
69
  ttl = ttl_seconds if ttl_seconds is not None else settings.LINE_CHECK_CACHE_TTL_SECONDS
70
- entry = CacheEntryModel(
71
  namespace=namespace,
72
  key=key,
73
  value=_serialize(value),
74
- )
75
- document = entry.to_mongo()
76
  document["deletedAt"] = datetime.now() + timedelta(seconds=ttl)
77
  await settings.DB_CLIENT.caches.update_one(
78
  {"namespace": namespace, "key": key},
@@ -81,30 +54,6 @@ async def set_cache(
81
  )
82
 
83
 
84
- @background_task()
85
- async def invalidate_cache(namespace: str, key: str | None = None) -> None:
86
- """
87
- Drop a single cache entry, or an entire namespace.
88
-
89
- Called after every successful reference-data import: answers computed from
90
- superseded rules must never be replayed.
91
- """
92
- filter_: dict[str, Any] = {"namespace": namespace}
93
- if key:
94
- filter_["key"] = key
95
- await settings.DB_CLIENT.caches.delete_many(filter_)
96
-
97
-
98
  @background_task()
99
  async def invalidate_line_check_cache() -> None:
100
- """
101
- Invalidate every cached line-check result.
102
- """
103
- await settings.DB_CLIENT.caches.delete_many({"namespace": LINE_CHECK_NAMESPACE})
104
-
105
-
106
- def cached_payload(value: Any) -> str:
107
- """
108
- Compact JSON rendering used for cache-key fingerprints.
109
- """
110
- return json.dumps(value, sort_keys=True, default=str, separators=(",", ":"))
 
 
 
 
 
 
 
 
 
 
 
 
1
  import hashlib
 
2
  from datetime import datetime, timedelta
3
  from typing import Any
4
 
 
8
  from cbh.core.config import settings
9
  from cbh.core.wrappers import background_task
10
 
 
 
11
 
12
  def build_cache_key(*parts: Any) -> str:
 
 
 
13
  raw = "|".join("" if part is None else str(part) for part in parts)
14
  return hashlib.sha256(raw.encode("utf-8")).hexdigest()
15
 
16
 
17
  def _serialize(value: Any) -> Any:
 
 
 
18
  if isinstance(value, BaseModel):
19
  return value.model_dump(mode="json")
20
  if isinstance(value, list):
 
25
 
26
 
27
  async def get_cache(namespace: str, key: str) -> Any | None:
 
 
 
28
  entry = await settings.DB_CLIENT.caches.find_one({"namespace": namespace, "key": key})
29
  if not entry:
30
  return None
 
40
  value: Any,
41
  ttl_seconds: int | None = None,
42
  ) -> None:
 
 
 
43
  ttl = ttl_seconds if ttl_seconds is not None else settings.LINE_CHECK_CACHE_TTL_SECONDS
44
+ document = CacheEntryModel(
45
  namespace=namespace,
46
  key=key,
47
  value=_serialize(value),
48
+ ).to_mongo()
 
49
  document["deletedAt"] = datetime.now() + timedelta(seconds=ttl)
50
  await settings.DB_CLIENT.caches.update_one(
51
  {"namespace": namespace, "key": key},
 
54
  )
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  @background_task()
58
  async def invalidate_line_check_cache() -> None:
59
+ await settings.DB_CLIENT.caches.delete_many({"namespace": "linecheck"})
 
 
 
 
 
 
 
 
 
 
cbh/api/common/utils.py CHANGED
@@ -1,56 +1,11 @@
1
- """
2
- Common utilities.
3
- """
4
-
5
  import re
6
  from datetime import date, datetime
7
- from typing import Any, Iterable, Iterator, TypeVar
8
-
9
- from cbh.api.common.dto import Paging
10
 
11
  T = TypeVar("T")
12
 
13
- _DATE_FORMATS = (
14
- "%Y-%m-%d",
15
- "%d-%m-%Y",
16
- "%d/%m/%Y",
17
- "%Y/%m/%d",
18
- "%d.%m.%Y",
19
- "%Y%m%d",
20
- )
21
-
22
-
23
- def chunked(items: list[T], size: int) -> Iterator[list[T]]:
24
- """
25
- Split a list into chunks of at most ``size`` elements.
26
- """
27
- for start in range(0, len(items), size):
28
- yield items[start : start + size]
29
-
30
-
31
- def to_paging(page_size: int, page_index: int, total_count: int) -> Paging:
32
- """
33
- Build a Paging object for a list response.
34
- """
35
- return Paging(pageSize=page_size, pageIndex=page_index, totalCount=total_count)
36
-
37
-
38
- def to_iso(value: date | datetime | None) -> str | None:
39
- """
40
- Serialize a date for MongoDB comparisons.
41
-
42
- Dates are stored as ISO strings by ``MongoSerializableModel.to_mongo``;
43
- ISO-8601 sorts lexicographically, so range queries stay correct.
44
- """
45
- if value is None:
46
- return None
47
- return value.isoformat()
48
-
49
 
50
  def parse_date(value: Any) -> date | None:
51
- """
52
- Parse a date coming from a client spreadsheet or a reference-data file.
53
- """
54
  if value is None or value == "":
55
  return None
56
  if isinstance(value, datetime):
@@ -60,7 +15,7 @@ def parse_date(value: Any) -> date | None:
60
  text = str(value).strip()
61
  if not text:
62
  return None
63
- for date_format in _DATE_FORMATS:
64
  try:
65
  return datetime.strptime(text, date_format).date()
66
  except ValueError:
@@ -72,9 +27,6 @@ def parse_date(value: Any) -> date | None:
72
 
73
 
74
  def normalize_ws(value: Any) -> str | None:
75
- """
76
- Collapse whitespace in a free-text cell.
77
- """
78
  if value is None:
79
  return None
80
  text = re.sub(r"\s+", " ", str(value)).strip()
@@ -82,14 +34,11 @@ def normalize_ws(value: Any) -> str | None:
82
 
83
 
84
  def parse_number(value: Any) -> float | None:
85
- """
86
- Parse a numeric cell tolerating both comma and dot decimal separators.
87
- """
88
  if value is None or value == "":
89
  return None
90
  if isinstance(value, (int, float)):
91
  return float(value)
92
- text = str(value).strip().replace(" ", "").replace(" ", "")
93
  if not text:
94
  return None
95
  if "," in text and "." in text:
@@ -104,9 +53,6 @@ def parse_number(value: Any) -> float | None:
104
 
105
 
106
  def normalize_country(value: Any) -> str | None:
107
- """
108
- Normalize an ISO country code to its uppercase two-letter form.
109
- """
110
  if value is None:
111
  return None
112
  text = re.sub(r"[^A-Za-z]", "", str(value)).upper()
@@ -116,9 +62,6 @@ def normalize_country(value: Any) -> str | None:
116
 
117
 
118
  def dedupe(items: Iterable[T]) -> list[T]:
119
- """
120
- Remove duplicates while preserving order.
121
- """
122
  seen: set[T] = set()
123
  result: list[T] = []
124
  for item in items:
 
 
 
 
 
1
  import re
2
  from datetime import date, datetime
3
+ from typing import Any, Iterable, TypeVar
 
 
4
 
5
  T = TypeVar("T")
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  def parse_date(value: Any) -> date | None:
 
 
 
9
  if value is None or value == "":
10
  return None
11
  if isinstance(value, datetime):
 
15
  text = str(value).strip()
16
  if not text:
17
  return None
18
+ for date_format in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y", "%Y/%m/%d", "%d.%m.%Y", "%Y%m%d"):
19
  try:
20
  return datetime.strptime(text, date_format).date()
21
  except ValueError:
 
27
 
28
 
29
  def normalize_ws(value: Any) -> str | None:
 
 
 
30
  if value is None:
31
  return None
32
  text = re.sub(r"\s+", " ", str(value)).strip()
 
34
 
35
 
36
  def parse_number(value: Any) -> float | None:
 
 
 
37
  if value is None or value == "":
38
  return None
39
  if isinstance(value, (int, float)):
40
  return float(value)
41
+ text = str(value).strip().replace(" ", "").replace(" ", "")
42
  if not text:
43
  return None
44
  if "," in text and "." in text:
 
53
 
54
 
55
  def normalize_country(value: Any) -> str | None:
 
 
 
56
  if value is None:
57
  return None
58
  text = re.sub(r"[^A-Za-z]", "", str(value)).upper()
 
62
 
63
 
64
  def dedupe(items: Iterable[T]) -> list[T]:
 
 
 
65
  seen: set[T] = set()
66
  result: list[T] = []
67
  for item in items:
cbh/api/importer/__init__.py CHANGED
@@ -1,10 +1,3 @@
1
- """
2
- Importer module initialization.
3
-
4
- This module defines the FastAPI router for the reference-data importer:
5
- triggering loads, inspecting the sync log and supplying files by hand.
6
- """
7
-
8
  from fastapi import APIRouter
9
 
10
  importer_router = APIRouter(
 
 
 
 
 
 
 
 
1
  from fastapi import APIRouter
2
 
3
  importer_router = APIRouter(
cbh/api/importer/db_requests.py CHANGED
@@ -1,7 +1,3 @@
1
- """
2
- Importer database requests.
3
- """
4
-
5
  from cbh.api.common.db_requests import find_objs, get_all_objs
6
  from cbh.api.importer.dto import SyncSource, SyncStatus
7
  from cbh.api.importer.models import SyncLogModel
@@ -13,9 +9,6 @@ async def get_sync_logs(
13
  source: SyncSource | None = None,
14
  status: SyncStatus | None = None,
15
  ) -> tuple[list[SyncLogModel], int]:
16
- """
17
- Page through the import history, newest first.
18
- """
19
  filter_: dict = {}
20
  if source:
21
  filter_["source"] = source.value
@@ -31,12 +24,6 @@ async def get_sync_logs(
31
 
32
 
33
  async def get_latest_sync_per_source() -> dict[str, SyncLogModel]:
34
- """
35
- The most recent run for each source.
36
-
37
- This is the operational view: which reference files are loaded, how fresh
38
- they are, and which ones last failed.
39
- """
40
  latest: dict[str, SyncLogModel] = {}
41
  for source in SyncSource:
42
  runs = await find_objs(
@@ -48,16 +35,3 @@ async def get_latest_sync_per_source() -> dict[str, SyncLogModel]:
48
  if runs:
49
  latest[source.name] = runs[0]
50
  return latest
51
-
52
-
53
- async def get_last_successful_sync(source: SyncSource) -> SyncLogModel | None:
54
- """
55
- The most recent successful run for one source.
56
- """
57
- runs = await find_objs(
58
- SyncLogModel,
59
- {"source": source.value, "status": SyncStatus.SUCCESS.value},
60
- limit=1,
61
- sort=("finishedAt", -1),
62
- )
63
- return runs[0] if runs else None
 
 
 
 
 
1
  from cbh.api.common.db_requests import find_objs, get_all_objs
2
  from cbh.api.importer.dto import SyncSource, SyncStatus
3
  from cbh.api.importer.models import SyncLogModel
 
9
  source: SyncSource | None = None,
10
  status: SyncStatus | None = None,
11
  ) -> tuple[list[SyncLogModel], int]:
 
 
 
12
  filter_: dict = {}
13
  if source:
14
  filter_["source"] = source.value
 
24
 
25
 
26
  async def get_latest_sync_per_source() -> dict[str, SyncLogModel]:
 
 
 
 
 
 
27
  latest: dict[str, SyncLogModel] = {}
28
  for source in SyncSource:
29
  runs = await find_objs(
 
35
  if runs:
36
  latest[source.name] = runs[0]
37
  return latest
 
 
 
 
 
 
 
 
 
 
 
 
 
cbh/api/importer/dto.py CHANGED
@@ -1,18 +1,7 @@
1
- """
2
- Importer DTOs.
3
- """
4
 
5
- from enum import Enum
6
-
7
-
8
- class SyncSource(Enum):
9
- """
10
- A reference-data file the importer knows how to load.
11
-
12
- TARIC is the source of truth for measures and document requirements;
13
- Codeboek Douane supplies the Dutch national code lists.
14
- """
15
 
 
16
  TARIC_NOMENCLATURE = 1
17
  TARIC_DECLARABLE_CODES = 2
18
  TARIC_DUTIES_IMPORT = 3
@@ -23,38 +12,16 @@ class SyncSource(Enum):
23
  TARIC_BOX44_CODES = 8
24
  TARIC_CODE_CHANGES = 9
25
  CODEBOEK_DOUANE = 10
26
-
27
- @property
28
- def is_taric(self) -> bool:
29
- """
30
- Whether this source comes from the TARIC/CIRCABC distribution.
31
- """
32
- return self is not SyncSource.CODEBOEK_DOUANE
33
 
34
 
35
- class SyncMode(Enum):
36
- """
37
- How a source is loaded.
38
-
39
- TARIC nomenclature arrives as a monthly full load plus daily deltas;
40
- Codeboek Douane is republished whole on every change, so it is replaced
41
- rather than merged.
42
- """
43
-
44
  FULL = 1
45
  DELTA = 2
46
  REPLACE = 3
47
 
48
 
49
- class SyncStatus(Enum):
50
- """
51
- Lifecycle of a single import run.
52
-
53
- A run left in ``RUNNING`` by a crash is safe to start again: imports are
54
- keyed on the natural key of each record, so re-running only rewrites what
55
- it would have written the first time.
56
- """
57
-
58
  PENDING = 1
59
  RUNNING = 2
60
  SUCCESS = 3
 
1
+ from enum import IntEnum
 
 
2
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ class SyncSource(IntEnum):
5
  TARIC_NOMENCLATURE = 1
6
  TARIC_DECLARABLE_CODES = 2
7
  TARIC_DUTIES_IMPORT = 3
 
12
  TARIC_BOX44_CODES = 8
13
  TARIC_CODE_CHANGES = 9
14
  CODEBOEK_DOUANE = 10
15
+ DTV_NATIONAL = 11
 
 
 
 
 
 
16
 
17
 
18
+ class SyncMode(IntEnum):
 
 
 
 
 
 
 
 
19
  FULL = 1
20
  DELTA = 2
21
  REPLACE = 3
22
 
23
 
24
+ class SyncStatus(IntEnum):
 
 
 
 
 
 
 
 
25
  PENDING = 1
26
  RUNNING = 2
27
  SUCCESS = 3
cbh/api/importer/models.py CHANGED
@@ -1,7 +1,3 @@
1
- """
2
- Importer models.
3
- """
4
-
5
  from datetime import datetime
6
 
7
  from pydantic import Field
@@ -11,14 +7,6 @@ from cbh.core.database import MongoBaseModel
11
 
12
 
13
  class SyncLogModel(MongoBaseModel):
14
- """
15
- One import run.
16
-
17
- The log is the audit trail behind every answer the engine gives: it records
18
- which file was loaded, when, and what it changed. ``fileHash`` makes a
19
- repeated load of an unchanged file cheap to detect and skip.
20
- """
21
-
22
  source: SyncSource
23
  mode: SyncMode
24
  status: SyncStatus = SyncStatus.PENDING
@@ -41,10 +29,3 @@ class SyncLogModel(MongoBaseModel):
41
  durationSeconds: float | None = None
42
 
43
  datetimeInserted: datetime = Field(default_factory=datetime.now)
44
-
45
- @property
46
- def succeeded(self) -> bool:
47
- """
48
- Whether this run can be relied on as a reference-data version.
49
- """
50
- return self.status is SyncStatus.SUCCESS
 
 
 
 
 
1
  from datetime import datetime
2
 
3
  from pydantic import Field
 
7
 
8
 
9
  class SyncLogModel(MongoBaseModel):
 
 
 
 
 
 
 
 
10
  source: SyncSource
11
  mode: SyncMode
12
  status: SyncStatus = SyncStatus.PENDING
 
29
  durationSeconds: float | None = None
30
 
31
  datetimeInserted: datetime = Field(default_factory=datetime.now)
 
 
 
 
 
 
 
cbh/api/importer/schemas.py CHANGED
@@ -1,7 +1,3 @@
1
- """
2
- Importer schemas.
3
- """
4
-
5
  from datetime import date, datetime
6
 
7
  from pydantic import BaseModel
@@ -10,10 +6,6 @@ from cbh.api.importer.dto import SyncMode, SyncSource, SyncStatus
10
 
11
 
12
  class SyncLogResponse(BaseModel):
13
- """
14
- One import run as returned by the API.
15
- """
16
-
17
  id: str
18
  source: SyncSource
19
  mode: SyncMode
@@ -38,32 +30,34 @@ class SyncLogResponse(BaseModel):
38
 
39
 
40
  class SyncRequest(BaseModel):
41
- """
42
- Request to import one source.
43
-
44
- With no ``url``, the importer reads the most recent file supplied for the
45
- source in local storage.
46
- """
47
-
48
  source: SyncSource
49
  mode: SyncMode = SyncMode.FULL
50
  url: str | None = None
51
 
52
 
53
  class CodebookSyncRequest(BaseModel):
54
- """
55
- Request to refresh one or all Dutch codebooks.
56
- """
57
-
58
  fileName: str | None = None
59
  publishedOn: date | None = None
60
 
61
 
62
- class ImporterStatusResponse(BaseModel):
63
- """
64
- Operational view of the reference data.
65
- """
66
 
 
 
 
 
 
 
 
 
 
 
67
  latestPerSource: dict[str, SyncLogResponse]
68
  counts: dict[str, int]
69
  referenceAsOf: str | None = None
 
 
 
 
 
 
 
 
1
  from datetime import date, datetime
2
 
3
  from pydantic import BaseModel
 
6
 
7
 
8
  class SyncLogResponse(BaseModel):
 
 
 
 
9
  id: str
10
  source: SyncSource
11
  mode: SyncMode
 
30
 
31
 
32
  class SyncRequest(BaseModel):
 
 
 
 
 
 
 
33
  source: SyncSource
34
  mode: SyncMode = SyncMode.FULL
35
  url: str | None = None
36
 
37
 
38
  class CodebookSyncRequest(BaseModel):
 
 
 
 
39
  fileName: str | None = None
40
  publishedOn: date | None = None
41
 
42
 
43
+ class PublicationSyncRequest(BaseModel):
44
+ mode: SyncMode = SyncMode.FULL
45
+ period: str | None = None
46
+ keepArchive: bool = False
47
 
48
+
49
+ class PublicationSyncResponse(BaseModel):
50
+ period: str | None = None
51
+ url: str | None = None
52
+ runs: list[SyncLogResponse] = []
53
+ missingSources: list[SyncSource] = []
54
+ note: str | None = None
55
+
56
+
57
+ class ImporterStatusResponse(BaseModel):
58
  latestPerSource: dict[str, SyncLogResponse]
59
  counts: dict[str, int]
60
  referenceAsOf: str | None = None
61
+ publicationPeriods: list[str] = []
62
+ latestPublication: str | None = None
63
+ scheduledJobs: dict[str, datetime | None] = {}
cbh/api/importer/services/base.py CHANGED
@@ -1,14 +1,6 @@
1
- """
2
- Import run scaffolding: the sync log, and the idempotent write path.
3
-
4
- Every import runs inside a :class:`SyncRun`, which opens a log entry, records
5
- what the run touched and closes it as success or failure. Runs are safe to
6
- repeat: records are written under their natural key, so a re-run rewrites
7
- exactly what it wrote before instead of duplicating it.
8
- """
9
-
10
  import time
11
  from datetime import datetime
 
12
  from typing import Any
13
 
14
  import httpx
@@ -22,10 +14,6 @@ from cbh.core.config import settings
22
 
23
 
24
  class SyncRun:
25
- """
26
- Async context manager wrapping one import run.
27
- """
28
-
29
  def __init__(
30
  self,
31
  source: SyncSource,
@@ -64,22 +52,14 @@ class SyncRun:
64
  )
65
 
66
  if self.log.status in (SyncStatus.SUCCESS, SyncStatus.PARTIAL):
67
- # Cached answers were computed against the previous reference data.
68
  await invalidate_line_check_cache()
69
 
70
- # Never swallow the exception: a failed import must surface.
71
  return False
72
 
73
  def record_error(self, message: str) -> None:
74
- """
75
- Note a recoverable problem; the run finishes as PARTIAL.
76
- """
77
  self.log.errors.append(message)
78
 
79
  def set_hash(self, payload: bytes) -> None:
80
- """
81
- Store the content hash of the file this run loaded.
82
- """
83
  self.log.fileHash = file_hash(payload)
84
 
85
  async def write(
@@ -88,9 +68,6 @@ class SyncRun:
88
  documents: list[dict],
89
  key_fields: list[str],
90
  ) -> None:
91
- """
92
- Upsert a batch of parsed records and account for them in the log.
93
- """
94
  self.log.recordsRead += len(documents)
95
  matched, upserted = await upsert_objs(model, documents, key_fields)
96
  self.log.recordsMatched += matched
@@ -98,9 +75,6 @@ class SyncRun:
98
 
99
 
100
  async def download(url: str) -> bytes:
101
- """
102
- Fetch a reference file over HTTP.
103
- """
104
  async with httpx.AsyncClient(
105
  timeout=settings.IMPORTER_HTTP_TIMEOUT, follow_redirects=True
106
  ) as client:
@@ -109,16 +83,43 @@ async def download(url: str) -> bytes:
109
  return response.content
110
 
111
 
112
- async def already_loaded(source: SyncSource, payload: bytes) -> bool:
113
- """
114
- Whether this exact file has already been loaded successfully.
 
 
 
 
 
 
 
 
 
 
115
 
116
- Lets a daily job run unconditionally and do nothing when upstream has not
117
- republished.
118
- """
 
 
 
 
 
 
 
 
 
119
  latest = await settings.DB_CLIENT.synclogs.find_one(
120
- {"source": source.value, "status": SyncStatus.SUCCESS.value},
 
 
 
 
121
  sort=[("finishedAt", -1)],
122
  projection={"_id": 0, "fileHash": 1},
123
  )
124
- return bool(latest) and latest.get("fileHash") == file_hash(payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import time
2
  from datetime import datetime
3
+ from pathlib import Path
4
  from typing import Any
5
 
6
  import httpx
 
14
 
15
 
16
  class SyncRun:
 
 
 
 
17
  def __init__(
18
  self,
19
  source: SyncSource,
 
52
  )
53
 
54
  if self.log.status in (SyncStatus.SUCCESS, SyncStatus.PARTIAL):
 
55
  await invalidate_line_check_cache()
56
 
 
57
  return False
58
 
59
  def record_error(self, message: str) -> None:
 
 
 
60
  self.log.errors.append(message)
61
 
62
  def set_hash(self, payload: bytes) -> None:
 
 
 
63
  self.log.fileHash = file_hash(payload)
64
 
65
  async def write(
 
68
  documents: list[dict],
69
  key_fields: list[str],
70
  ) -> None:
 
 
 
71
  self.log.recordsRead += len(documents)
72
  matched, upserted = await upsert_objs(model, documents, key_fields)
73
  self.log.recordsMatched += matched
 
75
 
76
 
77
  async def download(url: str) -> bytes:
 
 
 
78
  async with httpx.AsyncClient(
79
  timeout=settings.IMPORTER_HTTP_TIMEOUT, follow_redirects=True
80
  ) as client:
 
83
  return response.content
84
 
85
 
86
+ async def download_to(url: str, path: Path) -> Path:
87
+ """Stream a download to disk. The monthly extraction is ~80 MB zipped."""
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ async with httpx.AsyncClient(
90
+ timeout=settings.IMPORTER_HTTP_TIMEOUT, follow_redirects=True
91
+ ) as client:
92
+ async with client.stream("GET", url) as response:
93
+ response.raise_for_status()
94
+ with path.open("wb") as handle:
95
+ async for chunk in response.aiter_bytes(1 << 20):
96
+ handle.write(chunk)
97
+ return path
98
+
99
 
100
+ async def url_exists(url: str) -> bool:
101
+ async with httpx.AsyncClient(
102
+ timeout=settings.IMPORTER_HTTP_TIMEOUT, follow_redirects=True
103
+ ) as client:
104
+ try:
105
+ response = await client.head(url)
106
+ except httpx.HTTPError:
107
+ return False
108
+ return response.status_code == 200
109
+
110
+
111
+ async def latest_loaded_hash(source: SyncSource) -> str | None:
112
  latest = await settings.DB_CLIENT.synclogs.find_one(
113
+ {
114
+ "source": source.value,
115
+ "status": SyncStatus.SUCCESS.value,
116
+ "fileHash": {"$ne": None},
117
+ },
118
  sort=[("finishedAt", -1)],
119
  projection={"_id": 0, "fileHash": 1},
120
  )
121
+ return latest.get("fileHash") if latest else None
122
+
123
+
124
+ async def already_loaded(source: SyncSource, payload: bytes) -> bool:
125
+ return await latest_loaded_hash(source) == file_hash(payload)
cbh/api/importer/services/codeboek/codeboek.py CHANGED
@@ -1,22 +1,10 @@
1
- """
2
- The Codeboek Douane importer.
3
-
4
- The Dutch national code lists are republished whole on every change, at
5
- predictable URLs of the form ``.../codebooks/[yyyyMMdd]/[filename]``, as either
6
- a zipped XML file or JSON. They are therefore replaced rather than merged: the
7
- previous contents of a codebook are dropped and the new file takes its place.
8
-
9
- These files carry no code x country -> document mapping. That lives in the
10
- TARIC measure conditions; the codebooks explain what a national code means.
11
- """
12
-
13
  from datetime import date
14
  from typing import Any
15
 
16
  import httpx
17
 
18
  from cbh.api.common.db_requests import delete_objs, upsert_objs
19
- from cbh.api.common.utils import normalize_ws
20
  from cbh.api.importer.dto import SyncMode, SyncSource
21
  from cbh.api.importer.services.base import SyncRun, already_loaded, download
22
  from cbh.api.importer.utils import (
@@ -27,8 +15,6 @@ from cbh.api.importer.utils import (
27
  )
28
  from cbh.api.taric.models import CodeboekEntryModel
29
 
30
- # Codebooks worth loading for the chapters in scope. The key is the file name
31
- # as published; the value is the codebook name records are filed under.
32
  CODEBOOKS: dict[str, str] = {
33
  "Bescheiden.zip": "documents",
34
  "Landen.zip": "countries",
@@ -38,7 +24,7 @@ CODEBOOKS: dict[str, str] = {
38
  "Maatstaven.zip": "supplementary_units",
39
  }
40
 
41
- _XML_FIELDS: dict[str, tuple[str, ...]] = {
42
  "code": ("code", "Code", "waarde", "id"),
43
  "description": ("omschrijving", "Omschrijving", "description", "naam"),
44
  "descriptionEn": ("descriptionEN", "omschrijvingEN", "descriptionEn"),
@@ -48,15 +34,10 @@ _XML_FIELDS: dict[str, tuple[str, ...]] = {
48
 
49
 
50
  def _parse_entry(codebook: str, record: dict[str, Any]) -> dict[str, Any] | None:
51
- """
52
- Parse one codebook record.
53
- """
54
  code = normalize_ws(record.get("code"))
55
  if not code:
56
  return None
57
 
58
- from cbh.api.common.utils import parse_date
59
-
60
  known = {"code", "description", "descriptionEn", "validFrom", "validTo"}
61
  return {
62
  "codebook": codebook,
@@ -66,22 +47,19 @@ def _parse_entry(codebook: str, record: dict[str, Any]) -> dict[str, Any] | None
66
  "validFrom": parse_date(record.get("validFrom")),
67
  "validTo": parse_date(record.get("validTo")),
68
  "extra": {
69
- key: value for key, value in record.items() if key not in known and value not in (None, "")
 
 
70
  },
71
  }
72
 
73
 
74
  def _read_records(payload: bytes, file_name: str) -> list[dict[str, Any]]:
75
- """
76
- Read a codebook in whichever format it was published in.
77
- """
78
  unwrapped = load_payload(payload, file_name)
79
  if file_name.lower().endswith(".json") or unwrapped.lstrip()[:1] in (b"{", b"["):
80
  return read_json_records(unwrapped)
81
- # The record tag varies per codebook; every leaf element carrying a code is
82
- # treated as a record.
83
  for tag in ("record", "row", "item", "codeItem", "regel"):
84
- records = list(read_xml_records(unwrapped, tag, _XML_FIELDS))
85
  if records:
86
  return records
87
  return []
@@ -93,9 +71,6 @@ async def import_codebook(
93
  file_name: str,
94
  source_url: str | None = None,
95
  ) -> SyncRun:
96
- """
97
- Replace one codebook with the contents of a published file.
98
- """
99
  async with SyncRun(
100
  SyncSource.CODEBOEK_DOUANE,
101
  SyncMode.REPLACE,
@@ -120,8 +95,6 @@ async def import_codebook(
120
  continue
121
  documents.append(CodeboekEntryModel(**parsed).to_mongo())
122
 
123
- # Full replacement: the published file is the whole truth for this
124
- # codebook, so anything not in it is gone.
125
  run.log.recordsDeleted = await delete_objs(
126
  CodeboekEntryModel, {"codebook": codebook}
127
  )
@@ -135,12 +108,7 @@ async def import_codebook(
135
  return run
136
 
137
 
138
- async def sync_codebook(
139
- file_name: str, on_date: date | None = None
140
- ) -> SyncRun:
141
- """
142
- Download and replace one codebook from its published URL.
143
- """
144
  codebook = CODEBOOKS.get(file_name, file_name.rsplit(".", 1)[0].lower())
145
  url = codeboek_url(file_name, on_date)
146
  payload = await download(url)
@@ -148,12 +116,6 @@ async def sync_codebook(
148
 
149
 
150
  async def sync_all_codebooks(on_date: date | None = None) -> list[SyncRun]:
151
- """
152
- Refresh every configured codebook.
153
-
154
- A codebook that is not published for the requested date is skipped rather
155
- than failing the whole run: the Douane does not republish them all daily.
156
- """
157
  runs: list[SyncRun] = []
158
  for file_name in CODEBOOKS:
159
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from datetime import date
2
  from typing import Any
3
 
4
  import httpx
5
 
6
  from cbh.api.common.db_requests import delete_objs, upsert_objs
7
+ from cbh.api.common.utils import normalize_ws, parse_date
8
  from cbh.api.importer.dto import SyncMode, SyncSource
9
  from cbh.api.importer.services.base import SyncRun, already_loaded, download
10
  from cbh.api.importer.utils import (
 
15
  )
16
  from cbh.api.taric.models import CodeboekEntryModel
17
 
 
 
18
  CODEBOOKS: dict[str, str] = {
19
  "Bescheiden.zip": "documents",
20
  "Landen.zip": "countries",
 
24
  "Maatstaven.zip": "supplementary_units",
25
  }
26
 
27
+ XML_FIELDS: dict[str, tuple[str, ...]] = {
28
  "code": ("code", "Code", "waarde", "id"),
29
  "description": ("omschrijving", "Omschrijving", "description", "naam"),
30
  "descriptionEn": ("descriptionEN", "omschrijvingEN", "descriptionEn"),
 
34
 
35
 
36
  def _parse_entry(codebook: str, record: dict[str, Any]) -> dict[str, Any] | None:
 
 
 
37
  code = normalize_ws(record.get("code"))
38
  if not code:
39
  return None
40
 
 
 
41
  known = {"code", "description", "descriptionEn", "validFrom", "validTo"}
42
  return {
43
  "codebook": codebook,
 
47
  "validFrom": parse_date(record.get("validFrom")),
48
  "validTo": parse_date(record.get("validTo")),
49
  "extra": {
50
+ key: value
51
+ for key, value in record.items()
52
+ if key not in known and value not in (None, "")
53
  },
54
  }
55
 
56
 
57
  def _read_records(payload: bytes, file_name: str) -> list[dict[str, Any]]:
 
 
 
58
  unwrapped = load_payload(payload, file_name)
59
  if file_name.lower().endswith(".json") or unwrapped.lstrip()[:1] in (b"{", b"["):
60
  return read_json_records(unwrapped)
 
 
61
  for tag in ("record", "row", "item", "codeItem", "regel"):
62
+ records = list(read_xml_records(unwrapped, tag, XML_FIELDS))
63
  if records:
64
  return records
65
  return []
 
71
  file_name: str,
72
  source_url: str | None = None,
73
  ) -> SyncRun:
 
 
 
74
  async with SyncRun(
75
  SyncSource.CODEBOEK_DOUANE,
76
  SyncMode.REPLACE,
 
95
  continue
96
  documents.append(CodeboekEntryModel(**parsed).to_mongo())
97
 
 
 
98
  run.log.recordsDeleted = await delete_objs(
99
  CodeboekEntryModel, {"codebook": codebook}
100
  )
 
108
  return run
109
 
110
 
111
+ async def sync_codebook(file_name: str, on_date: date | None = None) -> SyncRun:
 
 
 
 
 
112
  codebook = CODEBOOKS.get(file_name, file_name.rsplit(".", 1)[0].lower())
113
  url = codeboek_url(file_name, on_date)
114
  payload = await download(url)
 
116
 
117
 
118
  async def sync_all_codebooks(on_date: date | None = None) -> list[SyncRun]:
 
 
 
 
 
 
119
  runs: list[SyncRun] = []
120
  for file_name in CODEBOOKS:
121
  try:
cbh/api/importer/services/demo/demo.py CHANGED
@@ -1,24 +1,3 @@
1
- """
2
- A small, realistic reference dataset for the chapters in scope.
3
-
4
- This exists so the whole pipeline — resolution, documents, warnings, Excel in
5
- and out — can be exercised before the real TARIC extracts and the Goenee gold
6
- set arrive. The shapes match what the importer produces from the published
7
- files; only the volume is different.
8
-
9
- It deliberately includes the awkward cases the engine has to get right:
10
-
11
- * an 8-digit code with two declarable children (ambiguous);
12
- * a code that expired last year, with a recorded successor (invalid + hint);
13
- * a code expiring inside the 60-day horizon (expiring);
14
- * an either/or document pair and a Y-code exemption;
15
- * an anti-dumping measure tied to an additional code;
16
- * a dual-use code that must be handed to a human.
17
-
18
- Everything here is illustrative and must be replaced by a real import before
19
- any acceptance run.
20
- """
21
-
22
  from datetime import date, timedelta
23
 
24
  from cbh.api.common.db_requests import upsert_objs
@@ -41,13 +20,8 @@ from cbh.api.taric.models import (
41
  NomenclatureModel,
42
  )
43
 
44
- EPOCH = date(2020, 1, 1)
45
-
46
 
47
  def _geo_areas() -> list[GeoAreaModel]:
48
- """
49
- Countries and the groups used by the demo measures.
50
- """
51
  countries = {
52
  "NL": "Netherlands",
53
  "DE": "Germany",
@@ -60,71 +34,56 @@ def _geo_areas() -> list[GeoAreaModel]:
60
  "ZA": "South Africa",
61
  "GB": "United Kingdom",
62
  }
63
- areas = [
64
  GeoAreaModel(
65
- code=code, name=name, areaType=GeoAreaType.COUNTRY, validFrom=EPOCH
66
  )
67
  for code, name in countries.items()
68
- ]
69
- areas.append(
70
  GeoAreaModel(
71
  code="1011",
72
  name="Erga Omnes (all third countries)",
73
  areaType=GeoAreaType.GROUP,
74
  memberCodes=list(countries),
75
- validFrom=EPOCH,
76
- )
77
- )
78
- areas.append(
79
  GeoAreaModel(
80
  code="1013",
81
  name="European Union",
82
  areaType=GeoAreaType.GROUP,
83
  memberCodes=["NL", "DE"],
84
- validFrom=EPOCH,
85
- )
86
- )
87
- areas.append(
88
  GeoAreaModel(
89
  code="2005",
90
  name="GSP beneficiary countries",
91
  areaType=GeoAreaType.GROUP,
92
  memberCodes=["BD", "IN"],
93
- validFrom=EPOCH,
94
- )
95
- )
96
- return areas
97
 
98
 
99
  def _nomenclature(today: date) -> list[NomenclatureModel]:
100
- """
101
- A slice of the nomenclature across the chapters in scope.
102
- """
103
- soon = today + timedelta(days=30)
104
-
105
  entries: list[tuple[str, str, str, bool, date, date | None]] = [
106
- # --- chapter 61/62: clothing (G-Star export) ---
107
- ("6109", "T-shirts, singlets and other vests, knitted", "T-shirts en onderhemden", False, EPOCH, None),
108
- ("61091000", "Of cotton", "Van katoen", False, EPOCH, None),
109
- ("6109100010", "T-shirts of cotton, men's or boys'", "T-shirts van katoen, heren", True, EPOCH, None),
110
- ("6109100090", "T-shirts of cotton, other", "T-shirts van katoen, overige", True, EPOCH, None),
111
- ("62034231", "Men's trousers of denim", "Herenbroeken van denim", False, EPOCH, None),
112
- ("6203423110", "Men's trousers of denim, workwear", "Herenbroeken van denim, werkkleding", True, EPOCH, None),
113
- # --- chapter 84/85: IT/AV (EFG Arabia -> Henx, T1) ---
114
- ("8471", "Automatic data-processing machines", "Automatische gegevensverwerkende machines", False, EPOCH, None),
115
- ("84713000", "Portable computers weighing <= 10 kg", "Draagbare computers", False, EPOCH, None),
116
- ("8471300000", "Portable automatic data-processing machines", "Draagbare computers", True, EPOCH, None),
117
- ("8517130000", "Smartphones", "Smartphones", True, EPOCH, None),
118
- ("8532241000", "Ceramic multilayer capacitors", "Keramische meerlaagse condensatoren", True, EPOCH, None),
119
- ("8543709099", "Other electrical machines, n.e.s.", "Andere elektrische machines", True, EPOCH, None),
120
- # Expires inside the warning horizon: the 1 January switch, seen early.
121
- ("8528721010", "Colour television sets, legacy subheading", "Kleurentelevisies, oude onderverdeling", True, EPOCH, soon),
122
- # --- chapter 22: wine ---
123
- ("22042110", "Wine in bottles of 2 litres or less", "Wijn in flessen van 2 liter of minder", False, EPOCH, None),
124
- ("2204211010", "Quality wine in bottles of 2 litres or less", "Kwaliteitswijn in flessen", True, EPOCH, None),
125
  ]
126
 
127
- models = [
128
  NomenclatureModel(
129
  code=code if len(code) == 10 else code.ljust(10, "0"),
130
  descriptionEn=description_en,
@@ -135,27 +94,19 @@ def _nomenclature(today: date) -> list[NomenclatureModel]:
135
  validTo=valid_to,
136
  )
137
  for code, description_en, description_nl, declarable, valid_from, valid_to in entries
138
- ]
139
-
140
- # A code withdrawn at a previous 1 January, kept so the engine can tell
141
- # "never existed" from "no longer valid".
142
- models.append(
143
  NomenclatureModel(
144
  code="8528721090",
145
  descriptionEn="Colour television sets, withdrawn subheading",
146
  descriptionNl="Kleurentelevisies, vervallen onderverdeling",
147
  declarable=True,
148
- validFrom=EPOCH,
149
  validTo=date(today.year - 1, 12, 31),
150
  )
151
- )
152
- return models
153
 
154
 
155
  def _document_codes() -> list[DocumentCodeModel]:
156
- """
157
- The Box 44 codes the demo measures point at.
158
- """
159
  entries = [
160
  ("C015", "V I 1 document (wine import)", "V I 1-document (wijninvoer)"),
161
  ("C400", "CITES certificate", "CITES-certificaat"),
@@ -172,40 +123,36 @@ def _document_codes() -> list[DocumentCodeModel]:
172
  ]
173
  return [
174
  DocumentCodeModel(
175
- code=code, description=description, descriptionNl=description_nl, validFrom=EPOCH
 
 
 
176
  )
177
  for code, description, description_nl in entries
178
  ]
179
 
180
 
181
  def _additional_codes() -> list[AdditionalCodeModel]:
182
- """
183
- Additional codes used by the demo anti-dumping measure.
184
- """
185
  return [
186
  AdditionalCodeModel(
187
  codeType="8",
188
  code="B999",
189
  fullCode="8B999",
190
  description="All other producers (residual anti-dumping rate)",
191
- validFrom=EPOCH,
192
  ),
193
  AdditionalCodeModel(
194
  codeType="8",
195
  code="B001",
196
  fullCode="8B001",
197
  description="Named cooperating producer (reduced rate)",
198
- validFrom=EPOCH,
199
  ),
200
  ]
201
 
202
 
203
  def _measures() -> list[MeasureModel]:
204
- """
205
- Measures covering the interesting paths through the engine.
206
- """
207
  return [
208
- # Wine import: either a V I 1 document or the exemption code.
209
  MeasureModel(
210
  measureSid="DEMO-2204-IMP",
211
  goodsCode="22042110",
@@ -215,7 +162,7 @@ def _measures() -> list[MeasureModel]:
215
  direction=TradeDirection.IMPORT,
216
  geoAreaCode="1011",
217
  geoAreaDescription="Erga Omnes",
218
- validFrom=EPOCH,
219
  conditions=[
220
  MeasureConditionEmbedded(
221
  conditionCode="B",
@@ -235,7 +182,6 @@ def _measures() -> list[MeasureModel]:
235
  ),
236
  ],
237
  ),
238
- # Wine is excise goods: information only, no document.
239
  MeasureModel(
240
  measureSid="DEMO-2204-EXC",
241
  goodsCode="22042110",
@@ -244,10 +190,8 @@ def _measures() -> list[MeasureModel]:
244
  category=MeasureCategory.EXCISE,
245
  direction=TradeDirection.IMPORT,
246
  geoAreaCode="1011",
247
- validFrom=EPOCH,
248
  ),
249
- # Anti-dumping tied to an additional code: the rate depends on the
250
- # producer, so the line has to carry one.
251
  MeasureModel(
252
  measureSid="DEMO-8532-ADD",
253
  goodsCode="8532241000",
@@ -259,7 +203,7 @@ def _measures() -> list[MeasureModel]:
259
  geoAreaDescription="China",
260
  additionalCode="8B999",
261
  dutyExpression="32.4 %",
262
- validFrom=EPOCH,
263
  conditions=[
264
  MeasureConditionEmbedded(
265
  conditionCode="A",
@@ -271,7 +215,6 @@ def _measures() -> list[MeasureModel]:
271
  ),
272
  ],
273
  ),
274
- # Dual-use: flagged as out of scope rather than answered.
275
  MeasureModel(
276
  measureSid="DEMO-8543-DU",
277
  goodsCode="8543709099",
@@ -280,7 +223,7 @@ def _measures() -> list[MeasureModel]:
280
  category=MeasureCategory.DUAL_USE,
281
  direction=TradeDirection.EXPORT,
282
  geoAreaCode="1011",
283
- validFrom=EPOCH,
284
  conditions=[
285
  MeasureConditionEmbedded(
286
  conditionCode="E",
@@ -300,8 +243,6 @@ def _measures() -> list[MeasureModel]:
300
  ),
301
  ],
302
  ),
303
- # Clothing export: a surveillance measure with no document obligation,
304
- # so the line legitimately comes back with none required.
305
  MeasureModel(
306
  measureSid="DEMO-6109-EXP",
307
  goodsCode="6109",
@@ -310,9 +251,8 @@ def _measures() -> list[MeasureModel]:
310
  category=MeasureCategory.SURVEILLANCE,
311
  direction=TradeDirection.EXPORT,
312
  geoAreaCode="1011",
313
- validFrom=EPOCH,
314
  ),
315
- # Preferential duty for GSP countries: fiscal only.
316
  MeasureModel(
317
  measureSid="DEMO-6109-GSP",
318
  goodsCode="61091000",
@@ -323,33 +263,12 @@ def _measures() -> list[MeasureModel]:
323
  geoAreaCode="2005",
324
  geoAreaDescription="GSP beneficiaries",
325
  dutyExpression="9.6 %",
326
- validFrom=EPOCH,
327
  ),
328
  ]
329
 
330
 
331
- def _code_changes(today: date) -> list[CodeChangeModel]:
332
- """
333
- The correlation entry behind the withdrawn-code demo case.
334
- """
335
- return [
336
- CodeChangeModel(
337
- oldCode="8528721090",
338
- newCodes=["8528721010"],
339
- changeType=CodeChangeType.REPLACED,
340
- changedOn=date(today.year, 1, 1).isoformat(),
341
- note="Subheading merged at the 1 January nomenclature update.",
342
- )
343
- ]
344
-
345
-
346
  async def seed_demo_reference_data() -> SyncRun:
347
- """
348
- Load the demo reference dataset.
349
-
350
- Written through the same idempotent upsert path as a real import, under a
351
- sync log that says plainly where the data came from.
352
- """
353
  today = date.today()
354
 
355
  async with SyncRun(
@@ -366,7 +285,19 @@ async def seed_demo_reference_data() -> SyncRun:
366
  (DocumentCodeModel, _document_codes(), ["code", "validFrom"]),
367
  (AdditionalCodeModel, _additional_codes(), ["fullCode", "validFrom"]),
368
  (MeasureModel, _measures(), ["measureSid"]),
369
- (CodeChangeModel, _code_changes(today), ["oldCode", "changedOn"]),
 
 
 
 
 
 
 
 
 
 
 
 
370
  ]
371
 
372
  for model, instances, key_fields in batches:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from datetime import date, timedelta
2
 
3
  from cbh.api.common.db_requests import upsert_objs
 
20
  NomenclatureModel,
21
  )
22
 
 
 
23
 
24
  def _geo_areas() -> list[GeoAreaModel]:
 
 
 
25
  countries = {
26
  "NL": "Netherlands",
27
  "DE": "Germany",
 
34
  "ZA": "South Africa",
35
  "GB": "United Kingdom",
36
  }
37
+ return [
38
  GeoAreaModel(
39
+ code=code, name=name, areaType=GeoAreaType.COUNTRY, validFrom=date(2020, 1, 1)
40
  )
41
  for code, name in countries.items()
42
+ ] + [
 
43
  GeoAreaModel(
44
  code="1011",
45
  name="Erga Omnes (all third countries)",
46
  areaType=GeoAreaType.GROUP,
47
  memberCodes=list(countries),
48
+ validFrom=date(2020, 1, 1),
49
+ ),
 
 
50
  GeoAreaModel(
51
  code="1013",
52
  name="European Union",
53
  areaType=GeoAreaType.GROUP,
54
  memberCodes=["NL", "DE"],
55
+ validFrom=date(2020, 1, 1),
56
+ ),
 
 
57
  GeoAreaModel(
58
  code="2005",
59
  name="GSP beneficiary countries",
60
  areaType=GeoAreaType.GROUP,
61
  memberCodes=["BD", "IN"],
62
+ validFrom=date(2020, 1, 1),
63
+ ),
64
+ ]
 
65
 
66
 
67
  def _nomenclature(today: date) -> list[NomenclatureModel]:
 
 
 
 
 
68
  entries: list[tuple[str, str, str, bool, date, date | None]] = [
69
+ ("6109", "T-shirts, singlets and other vests, knitted", "T-shirts en onderhemden", False, date(2020, 1, 1), None),
70
+ ("61091000", "Of cotton", "Van katoen", False, date(2020, 1, 1), None),
71
+ ("6109100010", "T-shirts of cotton, men's or boys'", "T-shirts van katoen, heren", True, date(2020, 1, 1), None),
72
+ ("6109100090", "T-shirts of cotton, other", "T-shirts van katoen, overige", True, date(2020, 1, 1), None),
73
+ ("62034231", "Men's trousers of denim", "Herenbroeken van denim", False, date(2020, 1, 1), None),
74
+ ("6203423110", "Men's trousers of denim, workwear", "Herenbroeken van denim, werkkleding", True, date(2020, 1, 1), None),
75
+ ("8471", "Automatic data-processing machines", "Automatische gegevensverwerkende machines", False, date(2020, 1, 1), None),
76
+ ("84713000", "Portable computers weighing <= 10 kg", "Draagbare computers", False, date(2020, 1, 1), None),
77
+ ("8471300000", "Portable automatic data-processing machines", "Draagbare computers", True, date(2020, 1, 1), None),
78
+ ("8517130000", "Smartphones", "Smartphones", True, date(2020, 1, 1), None),
79
+ ("8532241000", "Ceramic multilayer capacitors", "Keramische meerlaagse condensatoren", True, date(2020, 1, 1), None),
80
+ ("8543709099", "Other electrical machines, n.e.s.", "Andere elektrische machines", True, date(2020, 1, 1), None),
81
+ ("8528721010", "Colour television sets, legacy subheading", "Kleurentelevisies, oude onderverdeling", True, date(2020, 1, 1), today + timedelta(days=30)),
82
+ ("22042110", "Wine in bottles of 2 litres or less", "Wijn in flessen van 2 liter of minder", False, date(2020, 1, 1), None),
83
+ ("2204211010", "Quality wine in bottles of 2 litres or less", "Kwaliteitswijn in flessen", True, date(2020, 1, 1), None),
 
 
 
 
84
  ]
85
 
86
+ return [
87
  NomenclatureModel(
88
  code=code if len(code) == 10 else code.ljust(10, "0"),
89
  descriptionEn=description_en,
 
94
  validTo=valid_to,
95
  )
96
  for code, description_en, description_nl, declarable, valid_from, valid_to in entries
97
+ ] + [
 
 
 
 
98
  NomenclatureModel(
99
  code="8528721090",
100
  descriptionEn="Colour television sets, withdrawn subheading",
101
  descriptionNl="Kleurentelevisies, vervallen onderverdeling",
102
  declarable=True,
103
+ validFrom=date(2020, 1, 1),
104
  validTo=date(today.year - 1, 12, 31),
105
  )
106
+ ]
 
107
 
108
 
109
  def _document_codes() -> list[DocumentCodeModel]:
 
 
 
110
  entries = [
111
  ("C015", "V I 1 document (wine import)", "V I 1-document (wijninvoer)"),
112
  ("C400", "CITES certificate", "CITES-certificaat"),
 
123
  ]
124
  return [
125
  DocumentCodeModel(
126
+ code=code,
127
+ description=description,
128
+ descriptionNl=description_nl,
129
+ validFrom=date(2020, 1, 1),
130
  )
131
  for code, description, description_nl in entries
132
  ]
133
 
134
 
135
  def _additional_codes() -> list[AdditionalCodeModel]:
 
 
 
136
  return [
137
  AdditionalCodeModel(
138
  codeType="8",
139
  code="B999",
140
  fullCode="8B999",
141
  description="All other producers (residual anti-dumping rate)",
142
+ validFrom=date(2020, 1, 1),
143
  ),
144
  AdditionalCodeModel(
145
  codeType="8",
146
  code="B001",
147
  fullCode="8B001",
148
  description="Named cooperating producer (reduced rate)",
149
+ validFrom=date(2020, 1, 1),
150
  ),
151
  ]
152
 
153
 
154
  def _measures() -> list[MeasureModel]:
 
 
 
155
  return [
 
156
  MeasureModel(
157
  measureSid="DEMO-2204-IMP",
158
  goodsCode="22042110",
 
162
  direction=TradeDirection.IMPORT,
163
  geoAreaCode="1011",
164
  geoAreaDescription="Erga Omnes",
165
+ validFrom=date(2020, 1, 1),
166
  conditions=[
167
  MeasureConditionEmbedded(
168
  conditionCode="B",
 
182
  ),
183
  ],
184
  ),
 
185
  MeasureModel(
186
  measureSid="DEMO-2204-EXC",
187
  goodsCode="22042110",
 
190
  category=MeasureCategory.EXCISE,
191
  direction=TradeDirection.IMPORT,
192
  geoAreaCode="1011",
193
+ validFrom=date(2020, 1, 1),
194
  ),
 
 
195
  MeasureModel(
196
  measureSid="DEMO-8532-ADD",
197
  goodsCode="8532241000",
 
203
  geoAreaDescription="China",
204
  additionalCode="8B999",
205
  dutyExpression="32.4 %",
206
+ validFrom=date(2020, 1, 1),
207
  conditions=[
208
  MeasureConditionEmbedded(
209
  conditionCode="A",
 
215
  ),
216
  ],
217
  ),
 
218
  MeasureModel(
219
  measureSid="DEMO-8543-DU",
220
  goodsCode="8543709099",
 
223
  category=MeasureCategory.DUAL_USE,
224
  direction=TradeDirection.EXPORT,
225
  geoAreaCode="1011",
226
+ validFrom=date(2020, 1, 1),
227
  conditions=[
228
  MeasureConditionEmbedded(
229
  conditionCode="E",
 
243
  ),
244
  ],
245
  ),
 
 
246
  MeasureModel(
247
  measureSid="DEMO-6109-EXP",
248
  goodsCode="6109",
 
251
  category=MeasureCategory.SURVEILLANCE,
252
  direction=TradeDirection.EXPORT,
253
  geoAreaCode="1011",
254
+ validFrom=date(2020, 1, 1),
255
  ),
 
256
  MeasureModel(
257
  measureSid="DEMO-6109-GSP",
258
  goodsCode="61091000",
 
263
  geoAreaCode="2005",
264
  geoAreaDescription="GSP beneficiaries",
265
  dutyExpression="9.6 %",
266
+ validFrom=date(2020, 1, 1),
267
  ),
268
  ]
269
 
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  async def seed_demo_reference_data() -> SyncRun:
 
 
 
 
 
 
272
  today = date.today()
273
 
274
  async with SyncRun(
 
285
  (DocumentCodeModel, _document_codes(), ["code", "validFrom"]),
286
  (AdditionalCodeModel, _additional_codes(), ["fullCode", "validFrom"]),
287
  (MeasureModel, _measures(), ["measureSid"]),
288
+ (
289
+ CodeChangeModel,
290
+ [
291
+ CodeChangeModel(
292
+ oldCode="8528721090",
293
+ newCodes=["8528721010"],
294
+ changeType=CodeChangeType.REPLACED,
295
+ changedOn=date(today.year, 1, 1).isoformat(),
296
+ note="Subheading merged at the 1 January nomenclature update.",
297
+ )
298
+ ],
299
+ ["oldCode", "changedOn"],
300
+ ),
301
  ]
302
 
303
  for model, instances, key_fields in batches:
cbh/api/importer/services/dtv/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .dtv import (
2
+ DtvRefresh,
3
+ import_national_measures,
4
+ list_publications,
5
+ refresh_national_measures,
6
+ resolve_latest_snapshot,
7
+ )
8
+
9
+ __all__ = [
10
+ "DtvRefresh",
11
+ "import_national_measures",
12
+ "list_publications",
13
+ "refresh_national_measures",
14
+ "resolve_latest_snapshot",
15
+ ]
cbh/api/importer/services/dtv/dtv.py ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Dutch Gebruikstarief — national measures on top of the Union ones.
2
+
3
+ TARIC states what the Union requires. What the Netherlands adds on its own
4
+ authority — omzetbelasting, accijns, verbruiksbelasting, the invoer- and
5
+ uitvoerbeperkingen written in Dutch law — is published only here, by the
6
+ Belastingdienst, as an Arctic Tariff export.
7
+
8
+ The feed carries the whole tariff, Union rules included. Only the records
9
+ flagged `national="1"` are taken: the Union side is already loaded from the
10
+ published TARIC extraction and reloading it from a second source would make it
11
+ impossible to say which publication an answer came from.
12
+ """
13
+
14
+ import zipfile
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any, Iterator
18
+ from xml.etree import ElementTree
19
+
20
+ import httpx
21
+
22
+ from cbh.api.common.dto import TradeDirection
23
+ from cbh.api.common.utils import parse_date
24
+ from cbh.api.importer.dto import SyncMode, SyncSource
25
+ from cbh.api.importer.services.base import SyncRun, download_to, latest_loaded_hash
26
+ from cbh.api.importer.utils import file_hash_path
27
+ from cbh.api.taric.dto import MeasureCategory
28
+ from cbh.api.taric.models import (
29
+ DocumentCodeModel,
30
+ MeasureConditionEmbedded,
31
+ MeasureModel,
32
+ MeasureTypeModel,
33
+ )
34
+ from cbh.core.config import settings
35
+
36
+ NS = "{http://www.arcticgroup.se/tariff/arctictariff/export}"
37
+
38
+ # Arctic's `tradeMovementCode`, read off the published MeasureType table: the
39
+ # NLINV* (invoer) types are 0, the NLUIT* (uitvoer) types are 1.
40
+ TRADE_MOVEMENT = {
41
+ "0": TradeDirection.IMPORT,
42
+ "1": TradeDirection.EXPORT,
43
+ "2": TradeDirection.BOTH,
44
+ }
45
+
46
+ # The series a national measure type belongs to already says what kind of rule
47
+ # it is, so the fallback is data rather than a guess: A prohibits, B restricts,
48
+ # P is turnover tax, Q is one of the consumption taxes.
49
+ SERIES_CATEGORY = {
50
+ "A": MeasureCategory.PROHIBITION,
51
+ "B": MeasureCategory.RESTRICTION,
52
+ "P": MeasureCategory.VAT,
53
+ "Q": MeasureCategory.EXCISE,
54
+ }
55
+
56
+ # Where the series is too coarse to be useful, the type is named outright.
57
+ NATIONAL_TYPE_CATEGORY = {
58
+ "NLBTW": MeasureCategory.VAT,
59
+ "NLACC": MeasureCategory.EXCISE,
60
+ "NLACVH": MeasureCategory.EXCISE,
61
+ "NLVBB": MeasureCategory.EXCISE,
62
+ "NLKOBE": MeasureCategory.EXCISE,
63
+ "NLINVF": MeasureCategory.PHYTOSANITARY_CONTROL,
64
+ "NLUSGM": MeasureCategory.DUAL_USE,
65
+ "NLUITT": MeasureCategory.DUAL_USE,
66
+ "NLINVE": MeasureCategory.DUAL_USE,
67
+ "NLUITE": MeasureCategory.DUAL_USE,
68
+ }
69
+
70
+
71
+ @dataclass
72
+ class NationalMeasureType:
73
+ typeId: str
74
+ direction: TradeDirection
75
+ category: MeasureCategory
76
+ description: str | None = None
77
+ descriptionNl: str | None = None
78
+
79
+
80
+ @dataclass
81
+ class DtvRefresh:
82
+ fileName: str | None = None
83
+ url: str | None = None
84
+ runs: list[SyncRun] = field(default_factory=list)
85
+ note: str | None = None
86
+
87
+
88
+ def _attrs(element: ElementTree.Element) -> dict[str, str]:
89
+ return {key.split("}")[-1]: value for key, value in element.attrib.items()}
90
+
91
+
92
+ def _descriptions(element: ElementTree.Element, tag: str) -> dict[str, str]:
93
+ found: dict[str, str] = {}
94
+ for child in element.iter(NS + tag):
95
+ attributes = _attrs(child)
96
+ language = (attributes.get("languageId") or "").upper()
97
+ if language and attributes.get("description"):
98
+ found[language] = attributes["description"]
99
+ return found
100
+
101
+
102
+ def document_code(condition: dict[str, str]) -> str | None:
103
+ """Box 44 writes the certificate type and its number as one code.
104
+
105
+ An EU certificate comes out as `N851`, a Dutch national one as `1550`; the
106
+ type is the first character either way.
107
+ """
108
+ code = (condition.get("certificateCode") or "").strip()
109
+ if not code:
110
+ return None
111
+ return f"{(condition.get('certificateType') or '').strip()}{code}"
112
+
113
+
114
+ def _member(archive: zipfile.ZipFile, marker: str) -> str | None:
115
+ return next((name for name in archive.namelist() if marker in name), None)
116
+
117
+
118
+ def parse_measure_types(payload: bytes) -> dict[str, NationalMeasureType]:
119
+ root = ElementTree.fromstring(payload)
120
+ types: dict[str, NationalMeasureType] = {}
121
+ for element in root.iter(NS + "measureType"):
122
+ attributes = _attrs(element)
123
+ if attributes.get("national") != "1":
124
+ continue
125
+ type_id = attributes.get("measureType")
126
+ if not type_id:
127
+ continue
128
+ descriptions = _descriptions(element, "measureTypeDescription")
129
+ category = NATIONAL_TYPE_CATEGORY.get(
130
+ type_id,
131
+ SERIES_CATEGORY.get(
132
+ attributes.get("measureTypeSeriesId") or "", MeasureCategory.OTHER
133
+ ),
134
+ )
135
+ types[type_id] = NationalMeasureType(
136
+ typeId=type_id,
137
+ direction=TRADE_MOVEMENT.get(
138
+ attributes.get("tradeMovementCode") or "", TradeDirection.BOTH
139
+ ),
140
+ category=category,
141
+ description=descriptions.get("EN") or descriptions.get("NL"),
142
+ descriptionNl=descriptions.get("NL"),
143
+ )
144
+ return types
145
+
146
+
147
+ def parse_certificates(payload: bytes) -> list[dict[str, Any]]:
148
+ root = ElementTree.fromstring(payload)
149
+ documents: list[dict[str, Any]] = []
150
+ for element in root.iter(NS + "certificate"):
151
+ attributes = _attrs(element)
152
+ if attributes.get("national") != "1":
153
+ continue
154
+ code = document_code(
155
+ {
156
+ "certificateType": attributes.get("certificateType"),
157
+ "certificateCode": attributes.get("certificateCode"),
158
+ }
159
+ )
160
+ if not code:
161
+ continue
162
+ descriptions = _descriptions(element, "certificateDescription")
163
+ documents.append(
164
+ DocumentCodeModel(
165
+ code=code,
166
+ description=descriptions.get("EN") or descriptions.get("NL"),
167
+ descriptionNl=descriptions.get("NL"),
168
+ national=True,
169
+ validFrom=parse_date(attributes.get("dateStart")),
170
+ validTo=parse_date(attributes.get("dateEnd")),
171
+ ).to_mongo()
172
+ )
173
+ return documents
174
+
175
+
176
+ def iter_national_measures(
177
+ archive_path: Path, member: str, types: dict[str, NationalMeasureType]
178
+ ) -> Iterator[dict[str, Any]]:
179
+ """Stream the measure table, keeping only the national records.
180
+
181
+ The full snapshot's measure file is about three gigabytes once unpacked, so
182
+ it is never held in memory or written out: it is parsed straight off the
183
+ archive and each element is dropped as soon as it has been read.
184
+ """
185
+ with zipfile.ZipFile(archive_path) as archive, archive.open(member) as stream:
186
+ for _, element in ElementTree.iterparse(stream, events=("end",)):
187
+ if element.tag != NS + "measure":
188
+ continue
189
+ attributes = _attrs(element)
190
+ if attributes.get("national") != "1" or attributes.get("stoppedFlag") == "1":
191
+ element.clear()
192
+ continue
193
+
194
+ parsed = _parse_measure(element, attributes, types)
195
+ element.clear()
196
+ if parsed:
197
+ yield parsed
198
+
199
+
200
+ def _parse_measure(
201
+ element: ElementTree.Element,
202
+ attributes: dict[str, str],
203
+ types: dict[str, NationalMeasureType],
204
+ ) -> dict[str, Any] | None:
205
+ goods_code = (attributes.get("goodsNomenclatureCode") or "").strip()
206
+ type_id = (attributes.get("measureType") or "").strip()
207
+ sid = (attributes.get("SID") or "").strip()
208
+ if not goods_code or not type_id or not sid:
209
+ return None
210
+
211
+ measure_type = types.get(type_id)
212
+ conditions: list[dict[str, Any]] = []
213
+ for child in element.iter(NS + "measureCondition"):
214
+ condition = _attrs(child)
215
+ code = condition.get("conditionCodeId")
216
+ if not code:
217
+ continue
218
+ conditions.append(
219
+ MeasureConditionEmbedded(
220
+ conditionCode=code,
221
+ sequenceNumber=int(condition.get("sequenceNumber") or 1),
222
+ documentCode=document_code(condition),
223
+ actionCode=condition.get("actionCode"),
224
+ ).to_mongo()
225
+ )
226
+
227
+ return MeasureModel(
228
+ # Prefixed because the Belastingdienst numbers its own records and those
229
+ # numbers mean nothing in the TARIC publication we load beside them.
230
+ measureSid=f"dtv:{sid}",
231
+ goodsCode=goods_code,
232
+ additionalCode=(attributes.get("additionalCodeId") or "").strip() or None,
233
+ measureTypeId=type_id,
234
+ measureTypeDescription=measure_type.description if measure_type else None,
235
+ category=measure_type.category if measure_type else MeasureCategory.OTHER,
236
+ direction=measure_type.direction if measure_type else TradeDirection.BOTH,
237
+ geoAreaCode=(attributes.get("geographicalAreaId") or "").strip(),
238
+ dutyExpression=(attributes.get("expression") or "").strip() or None,
239
+ regulationId=(attributes.get("regulationId") or "").strip() or None,
240
+ national=True,
241
+ conditions=conditions,
242
+ validFrom=parse_date(attributes.get("dateStart")),
243
+ validTo=parse_date(attributes.get("dateEnd")),
244
+ ).to_mongo()
245
+
246
+
247
+ async def list_publications() -> list[tuple[str, str]]:
248
+ async with httpx.AsyncClient(
249
+ timeout=settings.IMPORTER_HTTP_TIMEOUT, follow_redirects=True
250
+ ) as client:
251
+ response = await client.get(settings.DTV_MANIFEST_URL)
252
+ response.raise_for_status()
253
+ root = ElementTree.fromstring(response.content)
254
+ return [
255
+ (entry.findtext("bestand") or "", entry.findtext("url") or "")
256
+ for entry in root.iter("download")
257
+ if entry.findtext("url")
258
+ ]
259
+
260
+
261
+ async def resolve_latest_snapshot() -> tuple[str, str] | None:
262
+ """Pick the newest full snapshot without downloading any of them.
263
+
264
+ The manifest lists nightly increments and full snapshots side by side with
265
+ nothing to tell them apart but their size, which the server reports.
266
+ """
267
+ publications = await list_publications()
268
+ async with httpx.AsyncClient(
269
+ timeout=settings.IMPORTER_HTTP_TIMEOUT, follow_redirects=True
270
+ ) as client:
271
+ for name, url in reversed(publications):
272
+ try:
273
+ head = await client.head(url)
274
+ except httpx.HTTPError:
275
+ continue
276
+ size = int(head.headers.get("content-length") or 0)
277
+ if size >= settings.DTV_FULL_SNAPSHOT_MIN_BYTES:
278
+ return name, url
279
+ return None
280
+
281
+
282
+ async def import_national_measures(
283
+ archive_path: Path, file_name: str, mode: SyncMode = SyncMode.FULL
284
+ ) -> SyncRun:
285
+ async with SyncRun(SyncSource.DTV_NATIONAL, mode, file_name=file_name) as run:
286
+ run.log.fileHash = file_hash_path(archive_path)
287
+ with zipfile.ZipFile(archive_path) as archive:
288
+ type_member = _member(archive, "/MeasureType_")
289
+ certificate_member = _member(archive, "/Certificate_")
290
+ measure_member = _member(archive, "/Measure_")
291
+
292
+ if not measure_member or not type_member:
293
+ raise ValueError(
294
+ "This archive holds no full measure table — it is a nightly "
295
+ "increment, not a snapshot."
296
+ )
297
+
298
+ types = parse_measure_types(archive.read(type_member))
299
+ certificates = (
300
+ parse_certificates(archive.read(certificate_member))
301
+ if certificate_member
302
+ else []
303
+ )
304
+
305
+ run.log.note = f"{len(types)} national measure types."
306
+
307
+ await run.write(
308
+ MeasureTypeModel,
309
+ [
310
+ MeasureTypeModel(
311
+ typeId=item.typeId,
312
+ description=item.description,
313
+ descriptionNl=item.descriptionNl,
314
+ category=item.category,
315
+ direction=item.direction,
316
+ ).to_mongo()
317
+ for item in types.values()
318
+ ],
319
+ ["typeId"],
320
+ )
321
+
322
+ if certificates:
323
+ await run.write(DocumentCodeModel, certificates, ["code", "validFrom"])
324
+
325
+ batch: list[dict[str, Any]] = []
326
+ for measure in iter_national_measures(archive_path, measure_member, types):
327
+ batch.append(measure)
328
+ if len(batch) >= 2000:
329
+ await run.write(MeasureModel, batch, ["measureSid"])
330
+ batch = []
331
+ if batch:
332
+ await run.write(MeasureModel, batch, ["measureSid"])
333
+
334
+ return run
335
+
336
+
337
+ async def refresh_national_measures(
338
+ mode: SyncMode = SyncMode.FULL, keep_archive: bool = False
339
+ ) -> DtvRefresh:
340
+ refresh = DtvRefresh()
341
+
342
+ resolved = await resolve_latest_snapshot()
343
+ if resolved is None:
344
+ refresh.note = (
345
+ "No full snapshot found in the Gebruikstarief feed; only increments "
346
+ f"are listed at {settings.DTV_MANIFEST_URL}."
347
+ )
348
+ return refresh
349
+
350
+ refresh.fileName, refresh.url = resolved
351
+ archive_path = settings.REFERENCE_STORAGE_DIR / "dtv_national" / refresh.fileName
352
+ await download_to(refresh.url, archive_path)
353
+
354
+ try:
355
+ if await latest_loaded_hash(SyncSource.DTV_NATIONAL) == file_hash_path(
356
+ archive_path
357
+ ):
358
+ refresh.note = "Unchanged since the last successful import; nothing loaded."
359
+ return refresh
360
+
361
+ refresh.runs.append(
362
+ await import_national_measures(archive_path, refresh.fileName, mode)
363
+ )
364
+ finally:
365
+ if not keep_archive:
366
+ archive_path.unlink(missing_ok=True)
367
+
368
+ return refresh