fix: support bilingual Booking Excel headers
This commit is contained in:
@@ -47,7 +47,7 @@
|
||||
|
||||
| Step | Source | Destination | Gate |
|
||||
|---|---|---|---|
|
||||
| 1 | Authenticated Booking XLSX upload | Bounded parser 2.0 | Requires Tour Code + exact `โรงแรม`, latest-row/cancellation semantics and room-item extraction/review |
|
||||
| 1 | Authenticated Booking XLSX upload | Bounded parser 2.1 | Requires one approved exact Tour Code/Group Code alias plus one approved exact hotel-detail alias on the same row; preserves latest-row/cancellation semantics and room-item extraction/review |
|
||||
| 2 | Parsed raw items | Private artifact + extraction draft | Live migration 015 preserves source coordinates, confirmed/pending/deleted item state and extracted values |
|
||||
| 3 | Human review | Draft item updates | Confirm/edit or atomically soft-delete 1-50 unique selected items while canonical current Booking source remains unchanged |
|
||||
| 4 | Fully reviewed draft | Accepted canonical batch + `booking.current_source_batch` | Live 014/015 plus repository zero-pending/atomic activation; real PostgreSQL transaction-only vertical slice passes |
|
||||
@@ -68,4 +68,4 @@ equals its persisted `as_of_date`. Every data-row `TOTAL PRICE` cell is `=R[row]
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-08-06
|
||||
2026-08-11
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|---|---|---|
|
||||
| `arr_web/auth.py` | Login credential verification and bounded attempt ledger | Environment-owned single-operator identity; dependency-free constant-time comparison; no secret logging |
|
||||
| `arr_web/programmatic.py` | XML upload and manual-price finalization coordinator | Registers the validated browser basename separately from canonical `source.xml`; pure price misses create no Finance version, while a frozen manifest replays the original source for final commit |
|
||||
| `arr_web/booking_uploads.py`, `booking_ingestion/excel.py` | Authenticated Booking source upload and bounded raw XLSX parsing | Parser 2.0 reads Tour Code plus exact `โรงแรม`, latest-row cancellations and extracted room items with review state; coordinator/backend route contracts target drafts |
|
||||
| `booking_ingestion/excel_postgres.py` | Earlier direct Booking full-source import/activation path | Its one-item immediate-accept implementation does not represent parser 2.0 review and must not be treated as the completed review repository |
|
||||
| `arr_web/booking_uploads.py`, `booking_ingestion/excel.py` | Authenticated Booking source upload and bounded raw XLSX parsing | Parser 2.1 uses approved full-cell exact aliases for Tour Code/Group Code and hotel detail (including legacy Thai and current bilingual forms), latest-row cancellations and extracted room items with review state; coordinator/backend route contracts target drafts |
|
||||
| `booking_ingestion/excel_postgres.py` | Earlier direct Booking full-source import/activation path | Its one-item immediate-accept implementation does not represent parser 2.1 review and must not be treated as the completed review repository |
|
||||
| `booking_ingestion/excel_review.py`, `booking_ingestion/excel_review_postgres.py` | Editable extraction drafts and atomic reviewed-source activation | Single-operator repository uses advisory locking and a zero-pending gate; real PostgreSQL transaction/rollback acceptance passes; actor/reason/revision history is not implemented |
|
||||
| `arr_web/processing_runtime.py` | PostgreSQL + OSS + processor composition | Active production processing composition |
|
||||
| `arr_processing/local.py` | Bounded subprocess and output manifest extraction | Never exposes stdout/stderr |
|
||||
@@ -37,4 +37,4 @@ by the active ARR2.0 entrypoint.
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-08-06
|
||||
2026-08-11
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## Current Architecture
|
||||
|
||||
ARR2.0 accepts Opera XML plus a separate Booking Tour Code/`โรงแรม` workbook and owns both deterministic processing
|
||||
ARR2.0 accepts Opera XML plus a separate Booking workbook recognized through approved exact Tour Code/Group Code and
|
||||
hotel-detail header aliases, and owns both deterministic processing
|
||||
lifecycles. It writes immutable private
|
||||
objects, runs the frozen processor locally, independently validates the complete artifacts, and atomically commits
|
||||
accepted facts to PostgreSQL. A pure `PRICE_UNMATCHED` result instead enters an authenticated, auditable manual-price
|
||||
@@ -21,7 +22,7 @@ from committed `ARRIVAL` facts, publishes a validated workbook, and records meta
|
||||
| `arr_storage` | Immutable staged/committed OSS workflow; all object ACLs private |
|
||||
| `arr_ingestion.validation` | Strict v3/v4 delivery/result/structured validation plus independent daily/review-manifest replay |
|
||||
| `arr_ingestion.postgres` | Job/review lifecycle, artifact registration, atomic final Finance version commit and outbox |
|
||||
| `booking_ingestion.excel` | Bounded Tour Code/`โรงแรม` extraction, latest-row/cancellation semantics and room-item normalization |
|
||||
| `booking_ingestion.excel` | Bounded exact-header Booking extraction, latest-row/cancellation semantics and room-item normalization |
|
||||
| `booking_ingestion.excel_review_postgres` | Durable editable draft state and zero-pending atomic current-source activation |
|
||||
| `arr_web.booking_uploads` | Private Booking workbook upload, draft review orchestration and privacy-minimized source summaries |
|
||||
| `arr_web.job_trace` | Read-only programmatic trace from persisted run/attempt/delivery/version/outbox facts |
|
||||
@@ -57,4 +58,4 @@ from committed `ARRIVAL` facts, publishes a validated workbook, and records meta
|
||||
|
||||
## Last Updated
|
||||
|
||||
2026-08-06
|
||||
2026-08-11
|
||||
|
||||
@@ -19,6 +19,18 @@ preserves its no-formula contract. Both publishers upload new workbook and `resu
|
||||
immutable OSS adapter, while download routing retains controlled-local compatibility for historical records and keeps
|
||||
`.web-jobs` queue state local.
|
||||
|
||||
## Implemented Locally On 2026-08-11
|
||||
|
||||
- Implemented Booking parser 2.1.0 exact-header compatibility for the user-confirmed July legacy and August bilingual
|
||||
source templates. Readable approved header labels are normalized once and then compared as complete cells; the
|
||||
parser does not use substring, fuzzy, month, filename or worksheet-name matching. Existing aliases, 20-row header
|
||||
scan, duplicate-header failure and one-file/full-replacement activation semantics remain unchanged.
|
||||
- Added parser/coordinator/i18n regressions. Four supplied workbooks now parse only their intended source sheets with
|
||||
the expected 79/111/16 (Lian Tai July), 67/100/16 (Lian Tai August), 235/447/432 (QBD July) and 104/218/212 (QBD
|
||||
August) rows/items/pending counts; two Daily/channel output workbooks remain rejected. No workbook, OSS object,
|
||||
draft, Booking batch, Finance fact or runtime process was changed. Deployment and a controlled post-restart
|
||||
read-only replay remain pending; QBD pending items are existing room-type review work, not a header failure.
|
||||
|
||||
## Runtime Recovery On 2026-08-06
|
||||
|
||||
- Restored the local ARR login page after a reported opening failure. No Web process was listening and the retained
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
| Date | Task | Outcome | Docs Updated |
|
||||
|---|---|---|---|
|
||||
| 2026-08-11 | Implement Lian Tai/QBD bilingual Booking-header compatibility | Bumped the bounded parser to 2.1.0, converted readable approved header labels into the existing exact normalized allowlist, preserved legacy aliases and fail-closed ambiguity, and added Chinese/English/Thai header-error mapping. Synthetic parser/coordinator tests, all four supplied workbook replays and wrong-report negatives pass; no API, migration, upload, draft, source activation or runtime deployment occurred | README, architecture/data-flow/business rules, current state, [header-parse evidence](../50-evidence/topics/2026-08-11-company-channel-booking-header-parse-failure.md), evidence index, stale deployment item |
|
||||
| 2026-08-06 | Repair manual-review navigation/progress and add English/Thai | Merged the status chip and progress into one review action that focuses the operation panel while row clicks still own logs; removed the frozen-case sentence; set upload status to `日报生成中` before finalization; localized the complete review UI and review API errors. Live browser QA covered zh/en/th and dialog copy without submitting a business action. Full suite: 393 passed, 3 intentional skips | Current state, success criteria, module map, [UI evidence](../50-evidence/topics/2026-08-06-daily-review-ui-navigation-i18n.md), scoped planning record |
|
||||
| 2026-08-06 | Use integer review prices and repair `请求未完成` finalization | Changed review display/PATCH input to non-negative integer text while retaining canonical `.00` storage and replay. Bound the visible failure to runs 66/67, repaired a 21-placeholder/20-value Finance INSERT and the missing `manual_override_json` artifact kind through additive migration 018, then proved the full PostgreSQL finalization path in an outer rollback. Web PID 26286 is healthy; latest run 67 remains explicitly retryable with frozen `200/0` and no Finance/outbox mutation. Full suite: 387 passed, 3 intentional skips | ADR/domain/architecture/current state, migration/deployment ledgers, [repair evidence](../50-evidence/topics/2026-08-06-integer-review-finalize-repair.md), scoped planning record |
|
||||
| 2026-08-06 | Deploy migration 017 and restore live `0805.XML` manual review | Reconciled already-live 016 semantics, corrected a legacy run-constraint drift found by rollback probe, formally applied 017 with unchanged business counts, closed runs 61–63 as infrastructure failures, then fixed a 16-value/15-placeholder delivery INSERT exposed by the first acceptance upload. Restarted Web and proved run 65 opens the exact two-key review panel at revision 0 with no prices, daily XLSX, Finance version or daily-commit/monthly event. Historical version 28 remains rejected | Migration ledger, current state, [live rollout evidence](../50-evidence/topics/2026-08-06-live-daily-price-review-rollout.md), SHA-protected runtime checkpoints, scoped planning record |
|
||||
|
||||
@@ -49,8 +49,13 @@
|
||||
- The company-report Booking allocation model consists of normalized Group Code, room type and quantity. A raw intake
|
||||
adapter may derive these fields from a source workbook, but raw workbook columns and normalized result dimensions are
|
||||
separate contracts; worksheet, physical row, artifact hash and parse version remain provenance.
|
||||
- A raw Booking workbook is recognized only on a worksheet containing one Tour Code/Group Code header and the exact
|
||||
Thai `โรงแรม` header. Tour Codes are Unicode-normalized and all whitespace is removed before matching.
|
||||
- A raw Booking workbook is recognized only on a worksheet containing exactly one approved Tour Code/Group Code header
|
||||
and exactly one approved hotel-detail header on the same row. Approved Tour Code labels are `Tour Code`, `Group Code`,
|
||||
`Gourp Code`, `团号`, `团队代码` and `Tour Code / 主团号`; approved hotel-detail labels are `โรงแรม`,
|
||||
`วางข้อมูลที่นี่ / 酒店明细` and `Raw Hotel Detail / วางข้อมูลที่นี่ / 酒店明细`. Header cells are
|
||||
Unicode/case/whitespace/punctuation-normalized before whole-cell exact comparison; substring, fuzzy, filename,
|
||||
month and worksheet-name matching are forbidden. Tour Codes are Unicode-normalized and all whitespace is removed
|
||||
before matching.
|
||||
- For repeated Tour Codes, the physically last source row replaces earlier rows. A final row with an explicit textual
|
||||
cancellation removes the Tour Code; cell color or other formatting alone never means cancellation.
|
||||
- Room extraction reads parenthesized hotel segments. A `【label】` token takes the following integer as quantity and
|
||||
@@ -79,4 +84,4 @@
|
||||
|
||||
## Last Reviewed
|
||||
|
||||
2026-08-06
|
||||
2026-08-11
|
||||
|
||||
@@ -4,6 +4,7 @@ Use this index for searchable, traceable evidence records.
|
||||
|
||||
| Date | Topic | Status | Source | Detail |
|
||||
|---|---|---|---|---|
|
||||
| 2026-08-11 | Company-channel Booking Excel parse failure | Parser 2.1.0 implemented locally; deployment pending | [Evidence topic](topics/2026-08-11-company-channel-booking-header-parse-failure.md) | The user confirmed both supplied formats must be accepted. Parser 2.1.0 preserves exact whole-cell matching while accepting the three approved bilingual aliases; synthetic/coordinator tests, four supplied workbook replays and wrong-report negatives pass. Separate-file append remains outside the singleton full-source model. |
|
||||
| 2026-08-06 | Daily review navigation, progress and three-language UI | Implemented and runtime-active | [Evidence topic](topics/2026-08-06-daily-review-ui-navigation-i18n.md) | The whole needs-review status now opens/focuses the operation panel without opening logs; the unwanted retry sentence is removed; finalization sets `日报生成中` before its request; review UI/API errors are localized in Chinese, English and Thai. Live read-only browser QA and 393 tests pass; no price/finalize/cancel/upload mutation was invoked. |
|
||||
| 2026-08-06 | Integer review prices and finalization repair | Deployed repair; run-67 checkpoint is historical | [Evidence topic](topics/2026-08-06-integer-review-finalize-repair.md) | Review amounts now display/accept integers and normalize to canonical `.00`. Migration 018 fixes the omitted manual-manifest artifact kind and the Finance INSERT now has 20 binds; rollback probes, live readiness and the then-current 387-test suite passed. Run 67 was still frozen at `200/0` when that repair ended; later browser-visible lifecycle state is recorded in the UI follow-up topic. |
|
||||
| 2026-08-06 | Live Daily price-review rollout and `0805.XML` acceptance | Historical initial-review snapshot; superseded by repair topic | [Evidence topic](topics/2026-08-06-live-daily-price-review-rollout.md) | Reconciled live 016 semantics without rerunning it, corrected and applied 017 after an exact up/down rollback probe, closed runs 61–63, fixed a delivery-placeholder runtime bug, and proved run 65 reached the two-key live panel with no Finance/report/outbox effect. Later runs and current retry instructions are in the integer/finalization repair topic. |
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Company-channel Booking Excel header parse failure
|
||||
|
||||
## Metadata
|
||||
|
||||
- Date: 2026-08-11
|
||||
- Status: Parser 2.1.0 compatibility implemented locally and verified; deployment pending
|
||||
- Scope: company-channel Booking-source parser 2.1.0, exact supplied Lian Tai/QBD July and August workbooks,
|
||||
wrong-input controls and existing one-file/full-replacement source lifecycle
|
||||
- Confidence: Fact for all supplied workbooks, synthetic regressions and parser/coordinator behavior; the original
|
||||
rejected request did not durably record its selected filename
|
||||
- Source: spreadsheet metadata/header-only renders, parser/upload/source-lifecycle inspection, exact-parser replay,
|
||||
focused/full tests and read-only PostgreSQL snapshot
|
||||
- Last verified: 2026-08-11 local workspace
|
||||
- Stale trigger: Booking Excel header matching, source-upload persistence or accepted source batch changes
|
||||
|
||||
## Observed state
|
||||
|
||||
- The deployed ARR service on public port 8765 remained healthy and protected authenticated APIs normally.
|
||||
- A read-only `booking_test` snapshot found no review draft, Booking Excel artifact or source batch created after
|
||||
2026-08-03. Accepted batch 7, `july-test.xlsx`, remains current with 72 source rows and 72 distinct Group Codes.
|
||||
- `ProgramBookingSourceCoordinator.submit` parses the workbook before uploading it to object storage or creating a
|
||||
review draft. A header rejection therefore leaves no durable upload identity and cannot replace the current source.
|
||||
|
||||
## Deterministic reproduction
|
||||
|
||||
The exact production function `booking_ingestion.excel.parse_excel_bytes` was run against four recently modified
|
||||
workbooks without changing them. Each returned:
|
||||
|
||||
`BOOKING_EXCEL_HEADERS_MISSING — Excel 中未找到同时包含 Tour Code 和โรงแรม的工作表`
|
||||
|
||||
- `8.4 (1).xlsx` and `各渠道情况-2026年08月-更新至8.5.xlsx` contain report-output columns such as `BLOCK_CODE` and
|
||||
`RATE_CODE`, not the Booking-source columns. If either was selected, rejection is correct because it is the wrong
|
||||
input type.
|
||||
- The Lian Tai source sheet has `Tour Code / 主团号` and `วางข้อมูลที่นี่ / 酒店明细`.
|
||||
- The QBD source sheet has `Tour Code / 主团号` and `Raw Hotel Detail / วางข้อมูลที่นี่ / 酒店明细`.
|
||||
|
||||
The parser normalizes punctuation and whitespace, then requires the entire normalized cell to equal one allowlisted
|
||||
alias. Its Tour Code aliases are `TOURCODE`, `GROUPCODE`, `GOURPCODE`, `团号` and `团队代码`; the hotel alias is only
|
||||
the exact Thai `โรงแรม`. The bilingual cells normalize to concatenated text and match neither set.
|
||||
|
||||
An in-memory-only copy of each raw workbook was then given exact `Tour Code` and `โรงแรม` headers, with every other
|
||||
cell unchanged:
|
||||
|
||||
- Lian Tai parsed successfully: 67 source rows, 100 extracted items and 16 pending review items.
|
||||
- QBD parsed successfully: 129 source rows, 252 extracted items and 245 pending review items.
|
||||
|
||||
Changing only the hotel header was insufficient because the bilingual Tour Code header also fails exact matching.
|
||||
These results isolate a parser/template compatibility gap rather than corrupt XLSX bytes, database failure or
|
||||
company-report publication failure.
|
||||
|
||||
## Exact supplied-template compatibility experiment
|
||||
|
||||
The user explicitly confirmed that both supplied formats must be accepted. Their exact operational header cells are:
|
||||
|
||||
- Lian Tai `วางข้อมูล(BOOKING AUG.26)`: `A2 = Tour Code / 主团号`,
|
||||
`D2 = วางข้อมูลที่นี่ / 酒店明细`.
|
||||
- QBD `2026年8月|AUGUST_26`: `C3 = Tour Code / 主团号`,
|
||||
`E3 = Raw Hotel Detail / วางข้อมูลที่นี่ / 酒店明细`.
|
||||
|
||||
A process-memory-only experiment retained exact whole-cell matching and added only the three corresponding normalized
|
||||
aliases: `TOURCODE主团号`, `วางข้อมูลที่นี่酒店明细` and
|
||||
`RAWHOTELDETAILวางข้อมูลที่นี่酒店明细`.
|
||||
|
||||
- The unchanged Lian Tai file parsed only its intended source sheet: 67 rows, 100 items and 16 pending.
|
||||
- The unchanged QBD 2026-08-06 file parsed only its intended source sheet: 104 rows, 218 items and 212 pending.
|
||||
- AUTO_CALC, dashboard, summary, guide and daily-usage sheets were not recognized as data sheets.
|
||||
- `8.4 (1).xlsx` and `各渠道情况-2026年08月-更新至8.5.xlsx` remained rejected with
|
||||
`BOOKING_EXCEL_HEADERS_MISSING` under the same experiment.
|
||||
- The focused current parser/coordinator baseline passed 14/14 tests.
|
||||
|
||||
This establishes that broad substring or fuzzy matching is unnecessary. Readable raw alias labels normalized into an
|
||||
exact allowlist are sufficient and preserve the wrong-report-input boundary.
|
||||
|
||||
## Local implementation and verification
|
||||
|
||||
- `booking_ingestion.excel` now reports parser version `2.1.0` and keeps readable raw labels for all approved legacy
|
||||
and bilingual header forms. It normalizes those labels into the pre-existing exact full-cell allowlist; no filename,
|
||||
month, worksheet-name, substring or fuzzy fallback was added.
|
||||
- Missing and duplicate header failures now describe `Tour Code/主团号` and `酒店明细` neutrally, and the Web error
|
||||
catalog maps both stable codes into Chinese, English and Thai.
|
||||
- New synthetic parser tests prove both Lian Tai and QBD bilingual forms work at their real offset rows, unrelated
|
||||
`AUTO_CALC` sheets are ignored, similar-but-longer labels fail, and duplicate recognized labels fail closed.
|
||||
Coordinator tests prove a valid bilingual upload reaches private storage/draft creation while an unrecognized
|
||||
bilingual label reaches neither storage nor draft persistence.
|
||||
- The unchanged supplied files replay under parser 2.1.0 as follows: Lian Tai July `79/111/16`, Lian Tai August
|
||||
`67/100/16`, QBD July `235/447/432` and QBD August `104/218/212` for rows/extracted items/pending items. The two
|
||||
wrong report-output workbooks remain `BOOKING_EXCEL_HEADERS_MISSING`. Focused tests and full test discovery pass.
|
||||
|
||||
## Impact and repair boundary
|
||||
|
||||
The failed request stopped before object-store registration and draft persistence, so no Booking source, Finance fact,
|
||||
company report or downstream event changed. The current accepted batch remains available.
|
||||
|
||||
The implemented Booking parser 2.1.0 behavior normalizes the three readable full-header labels into the existing
|
||||
exact allowlist, retains all current aliases, adds synthetic Lian Tai/QBD plus wrong-input and ambiguity regressions,
|
||||
and updates neutral error copy/current-contract documentation. The existing rule-set hash already includes aliases and
|
||||
version, and draft/run/parse lineage stores both, so no database migration is needed.
|
||||
|
||||
The upload endpoint and UI accept one workbook, and activation switches the singleton full Booking source. Supporting
|
||||
both header formats therefore does not mean that two separate uploads append or remain active together; multi-file
|
||||
merge would be a separate product change. The high QBD pending count also remains an operational review workload after
|
||||
header acceptance. No workbook, database, object-store object or runtime process was changed during implementation;
|
||||
deployment and any real upload/review/activation remain separate authorized actions.
|
||||
@@ -5,7 +5,8 @@
|
||||
| Date | Document / Module | Issue | Next Action |
|
||||
|---|---|---|---|
|
||||
| 2026-08-06 | Daily `PRICE_UNMATCHED` final Finance acceptance / business date 2026-08-05 | Integer UI/API and both finalization defects are repaired and deployed: migration 018 admits the immutable manifest artifact, the Finance INSERT bind count is exact, and a real PostgreSQL rollback slice passes. Latest run 67 is still revision-2 `generation_failed` with frozen GRP1/LBLT `200/0`; it has no Finance version or run outbox. Run 66 separately retains `2300/0`, run 65 remains unset, and rejected version 28 remains immutable | Refresh/login, open latest run 67, verify `200/0` are the intended approved values, then explicitly retry generation once. Do not retry run 66 by mistake. After success, prove 153 retained rows with 2 `manual_review` rows, one active Finance version, matching frozen-manifest SHA and exactly one new final daily-commit/monthly event; do not modify version 28 |
|
||||
| 2026-07-31 | Booking raw Excel parser/review business acceptance | Parser 2.0, migrations 014/015, atomic draft repository, routes, editor, HTTP transport and current runtime are ready. Supplied-workbook replay, 55 expanded tests, live 5/5 processing and a real-PostgreSQL create/edit/activate/rollback slice pass. No authorized real workbook has replaced the current source | Run one operator-authorized complete-workbook upload/review/activation, then one released five-company job and verify all downloads |
|
||||
| 2026-08-11 | `booking_ingestion.excel` header compatibility deployment | Parser 2.1.0 now supports the approved legacy Thai and bilingual Lian Tai/QBD full headers through exact normalized aliases; synthetic/coordinator tests, four exact workbook replays and wrong-report rejections pass. The UI remains one-file and activation replaces the singleton full source rather than appending separate files | Deploy/restart Web, verify authenticated readiness, then run the four supplied files through a read-only parser replay before any separately authorized upload/review/activation. Treat multi-file append as a separate product decision; QBD's pending room-type items still require the existing manual review workflow |
|
||||
| 2026-07-31 | Booking raw Excel parser/review business acceptance | Parser 2.1, migrations 014/015, atomic draft repository, routes, editor, HTTP transport and current runtime are ready. Supplied-workbook replay, expanded tests, live 5/5 processing and a real-PostgreSQL create/edit/activate/rollback slice pass. No authorized real workbook has replaced the current source | Run one operator-authorized complete-workbook upload/review/activation, then one released five-company job and verify all downloads |
|
||||
| 2026-07-31 | Booking review audit depth | Migration 015 stores current confirmed/pending/deleted state but no reviewer, reason, explicit review event, old/new values or immutable revision history. Repository logic serializes and gates activation, but declarative DB guards are limited and same-artifact re-extraction deletes earlier unactivated edits | Confirm whether single-operator latest-state editing is sufficient. If review must be auditable, add actor/reason/event or revision tables, optimistic concurrency and DB transition guards before migration acceptance |
|
||||
| 2026-07-31 | Booking Excel current-source runtime acceptance | Current port 8766 loads the review composition with all readiness flags true; migrations 014/015 are recorded and `booking.current_source_batch` still selects Markdown batch 1. A 14:14 +08 read-only recheck found no reviewing draft; the prior draft is no longer an active task-creation blocker and did not switch the current source. The Markdown five-workbook baseline has been frozen with exact hashes | Run one Markdown-backed five-company Web job after the July period completes if a final-period snapshot is desired, then verify downloads. Keep any later complete-workbook activation operator-authorized, then compare its five overlapping Group Codes with the frozen baseline |
|
||||
| 2026-07-31 | Booking Group Code uniqueness invariant | Current views globally merge the same Group Code across worksheets. The fixture has 23 codes shared by `DY-AI-Easy-KB` and `LIANTAI-FIT`; none is used by current Finance, so current output is unaffected but real-source semantics are not confirmed | Confirm whether those rows are intentionally additive. If Group Code can vary by channel or stay segment, add explicit business dimensions to the Excel contract, schema and report join before real imports |
|
||||
|
||||
@@ -69,7 +69,7 @@ worker 是独立无端口进程,应由进程管理器单独保活。
|
||||
|
||||
## Booking Excel 房型提取
|
||||
|
||||
“公司渠道明细”页可以上传原始 `.xlsx`,程序从同一工作表的 `Tour Code` 与精确泰文表头 `โรงแรม` 提取房型记录。提取结果先进入草稿,不会直接覆盖当前 Booking 数据:
|
||||
“公司渠道明细”页可以上传原始 `.xlsx`。程序只在同一工作表中识别一组完整、精确的 Tour Code/Group Code 与酒店明细表头后提取房型记录:Tour Code 可为 `Tour Code`、`Group Code`、`Gourp Code`、`团号`、`团队代码` 或 `Tour Code / 主团号`;酒店明细可为 `โรงแรม`、`วางข้อมูลที่นี่ / 酒店明细` 或 `Raw Hotel Detail / วางข้อมูลที่นี่ / 酒店明细`。表头经过 Unicode、大小写、空白和标点归一化后仍须整格精确相等,不按月份、文件名、工作表名、关键词包含或模糊匹配。提取结果先进入草稿,不会直接覆盖当前 Booking 数据:
|
||||
|
||||
- Tour Code 会删除制表符、换行和空格;同一 Tour Code 以物理位置最后一行为准,最后一行明确写明取消时移除,单元格底色不代表取消。
|
||||
- 只分析酒店文本圆括号内的房型信息。`【房型】` 后面的整数是数量;未单独写数量时按 1。
|
||||
|
||||
@@ -446,6 +446,8 @@
|
||||
"error.company_download": ["该公司的正式表格尚不可下载", "The official workbook for this company is not ready to download", "ไฟล์อย่างเป็นทางการของบริษัทนี้ยังไม่พร้อมดาวน์โหลด"],
|
||||
"error.booking_unavailable": ["Excel 数据源服务暂未就绪", "The Excel source service is not ready", "บริการแหล่ง Excel ยังไม่พร้อม"],
|
||||
"error.booking_extract_failed": ["Excel 文件未能完成提取", "The Excel file could not be extracted", "แยกข้อมูลจากไฟล์ Excel ไม่สำเร็จ"],
|
||||
"error.booking_headers_missing": ["Excel 中未找到同时包含 Tour Code/主团号和酒店明细的工作表", "No worksheet contains both a Tour Code/Group Code header and a hotel-detail header.", "ไม่พบเวิร์กชีตที่มีทั้งหัวข้อ Tour Code/Group Code และรายละเอียดโรงแรม"],
|
||||
"error.booking_headers_ambiguous": ["Excel 表头重复,无法唯一确定 Tour Code/主团号或酒店明细列", "Duplicate headers prevent ARR from uniquely identifying the Tour Code/Group Code or hotel-detail column.", "มีหัวข้อซ้ำ จึงไม่สามารถระบุคอลัมน์ Tour Code/Group Code หรือรายละเอียดโรงแรมได้อย่างชัดเจน"],
|
||||
"error.processing_failed": ["文件已安全接收,但程序化处理未能完成", "The file was received safely, but processing did not complete", "รับไฟล์อย่างปลอดภัยแล้ว แต่การประมวลผลไม่เสร็จ"],
|
||||
"error.invalid_request": ["请求字段无效", "Invalid request fields", "ฟิลด์คำขอไม่ถูกต้อง"],
|
||||
"error.invalid_period": ["期间无效", "Invalid period", "ช่วงเวลาไม่ถูกต้อง"],
|
||||
@@ -578,6 +580,8 @@
|
||||
DOWNLOAD_IDENTITY_MISMATCH: "error.download_integrity",
|
||||
BOOKING_EXCEL_SERVICE_UNAVAILABLE: "error.booking_unavailable",
|
||||
BOOKING_EXCEL_PROCESSING_FAILED: "error.booking_extract_failed",
|
||||
BOOKING_EXCEL_HEADERS_MISSING: "error.booking_headers_missing",
|
||||
BOOKING_EXCEL_HEADERS_AMBIGUOUS: "error.booking_headers_ambiguous",
|
||||
BOOKING_EXCEL_REVIEW_OPEN: "error.company_review_open",
|
||||
BOOKING_EXCEL_SOURCE_REQUIRED: "error.company_source_required",
|
||||
BOOKING_EXCEL_SOURCE_ALREADY_ACTIVATED: "error.company_source_already_active",
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Iterable, Mapping, Optional
|
||||
|
||||
|
||||
PROCESSOR_NAME = "booking-excel-importer"
|
||||
PROCESSOR_VERSION = "2.0.0"
|
||||
PROCESSOR_VERSION = "2.1.0"
|
||||
RESULT_SCHEMA_VERSION = "1.0"
|
||||
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
||||
@@ -183,23 +183,30 @@ def _normalized_header(value: object) -> str:
|
||||
return re.sub(r"[\s._\-/\\::()()]+", "", normalized)
|
||||
|
||||
|
||||
HEADER_ALIASES = {
|
||||
"tour_code": frozenset(
|
||||
{
|
||||
"TOURCODE",
|
||||
"GROUPCODE",
|
||||
"GOURPCODE",
|
||||
HEADER_LABELS = {
|
||||
"tour_code": (
|
||||
"Tour Code",
|
||||
"Group Code",
|
||||
"Gourp Code",
|
||||
"团号",
|
||||
"团队代码",
|
||||
}
|
||||
"Tour Code / 主团号",
|
||||
),
|
||||
"hotel": frozenset(
|
||||
{
|
||||
"hotel": (
|
||||
"โรงแรม",
|
||||
}
|
||||
"วางข้อมูลที่นี่ / 酒店明细",
|
||||
"Raw Hotel Detail / วางข้อมูลที่นี่ / 酒店明细",
|
||||
),
|
||||
}
|
||||
|
||||
# Keep the approved source labels readable while applying one exact normalized
|
||||
# comparison to every workbook header. This is deliberately not substring or
|
||||
# fuzzy matching: an unrecognized supplier change must fail safely.
|
||||
HEADER_ALIASES = {
|
||||
key: frozenset(_normalized_header(label) for label in labels)
|
||||
for key, labels in HEADER_LABELS.items()
|
||||
}
|
||||
|
||||
ROOM_BRACKET_PATTERN = re.compile(
|
||||
r"【(?P<label>[^】]{1," + str(MAX_ROOM_LABEL_LENGTH) + r"})】"
|
||||
r"(?P<spacing>\s*)(?P<quantity>[0-9]{1,4})?"
|
||||
@@ -305,7 +312,7 @@ def _header_columns(cells: Iterable[object]) -> dict[str, int] | None:
|
||||
if any(len(matches[key]) != 1 for key in matches):
|
||||
raise BookingExcelError(
|
||||
"BOOKING_EXCEL_HEADERS_AMBIGUOUS",
|
||||
"Excel 表头重复,无法确定 Tour Code 或โรงแรม列",
|
||||
"Excel 表头重复,无法唯一确定 Tour Code/主团号或酒店明细列",
|
||||
)
|
||||
return {key: values[0] for key, values in matches.items()}
|
||||
|
||||
@@ -643,7 +650,7 @@ def parse_excel_bytes(payload: bytes) -> ExcelDocument:
|
||||
if not data_sheets:
|
||||
raise BookingExcelError(
|
||||
"BOOKING_EXCEL_HEADERS_MISSING",
|
||||
"Excel 中未找到同时包含 Tour Code 和โรงแรม的工作表",
|
||||
"Excel 中未找到同时包含 Tour Code/主团号和酒店明细的工作表",
|
||||
)
|
||||
if not rows:
|
||||
raise BookingExcelError(
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date
|
||||
@@ -1077,5 +1078,26 @@ class ControlledDownloadTests(unittest.TestCase):
|
||||
reader.read(bad)
|
||||
|
||||
|
||||
class BookingHeaderI18nContractTests(unittest.TestCase):
|
||||
def test_booking_header_errors_have_three_locale_messages(self) -> None:
|
||||
script = (STATIC_ROOT / "i18n.js").read_text(encoding="utf-8")
|
||||
expected = {
|
||||
"BOOKING_EXCEL_HEADERS_MISSING": "error.booking_headers_missing",
|
||||
"BOOKING_EXCEL_HEADERS_AMBIGUOUS": "error.booking_headers_ambiguous",
|
||||
}
|
||||
for code, key in expected.items():
|
||||
self.assertIn(f'{code}: "{key}"', script)
|
||||
match = re.search(
|
||||
rf'^\s*"{re.escape(key)}": (\[.*\]),$',
|
||||
script,
|
||||
re.MULTILINE,
|
||||
)
|
||||
self.assertIsNotNone(match, key)
|
||||
values = json.loads(match.group(1))
|
||||
self.assertEqual(len(values), 3, key)
|
||||
self.assertTrue(all(values), key)
|
||||
self.assertRegex(values[2], r"[\u0e00-\u0e7f]", key)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -31,6 +31,37 @@ def valid_workbook() -> bytes:
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def bilingual_workbook(*, exact_headers: bool = True) -> bytes:
|
||||
workbook = Workbook()
|
||||
worksheet = workbook.active
|
||||
worksheet.title = "QBD"
|
||||
worksheet.append(["QBD export"])
|
||||
worksheet.append([])
|
||||
tour_header = "Tour Code / 主团号" if exact_headers else "Tour Code / 主团号(备用)"
|
||||
worksheet.append(
|
||||
[
|
||||
"Month",
|
||||
"NO.",
|
||||
tour_header,
|
||||
"Tour Days",
|
||||
"Raw Hotel Detail / วางข้อมูลที่นี่ / 酒店明细",
|
||||
]
|
||||
)
|
||||
worksheet.append(
|
||||
[
|
||||
"2026-08",
|
||||
1,
|
||||
"SYN-G1",
|
||||
"",
|
||||
"酒店 (【U-TWN8.5】 2,【U-เตียงเสริม13】 1)",
|
||||
]
|
||||
)
|
||||
output = io.BytesIO()
|
||||
workbook.save(output)
|
||||
workbook.close()
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def summary(disposition: str = "imported_and_activated") -> BookingSourceSummary:
|
||||
return BookingSourceSummary(
|
||||
source_batch_id=7,
|
||||
@@ -199,6 +230,31 @@ class ProgramBookingSourceCoordinatorTests(unittest.TestCase):
|
||||
self.assertEqual(len(repository.created), 1)
|
||||
self.assertEqual(repository.created[0][1].original_filename, "Booking 报表.xlsx")
|
||||
|
||||
def test_bilingual_header_upload_is_stored_then_becomes_review_draft(self) -> None:
|
||||
store = FakeStore()
|
||||
repository = FakeRepository()
|
||||
coordinator = ProgramBookingSourceCoordinator(store, repository)
|
||||
|
||||
result = coordinator.submit("QBD 八月.xlsx", bilingual_workbook())
|
||||
|
||||
self.assertEqual(result["summary"]["filename"], "Booking 报表.xlsx")
|
||||
self.assertEqual(result["summary"]["pending_items"], 1)
|
||||
self.assertEqual(len(store.calls), 1)
|
||||
self.assertEqual(store.calls[0]["original_filename"], "QBD 八月.xlsx")
|
||||
self.assertEqual(len(repository.created), 1)
|
||||
|
||||
def test_unrecognized_bilingual_header_never_reaches_storage_or_database(self) -> None:
|
||||
store = FakeStore()
|
||||
repository = FakeRepository()
|
||||
coordinator = ProgramBookingSourceCoordinator(store, repository)
|
||||
|
||||
with self.assertRaises(PortalError) as raised:
|
||||
coordinator.submit("not.xlsx", bilingual_workbook(exact_headers=False))
|
||||
|
||||
self.assertEqual(raised.exception.code, "BOOKING_EXCEL_HEADERS_MISSING")
|
||||
self.assertEqual(store.calls, [])
|
||||
self.assertEqual(repository.created, [])
|
||||
|
||||
def test_reextracting_same_content_still_creates_a_fresh_draft(self) -> None:
|
||||
store = FakeStore()
|
||||
repository = FakeRepository(summary())
|
||||
|
||||
@@ -9,6 +9,7 @@ from openpyxl.styles import PatternFill
|
||||
|
||||
from booking_ingestion.excel import (
|
||||
BookingExcelError,
|
||||
PROCESSOR_VERSION,
|
||||
REVIEW_CONFIRMED,
|
||||
REVIEW_PENDING,
|
||||
build_excel_parse_result,
|
||||
@@ -113,6 +114,128 @@ class BookingExcelParserTests(unittest.TestCase):
|
||||
self.assertEqual(parsed["status"], "needs_review")
|
||||
self.assertEqual(len(parsed["room_items"]), 2)
|
||||
self.assertEqual(parsed["warnings"][0]["code"], "BOOKING_ROOM_TYPE_REVIEW_REQUIRED")
|
||||
self.assertEqual(parsed["processor_version"], "2.1.0")
|
||||
self.assertEqual(PROCESSOR_VERSION, "2.1.0")
|
||||
|
||||
def test_bilingual_headers_accept_lian_tai_and_qbd_variants(self) -> None:
|
||||
payload = workbook_bytes(
|
||||
[
|
||||
(
|
||||
"AUTO_CALC",
|
||||
[["BLOCK_CODE", "RATE_CODE"], ["LT-NEW", "RACK"]],
|
||||
),
|
||||
(
|
||||
"LianTai",
|
||||
[
|
||||
["Booking export"],
|
||||
[
|
||||
"Tour Code \n主团号",
|
||||
"Tour Days",
|
||||
"Notes",
|
||||
"วางข้อมูลที่นี่ / 酒店明细",
|
||||
],
|
||||
["LT-NEW", "", "", "酒店 (【U-TWN8.5】 2)"],
|
||||
],
|
||||
),
|
||||
(
|
||||
"QBD",
|
||||
[
|
||||
["QBD export"],
|
||||
[None],
|
||||
[
|
||||
"Month",
|
||||
"NO.",
|
||||
"Tour Code \n主团号",
|
||||
"Tour Days",
|
||||
"Raw Hotel Detail / วางข้อมูลที่นี่ / 酒店明细",
|
||||
],
|
||||
["", 1, "QBD-NEW", "", "酒店 (【U-DBL12】 3)"],
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
document = parse_excel_bytes(payload)
|
||||
|
||||
self.assertEqual(document.worksheets, ("LianTai", "QBD"))
|
||||
self.assertEqual(
|
||||
[row.group_code_raw for row in document.rows], ["LT-NEW", "QBD-NEW"]
|
||||
)
|
||||
self.assertEqual(document.extracted_item_count, 2)
|
||||
self.assertEqual(document.pending_item_count, 0)
|
||||
|
||||
def test_legacy_tour_code_aliases_remain_supported(self) -> None:
|
||||
for tour_header in (
|
||||
"Tour Code",
|
||||
"Group Code",
|
||||
"Gourp Code",
|
||||
"团号",
|
||||
"团队代码",
|
||||
):
|
||||
with self.subTest(tour_header=tour_header):
|
||||
payload = workbook_bytes(
|
||||
[
|
||||
(
|
||||
"Legacy",
|
||||
[
|
||||
[tour_header, "โรงแรม"],
|
||||
["LEGACY", "酒店 (【U-TWN8.5】 1)"],
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
document = parse_excel_bytes(payload)
|
||||
|
||||
self.assertEqual(document.worksheets, ("Legacy",))
|
||||
self.assertEqual(document.rows[0].group_code_raw, "LEGACY")
|
||||
|
||||
def test_lookalike_bilingual_header_is_rejected(self) -> None:
|
||||
payload = workbook_bytes(
|
||||
[
|
||||
(
|
||||
"Data",
|
||||
[
|
||||
[
|
||||
"Tour Code / 主团号(备用)",
|
||||
"Raw Hotel Detail / วางข้อมูลที่นี่ / 酒店明细",
|
||||
],
|
||||
["SYN", "酒店 (【U-TWN8.5】 1)"],
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with self.assertRaises(BookingExcelError) as raised:
|
||||
parse_excel_bytes(payload)
|
||||
|
||||
self.assertEqual(raised.exception.code, "BOOKING_EXCEL_HEADERS_MISSING")
|
||||
self.assertIn("Tour Code/主团号", raised.exception.safe_message)
|
||||
self.assertIn("酒店明细", raised.exception.safe_message)
|
||||
|
||||
def test_duplicate_recognized_headers_are_ambiguous(self) -> None:
|
||||
payload = workbook_bytes(
|
||||
[
|
||||
(
|
||||
"Data",
|
||||
[
|
||||
[
|
||||
"Tour Code",
|
||||
"Tour Code / 主团号",
|
||||
"วางข้อมูลที่นี่ / 酒店明细",
|
||||
],
|
||||
["SYN", "SYN", "酒店 (【U-TWN8.5】 1)"],
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with self.assertRaises(BookingExcelError) as raised:
|
||||
parse_excel_bytes(payload)
|
||||
|
||||
self.assertEqual(raised.exception.code, "BOOKING_EXCEL_HEADERS_AMBIGUOUS")
|
||||
self.assertIn("Tour Code/主团号", raised.exception.safe_message)
|
||||
self.assertIn("酒店明细", raised.exception.safe_message)
|
||||
|
||||
def test_physically_last_tour_row_replaces_earlier_and_cancel_removes_it(self) -> None:
|
||||
payload = booking_rows(
|
||||
|
||||
Reference in New Issue
Block a user