Spaces:
Sleeping
Sleeping
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
- .gitignore +6 -0
- README.md +89 -11
- cargoflow-codecontrole.html +23 -0
- cbh/__init__.py +3 -32
- cbh/api/account/__init__.py +0 -12
- cbh/api/account/dto.py +0 -38
- cbh/api/account/models.py +0 -124
- cbh/api/account/schemas.py +0 -11
- cbh/api/account/utils.py +0 -36
- cbh/api/account/views.py +0 -44
- cbh/api/batch/__init__.py +0 -8
- cbh/api/batch/db_requests.py +1 -18
- cbh/api/batch/dto.py +35 -28
- cbh/api/batch/models.py +29 -47
- cbh/api/batch/schemas.py +162 -22
- cbh/api/batch/services/excel/__init__.py +15 -3
- cbh/api/batch/services/excel/export.py +258 -0
- cbh/api/batch/services/excel/reader.py +80 -68
- cbh/api/batch/services/excel/writer.py +113 -93
- cbh/api/batch/services/metrics/__init__.py +13 -2
- cbh/api/batch/services/metrics/metrics.py +34 -78
- cbh/api/batch/services/metrics/recompute.py +66 -0
- cbh/api/batch/services/metrics/totals.py +151 -0
- cbh/api/batch/services/processor.py +145 -50
- cbh/api/batch/utils.py +7 -42
- cbh/api/batch/views.py +513 -112
- cbh/api/client/__init__.py +0 -7
- cbh/api/client/db_requests.py +26 -24
- cbh/api/client/dto.py +15 -30
- cbh/api/client/models.py +172 -66
- cbh/api/client/schemas.py +0 -16
- cbh/api/client/utils.py +0 -20
- cbh/api/client/views.py +5 -47
- cbh/api/common/db_requests.py +10 -163
- cbh/api/common/dto.py +67 -75
- cbh/api/common/models.py +0 -36
- cbh/api/common/schemas.py +1 -70
- cbh/api/common/services/cache/__init__.py +2 -18
- cbh/api/common/services/cache/cache.py +3 -54
- cbh/api/common/utils.py +3 -60
- cbh/api/importer/__init__.py +0 -7
- cbh/api/importer/db_requests.py +0 -26
- cbh/api/importer/dto.py +5 -38
- cbh/api/importer/models.py +0 -19
- cbh/api/importer/schemas.py +17 -23
- cbh/api/importer/services/base.py +36 -35
- cbh/api/importer/services/codeboek/codeboek.py +7 -45
- cbh/api/importer/services/demo/demo.py +54 -123
- cbh/api/importer/services/dtv/__init__.py +15 -0
- 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` |
|
|
|
|
| 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 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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(
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
"""
|
| 41 |
-
Where a pack stands in human review.
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
"""
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 .
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
__all__ = [
|
| 5 |
"ParsedRow",
|
| 6 |
"ParsedSheet",
|
|
|
|
| 7 |
"build_output_workbook",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"parse_pack",
|
| 9 |
"read_headers",
|
| 10 |
-
"
|
| 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
|
| 54 |
-
"""
|
| 55 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
|
|
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
| 65 |
OutputField.WARNINGS: lambda: render_warnings(result),
|
| 66 |
-
OutputField.STATUS: lambda:
|
| 67 |
OutputField.CODE_DESCRIPTION: lambda: result.codeDescription or "",
|
| 68 |
-
OutputField.CANDIDATES: lambda:
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 =
|
| 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 =
|
| 155 |
if fill is None:
|
| 156 |
continue
|
| 157 |
-
|
| 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 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
if not totals:
|
| 181 |
return
|
| 182 |
|
| 183 |
totals_row = last_data_row + 1
|
| 184 |
-
|
| 185 |
-
header: index + 1
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 20 |
from cbh.api.client.models import ClientProfileModel
|
| 21 |
-
from cbh.api.common.db_requests import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
| 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 =
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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),
|
| 142 |
-
await insert_objs(rows[start : start +
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
from cbh.api.client.db_requests import get_profile
|
| 21 |
-
from cbh.api.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
from cbh.api.common.schemas import AllObjectsResponse
|
| 23 |
-
from cbh.
|
| 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 |
-
|
|
|
|
| 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("/
|
| 88 |
-
async def
|
| 89 |
-
|
| 90 |
-
account: AccountModel = Depends(PermissionDependency(ANY_ACCOUNT)),
|
| 91 |
) -> CbhResponseWrapper[BatchResponse]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
"""
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 140 |
data=[_to_response(batch) for batch in batches],
|
| 141 |
)
|
| 142 |
)
|
| 143 |
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
)
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 174 |
data=[
|
| 175 |
BatchRowResponse(
|
| 176 |
rowIndex=row.rowIndex,
|
|
@@ -185,17 +331,306 @@ async def list_batch_rows(
|
|
| 185 |
)
|
| 186 |
|
| 187 |
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
batchId: str,
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
"""
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
batch = await get_batch(batchId)
|
| 197 |
if not batch.resultFile:
|
| 198 |
-
raise HTTPException(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 29 |
-
|
| 30 |
"""
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-
name: str
|
| 113 |
-
exportProfile: ExportProfileType
|
| 114 |
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
"""
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
"""
|
| 124 |
return ClientProfileModel(
|
| 125 |
-
name="
|
| 126 |
-
description="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
exportProfile=ExportProfileType.SOFTPAK,
|
| 128 |
columnMapping={
|
| 129 |
-
InputField.CODE.value: "
|
| 130 |
-
InputField.
|
| 131 |
-
InputField.
|
| 132 |
-
InputField.
|
| 133 |
-
InputField.
|
| 134 |
-
InputField.
|
| 135 |
-
InputField.
|
| 136 |
-
InputField.
|
| 137 |
-
InputField.
|
| 138 |
-
InputField.VALUE.value: "Waarde",
|
| 139 |
},
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 8 |
|
| 9 |
-
from cbh.api.
|
| 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=
|
| 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
|
| 132 |
model: T,
|
| 133 |
-
filter_: dict,
|
| 134 |
page_size: int,
|
| 135 |
page_index: int,
|
| 136 |
-
sort: tuple[str, int],
|
| 137 |
-
|
|
|
|
| 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":
|
| 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
|
| 46 |
-
"""
|
| 47 |
-
Search filter model for constructing database queries.
|
| 48 |
-
"""
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 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 |
-
|
| 69 |
-
|
|
|
|
|
|
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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 |
-
|
| 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 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 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
|
| 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 |
-
|
| 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,
|
| 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
|
| 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(
|
| 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(
|
| 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
|
| 63 |
-
|
| 64 |
-
|
| 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
|
| 113 |
-
"""
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
latest = await settings.DB_CLIENT.synclogs.find_one(
|
| 120 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
sort=[("finishedAt", -1)],
|
| 122 |
projection={"_id": 0, "fileHash": 1},
|
| 123 |
)
|
| 124 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
| 64 |
GeoAreaModel(
|
| 65 |
-
code=code, name=name, areaType=GeoAreaType.COUNTRY, validFrom=
|
| 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=
|
| 76 |
-
)
|
| 77 |
-
)
|
| 78 |
-
areas.append(
|
| 79 |
GeoAreaModel(
|
| 80 |
code="1013",
|
| 81 |
name="European Union",
|
| 82 |
areaType=GeoAreaType.GROUP,
|
| 83 |
memberCodes=["NL", "DE"],
|
| 84 |
-
validFrom=
|
| 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=
|
| 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 |
-
|
| 107 |
-
("
|
| 108 |
-
("
|
| 109 |
-
("
|
| 110 |
-
("
|
| 111 |
-
("
|
| 112 |
-
("
|
| 113 |
-
|
| 114 |
-
("
|
| 115 |
-
("
|
| 116 |
-
("
|
| 117 |
-
("
|
| 118 |
-
("
|
| 119 |
-
("
|
| 120 |
-
|
| 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 |
-
|
| 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=
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 192 |
),
|
| 193 |
AdditionalCodeModel(
|
| 194 |
codeType="8",
|
| 195 |
code="B001",
|
| 196 |
fullCode="8B001",
|
| 197 |
description="Named cooperating producer (reduced rate)",
|
| 198 |
-
validFrom=
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 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=
|
| 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 |
-
(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|