diff --git a/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md b/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md new file mode 100644 index 0000000..0978905 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261008-production-review-9e7b.md @@ -0,0 +1,76 @@ +# Task: 修复生产取数并接入字段人工完善 + +## Identity + +- Task ID: 20261008-production-review-9e7b +- Mode: Feature +- Branch: codex/arr-production-review +- Worktree: /Users/chillishark/.codex/worktrees/arr-production-review +- Base commit: 2417b1a41c851dd4d6df90703950ca90013a202f +- Owner: codex +- Status: Ready for Integration + +## Scope + +- 修复生产 OHIP 查询、环境内任务恢复及接口字段人工完善;保留 XML 和原筛选、定价、日报/月报规则。 + +## Intent And Constraints + +- 用户授权修复并继续 2026-10-07 验证,已允许多个子智能体;仅此日期真实调用。原始数据不可变,人工修改留痕,完整采集和最终独立校验通过后才可提交 Finance/月报。未知主工作区改动不采用。 + +## Outcome + +- Production direct reads now omit unsupported server sorting and use the platform's working GET `getProfiles` query. Original acquisition order, complete paging/recheck and response identity checks remain enforced. Legacy collection keeps its original sorting default. +- Added source-field completion before deterministic processing: only candidate records' unresolved/invalid fields may be edited; optional absence requires an explicit decision. Original bytes are retained, revisioned decisions record the authenticated actor, and confirmation freezes a derived source. The same download request resumes from its saved acquisition without querying OHIP again. Existing price review, Finance validation, daily generation and monthly outbox behavior remain in use; XML processing is retained. +- Browser recovery is scoped to this service/source context and user, preventing an old sandbox request from restoring into the production instance. Lost save/finalize responses, concurrent revisions and interrupted confirmation recover without a second processing intent. +- Updated the existing local service at `http://127.0.0.1:8875/` to run this isolated checkout, retaining hotel57106, credentials, private database and output storage. Previous private launcher/config/plist were backed up before restart. No production deployment, remote push or integration into the unowned dirty primary checkout occurred. +- Reused the complete, immutable real 2026-10-07 capture; verified all531 inventory files and both original hashes before importing into request `45e0e20ee75a46e098a0e32d650133a9`. Import made zero new Oracle calls. The live task is `needs_data_review`, no processing job exists, and it has26 unresolved fields across19 rows:1 room,18 block codes and7 package lists. No real business values were supplied or implicitly confirmed; no real daily/monthly was generated. + +## Verification + +- Python regression:185 tests passed with real disposable local PostgreSQL enabled and no skips across source review, direct processing, queue/runtime, local wrappers, Postgres integration, existing price review, OHIP acquisition/profile/collection. A subsequent review-suite run passed22 tests after atomic-publication recovery changes; the additional initial-publication crash recovery test passed separately (186 distinct Python tests in total). +- JavaScript regression:25 tests passed; `node --check arr_web/static/app.js` and `git diff --check` passed. Shell browser automation was not used. +- Saved production capture:176 recorded successful reads,58 ordered records, complete collection. Offline reader replay matched all recorded method/path/body requests and preserved fields/order. Actual source review reproduced19 affected rows/26 fields without changing the original source. +- Local authenticated API: service and hotel context ready; latest task date2026-10-07, `needs_data_review`, `job_id=null`, `pending_count=26`, `can_finalize=false`. +- CUA browser verification on the updated service: authenticated page shows date2026-10-07,26 field rows,0/26 confirmed, disabled generation button and original XML upload. Reload restored the same pending review. A fresh tab was needed because the old tab's browser-control connection timed out; the new result tab is retained as a deliverable. Screenshot contains the visible task and review context, no guest names. +- Private evidence only (excluded from Git): `/Users/chillishark/Library/Application Support/ARR2.0/production-validation-20261007/compatible-day-d45e0fd2/capture`, `field-review-page-20261008.png`; saved-capture import receipt in production instance `processing/requests/45e0e20ee75a46e098a0e32d650133a9/saved-capture-import.json`. Capture manifest SHA256 `bcfeb6390fc553754b917f9c935a532916e6bd10d4e95ee43450895e760cd9fc`, original data SHA256 `805799ba2f3edd7f1cf18c14e5304908061d6194a241c5fc255192728b7f388a`. +- Project documentation ownership drift check passed for this task; primary checkout's pre-existing documentation changes remain untouched. + +## Follow-ups + +- Staff must supply/confirm the26 actual field decisions and complete any subsequent existing price review before the real10/7 daily/monthly result can be verified. The room gap belongs to a cancelled reservation retained by the existing rules; no synthetic room or new status-based exclusion was introduced. Any proposed change to cancellation/no-show eligibility requires a separate business decision. +- Integration owner should promote the accepted behavior and production compatibility evidence, then integrate this branch into the primary project. Keep this checkout while the local service runs from it. It is manually created, not eligible for automatic skill-managed retirement. +- Production deployment/configuration and remote publishing are separate follow-ups. This turn only activated the local instance against the existing production read service. + +## Promotion Candidates + +- Update canonical current-state/data-flow/success-criteria with the separate upstream source-field review and its boundary with the existing price-only processor/Finance review. Preserve ADR-006's original scope; decide whether an additional ADR is appropriate at integration. +- Promote the four supported direct search parameters, GET profile lookup compatibility, service-context browser recovery and immutable original/manual decision audit into integration-owned documentation. +- Record that real10/7 acquisition is complete but its daily/monthly acceptance awaits staff decisions. Avoid claiming successful report generation from synthetic regression evidence. + +## Concurrent Task Gate + +- Passed: task_context.py start exited 0; status verified task ID, feature mode, branch and absolute worktree. +- Base: 2417b1a41c851dd4d6df90703950ca90013a202f (non-overwriting legacy documentation bootstrap on ad2c592). +- Other local owners: none. Primary checkout has unowned documentation changes, retained untouched. +- Isolated linked checkout created manually for legacy bootstrap; not eligible for automatic managed retirement. + +## Project Context Loaded + +Read: memory-index, project-positioning, current-state latest September sections, decision-index, ADR-004/006/007, system-overview, module-map, data-flow, business-rules, success-criteria, evidence-index, reflection-index, commitments, stale-items, read-before-coding. + +- Goal: direct OHIP data and manual XML use shared deterministic rules and verified Finance/outbox/monthly. +- Focus: production search/profile compatibility, scoped client recovery and source-field completion before processing. +- Constraints: retain original source/order, reject incomplete collection, audit manual data changes, preserve existing price review and XML behavior. No credential or hotel routing changes. +- Decisions: ADR-007 direct JSON; ADR-006 remains price-only for processor/Finance review. User now explicitly authorizes a separate upstream source-field review. +- Evidence: private real 10/7 capture has 58 rows, complete acquisition with gaps; current page task accidentally restored old 9/15 sandbox request then failed502. +- Stale docs: September live-query/delegation restrictions superseded by current explicit production verification and multi-agent consent. Current local production uses hotel57106 and separate private state. +- Modules: integrations/ohip, arr_web downloads/direct executor/UI, targeted tests. +- Gate: Passed. + +## Plan + +1. Production reader compatibility, preserving strict response identity and collection recheck. +2. Durable revisioned field review before handoff; required candidate gaps only; immutable derived source and audit; same-request continuation. +3. Authenticated review UI and context-scoped recovery. +4. Synthetic regression and saved real 10/7 validation; activate local service safely and leave actual unresolved business values for user. diff --git a/ARR_OHIP_FIELD_MAPPING.md b/ARR_OHIP_FIELD_MAPPING.md index 62e1893..c320324 100644 --- a/ARR_OHIP_FIELD_MAPPING.md +++ b/ARR_OHIP_FIELD_MAPPING.md @@ -1,39 +1,42 @@ # ARR 自动取数字段对接表 -核对日期:2026-09-18。 +核对日期:2026-10-08。 每个接口实际传什么参数、参数来源和补查条件,见[接口与传参确认单](ARR_OHIP_REQUEST_PARAMETERS.md)。 -实施进展:用户随后要求直接接接口、跳过实际取数验证。现已完成 [按日期取数入口](integrations/ohip/DATA_SOURCE.md), -覆盖本表 15 个字段及必要的关联查询。41 项新增本机检查及相关回归共 150 项通过,未查询沙箱订单。 -页面日期按钮、直接数据处理、日报、缺价复核和月报均已衔接;人工XML上传入口继续保留。 -所需平台权限已核对齐全。用户确认测试环境目前没有可用于核对的数据,当前状态为“接入完成,待有数据后验收”。 -下方“本次确认结果”和逐字段表保留接口目录核对时的判断,当前实现细节以链接说明为准。 +实施进展:[按日期取数入口](integrations/ohip/DATA_SOURCE.md)覆盖本表15个字段及必要关联查询。 +已用2026-10-07到店日保存的真实生产响应在本机离线重放176次读取,识别58条预订,字段及原始顺序与捕获一致。 +结构化列表请求省略生产拒绝的可选排序;姓名摘要改用精确ID的`getProfiles` GET。 +原始缺口由新增字段完善流程先处理,随后进入共用处理、独立校验、既有缺价复核、Finance日报与自动月报流程;人工XML上传继续保留。 +当前状态是“生产请求兼容和字段识别已核对,10月7日业务字段值待确认”,不表示正式日报或生产报表验收通过。 当前目标:用户选一天,系统通过 OHIP 平台取得该日到店预订所需的数据,直接交给数据处理入口,沿用现有筛选、去重、定价、复核和报表规则。人工 XML 上传入口保留。自动取数不以取得或重建 XML 为前提。 字段范围来自 [ARR XML 加工前字段清单](ARR_XML_RAW_FIELDS.md)。自动取数需要其中 15 个业务字段;Trace 沿用已确认的“不获取”。字段允许为空的规则保持不变。 -## 本次确认结果 +## 契约与生产捕获核对 -已实时读取 OHIP 平台接口目录及接口说明:版本 **0.11.0,共 184 项操作**。下面涉及的查询入口均已列出。 -这次确认的是平台提供了哪些接口;没有重新查询沙箱订单。因此,下面的“已有取值”来自当前代码和历史采集记录,不表示今天已经完成全部字段的实际取数验收。 +2026-09-18曾读取OHIP平台接口目录及接口说明:版本 **0.11.0,共184项操作**,历史查询编号保留在本文下方。 +本轮对照保存的公开契约及生产捕获核对当前实现,不新增网络查询。58条完整采集仍有原始字段缺口: +团队代码36项、套餐16项、公司2项、房号1项。`collection_complete=true`只表示预订列表与读取完整, +`input_complete=false`表示原始输入仍未齐备;不能用补空将其改成已验收。 +人工完善只列出同一冻结处理器认定会阻塞候选记录的字段,因此待确认清单不必等于全部原始缺口数量。 ## 逐字段对接 | 业务字段 | 从哪里取得 | 目前已有成果 | 接下来核实什么 | |---|---|---|---| -| 团队 Block 代码 | 预订详情中的团队资料;必要时补查团队详情 | 已能读取明确标为 Block 代码的值 | 区分没有团队与资料未返回;不把团队编号或名称当成代码 | +| 团队 Block 代码 | 预订详情中的团队资料;必要时补查团队详情 | 已识别22条有值;36条原生省略仍为缺口 | 确认代码或人工明确无此项;不把团队编号、名称或省略当成代码或空值 | | 成人数 | 预订详情、所选日的入住资料 | 已有取值和一致性检查 | 实际样本中的共享房、多房人数含义 | | 儿童数 | 预订详情、所选日的入住资料 | 已有取值和一致性检查 | 零儿童与字段未返回的区别 | -| 公司/旅行社名称 | 预订关联的公司、旅行社资料;必要时补查相应档案 | 已能识别关联角色和名称 | 同一预订存在多个关联对象时,哪个名称进入既有定价流程 | +| 公司/旅行社名称 | 预订关联的公司、旅行社资料;必要时补查相应档案 | 已识别56条有值;2条关联资料省略仍为缺口 | 缺失或多个不同名称时人工完善;公司名称须填写有效值 | | 预订确认号 | 预订详情 | 已有取值 | 与同一笔预订准确对应,保留原始文字 | -| 房号 | 预订详情;换房、退房等情况补查房间历史 | 已能读取多处一致的房号;已有历史资料检查 | 取得与这笔预订及目标日期对应的房号,区分未分房与未取得资料 | +| 房号 | 预订详情;换房、退房等情况补查房间历史 | 已识别57条有值;1条补查返回空日历仍为缺口 | 填写与目标日期对应的有效房号;`roomCalendar={}`不能证明无房号,不能确认为空 | | 有效房价 | 对指定预订查询指定日期的房价 | 已有独立查询和金额检查 | 返回有效房价及币种;隐藏价格、缺失价格不能作为零价 | -| 住客姓名 | 预订主客资料、对应客人档案 | 已有主客关联和姓名补查 | 实际取到该主客的完整显示姓名;历史部分查询失败需要新一轮验证 | +| 住客姓名 | 预订唯一主客资料、`getProfiles`精确GET摘要,必要时补查档案详情 | 58条姓名已识别,身份和姓名组成核对通过 | 保留摘要完整显示姓名原文;不按姓名模糊关联或自行拼接 | | 预订备注 | 预订详情中的 GEN 预订备注,包含内部备注 | 已有提取,并保留多条内容及顺序 | 多条备注进入既有处理规则后,团号是否正确 | | 房间数 | 所选日对应的预订入住资料 | 已有取值 | 不把不同日期重复出现的房间数累加 | -| 套餐/附加产品 | 预订详情中的套餐清单;需要补充时再查对应套餐 | 已保留套餐代码和适用日期等资料 | 多个套餐的适用范围,以及如何呈现在结果中 | +| 套餐/附加产品 | 预订详情中的套餐清单;需要补充时再查对应套餐 | 已识别42条有套餐;16条原生省略仍为缺口 | 人工确认按序套餐代码或明确无此项;套餐金额为零不能证明无套餐 | | 费率代码 | 所选日对应的预订房价资料 | 已有取值 | 取得目标日期的代码;取得后才交由原有白名单处理 | | 房型 | 预订详情中的房型代码 | 已有取值和一致性检查 | 房型变更时采用哪个日期对应的值;不以计价房型替换 | | 入住日期 | 预订入住资料 | 已有取值 | 与用户选择的到店日期一致 | @@ -43,6 +46,19 @@ 套餐补查接口需要已经知道套餐代码,因此应先读取预订中已有的套餐清单;它不是一次返回全部未知套餐的入口。 团队代码和房型在预订资料已经足够时,不必额外查询团队或房型资料。 +## 先完善字段,再复核价格 + +完整取数后,`DataFieldReviews`使用当前冻结处理器本身的费率白名单和字段校验识别阻塞项。 +任务进入`needs_data_review`,此时尚未创建处理job或写入Finance。费率代码自身待确认时先完善费率, +再按原规则重新识别该笔需要完善的字段;不另设筛选或定价规则。 + +业务人员仅能修改清单列出的异常字段。团队代码、备注、套餐和房型可人工明确“确认无此项”; +公司、房号及其他必填字段必须提供有效值。任何未返回字段在人工确认前都不会自动补空。 +原始捕获保持不变,人工决定保存原值、新值、操作者和版本,全部阻塞项通过后冻结派生来源并自动继续原请求。 + +随后共用原白名单、原顺序去重、定价及独立校验。若缺少pureprice,进入原有`needs_review`价格复核; +完成价格复核后才提交Finance日报,并沿用原月报自动更新。字段完善处理输入事实,价格复核处理后续定价缺口,二者顺序不同。 + ## 接通后的判断标准 - 用户选择的日期用于查找当日到店预订,并用于取得相应日价等资料。 @@ -52,21 +68,25 @@ - 公司、房价、房号等会影响结果的字段先验证业务含义。相同业务输入应得到与既有规则一致的处理结果。 - 原生 XML 是否可下载、XML 字节或显示格式是否相同,不作为新数据入口接通的前提。 -## 本次查询依据 +## 历史公开目录查询依据(2026-09-18) -来源:[OHIP 平台接口说明](https://ohip.nianxx.cn/developer/v1/openapi)。本次公开查询无酒店业务数据、无权限修改。 +来源:[OHIP 平台接口说明](https://ohip.nianxx.cn/developer/v1/openapi)。以下是历史公开查询,无酒店业务数据、无权限修改;本轮未重新请求该地址。 | 查询 | 返回 | 查询编号 | |---|---|---| | 完整接口目录 | 0.11.0,184 项操作 | `9f9ee152-f97d-45ad-b672-cd3e3d1da749` | | 完整接口说明 | 0.11.0,包含参数及返回资料说明 | `0968f5e3-dbbf-42f8-aee1-60ff9ff2d855` | -开发定位保留在现有 [接口对应资料](integrations/ohip/arr-api-mapping.md)、[字段提取](integrations/ohip/source_fields.py)、[房价查询](integrations/ohip/rate_info.py)、[姓名补查](integrations/ohip/profile_reader.py)和[房间历史检查](integrations/ohip/room_calendar_evidence.py)中。 +当前开发定位:[字段提取](integrations/ohip/source_fields.py)、[房价查询](integrations/ohip/rate_info.py)、 +[姓名摘要](integrations/ohip/profile_summary.py)、[字段完善](arr_web/arr_data_review.py)、 +[处理衔接](arr_web/arr_data_executor.py)和[房间历史检查](integrations/ohip/room_calendar_evidence.py)。 +旧[接口对应资料](integrations/ohip/arr-api-mapping.md)与[姓名补查](integrations/ohip/profile_reader.py)保留历史路线。 历史资料中以“生成 XML、证明原生报表显示一致”为条件的部分,仅说明当时的路线;当前重点以本页和用户本轮明确的数据入口方向为准。 -字段对应阶段只核对目录,后续已按用户要求直接完成取数代码和本机检查,跳过实际订单验证。 -后续直接处理衔接和本机OHIP配置已完成,见下节。实际字段返回和业务报表结果待有数据后验收,不主动补做取数。 +2026-09-18字段对应阶段按当时授权只做目录及本机模拟验证。2026-10-08已完成实际生产捕获的离线字段识别, +但未确认缺失业务值、未据此生成或验收10月7日正式日报。 ## 2026-09-18 直接处理衔接 -15字段数据现已直接衔接页面、日报、缺价复核和月报;不再需要XML适配。备注取第一条非空,套餐按代码顺序显示,原始多值完整保留。详见[入口说明](arr_web/DIRECT_DATA_ENTRY.md)。开发和本机模拟已验证,实际订单未验证。 +15字段数据直接衔接页面、日报、缺价复核和月报,不需要XML适配。备注取第一条非空,套餐按代码顺序显示,原始多值完整保留。 +这段记录指当时的本机模拟验证;当前字段完善及生产捕获核对进展见本文上方和[入口说明](arr_web/DIRECT_DATA_ENTRY.md)。 diff --git a/ARR_OHIP_REQUEST_PARAMETERS.md b/ARR_OHIP_REQUEST_PARAMETERS.md index d742592..1ccb3e6 100644 --- a/ARR_OHIP_REQUEST_PARAMETERS.md +++ b/ARR_OHIP_REQUEST_PARAMETERS.md @@ -1,7 +1,8 @@ # ARR 已接接口与实际传参确认单 -核对日期:2026-09-18。依据当前调用代码及在本机生成的请求样例,不发起OHIP请求、不读取密钥、不改变任务。 -这是已实现行为的说明,供用户确认;不表示已经通过实际订单验收。 +核对日期:2026-10-08。依据当前调用代码、已保存的公开契约和2026-10-07到店日的生产响应捕获。 +已在本机离线重放176次已捕获读取,识别58条预订;本轮说明更新不发起OHIP请求、不读取密钥、不改变真实任务。 +生产请求兼容和字段识别已核对,10月7日正式日报仍待业务字段值确认,尚未通过生产报表验收。 ## 页面输入与参数来源 @@ -33,11 +34,11 @@ POST /api/arr-downloads ## 共同连接设置 - 平台地址:`https://ohip.nianxx.cn`。 -- 当前应用:`Wyndham-ARR2.0-Codex`,应用ID`caller_zloxalzsQ9pYsztt`;当前本机预期酒店`OHIPSB02`。 +- 当前应用:`Wyndham-ARR2.0-Codex`,应用ID`caller_zloxalzsQ9pYsztt`;预期酒店由实例配置提供。 - 每个请求携带`X-API-Key`,值由私有凭据文件提供;本文不包含密钥。 - 同时携带`Accept: application/json`与`Content-Type: application/json`。 - 当前请求没有单独传`hotelId`或`x-hotelid`;酒店由平台路由确定,ARR核对返回酒店与配置一致。应用ID也不作为单独请求头传出。 -- 用户已确认接受上述酒店路由与`OHIPSB02`核对方式,正式环境切换时再调整对应配置;本次发布不切换酒店。 +- 酒店核对值来自当前实例配置。2026-09-18历史沙箱记录使用`OHIPSB02`,不能据此认定生产实例酒店;生产环境沿用已批准的绑定配置。 - 下列8项均为查询,不修改Oracle订单。POST在此用于查询条件提交。 ## 1. 到店预订列表 — searchHotelReservations @@ -51,15 +52,15 @@ POST /api/arr-downloads "arrivalStartDate": "2026-09-15", "arrivalEndDate": "2026-09-15", "limit": 100, - "offset": 0, - "orderBy": ["ConfirmationNo"], - "sortOrder": ["Asc"] + "offset": 0 } ``` - 两个日期都等于D;按到店日期查询,不是D当天所有在住或离店客人。 - 每页100条,后续`offset`为100、200……;读取平台分页结果决定何时结束。 -- 所有详情和补查结束后,重新查询同样的完整列表,检查采集中记录是否变化;这不是一个额外接口。 +- 结构化入口不传可选`orderBy`和`sortOrder`:生产核对中带排序参数的请求被拒绝,省略后成功。历史候选捕获入口保留其原请求。 +- 生产默认排序规则尚未证实。每页按接口返回顺序保留,沿用`source_sequence`;不另行重排或提前去重。 +- 所有详情和补查结束后,重新查询同样的完整列表,逐项检查内容与顺序。每轮还验证唯一预订身份、重复ID、总数、页数和末页完整性;这不是一个额外接口,也不构成原子快照。 - 当前未显式传预订状态、分房状态、费率代码、市场代码或公司筛选条件。**不传状态不等于已经确认平台默认包含全部状态**。 - ARR不在查询阶段应用后续费率白名单和业务去重。 @@ -101,23 +102,24 @@ Shares `id=R`,`detailDate=D`;每笔预订调用,不是按确认号查询。当前自动数据入口使用POST的`searchRateInfo`,未使用GET的`getRateInfo`。 不传币种或指定价格数值;从返回数据取有效价及币种并核对,隐藏/缺失价格不当成零。 -## 4. 主客姓名摘要 — searchProfiles +## 4. 主客姓名摘要 — getProfiles 用途:取得唯一主客的完整显示姓名,并与预订中的姓名组成核对。 -`POST /api/v1/profiles/searches`,正文: +`GET /api/v1/profiles`,无正文,查询参数: -```json -{ - "profileIds": ["P456"], - "summaryInfo": true, - "limit": 1, - "offset": 0 -} +```text +profileIds=P456 +summaryInfo=true +limit=1 +offset=0 ``` -`profileIds=[P]`,每次只查已关联主客的精确档案ID;不按姓名模糊搜索、不查询全部住客或陪同人。 -相同档案的同类查询可在本次采集中复用。 +即`GET /api/v1/profiles?profileIds=P456&summaryInfo=true&limit=1&offset=0`。 +每次只查已关联主客的一个精确档案ID;不按姓名模糊搜索、不查询全部住客或陪同人。 +相同档案的同类查询可在本次采集中复用。严格核对返回`operation_id=getProfiles`、酒店、Oracle请求编号、 +唯一档案ID、单条完整结果及姓名组成,再保留完整显示姓名原文。 +生产核对中的`searchProfiles` POST不可用;结构化入口改用已验证的GET,旧POST姓名探针保留给历史流程。 ## 5. 档案详情补查 — getProfile @@ -153,6 +155,7 @@ showRoomMoveSegments=true - 若返回尚未完整,后续增加`pageIndex=上一页返回的pageIndex+1`、`recordsPerPage=返回的每页条数`,其余条件不变。 - **没有向该接口传预订ID或房号筛选**,程序读取返回的房间集合后按R匹配;不是每一笔缺房号都重新查一遍全酒店。 - 已有明确房号或明确为空的记录不触发这项补查;历史有多个不同房号且无法唯一确定时保留异常。 +- `roomCalendar={}`表示本次没有取得可用房间集合,不证明该预订在D没有房号;缺口进入字段完善,房号不能确认为空。 ## 8. 套餐详情补查 — getPackage @@ -178,6 +181,18 @@ fetchInstructions=Schedule 这些是本地处理上限,只有各接口表中列出的分页值会作为查询参数传出。 如果列表没有任何预订,当前程序以`empty_source_requires_review`结束采集,不自动生成零条日报。 +## 字段完善与价格复核 + +列表完整取得后,`reservationBlock`、`reservationPackages`、`reservationProfiles`等原生可选字段省略, +以及未能唯一确定房号等情况,保留原始观察与缺口;未经人工确认不补成空值,也不按Cancelled/NoShow状态丢弃预订。 +后台`DataFieldReviews`按同一冻结处理器的白名单和字段验证规则识别阻塞项,任务进入`needs_data_review`。 +业务人员填写缺失值;只有原规则允许为空的团队代码、预订备注、套餐和房型可明确“确认无此项”,公司与房号仍须有效值。 + +全部阻塞项确认后,冻结带操作者、版本、原值及改值的派生来源,原捕获不改写。 +原请求自动继续共用筛选、去重、定价和独立校验;若此后缺少pureprice,才进入既有`needs_review`价格复核。 +价格复核完成或无需复核时,沿用原Finance日报提交与月报自动更新链路。字段完善和价格复核是前后两步。 +操作入口见[直接数据入口说明](arr_web/DIRECT_DATA_ENTRY.md),人工XML上传继续保留。 + ## 本次请确认的业务范围 | 项目 | 当前实现 | @@ -186,10 +201,11 @@ fetchInstructions=Schedule | 客人范围 | 姓名使用唯一主客档案,不逐一查陪同人姓名 | | 取数与加工 | 先取数据,再使用原白名单、去重和定价规则;不请求XML,不获取Trace | | 状态覆盖 | 没有显式传全部预订/分房状态,需要核对平台默认范围,不能把这一点算作全状态验收通过 | -| 备注覆盖 | 请求Comments后提取GEN并保留内部备注;接口默认是否返回完整所需备注未实测 | +| 备注覆盖 | 请求Comments后提取GEN并保留内部备注;已识别实际响应,业务所需备注是否完整仍待确认 | | 公司与房号冲突 | 保留异常,不自行选择公司优先级或随意选一个换房房号 | -本次仅核对现有参数,未改变查询范围和业务规则。参数样例中的R123/P456/B789/PKG1是虚构标识;通过实际请求构造函数在本机生成,无网络、密钥读取或真实业务调用。 +本轮只调整结构化入口的可选排序和姓名摘要请求兼容,保留原取数范围及处理规则。 +参数样例中的R123/P456/B789/PKG1是虚构标识。已完成的生产捕获离线重放不等于10月7日业务字段确认或日报验收完成。 实现依据:[请求封装](integrations/ohip/data_client.py)、[调用与补查条件](integrations/ohip/arr_data.py)、 [预订查询及连接](integrations/ohip/collect_arr_source.py)、[房价请求](integrations/ohip/rate_info.py)、[姓名摘要请求](integrations/ohip/profile_summary.py)。 diff --git a/arr_web/DIRECT_DATA_ENTRY.md b/arr_web/DIRECT_DATA_ENTRY.md index c4ec888..7d015d4 100644 --- a/arr_web/DIRECT_DATA_ENTRY.md +++ b/arr_web/DIRECT_DATA_ENTRY.md @@ -1,29 +1,73 @@ # 按日期获取接口数据并生成 ARR -2026-09-18:新增入口已连接页面、日报、缺价复核和原有月报流程。人工 XML 上传保留。 -用户已确认页面交互,并授权配置OHIP连接;当前8875为本机OHIP沙箱入口,实际沙箱取数未验证。 -接口数据直接进入共用筛选、去重、定价和报表规则,不生成中间 XML。 -开发已收尾;生产启用条件、部署模板现有边界和首次数据验收见[交付说明](../deploy/OHIP_RELEASE_HANDOVER.md)。 +2026-10-08:直接数据入口增加处理前的人工字段完善,完成后继续共用日报、缺价复核与原有月报流程。人工XML上传保留。 +已在本机离线重放2026-10-07到店日的真实生产捕获:176次读取、58条预订,字段识别和来源顺序与捕获一致。 +这次核对未发起新OHIP请求。10月7日正式日报仍待业务字段值确认,尚未通过生产报表验收。 +接口数据沿用共用筛选、去重、定价、独立校验和Finance提交规则,不生成中间XML。 +本文描述当前代码;部署与服务状态以[交付说明](../deploy/OHIP_RELEASE_HANDOVER.md)和对应部署记录为准。 ## 使用流程 -选择报表日期 → 点击“下载并处理” → 获取该日数据 → 处理并核对 → 日报进入历史,月报自动更新。 +选择报表日期 → 点击“下载并处理” → 完整获取该日数据 → 如有阻塞字段则人工完善并确认 → 原规则处理与独立核对 → 如缺价则沿用价格复核 → Finance日报进入历史,月报自动更新。 点击日期文字或右侧日历图标,再点击日历中的某一天即可选择。任务进行中仍可预选下一日期; 当前任务结束后需再次点击下载,预选不会自动提交或修改原任务。页面进度始终显示原任务的日期。 任务结果待确认时,先检查或继续按钮上标明日期的原任务,避免重复提交。 -只有缺少价格时进入原有价格复核;确认后从保存的原始数据重新处理。缺字段或多个候选会报错,不会当成零价。 +完整采集中的缺字段、多个候选或不满足原规则的字段先进入“待完善数据”(`needs_data_review`)。 +全部阻塞字段通过并确认后,原任务自动继续处理;随后只有缺少pureprice时进入原有价格复核(`needs_review`)。 +字段完善和价格复核是前后两步,不会用补零或未经确认的空值跳过前一步。 任务中断后“继续原任务”复用原始数据和结果,同一请求不会重复入库。历史来源显示“接口获取”。 备注使用清理表情后的第一条非空预订备注;套餐按原顺序显示套餐代码(逗号分隔,保留重复)。 完整备注、套餐明细和查询出处保留在原始数据中。Trace 不获取,旧报表对应列留空。 这是一套明确的新入口显示规则,不声明与 Oracle 原生报表的文字格式完全一致。 -## 当前本机 OHIP 沙箱入口 +## 字段完善的保存与继续处理 -[打开原地址](http://127.0.0.1:8875/),沿用原账号密码。酒店OHIPSB02、平台地址、应用凭据路径已配置; +`DataFieldReviews`只接受`collection_complete=true`的完整采集,以当前冻结处理器的费率白名单和字段验证识别阻塞项。 +预订列表仍保留全部取得的记录及原顺序;非白名单记录按原规则处理,不因Cancelled/NoShow等状态在取数阶段被丢弃。 +费率本身未确定时先完善费率,再重新识别该记录的其余问题。查询失败或采集不完整仍走原重试流程。 + +人工仅能完善任务列出的异常字段。团队代码、预订备注、套餐和房型允许明确“确认无此项”; +公司、房号及其他必填字段必须填写有效值。`reservationBlock`、`reservationPackages`或关联档案省略不自动成为空值, +套餐金额为零不能证明无套餐,`roomCalendar={}`也不能证明目标日期没有房号。 + +每次保存核对当前`revision`,记录操作者、原值、新值和事件版本;旧版本提交会返回冲突,须重新查看。 +所有阻塞项通过后,确认操作冻结`reviewed-source.json`及审计绑定,并将同一请求重新排队。 +后续从已保存来源继续,不重新获取订单、不改写原始捕获。冻结后不能再修改字段,重复确认或恢复不会重复提交日报。 +正常完整输入无需人工步骤,直接进入原处理链路。 + +字段完善后端接口归属于ARR自身,沿用网页登录权限与POST的CSRF保护;它们不向Oracle写入业务资料: + +| 操作 | ARR接口 | 正文 | +|---|---|---| +| 读取待完善清单 | `GET /api/arr-downloads/{request_id}/data-review` | 无 | +| 保存一项字段 | `POST /api/arr-downloads/{request_id}/data-review/items/{item_id}` | `{"revision":0,"value":"<有效值>"}`;套餐值为有序代码数组 | +| 确认并继续生成 | `POST /api/arr-downloads/{request_id}/data-review/finalize` | `{"revision":1}`;使用最近返回版本 | + +字段完善期间尚未生成处理job或写入Finance。确认后才执行原筛选、去重、定价、独立校验与处理交付; +如果进入后续价格复核,Finance日报仍等待价格确认。最终提交继续触发原月报更新。 +缺少Opera有效房价属于前面的来源字段问题;缺少pureprice属于后面的定价复核,不能相互替代。 + +## 生产请求兼容与当前验收边界 + +结构化到店查询只发送`arrivalStartDate`、`arrivalEndDate`、`limit`、`offset`,省略生产拒绝的可选排序。 +生产默认排序规则尚未证实,因此保留接口返回顺序,并在完整分页及补查后逐项比较首尾两次完整列表。 +每轮验证重复预订ID、总数、页数和末页完整性;首尾一致仍不是原子快照的证明。 + +姓名摘要使用`GET /api/v1/profiles?profileIds=P&summaryInfo=true&limit=1&offset=0`,只查已关联唯一主客的精确档案ID。 +严格核对`operation_id=getProfiles`、酒店、Oracle请求编号、唯一档案结果和姓名组成;不再使用生产不可用的`searchProfiles` POST。 +历史候选捕获及POST探针保留原请求,详细参数见[传参确认单](../ARR_OHIP_REQUEST_PARAMETERS.md)。 + +58条原始采集中保留团队代码36项、套餐16项、公司2项、房号1项缺口。字段完善清单只列出原规则判定会阻塞候选记录的问题, +与全部原始缺口数量可以不同。离线重放及本机流程验证不替代真实业务值确认,亦不表示10月7日正式日报已生成或验收。 + +## 历史本机 OHIP 沙箱入口(2026-09-18) + +以下保留2026-09-18的沙箱配置与运行方式,不代表当前生产服务状态。 +历史[本机地址](http://127.0.0.1:8875/)沿用当时账号密码。酒店OHIPSB02、平台地址、应用凭据路径已配置; 本机数据库和报表单独保存,人工XML上传保留。平台同事已补齐团队资料读取权限`blocks.read`, 2026-09-18 06:00Z核对所需权限齐全,下载按钮已启用;见[补权记录](../integrations/ohip/PLATFORM_ACCESS_REQUEST.md)。 -本轮只检查权限和页面就绪状态,没有提交真实下载任务;用户选日期并点击后才会查询。 +当轮只检查权限和页面就绪状态,没有提交真实下载任务;用户选日期并点击后才会查询。 实例使用`arr_web.local_ohip`,启动只核对本机配置和凭据,不调用酒店接口。 `check-access`只读取平台开发身份、应用列表和密钥状态,不改权限、不签发密钥、不查询订单; @@ -65,7 +109,7 @@ LC_ALL=C LANG=C .venv/bin/python -m arr_web.local_ohip serve \ ## 正常运行配置 -数据库须已应用019迁移;本轮只在独立本机测试库应用,未修改既有数据库。 +数据库须已应用019迁移;2026-09-18首次验证只在独立本机测试库应用,未修改当时既有数据库。 该迁移允许真实的 `ohip_json` 源制品和5.0结果进入已有复核流程。 启动会先检查迁移,未具备条件时不开放下载服务。原来的月报后台程序仍须正常运行。 @@ -85,20 +129,20 @@ LC_ALL=C LANG=C .venv/bin/python -m arr_web.local_ohip serve \ 它不再是新数据入口的前置条件。不得把模拟传输当成实际平台传输。 取数字段和八项只读操作见[数据入口说明](../integrations/ohip/DATA_SOURCE.md)。 -沙箱所需权限已按上述记录核对齐全;正式环境权限和实际返回字段覆盖率仍待上线配置及有数据后的验收。 +每个运行实例仍须核对其实际环境权限。当前生产捕获已用于字段识别,正式日报验收仍须完成业务字段值确认。 `getBlock` 等补查如无权限会明确失败,不补造空值。 ## 检查 ```sh -ARR_TEST_LOCAL_POSTGRES=1 .venv/bin/python -m unittest tests.test_arr_direct_data -v +ARR_TEST_LOCAL_POSTGRES=1 .venv/bin/python -m unittest tests.test_arr_direct_data tests.test_arr_data_review -v ``` -检查覆盖6笔正常订单总额18200、缺价复核、缺字段、去重、原始数据保留、独立报表核对、 -页面请求、月报公式、提交后断线恢复、来源版本隔离,以及019迁移检查和回退保护。 +检查覆盖6笔正常订单总额18200、字段完善后继续处理、既有缺价复核、必填及可空字段验证、去重、原始数据保留、独立报表核对、 +字段修订冲突与冻结恢复、页面请求、月报公式、提交后断线恢复、来源版本隔离,以及019迁移检查和回退保护。 浏览器检查:`tests/browser/arr_direct_data.cjs`,只接受明确指定的本机模拟实例。 -当前8875已交给用户会话后台服务运行,避免对话结束后登录页无法连接。仅在当前用户会话运行; -当前OHIP沙箱的停止命令为 `launchctl bootout gui/$(id -u)/com.arr.local-ohip.8875`。服务配置位于私有实例的 +历史8875沙箱曾交给用户会话后台服务运行,避免对话结束后登录页无法连接。仅在用户会话运行; +该历史OHIP沙箱的停止命令为 `launchctl bootout gui/$(id -u)/com.arr.local-ohip.8875`。服务配置位于私有实例的 `service.plist`;重新加载使用 `launchctl bootstrap gui/$(id -u) /absolute/private/instance/service.plist`。 后台环境已设 `LC_ALL=C`、`LANG=C`。先停后台服务再使用前台serve命令,不同时启动两份。 diff --git a/arr_web/app.py b/arr_web/app.py index 784dbb7..6ec2c11 100644 --- a/arr_web/app.py +++ b/arr_web/app.py @@ -14,7 +14,7 @@ from http.cookies import SimpleCookie from pathlib import Path from typing import Any, Dict, Mapping, Optional from urllib.parse import parse_qs, urlsplit -from urllib.parse import quote +from urllib.parse import quote, unquote from zoneinfo import ZoneInfo from arr_web.auth import LoginAttemptLedger, LoginCredentials @@ -364,6 +364,7 @@ class PortalApplication: if method == "GET" and route.path == "/api/arr-downloads": return Response.json(200, success({ "ready": self._arr_downloads.ready, + "context_id": getattr(self._arr_downloads, "context_id", None), "default_date": default_report_date(), "business_time_zone": "Asia/Bangkok", "latest_task": self._arr_download_snapshot(self._arr_downloads.latest()), @@ -386,6 +387,23 @@ class PortalApplication: if body and _strict_json(body): raise PortalError("ARR_DOWNLOAD_REQUEST_INVALID", "重试沿用原任务日期") return Response.json(202, success(self._arr_downloads.retry(request_id))) + if len(parts) >= 2 and parts[1] == "data-review": + if method == "GET" and len(parts) == 2: + return Response.json(200, success(self._arr_downloads.get_data_review(request_id))) + if method == "POST": + self._require_csrf(normalized_headers) + if len(body) > 16384: + raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413) + payload = _strict_json(body) + if type(payload.get("revision")) is not int or not 0 <= payload["revision"] <= 1000000: + raise PortalError("ARR_DATA_REVIEW_INVALID", "人工完善版本号无效") + if len(parts) == 4 and parts[2] == "items" and set(payload) == {"revision", "value"}: + return Response.json(200, success(self._arr_downloads.update_data_review_item( + request_id, unquote(parts[3]), payload["revision"], payload["value"], current_session[0]))) + if len(parts) == 3 and parts[2] == "finalize" and set(payload) == {"revision"}: + return Response.json(202, success(self._arr_downloads.finalize_data_review( + request_id, payload["revision"], current_session[0]))) + raise PortalError("ARR_DATA_REVIEW_INVALID", "人工完善请求无效") if method == "GET" and route.path == "/api/jobs": query = parse_qs(route.query, keep_blank_values=True) month = validate_month(self._one(query, "month", self._current_month())) diff --git a/arr_web/arr_data_executor.py b/arr_web/arr_data_executor.py index 35d73b9..95a68ad 100644 --- a/arr_web/arr_data_executor.py +++ b/arr_web/arr_data_executor.py @@ -18,6 +18,18 @@ class DirectARRExecutor: "data_executor_store_must_be_outside_repository") self.source, self.policy, self.processor = source, policy, processor self.object_store, self.repository, self.ingestion = object_store, repository, ingestion + from arr_web.arr_data_review import DataFieldReviews + self.data_reviews = DataFieldReviews(root=self.root / "data-reviews", policy=policy, + context=self.source_identity()) + + def get_data_review(self, request_id): + return self.data_reviews.get(request_id) + + def update_data_review_item(self, request_id, item_id, revision, value, actor): + return self.data_reviews.update(request_id, item_id, revision, value, actor) + + def finalize_data_review(self, request_id, revision, actor): + return self.data_reviews.finalize(request_id, revision, actor) def source_identity(self): return {"version": "arr-direct-data-executor/v1", "source_version": SOURCE_VERSION, @@ -53,14 +65,34 @@ class DirectARRExecutor: == ("data-" + request_id, self.source.hotel_id, from_date.isoformat(), SOURCE_VERSION), "data_checkpoint_binding_mismatch") else: - report_stage("downloading") - collected = self.source.fetch(from_date.isoformat(), request_id) - if collected.get("collection_complete") is not True: - return DownloadOutcome("failed", retryable=True) - payload = protected_read(Path(collected["data_path"]), 100 * 1024 * 1024) - require(handoff._hash(payload) == collected["data_sha256"], "data_source_changed") + collected_file = directory / "collected.json" + if collected_file.exists(): + checkpoint = strict_json(protected_read(collected_file, 65536)) + require(fingerprint(checkpoint["identity"]) == fingerprint(identity), "data_collection_checkpoint_conflict") + payload, original_manifest = self.data_reviews.original(request_id) + require(handoff._hash(payload) == checkpoint["data_sha256"] + and original_manifest == checkpoint["manifest_sha256"], "data_collection_checkpoint_changed") + else: + report_stage("downloading") + collected = self.source.fetch(from_date.isoformat(), request_id) + if collected.get("collection_complete") is not True: + return DownloadOutcome("failed", retryable=True) + payload = protected_read(Path(collected["data_path"]), 100 * 1024 * 1024) + require(handoff._hash(payload) == collected["data_sha256"], "data_source_changed") + original_manifest = collected["manifest_sha256"] + review = self.data_reviews.prepare(request_id, payload, original_manifest, + from_date.isoformat()) + if not collected_file.exists(): + atomic_json(collected_file, {"identity": identity, "data_sha256": handoff._hash(payload), + "manifest_sha256": original_manifest}, replace=False) + manifest_sha256 = original_manifest + if review: + completed = self.data_reviews.payload(request_id) + if completed is None: + return DownloadOutcome("needs_data_review") + payload, manifest_sha256 = completed binding = handoff.CaptureBinding("data-" + request_id, self.source.hotel_id, - from_date.isoformat(), collected["manifest_sha256"], SOURCE_VERSION) + from_date.isoformat(), manifest_sha256, SOURCE_VERSION) report_stage("processing") result = handoff.prepare(self.root / "handoffs", binding, "ARR.json", payload, self.policy, processor=self.processor, source_role="source_data") diff --git a/arr_web/arr_data_review.py b/arr_web/arr_data_review.py new file mode 100644 index 0000000..7f270ac --- /dev/null +++ b/arr_web/arr_data_review.py @@ -0,0 +1,396 @@ +"""Revisioned source-field completion before deterministic OHIP processing. + +Original acquisition bytes remain immutable. Staff may resolve only fields that +block the unchanged processor's candidate validation. Finalization freezes a +reproducible derived source with the original observations and decision audit. +""" +from __future__ import annotations + +import copy +from contextlib import contextmanager +from datetime import date, datetime, timezone +from decimal import Decimal, InvalidOperation +import hashlib +import importlib.util +import os +from pathlib import Path +import re +import sys +import tempfile +import threading +import uuid + +from arr_web.arr_downloads import validate_request_id +from arr_web.contracts import PortalError +from integrations.ohip.audit_arr_capture import protected_read +from integrations.ohip.capture_job import atomic_json, fingerprint, job_lock, private_directory, sync_directory +from integrations.ohip.collect_arr_source import json_bytes, require, strict_json +from integrations.ohip.collect_arr_source import CollectionError + +VERSION = "arr-source-field-review/v1" +LIMIT = 100 * 1024 * 1024 +OPTIONAL = frozenset({"BLOCK_CODE", "RES_COMMENT", "PRODUCTS", "ROOM_CATEGORY_LABEL"}) +LABELS = { + "BLOCK_CODE": "团队代码", "ADULTS": "成人人数", "CHILDREN": "儿童人数", + "COMPANY_NAME": "公司名称", "CONFIRMATION_NO": "确认号", "DISP_ROOM_NO": "房号", + "EFFECTIVE_RATE_AMOUNT": "Opera 房价", "FULL_NAME": "客人姓名", "RES_COMMENT": "预订备注", + "NO_OF_ROOMS": "房间数", "PRODUCTS": "包价项目", "RATE_CODE": "费率代码", + "ROOM_CATEGORY_LABEL": "房型", "ARRIVAL": "到店日期", "DEPARTURE": "离店日期", +} +FIELD_ALIASES = {"CF_CHILDREN": "CHILDREN", "TRUNC_BEGIN": "ARRIVAL", "TRUNC_END": "DEPARTURE"} + + +def _error(code="INVALID", message="请填写有效的字段值", status=400): + return PortalError("ARR_DATA_REVIEW_" + code, message, status) + + +def _hash(raw): + return hashlib.sha256(raw).hexdigest() + + +def _write_once(path, raw): + if path.exists(): + require(protected_read(path, LIMIT) == raw, "data_review_immutable_file_changed") + return + fd, temporary = tempfile.mkstemp(prefix=".review-", dir=path.parent) + temporary = Path(temporary) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + os.link(temporary, path) # Publish complete bytes atomically and exclusively. + sync_directory(path.parent) + finally: + temporary.unlink(missing_ok=True) + + +def _rules(policy): + name = "_arr_source_review_" + uuid.uuid4().hex + spec = importlib.util.spec_from_file_location(name, policy.skill_root / "scripts" / "process_daily.py") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + try: + spec.loader.exec_module(module) + finally: + sys.modules.pop(name, None) + require(module.PROCESSOR_VERSION == policy.processor_version + and module.rule_set_sha256() == policy.rule_set_sha256, "data_review_rules_changed") + return module + + +class DataFieldReviews: + def __init__(self, *, root, policy, context): + self.root = Path(root).absolute() + require(not self.root.resolve().is_relative_to(Path(__file__).resolve().parents[1]), + "data_review_store_must_be_outside_repository") + self.policy, self.context = policy, copy.deepcopy(context) + self.rules = _rules(policy) + self._mutex = threading.RLock() + + @contextmanager + def _lock(self, directory): + with self._mutex: + try: + with job_lock(directory): + yield + except CollectionError as error: + if str(error) == "batch_busy": + raise _error("CONFLICT", "数据正在更新,请重新查看后再保存", 409) from None + raise + + def _directory(self, request_id, *, create=False): + validate_request_id(request_id) + directory = self.root / request_id + if create: + private_directory(self.root) + private_directory(directory) + sync_directory(self.root) + if not directory.exists(): + raise _error("NOT_FOUND", "该任务没有待完善的数据", 404) + private_directory(directory) + return directory + + def _read(self, directory): + meta = strict_json(protected_read(directory / "identity.json", 65536)) + require(meta["version"] == VERSION and fingerprint(meta["context"]) == fingerprint(self.context) + and meta["request_id"] == directory.name, "data_review_identity_changed") + raw = protected_read(directory / "original.json", LIMIT) + require(_hash(raw) == meta["original_sha256"], "data_review_original_changed") + original = strict_json(raw) + require(original["report_date"] == meta["report_date"] and original.get("collection_complete") is True, + "data_review_source_context_changed") + pointer = strict_json(protected_read(directory / "state.json", 65536)) + filename = pointer.get("file", "") + require(bool(re.fullmatch(r"revision-[0-9]{6}-[0-9a-f]{64}\.json", filename)), "data_review_pointer_invalid") + state_raw = protected_read(directory / filename, LIMIT) + require(_hash(state_raw) == pointer["sha256"], "data_review_state_changed") + state = strict_json(state_raw) + require(state["identity_sha256"] == fingerprint(meta) and type(state["revision"]) is int + and state["status"] in {"editing", "finalized"}, "data_review_state_context_changed") + return meta, original, state + + def _publish(self, directory, state): + raw = json_bytes(state) + digest = _hash(raw) + filename = f"revision-{state['revision']:06d}-{digest}.json" + _write_once(directory / filename, raw) + atomic_json(directory / "state.json", {"file": filename, "sha256": digest}, replace=True) + + @staticmethod + def _observation(field, value): + empty = value == "" or value == [] + if empty and field in OPTIONAL: + return {"state": "empty", "value": [] if field in {"PRODUCTS", "RES_COMMENT"} else ""} + if field == "PRODUCTS": + value = [{"package": {"packageCode": code}} for code in value] + elif field == "RES_COMMENT": + value = [value] + return {"state": "available", "value": value} + + def _derive(self, original, state): + data = copy.deepcopy(original) + for item_id, decision in state["decisions"].items(): + sequence, field = item_id.split(":") + row = data["records"][int(sequence) - 1] + require(row["source_sequence"] == int(sequence) and field in LABELS, "data_review_decision_invalid") + row["fields"][field] = {**self._observation(field, decision["value"]), + "origin": "manual_review", "review_item_id": item_id} + unresolved = any(observation.get("state") != "available" + and not (field in OPTIONAL and observation.get("state") == "empty") + for row in data["records"] for field, observation in row["fields"].items()) + data["input_complete"] = not unresolved + data["status"] = "collected_with_gaps" if unresolved else "collected" + return data + + def _issues(self, directory, data): + # Use the exact frozen processor's normalizer and field validation; the + # acquisition layer never maintains a second whitelist or numeric rule. + with tempfile.NamedTemporaryFile(suffix=".json", dir=directory) as stream: + stream.write(json_bytes(data)) + stream.flush() + business_date, records = self.rules.read_data_source(Path(stream.name)) + issues = {} + for row, record in zip(data["records"], records): + rate = record["_NORMALIZED_RATE_CODE"] + fields = {} + if not rate or "RATE_CODE" in record["_DATA_GAPS"]: + fields["RATE_CODE"] = "rate_code_unresolved" + elif rate in self.rules.RATE_WHITELIST: + for field in record["_DATA_GAPS"]: + fields[field] = row["fields"][field].get("reason", "field_unresolved") + for error in self.rules.validate_whitelisted_record(record, business_date): + field = str(error.source_location or "").rsplit("/", 1)[-1] + if error.code == "DATA_NEGATIVE_NIGHTS": + field = "DEPARTURE" + field = FIELD_ALIASES.get(field, field) + if field in LABELS: + fields.setdefault(field, error.code) + for field, reason in fields.items(): + issues[f"{row['source_sequence']}:{field}"] = str(reason) + return issues + + @staticmethod + def _display(row, field): + observation = row["fields"][field] + value = observation.get("value") + return value if observation.get("state") == "available" and isinstance(value, (str, int)) else "" + + def _public(self, directory, meta, original, state): + data = self._derive(original, state) + issues = self._issues(directory, data) + keys = set(issues) | set(state["decisions"]) + items = [] + for item_id in sorted(keys, key=lambda key: (int(key.split(":")[0]), key.split(":")[1])): + sequence, field = item_id.split(":") + row = data["records"][int(sequence) - 1] + decision = state["decisions"].get(item_id) + items.append({"item_id": item_id, "source_sequence": int(sequence), "field": field, + "field_label": LABELS[field], "confirmation_no": self._display(row, "CONFIRMATION_NO"), + "room_no": self._display(row, "DISP_ROOM_NO"), "company_name": self._display(row, "COMPANY_NAME"), + "rate_code": self._display(row, "RATE_CODE"), "reason_code": issues.get(item_id, "confirmed"), + "can_be_empty": field in OPTIONAL, "value": decision["value"] if decision else None, + "confirmed": decision is not None and item_id not in issues}) + return {"request_id": meta["request_id"], "report_date": meta["report_date"], + "revision": state["revision"], "status": state["status"], "items": items, + "pending_count": len(issues), "total_count": len(items), + "can_finalize": not issues and state["status"] == "editing"} + + def prepare(self, request_id, payload, manifest_sha256, report_date): + directory = self._directory(request_id, create=True) + with self._lock(directory): + meta = {"version": VERSION, "context": self.context, "request_id": request_id, + "report_date": report_date, "original_sha256": _hash(payload), + "source_manifest_sha256": manifest_sha256} + if not (directory / "identity.json").exists(): + require(bool(re.fullmatch(r"[0-9a-f]{64}", manifest_sha256)), "data_review_manifest_invalid") + original = strict_json(payload) + require(original.get("report_date") == report_date and original.get("collection_complete") is True, + "data_review_requires_complete_collection") + for key in ("hotel_id", "source_kind"): + if key in self.context: + require(original.get(key) == self.context[key], "data_review_source_context_mismatch") + _write_once(directory / "original.json", payload) + atomic_json(directory / "identity.json", meta, replace=False) + require(fingerprint(strict_json(protected_read(directory / "identity.json", 65536))) == fingerprint(meta), + "data_review_prepare_conflict") + if not (directory / "state.json").exists(): + state = {"identity_sha256": fingerprint(meta), "revision": 0, "status": "editing", + "decisions": {}, "events": []} + initial_file = f"revision-000000-{_hash(json_bytes(state))}.json" + require({p.name for p in directory.glob("revision-*.json")} <= {initial_file} + and not (directory / "finalize-intent.json").exists(), "data_review_state_missing") + self._publish(directory, state) + actual, original, state = self._read(directory) + require(fingerprint(actual) == fingerprint(meta), "data_review_prepare_conflict") + review = self._public(directory, actual, original, state) + # A complete ordinary source needs no human step or derived source. + return review if review["total_count"] else None + + def get(self, request_id): + directory = self._directory(request_id) + with self._lock(directory): + return self._public(directory, *self._read(directory)) + + @staticmethod + def _revision(state, revision): + if type(revision) is not int or revision != state["revision"]: + raise _error("CONFLICT", "数据已更新,请重新查看后再保存", 409) + + @staticmethod + def _actor(actor): + if not isinstance(actor, str) or not actor.strip() or len(actor) > 128 or re.search(r"[\x00-\x1f\x7f]", actor): + raise _error() + return actor + + def _value(self, field, value, report_date, data, sequence): + if field == "PRODUCTS": + if (not isinstance(value, list) or len(value) > 100 or any(not isinstance(code, str) + or not code.strip() or len(code) > 100 or re.search(r"[\x00-\x1f\x7f]", code) for code in value)): + raise _error(message="请按顺序填写有效的包价代码,或确认无此项") + return [code.strip() for code in value] + if isinstance(value, bool) or not isinstance(value, (str, int)): + raise _error() + value = str(value).strip() + if len(value) > 4000 or re.search(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", value): + raise _error() + if not value: + if field in OPTIONAL: + return "" + raise _error(message="此字段必须填写,不能确认为空") + if field in {"ADULTS", "CHILDREN", "NO_OF_ROOMS"}: + if not re.fullmatch(r"[0-9]{1,8}", value): + raise _error(message="人数和房间数必须是非负整数") + number = int(value) + if field == "NO_OF_ROOMS" and number == 0: + raise _error(message="房间数必须大于零") + return number + if field == "EFFECTIVE_RATE_AMOUNT": + if not re.fullmatch(r"[0-9]{1,16}(?:\.[0-9]{1,2})?", value): + raise _error(message="Opera 房价必须是非负金额,最多两位小数") + try: + amount = Decimal(value) + except InvalidOperation: + raise _error() from None + return format(amount, "f") + if field in {"ARRIVAL", "DEPARTURE"}: + try: + day = date.fromisoformat(value) + if day.isoformat() != value: + raise ValueError() + except ValueError: + raise _error(message="日期请填写 YYYY-MM-DD") from None + if (field == "ARRIVAL" and value != report_date) or (field == "DEPARTURE" and value < report_date): + raise _error(message="到店日期须与报表日期一致,离店日期不得早于到店日期") + return value.upper() if field == "RATE_CODE" else value + + def update(self, request_id, item_id, revision, value, actor): + directory = self._directory(request_id) + with self._lock(directory): + meta, original, state = self._read(directory) + if state["status"] != "editing" or (directory / "finalize-intent.json").exists(): + raise _error("FROZEN", "已确认生成,字段不能再修改", 409) + self._revision(state, revision) + actor = self._actor(actor) + data = self._derive(original, state) + if item_id not in self._issues(directory, data) and item_id not in state["decisions"]: + raise _error(message="只能完善当前任务列出的异常字段") + sequence, field = item_id.split(":") + value = self._value(field, value, meta["report_date"], data, int(sequence)) + previous = copy.deepcopy(state["decisions"].get(item_id)) + state["revision"] += 1 + state["decisions"][item_id] = {"value": value, "actor": actor, "revision": state["revision"]} + state["events"].append({"revision": state["revision"], "action": "set_field", "item_id": item_id, + "actor": actor, "value": value, "previous": previous, "at": datetime.now(timezone.utc).isoformat()}) + result = self._public(directory, meta, original, state) + if item_id in {item["item_id"] for item in result["items"] if not item["confirmed"]}: + raise _error(message="该字段仍不满足报表规则,请核对填写内容") + self._publish(directory, state) + return result + + def _frozen(self, meta, original, state): + changes = [] + for item_id, decision in sorted(state["decisions"].items()): + sequence, field = item_id.split(":") + changes.append({"item_id": item_id, "source_sequence": int(sequence), "field": field, + "original_observation": original["records"][int(sequence) - 1]["fields"][field], + **decision}) + audit = {"version": VERSION, "request_id": meta["request_id"], "report_date": meta["report_date"], + "context": self.context, "original_sha256": meta["original_sha256"], + "source_manifest_sha256": meta["source_manifest_sha256"], "revision": state["revision"], + "changes": changes, "events": state["events"]} + data = self._derive(original, state) + data["manual_data_review"] = {"manifest": audit, "manifest_sha256": fingerprint(audit)} + binding = fingerprint({"source_manifest_sha256": meta["source_manifest_sha256"], + "review_manifest_sha256": fingerprint(audit), "data_sha256": _hash(json_bytes(data))}) + return json_bytes(data), binding + + def finalize(self, request_id, revision, actor): + directory = self._directory(request_id) + with self._lock(directory): + meta, original, state = self._read(directory) + if state["status"] == "finalized": + self._verify_frozen(directory, meta, original, state) + return self._public(directory, meta, original, state) + self._revision(state, revision) + actor = self._actor(actor) + if self._issues(directory, self._derive(original, state)): + raise _error("INCOMPLETE", "请先完善并确认所有异常字段", 409) + intent = directory / "finalize-intent.json" + prior_sha256 = fingerprint(state) + if intent.exists(): + frozen = strict_json(protected_read(intent, LIMIT)) + require(frozen["prior_sha256"] == prior_sha256, "data_review_finalize_context_changed") + state = frozen["state"] + else: + state["revision"] += 1 + state["status"] = "finalized" + state["events"].append({"revision": state["revision"], "action": "finalize", "actor": actor, + "at": datetime.now(timezone.utc).isoformat()}) + atomic_json(intent, {"prior_sha256": prior_sha256, "state": state}, replace=False) + payload, binding = self._frozen(meta, original, state) + _write_once(directory / "reviewed-source.json", payload) + state["derived_sha256"], state["binding_manifest_sha256"] = _hash(payload), binding + self._publish(directory, state) + return self._public(directory, meta, original, state) + + def _verify_frozen(self, directory, meta, original, state): + payload, binding = self._frozen(meta, original, state) + require(_hash(payload) == state["derived_sha256"] and binding == state["binding_manifest_sha256"] + and protected_read(directory / "reviewed-source.json", LIMIT) == payload, + "data_review_frozen_source_changed") + return payload, binding + + def payload(self, request_id): + directory = self._directory(request_id) + with self._lock(directory): + meta, original, state = self._read(directory) + if state["status"] != "finalized": + return None + return self._verify_frozen(directory, meta, original, state) + + def original(self, request_id): + directory = self._directory(request_id) + with self._lock(directory): + meta, _original, _state = self._read(directory) + return protected_read(directory / "original.json", LIMIT), meta["source_manifest_sha256"] diff --git a/arr_web/arr_downloads.py b/arr_web/arr_downloads.py index b53f538..2012d26 100644 --- a/arr_web/arr_downloads.py +++ b/arr_web/arr_downloads.py @@ -8,6 +8,8 @@ The SQLite queue has one process owner on one host, not a distributed lease. from __future__ import annotations import fcntl +import hashlib +import json import os import re import sqlite3 @@ -58,11 +60,13 @@ class DownloadOutcome: retryable: bool = False def validate(self) -> None: - if self.status not in {"succeeded", "needs_review", "failed"}: + if self.status not in {"succeeded", "needs_review", "needs_data_review", "failed"}: raise ValueError("invalid download outcome") if self.job_id is not None: validate_job_id(self.job_id) - if self.status != "failed" and (not self.job_id or self.retryable): + if self.status == "needs_data_review" and (self.job_id is not None or self.retryable): + raise ValueError("source review precedes a processing job") + if self.status in {"succeeded", "needs_review"} and (not self.job_id or self.retryable): raise ValueError("a processing outcome requires a stable job identity") if type(self.retryable) is not bool: raise ValueError("invalid retryability") @@ -90,6 +94,9 @@ class ARRDownloadCoordinator(Protocol): def create(self, report_date: str, request_id: str) -> dict: ... def get(self, request_id: str) -> dict: ... def retry(self, request_id: str) -> dict: ... + def get_data_review(self, request_id: str) -> dict: ... + def update_data_review_item(self, request_id, item_id, revision, value, actor) -> dict: ... + def finalize_data_review(self, request_id, revision, actor) -> dict: ... class UnavailableARRDownloads: @@ -111,6 +118,10 @@ class UnavailableARRDownloads: def retry(self, request_id: str) -> dict: raise self._unavailable() + def get_data_review(self, *args): raise self._unavailable() + def update_data_review_item(self, *args): raise self._unavailable() + def finalize_data_review(self, *args): raise self._unavailable() + class PersistentARRDownloads: """Persist intent before dispatch; response loss never allocates another job.""" @@ -131,6 +142,10 @@ class PersistentARRDownloads: os.close(self._lease) raise self._executor = executor + identity = getattr(executor, "source_identity", None) + source_context = identity() if callable(identity) else {"kind": type(executor).__name__} + self.context_id = hashlib.sha256(json.dumps({"root": str(jobs_root.resolve()), "source": source_context}, + sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest() self._condition = threading.Condition(threading.RLock()) self._stopping = False try: @@ -171,6 +186,9 @@ class PersistentARRDownloads: "request_id": row["request_id"], "report_date": row["report_date"], "from_date": row["report_date"], "to_date": row["report_date"], "status": row["status"], "job_id": row["job_id"], + "error_code": ("ARR_SOURCE_FETCH_FAILED" if row["status"] == "failed" else + "ARR_DOWNLOAD_INTERRUPTED" if row["status"] == "interrupted" else None) + if not row["job_id"] else None, "can_retry": bool(row["retryable"]) and row["attempts"] < MAX_ATTEMPTS, "created_at": row["created_at"], "updated_at": row["updated_at"], } @@ -186,6 +204,17 @@ class PersistentARRDownloads: row = db.execute("SELECT * FROM downloads WHERE request_id=?", (request_id,)).fetchone() if row is None: raise PortalError("ARR_DOWNLOAD_NOT_FOUND", "下载任务不存在", 404) + # An explicit finalization may have frozen successfully just before the + # process/response was lost. Reconciliation resumes that same intent. + review_getter = getattr(self._executor, "get_data_review", None) + if row["status"] == "needs_data_review" and callable(review_getter) and self.ready: + with self._condition: + if review_getter(request_id)["status"] == "finalized": + with self._connect() as db: + db.execute("UPDATE downloads SET status='queued',retryable=0,updated_at=? WHERE request_id=? AND status='needs_data_review'", + (_now(), request_id)) + row = db.execute("SELECT * FROM downloads WHERE request_id=?", (request_id,)).fetchone() + self._condition.notify_all() return self._public(row) def create(self, report_date: str, request_id: str) -> dict: @@ -203,7 +232,7 @@ class PersistentARRDownloads: return self._public(row) # Separate tabs cannot start another acquisition for an unresolved day. row = db.execute("""SELECT * FROM downloads WHERE report_date=? AND - status IN ('queued','downloading','processing','interrupted') + status IN ('queued','downloading','processing','interrupted','needs_data_review') ORDER BY created_at DESC LIMIT 1""", (report_date,)).fetchone() if row: return self._public(row) @@ -213,13 +242,46 @@ class PersistentARRDownloads: self._condition.notify_all() return self.get(request_id) + def _review_method(self, name): + method = getattr(self._executor, name, None) + if not callable(method): + raise PortalError("ARR_DATA_REVIEW_NOT_FOUND", "该任务没有待完善的数据", 404) + return method + + def get_data_review(self, request_id): + self.get(request_id) + return self._review_method("get_data_review")(request_id) + + def update_data_review_item(self, request_id, item_id, revision, value, actor): + with self._condition: + if self.get(request_id)["status"] != "needs_data_review": + raise PortalError("ARR_DATA_REVIEW_FROZEN", "该任务当前不能修改字段", 409) + return self._review_method("update_data_review_item")(request_id, item_id, revision, value, actor) + + def finalize_data_review(self, request_id, revision, actor): + with self._condition: + if not self.ready: + raise UnavailableARRDownloads._unavailable() + task = self.get(request_id) + review = self.get_data_review(request_id) + if task["status"] != "needs_data_review": + if review["status"] == "finalized": + return task # Reconcile a lost finalization response without another run. + raise PortalError("ARR_DATA_REVIEW_FROZEN", "该任务当前不能确认生成", 409) + self._review_method("finalize_data_review")(request_id, revision, actor) + with self._connect() as db: + db.execute("UPDATE downloads SET status='queued',retryable=0,updated_at=? WHERE request_id=? AND status='needs_data_review'", + (_now(), request_id)) + self._condition.notify_all() + return self.get(request_id) + def retry(self, request_id: str) -> dict: validate_request_id(request_id) with self._condition: if not self.ready: raise UnavailableARRDownloads._unavailable() task = self.get(request_id) - if task["status"] in ACTIVE or task["status"] in {"succeeded", "needs_review"}: + if task["status"] in ACTIVE or task["status"] in {"succeeded", "needs_review", "needs_data_review"}: return task if not task["can_retry"]: raise PortalError("ARR_DOWNLOAD_RETRY_UNAVAILABLE", "该任务不能继续重试,请查看任务日志", 409) diff --git a/arr_web/local_ohip.py b/arr_web/local_ohip.py index 4b5cdf7..fd6fc8a 100644 --- a/arr_web/local_ohip.py +++ b/arr_web/local_ohip.py @@ -90,6 +90,17 @@ class PermissionDownloads: def latest(self): return self.queue.latest() def get(self, request_id): return self.queue.get(request_id) + @property + def context_id(self): return self.queue.context_id + + def get_data_review(self, request_id): return self.queue.get_data_review(request_id) + + def update_data_review_item(self, *args): return self.queue.update_data_review_item(*args) + + def finalize_data_review(self, *args): + self.access.require_ready() + return self.queue.finalize_data_review(*args) + def create(self, report_date, request_id): self.access.require_ready() return self.queue.create(report_date, request_id) @@ -107,6 +118,14 @@ class AuthorizedExecutor: self.access.require_ready() return self.executor.execute(**kwargs) + def source_identity(self): return self.executor.source_identity() + + def get_data_review(self, request_id): return self.executor.get_data_review(request_id) + + def update_data_review_item(self, *args): return self.executor.update_data_review_item(*args) + + def finalize_data_review(self, *args): return self.executor.finalize_data_review(*args) + class LocalOHIPPortal(LocalReplayPortal): allow_xml_upload = True diff --git a/arr_web/local_xml_replay.py b/arr_web/local_xml_replay.py index 4bb5152..2f0ea5c 100644 --- a/arr_web/local_xml_replay.py +++ b/arr_web/local_xml_replay.py @@ -123,6 +123,9 @@ class SourceDateDownloads: def ready(self): return self.queue.ready + @property + def context_id(self): return self.queue.context_id + def latest(self): return self.queue.latest() @@ -168,6 +171,7 @@ class LocalReplayPortal: return self._error("REPLAY_ROUTE_DISABLED", 404) allowed = (self.allow_xml_upload and path == "/api/jobs") or path in {"/api/login", "/api/logout", "/api/arr-downloads"} or bool( re.fullmatch(r"/api/arr-downloads/[a-f0-9]{32}/retry", path)) or bool( + re.fullmatch(r"/api/arr-downloads/[a-f0-9]{32}/data-review/(?:items/[^/]+|finalize)", path)) or bool( re.fullmatch(r"/api/jobs/[^/]+/review/(?:items/[0-9]+|finalize|cancel)", path)) if method != "GET" and not allowed: return self._error("REPLAY_ROUTE_DISABLED", 405) diff --git a/arr_web/static/app.js b/arr_web/static/app.js index 04a8bac..8217390 100644 --- a/arr_web/static/app.js +++ b/arr_web/static/app.js @@ -22,6 +22,17 @@ arrDownloadPollTimer: null, arrDownloadSynced: "", arrDownloadStorageKey: "", + arrDownloadUsername: "", + arrDownloadContextId: "", + arrDataReview: null, + arrDataReviewRequestId: "", + arrDataReviewLoading: false, + arrDataReviewMutating: false, + arrDataReviewDisconnected: false, + arrDataReviewFinalizing: false, + arrDataReviewDrafts: {}, + arrDataReviewMessage: "", + arrDataReviewMessageError: false, companySourceFile: null, companySource: null, companySourceDraft: null, @@ -286,6 +297,7 @@ function rememberARRIntent(intent) { state.arrDownloadIntent = intent; + if (!state.arrDownloadStorageKey) return; try { if (intent) localStorage.setItem(state.arrDownloadStorageKey, JSON.stringify(intent)); else localStorage.removeItem(state.arrDownloadStorageKey); @@ -306,10 +318,14 @@ || $("#arr-download-date").value === state.arrDownloadTask.report_date)); } + function arrDownloadNeedsDataReview() { + return state.arrDownloadTask?.status === "needs_data_review"; + } + function handleARRDownloadDateChange() { state.arrDownloadDateEdited = true; // Choosing the next report date must not abandon a running or uncertain request. - if (!arrDownloadActive() && !state.arrDownloadBusy && !arrDownloadNeedsReconciliation()) { + if (!arrDownloadActive() && !state.arrDownloadBusy && !arrDownloadNeedsReconciliation() && !arrDownloadNeedsDataReview()) { state.arrDownloadTask = null; state.arrDownloadDisconnected = false; rememberARRIntent(null); @@ -322,7 +338,7 @@ function renderARRDownload() { const task = state.arrDownloadTask; const pending = Boolean(state.arrDownloadIntent && !task); - const locked = state.arrDownloadBusy; + const locked = state.arrDownloadBusy || state.arrDataReviewMutating; const reconciling = arrDownloadNeedsReconciliation(); const dateInput = $("#arr-download-date"); const button = $("#arr-download-button"); @@ -334,7 +350,7 @@ : reconciling ? "arr_download.date_resume" : "arr_download.help", { date: state.arrDownloadIntent?.report_date }); if (locked) closeARRCalendar(); - button.disabled = !state.arrDownloadLoaded || !state.arrDownloadReady || state.arrDownloadBusy || arrDownloadActive() + button.disabled = !state.arrDownloadLoaded || !state.arrDownloadReady || locked || arrDownloadActive() || state.arrDataReviewFinalizing || (!reconciling && !arrDownloadCanResume() && !validARRDate(dateInput.value)) || task?.status === "date_unavailable" || (task?.status === "interrupted" && !task.can_retry); @@ -342,6 +358,7 @@ if (state.arrDownloadBusy) label = "arr_download.submitting"; else if (arrDownloadActive()) label = "arr_download.running"; else if (pending || state.arrDownloadDisconnected) label = "arr_download.check"; + else if (arrDownloadNeedsDataReview()) label = "arr_download.complete_data"; else if (arrDownloadCanResume() || task?.status === "not_received") label = "arr_download.retry_date"; button.textContent = I18N.t(label, { date: task?.report_date }); button.setAttribute("aria-busy", String(state.arrDownloadBusy || arrDownloadActive())); @@ -349,11 +366,15 @@ if (state.arrDownloadConfigDisconnected) status = "service_reconnecting"; if (state.arrDownloadDisconnected || pending) status = "reconnecting"; const feedback = $("#arr-download-feedback"); - feedback.className = `arr-download-feedback${arrDownloadActive() ? " is-running" : status === "succeeded" ? " is-success" : status === "needs_review" ? " is-review" : ["failed", "interrupted", "reconnecting", "service_reconnecting", "date_unavailable"].includes(status) ? " is-error" : ""}`; - const text = I18N.t(`arr_download.${status}`); + feedback.className = `arr-download-feedback${arrDownloadActive() ? " is-running" : status === "succeeded" ? " is-success" : ["needs_review", "needs_data_review"].includes(status) ? " is-review" : ["failed", "interrupted", "reconnecting", "service_reconnecting", "date_unavailable"].includes(status) ? " is-error" : ""}`; + const taskError = !task?.job_id && status === "failed" && task.error_code === "ARR_SOURCE_FETCH_FAILED" ? "source_failed" + : !task?.job_id && status === "interrupted" && task.error_code === "ARR_DOWNLOAD_INTERRUPTED" ? "source_interrupted" : status; + const text = I18N.t(`arr_download.${taskError}`); const taskDate = task?.report_date || state.arrDownloadIntent?.report_date; $("#arr-download-status").textContent = taskDate ? `${taskDate} · ${text}` : text; $("#arr-download-review").hidden = task?.status !== "needs_review" || !task.job_id; + $("#arr-download-data-review").hidden = !arrDownloadNeedsDataReview(); + $("#arr-download-data-review").disabled = locked; } function clearARRDownloadPoll() { @@ -366,7 +387,7 @@ if (document.hidden || state.arrDownloadBusy) return; if (!state.arrDownloadReady || state.arrDownloadConfigDisconnected) { state.arrDownloadPollTimer = window.setTimeout(initARRDownload, 10000); - } else if (state.arrDownloadIntent && (arrDownloadActive() || state.arrDownloadDisconnected || !state.arrDownloadTask || state.arrDownloadTask.status === "needs_review")) { + } else if (state.arrDownloadIntent && (arrDownloadActive() || state.arrDownloadDisconnected || !state.arrDownloadTask || state.arrDownloadTask.status === "needs_review" || state.arrDataReviewFinalizing)) { state.arrDownloadPollTimer = window.setTimeout(loadARRDownloadTask, 4000); } } @@ -377,6 +398,13 @@ rememberARRIntent({ request_id: task.request_id, report_date: task.report_date }); if (!state.arrDownloadDateEdited) $("#arr-download-date").value = task.report_date; renderARRDownload(); + if (arrDownloadNeedsDataReview()) { + if (state.arrDataReviewRequestId !== task.request_id || !state.arrDataReview || state.arrDataReviewFinalizing) { + await loadARRDataReview(task.request_id, { showErrors: false }); + } + } else { + resetARRDataReview(); + } const signature = `${task.request_id}:${task.status}`; if (sync && task.job_id && ["succeeded", "needs_review", "failed"].includes(task.status) && state.arrDownloadSynced !== signature) { state.jobsMonth = task.report_date.slice(0, 7); @@ -414,23 +442,40 @@ } async function initARRDownload() { - if (!state.arrDownloadStorageKey || document.hidden || state.arrDownloadConfigLoading || state.arrDownloadBusy) return; + if (!state.arrDownloadUsername || document.hidden || state.arrDownloadConfigLoading || state.arrDownloadBusy || state.arrDataReviewMutating) return; state.arrDownloadConfigLoading = true; clearARRDownloadPoll(); - // Restore only once, even when the first readiness request fails. Reconnecting - // must not discard an uncertain POST or reset an operator's chosen date. - if (!state.arrDownloadRestored) { - state.arrDownloadRestored = true; - let saved; - try { saved = JSON.parse(localStorage.getItem(state.arrDownloadStorageKey)); } catch (_) { /* No saved task. */ } - if (saved && /^[0-9a-f]{32}$/.test(saved.request_id) && validARRDate(saved.report_date)) { - state.arrDownloadIntent = saved; - $("#arr-download-date").value = saved.report_date; - } - renderARRDownload(); - } try { const config = await api("/api/arr-downloads"); + // The server identity must be known before reading any saved request. Never + // restore the legacy username-only key, including when config is unavailable. + if (typeof config.context_id !== "string" || !config.context_id) { + if (config.ready) throw new Error("ARR context unavailable"); + state.arrDownloadReady = false; + state.arrDownloadConfigDisconnected = false; + if (!state.arrDownloadDateEdited && !state.arrDownloadIntent && validARRDate(config.default_date)) $("#arr-download-date").value = config.default_date; + return; + } + const storageKey = `arr:last-download:v2:${encodeURIComponent(config.context_id)}:${encodeURIComponent(state.arrDownloadUsername)}`; + if (state.arrDownloadStorageKey !== storageKey) { + state.arrDownloadStorageKey = storageKey; + state.arrDownloadContextId = config.context_id; + state.arrDownloadRestored = false; + state.arrDownloadIntent = null; + state.arrDownloadTask = null; + state.arrDownloadDisconnected = false; + state.arrDownloadSynced = ""; + resetARRDataReview(); + } + if (!state.arrDownloadRestored) { + state.arrDownloadRestored = true; + let saved; + try { saved = JSON.parse(localStorage.getItem(storageKey)); } catch (_) { /* No saved task. */ } + if (saved && /^[0-9a-f]{32}$/.test(saved.request_id) && validARRDate(saved.report_date)) { + state.arrDownloadIntent = { request_id: saved.request_id, report_date: saved.report_date }; + if (!state.arrDownloadDateEdited) $("#arr-download-date").value = saved.report_date; + } + } state.arrDownloadReady = Boolean(config.ready); state.arrDownloadConfigDisconnected = false; if (!state.arrDownloadDateEdited && !state.arrDownloadIntent && !state.arrDownloadTask && !state.arrDownloadBusy && validARRDate(config.default_date)) { @@ -438,7 +483,8 @@ } if (state.arrDownloadIntent) { await loadARRDownloadTask(); - } else if (config.latest_task && !state.arrDownloadBusy && !state.arrDownloadTask) { + } else if (config.latest_task && ["queued", "downloading", "processing", "interrupted", "needs_data_review", "needs_review"].includes(config.latest_task.status) + && !state.arrDownloadBusy && !state.arrDownloadTask) { await acceptARRDownloadTask(config.latest_task, { sync: false }); } } catch (_) { @@ -454,11 +500,15 @@ async function submitARRDownload(event) { event.preventDefault(); - if (!state.arrDownloadReady || state.arrDownloadBusy || arrDownloadActive()) return; + if (!state.arrDownloadReady || state.arrDownloadBusy || state.arrDataReviewMutating || state.arrDataReviewFinalizing || arrDownloadActive()) return; if (state.arrDownloadDisconnected || (state.arrDownloadIntent && !state.arrDownloadTask)) { await loadARRDownloadTask(); return; } + if (arrDownloadNeedsDataReview()) { + await loadARRDataReview(state.arrDownloadTask.request_id, { scroll: true }); + return; + } const task = state.arrDownloadTask; if (task?.status === "interrupted" && !task.can_retry) return; const resume = arrDownloadCanResume(); @@ -503,6 +553,216 @@ } } + function resetARRDataReview() { + state.arrDataReview = null; + state.arrDataReviewRequestId = ""; + state.arrDataReviewLoading = false; + state.arrDataReviewDisconnected = false; + state.arrDataReviewFinalizing = false; + state.arrDataReviewDrafts = {}; + state.arrDataReviewMessage = ""; + state.arrDataReviewMessageError = false; + renderARRDataReview(); + } + + function arrDataReviewInputValue(item) { + // Unconfirmed source values may be ambiguous. Require an explicit entry. + if (!item.confirmed) return ""; + return Array.isArray(item.value) ? item.value.join("\n") : String(item.value ?? ""); + } + + function arrDataReviewCanFinalize(review = state.arrDataReview) { + return Boolean(review && review.status === "editing" && review.can_finalize && Number(review.pending_count) === 0 + && Array.isArray(review.items) && review.items.every((item) => item.confirmed) + && !Object.keys(state.arrDataReviewDrafts).length && !state.arrDataReviewLoading && !state.arrDataReviewMutating + && !state.arrDataReviewDisconnected && !state.arrDataReviewFinalizing); + } + + function setARRDataReviewMessage(message = "", isError = false) { + state.arrDataReviewMessage = message; + state.arrDataReviewMessageError = isError; + const node = $("#arr-data-review-message"); + node.textContent = message.startsWith("data_review.") ? I18N.t(message) : message; + node.classList.toggle("is-error", isError); + } + + function renderARRDataReview() { + const panel = $("#arr-data-review-panel"); + const review = state.arrDataReview; + panel.hidden = !state.arrDataReviewRequestId || !arrDownloadNeedsDataReview(); + if (panel.hidden) return; + const busy = state.arrDataReviewLoading || state.arrDataReviewMutating; + const editable = review?.status === "editing" && !busy && !state.arrDataReviewDisconnected && !state.arrDataReviewFinalizing; + panel.setAttribute("aria-busy", String(busy)); + $("#arr-data-review-title").textContent = I18N.t("data_review.title"); + $("#arr-data-review-copy").textContent = I18N.t(state.arrDataReviewFinalizing || review?.status === "finalized" ? "data_review.finalizing_copy" : "data_review.copy"); + $("#arr-data-review-progress").textContent = review ? I18N.t("data_review.progress", { + date: review.report_date, completed: Number(review.total_count) - Number(review.pending_count), total: review.total_count, + }) : I18N.t("data_review.loading"); + $("#arr-data-review-refresh").textContent = I18N.t("data_review.refresh"); + $("#arr-data-review-refresh").disabled = busy; + const finalize = $("#arr-data-review-finalize"); + finalize.textContent = I18N.t(state.arrDataReviewFinalizing ? "data_review.finalizing" : "data_review.finalize"); + finalize.disabled = !arrDataReviewCanFinalize(review); + setARRDataReviewMessage(state.arrDataReviewMessage, state.arrDataReviewMessageError); + const items = Array.isArray(review?.items) ? review.items : []; + $("#arr-data-review-body").innerHTML = items.map((item, index) => { + const field = String(item.field || "").toUpperCase(); + const labelKey = `data_review.field_${field.toLowerCase()}`; + const translatedLabel = I18N.t(labelKey); + const label = translatedLabel === labelKey ? (item.field_label || field) : translatedLabel; + const reason = String(item.reason_code || "").toUpperCase(); + const reasonKey = item.confirmed ? "confirmed_value" : /CONFLICT|AMBIGUOUS/.test(reason) ? "conflict_value" : /INVALID|MALFORMED/.test(reason) ? "invalid_value" : "missing_value"; + const inputId = `arr-data-review-value-${index}`; + const value = Object.prototype.hasOwnProperty.call(state.arrDataReviewDrafts, item.item_id) + ? state.arrDataReviewDrafts[item.item_id] : arrDataReviewInputValue(item); + const inputMode = ["ADULTS", "CHILDREN", "NO_OF_ROOMS"].includes(field) ? "numeric" : field === "EFFECTIVE_RATE_AMOUNT" ? "decimal" : "text"; + const placeholder = ["ARRIVAL", "DEPARTURE"].includes(field) ? "YYYY-MM-DD" : field === "PRODUCTS" ? I18N.t("data_review.product_hint") : ""; + const input = field === "PRODUCTS" || field === "RES_COMMENT" + ? `` + : ``; + return ` + ${escapeHtml(item.confirmation_no || "—")}${escapeHtml(I18N.t("data_review.source_row", { sequence: item.source_sequence }))} · ${escapeHtml(I18N.t("data_review.room", { room: item.room_no || "—" }))}${escapeHtml(item.company_name || "—")} · ${escapeHtml(item.rate_code || "—")} + ${escapeHtml(I18N.t(`data_review.${reasonKey}`))} + ${input} + ${escapeHtml(I18N.t(item.confirmed ? "data_review.confirmed" : "data_review.pending"))}${item.can_be_empty ? `` : ""} + `; + }).join(""); + $("#arr-data-review-empty").hidden = items.length > 0; + $("#arr-data-review-empty").textContent = I18N.t(state.arrDataReviewLoading ? "data_review.loading" : state.arrDataReviewDisconnected ? "data_review.disconnected" : "data_review.no_items"); + } + + async function loadARRDataReview(requestId = state.arrDownloadTask?.request_id, { scroll = false, showErrors = true } = {}) { + if (!requestId || state.arrDataReviewLoading || !arrDownloadNeedsDataReview()) return null; + if (state.arrDataReviewRequestId !== requestId) { + resetARRDataReview(); + state.arrDataReviewRequestId = requestId; + } + const contextId = state.arrDownloadContextId; + state.arrDataReviewLoading = true; + renderARRDataReview(); + try { + const review = await api(`/api/arr-downloads/${encodeURIComponent(requestId)}/data-review`); + if (state.arrDataReviewRequestId !== requestId || state.arrDownloadContextId !== contextId) return null; + state.arrDataReview = review; + state.arrDataReviewDisconnected = false; + state.arrDataReviewFinalizing = review.status === "finalized"; + review.items?.forEach((item) => { + if (item.confirmed && state.arrDataReviewDrafts[item.item_id] === arrDataReviewInputValue(item)) delete state.arrDataReviewDrafts[item.item_id]; + }); + if (scroll) { + $("#arr-data-review-panel").scrollIntoView({ behavior: "smooth", block: "start" }); + $("#arr-data-review-panel").focus({ preventScroll: true }); + } + return review; + } catch (error) { + if (state.arrDataReviewRequestId === requestId && state.arrDownloadContextId === contextId) { + state.arrDataReviewDisconnected = true; + setARRDataReviewMessage(error.message || I18N.t("data_review.disconnected"), true); + if (showErrors) showToast(error.message, true); + } + return null; + } finally { + if (state.arrDataReviewRequestId === requestId && state.arrDownloadContextId === contextId) { + state.arrDataReviewLoading = false; + renderARRDataReview(); + renderARRDownload(); + scheduleARRDownloadPoll(); + } + } + } + + function trackARRDataReviewDraft(input) { + const row = input.closest("[data-arr-data-review-item-id]"); + const item = state.arrDataReview?.items.find((candidate) => candidate.item_id === row?.dataset.arrDataReviewItemId); + if (!item) return; + if (input.value === arrDataReviewInputValue(item)) delete state.arrDataReviewDrafts[item.item_id]; + else state.arrDataReviewDrafts[item.item_id] = input.value; + $("#arr-data-review-finalize").disabled = !arrDataReviewCanFinalize(); + } + + function parseARRDataReviewValue(item, raw, empty) { + const field = String(item.field || "").toUpperCase(); + if (empty) { + if (!item.can_be_empty) throw new Error(I18N.t("data_review.required")); + return field === "PRODUCTS" ? [] : ""; + } + const value = String(raw ?? "").trim(); + if (!value) throw new Error(I18N.t(item.can_be_empty ? "data_review.use_empty" : "data_review.required")); + if (["ADULTS", "CHILDREN", "NO_OF_ROOMS"].includes(field) && !/^(?:0|[1-9][0-9]*)$/.test(value)) throw new Error(I18N.t("data_review.integer_required")); + if (field === "EFFECTIVE_RATE_AMOUNT" && !/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(value)) throw new Error(I18N.t("data_review.decimal_required")); + if (["ARRIVAL", "DEPARTURE"].includes(field) && !validARRDate(value)) throw new Error(I18N.t("data_review.date_required")); + return field === "PRODUCTS" ? value.split(/\r?\n/).map((code) => code.trim()).filter(Boolean) : value; + } + + async function saveARRDataReviewItem(button, { empty = false } = {}) { + const row = button.closest("[data-arr-data-review-item-id]"); + const review = state.arrDataReview; + const requestId = state.arrDataReviewRequestId; + const item = review?.items.find((candidate) => candidate.item_id === row?.dataset.arrDataReviewItemId); + if (!item || !requestId || review.status !== "editing" || state.arrDataReviewLoading || state.arrDataReviewMutating || state.arrDataReviewDisconnected || state.arrDataReviewFinalizing) return; + const input = $("[data-arr-data-review-value]", row); + let value; + try { value = parseARRDataReviewValue(item, input?.value, empty); } + catch (error) { setARRDataReviewMessage(error.message, true); input?.focus(); return; } + state.arrDataReviewMutating = true; + setARRDataReviewMessage("data_review.saving"); + renderARRDataReview(); + renderARRDownload(); + try { + await api(`/api/arr-downloads/${encodeURIComponent(requestId)}/data-review/items/${encodeURIComponent(item.item_id)}`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ revision: review.revision, value }), + }); + delete state.arrDataReviewDrafts[item.item_id]; + const updated = await loadARRDataReview(requestId); + if (updated) setARRDataReviewMessage("data_review.saved"); + } catch (error) { + // Re-read even after a dropped response: the save may already be durable. + const updated = await loadARRDataReview(requestId, { showErrors: false }); + setARRDataReviewMessage(error.code === "ARR_DATA_REVIEW_CONFLICT" ? (updated ? "data_review.conflict" : "data_review.disconnected") : error.message, true); + showToast(error.message, true); + } finally { + state.arrDataReviewMutating = false; + renderARRDataReview(); + renderARRDownload(); + } + } + + async function finalizeARRDataReview() { + if (!arrDataReviewCanFinalize()) { + setARRDataReviewMessage("data_review.complete_first", true); + return; + } + const review = state.arrDataReview; + const requestId = state.arrDataReviewRequestId; + state.arrDataReviewMutating = true; + state.arrDataReviewFinalizing = true; + setARRDataReviewMessage("data_review.finalizing_copy"); + renderARRDataReview(); + renderARRDownload(); + clearARRDownloadPoll(); + try { + const task = await api(`/api/arr-downloads/${encodeURIComponent(requestId)}/data-review/finalize`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ revision: review.revision }), + }); + await acceptARRDownloadTask(task); + if (arrDownloadNeedsDataReview() && state.arrDataReview?.status === "editing") setARRDataReviewMessage("data_review.complete_first", true); + } catch (error) { + // Reconcile the same request before offering another action. Never create + // a second download after an uncertain finalize response. + state.arrDataReviewDisconnected = true; + await loadARRDownloadTask(); + if (arrDownloadNeedsDataReview()) await loadARRDataReview(requestId, { showErrors: false }); + if (state.arrDataReviewRequestId === requestId) setARRDataReviewMessage(error.code === "ARR_DATA_REVIEW_CONFLICT" ? "data_review.conflict" : error.message, true); + if (error.status && error.status < 500) showToast(error.message, true); + } finally { + state.arrDataReviewMutating = false; + renderARRDataReview(); + renderARRDownload(); + scheduleARRDownloadPoll(); + } + } + function showToast(message, isError = false) { const toast = $("#toast"); toast.textContent = I18N?.text(message) || message; @@ -707,7 +967,7 @@ async function initSession() { const session = await api("/api/session"); state.csrf = session.csrf_token; - state.arrDownloadStorageKey = `arr:last-download:${session.username || "operator"}`; + state.arrDownloadUsername = session.username || "operator"; state.maxUploadBytes = Number(session.max_upload_bytes) || state.maxUploadBytes; const button = $("#logout-button"); if (!session.username) return; @@ -2799,6 +3059,7 @@ function refreshLocalizedView() { renderARRDownload(); + renderARRDataReview(); renderARRCalendar(); renderJobs(state.jobs); renderPagination("jobs", state.jobsTotal, state.jobsOffset, state.jobsLoading); @@ -2918,6 +3179,17 @@ if (!jobId) return; try { await openDailyPriceReview(jobId, true); } catch (error) { showToast(error.message, true); } }); + $("#arr-download-data-review").addEventListener("click", () => loadARRDataReview(state.arrDownloadTask?.request_id, { scroll: true })); + $("#arr-data-review-refresh").addEventListener("click", () => loadARRDataReview()); + $("#arr-data-review-finalize").addEventListener("click", finalizeARRDataReview); + $("#arr-data-review-body").addEventListener("input", (event) => { + if (event.target.matches("[data-arr-data-review-value]")) trackARRDataReviewDraft(event.target); + }); + $("#arr-data-review-body").addEventListener("click", (event) => { + const save = event.target.closest("[data-arr-data-review-save]"); + const empty = event.target.closest("[data-arr-data-review-empty]"); + if (save || empty) saveARRDataReviewItem(save || empty, { empty: Boolean(empty) }); + }); $("#company-excel-file").addEventListener("change", (event) => { selectCompanySourceFile(event.target.files?.[0] || null); }); diff --git a/arr_web/static/i18n.js b/arr_web/static/i18n.js index ce8ff5d..4d2c02a 100644 --- a/arr_web/static/i18n.js +++ b/arr_web/static/i18n.js @@ -122,6 +122,10 @@ "arr_download.processing": ["正在处理并校验报表", "Processing and validating report", "กำลังประมวลผลและตรวจสอบรายงาน"], "arr_download.succeeded": ["日报已处理并入库", "Daily report processed and committed", "ประมวลผลและบันทึกรายงานรายวันแล้ว"], "arr_download.needs_review": ["等待人工价格复核", "Awaiting price review", "รอตรวจสอบราคาด้วยตนเอง"], + "arr_download.needs_data_review": ["等待人工完善报表字段", "Awaiting source field review", "รอตรวจสอบข้อมูลรายงานด้วยตนเอง"], + "arr_download.complete_data": ["完善报表字段", "Complete report fields", "กรอกข้อมูลรายงานให้ครบ"], + "arr_download.source_failed": ["到店数据获取未完成,请继续原任务重试。", "Arrival data could not be fetched. Resume the original task to retry.", "ยังดึงข้อมูลผู้เข้าพักไม่สำเร็จ โปรดดำเนินงานเดิมต่อเพื่อลองอีกครั้ง"], + "arr_download.source_interrupted": ["到店数据获取中断,请继续原任务。", "Arrival data fetch was interrupted. Resume the original task.", "การดึงข้อมูลผู้เข้าพักขัดจังหวะ โปรดดำเนินงานเดิมต่อ"], "arr_download.failed": ["任务未完成,请查看任务日志", "Task failed. Check the task log.", "งานไม่สำเร็จ โปรดดูบันทึกงาน"], "arr_download.interrupted": ["任务中断,继续原任务以核对处理结果", "Task interrupted. Resume to reconcile the result.", "งานขัดจังหวะ ดำเนินงานเดิมต่อเพื่อตรวจสอบผล"], "arr_download.reconnecting": ["正在重新查询原任务状态…", "Checking the original task status…", "กำลังตรวจสอบสถานะงานเดิม…"], @@ -138,6 +142,58 @@ "arr_download.date_conflict": ["原任务日期不能修改", "The original task date cannot be changed", "ไม่สามารถเปลี่ยนวันที่ของงานเดิม"], "arr_download.retry_unavailable": ["该任务不能继续重试,请查看任务日志", "This task cannot be retried. Check the task log.", "ไม่สามารถลองงานนี้อีกครั้ง โปรดดูบันทึกงาน"], "arr_download.other_active": ["该日期已有进行中的下载任务", "A download for this date is already in progress", "มีงานดาวน์โหลดสำหรับวันที่นี้กำลังดำเนินการอยู่"], + "data_review.title": ["人工完善报表字段", "Complete report fields", "ตรวจสอบและกรอกข้อมูลรายงาน"], + "data_review.copy": ["请核实并逐项保存缺失或有冲突的字段。允许为空的字段也须明确确认,全部完成后才能生成日报。", "Verify and save each missing or conflicting field. Explicitly confirm any allowed empty values, then generate the daily report when all fields are complete.", "ตรวจสอบและบันทึกข้อมูลที่ขาดหายหรือขัดแย้งทีละรายการ ต้องยืนยันค่าที่เว้นว่างได้อย่างชัดเจน ก่อนสร้างรายงานเมื่อครบทุกช่อง"], + "data_review.progress": ["{date} · 已确认 {completed} / {total} 项", "{date} · {completed} of {total} confirmed", "{date} · ยืนยันแล้ว {completed} / {total} รายการ"], + "data_review.reservation": ["预订信息", "Reservation", "ข้อมูลการจอง"], + "data_review.field": ["待完善字段", "Field to review", "ข้อมูลที่ต้องตรวจสอบ"], + "data_review.value": ["核实后的值", "Verified value", "ค่าที่ตรวจสอบแล้ว"], + "data_review.source_row": ["原始记录 {sequence}", "Source row {sequence}", "รายการต้นฉบับ {sequence}"], + "data_review.room": ["房号 {room}", "Room {room}", "ห้อง {room}"], + "data_review.missing_value": ["来源字段缺失,请核实", "Source value missing. Verify it.", "ไม่มีข้อมูลต้นฉบับ โปรดตรวจสอบ"], + "data_review.conflict_value": ["来源值有冲突,请核实", "Source values conflict. Verify them.", "ข้อมูลต้นฉบับขัดแย้งกัน โปรดตรวจสอบ"], + "data_review.invalid_value": ["来源值格式无效,请核实", "Source value is invalid. Verify it.", "รูปแบบข้อมูลต้นฉบับไม่ถูกต้อง โปรดตรวจสอบ"], + "data_review.confirmed_value": ["已使用人工核实值", "Manually verified value saved", "บันทึกค่าที่ตรวจสอบด้วยตนเองแล้ว"], + "data_review.confirmed": ["已确认", "Confirmed", "ยืนยันแล้ว"], + "data_review.pending": ["待确认", "Awaiting confirmation", "รอยืนยัน"], + "data_review.save": ["保存并确认", "Save and confirm", "บันทึกและยืนยัน"], + "data_review.confirm_empty": ["确认无此信息", "Confirm no value", "ยืนยันว่าไม่มีข้อมูล"], + "data_review.product_hint": ["每行填写一个包价代码", "One package code per line", "กรอกรหัสแพ็กเกจหนึ่งรหัสต่อบรรทัด"], + "data_review.refresh": ["刷新字段", "Refresh fields", "รีเฟรชข้อมูล"], + "data_review.loading": ["正在读取待完善字段…", "Loading fields for review…", "กำลังโหลดข้อมูลที่ต้องตรวจสอบ…"], + "data_review.no_items": ["没有待完善字段。", "No fields need review.", "ไม่มีข้อมูลที่ต้องตรวจสอบ"], + "data_review.region": ["待完善报表字段", "Report fields to review", "ข้อมูลรายงานที่ต้องตรวจสอบ"], + "data_review.disconnected": ["字段读取中断,请刷新字段后继续。", "Field connection lost. Refresh the fields to continue.", "การเชื่อมต่อข้อมูลขัดข้อง โปรดรีเฟรชข้อมูลเพื่อดำเนินการต่อ"], + "data_review.saving": ["正在保存并确认字段…", "Saving and confirming field…", "กำลังบันทึกและยืนยันข้อมูล…"], + "data_review.saved": ["字段已保存并确认。", "Field saved and confirmed.", "บันทึกและยืนยันข้อมูลแล้ว"], + "data_review.required": ["该字段不可为空,请填写核实后的值。", "This field is required. Enter the verified value.", "ต้องกรอกข้อมูลนี้ โปรดกรอกค่าที่ตรวจสอบแล้ว"], + "data_review.use_empty": ["如已核实无此信息,请点击“确认无此信息”。", "If you verified there is no value, choose “Confirm no value”.", "หากตรวจสอบแล้วว่าไม่มีข้อมูล โปรดเลือก “ยืนยันว่าไม่มีข้อมูล”"], + "data_review.integer_required": ["请填写非负整数。", "Enter a non-negative integer.", "กรอกจำนวนเต็มตั้งแต่ศูนย์ขึ้นไป"], + "data_review.decimal_required": ["请填写非负金额,使用小数点分隔。", "Enter a non-negative amount using a decimal point.", "กรอกจำนวนเงินตั้งแต่ศูนย์ขึ้นไป โดยใช้จุดทศนิยม"], + "data_review.date_required": ["请填写有效日期,格式为 YYYY-MM-DD。", "Enter a valid date in YYYY-MM-DD format.", "กรอกวันที่ที่ถูกต้องในรูปแบบ YYYY-MM-DD"], + "data_review.finalize": ["确认并生成日报", "Confirm and generate daily report", "ยืนยันและสร้างรายงานรายวัน"], + "data_review.finalizing": ["正在确认生成状态…", "Checking report generation…", "กำลังตรวจสอบการสร้างรายงาน…"], + "data_review.finalizing_copy": ["已提交字段确认,正在核对原任务的生成状态。", "Field confirmation submitted. Checking report generation for the original task.", "ส่งการยืนยันข้อมูลแล้ว กำลังตรวจสอบการสร้างรายงานของงานเดิม"], + "data_review.complete_first": ["请先保存并确认全部字段,再生成日报。尚未保存的修改也须先确认。", "Save and confirm all fields before generating the report, including any unsaved changes.", "โปรดบันทึกและยืนยันข้อมูลทุกช่อง รวมถึงการแก้ไขที่ยังไม่บันทึก ก่อนสร้างรายงาน"], + "data_review.invalid": ["字段值无效,请核实后重新填写。", "Invalid field value. Verify and enter it again.", "ข้อมูลไม่ถูกต้อง โปรดตรวจสอบและกรอกใหม่"], + "data_review.conflict": ["字段已被更新,已重新读取最新版本,请核对后继续。", "Fields changed. The latest revision has been reloaded; check it before continuing.", "ข้อมูลมีการเปลี่ยนแปลง โหลดฉบับล่าสุดแล้ว โปรดตรวจสอบก่อนดำเนินการต่อ"], + "data_review.not_found": ["未找到该任务的字段复核,请刷新任务状态。", "No field review found for this task. Refresh the task status.", "ไม่พบการตรวจสอบข้อมูลของงานนี้ โปรดรีเฟรชสถานะงาน"], + "data_review.frozen": ["字段确认已冻结,正在核对原任务状态。", "Confirmed fields are frozen. Checking the original task status.", "ข้อมูลที่ยืนยันถูกล็อกแล้ว กำลังตรวจสอบสถานะงานเดิม"], + "data_review.field_block_code": ["团队代码", "Block code", "รหัสกลุ่ม"], + "data_review.field_adults": ["成人数", "Adults", "จำนวนผู้ใหญ่"], + "data_review.field_children": ["儿童数", "Children", "จำนวนเด็ก"], + "data_review.field_company_name": ["公司名称", "Company name", "ชื่อบริษัท"], + "data_review.field_confirmation_no": ["确认号", "Confirmation number", "หมายเลขยืนยัน"], + "data_review.field_disp_room_no": ["房号", "Room number", "หมายเลขห้อง"], + "data_review.field_effective_rate_amount": ["Opera 房价", "Opera room rate", "ราคาห้อง Opera"], + "data_review.field_full_name": ["住客姓名", "Guest name", "ชื่อผู้เข้าพัก"], + "data_review.field_res_comment": ["预订备注", "Reservation comment", "หมายเหตุการจอง"], + "data_review.field_no_of_rooms": ["房间数", "Number of rooms", "จำนวนห้อง"], + "data_review.field_products": ["包价代码", "Package codes", "รหัสแพ็กเกจ"], + "data_review.field_rate_code": ["房价代码", "Rate code", "รหัสราคา"], + "data_review.field_room_category_label": ["房型", "Room category", "ประเภทห้อง"], + "data_review.field_arrival": ["到店日期", "Arrival date", "วันที่เข้าพัก"], + "data_review.field_departure": ["离店日期", "Departure date", "วันที่ออก"], "upload.xml.title": ["上传 ARR.XML", "Upload ARR.XML", "อัปโหลด ARR.XML"], "upload.xml.drop": ["拖放 XML 文件到这里", "Drop an XML file here", "ลากไฟล์ XML มาที่นี่"], "upload.xml.choose": ["或点击选择文件,单个文件最大 25 MB", "or click to choose a file; maximum 25 MB", "หรือคลิกเพื่อเลือกไฟล์ ขนาดไม่เกิน 25 MB"], @@ -595,6 +651,13 @@ ARR_DOWNLOAD_RETRY_UNAVAILABLE: "arr_download.retry_unavailable", ARR_DOWNLOAD_OTHER_ACTIVE: "arr_download.other_active", ARR_DOWNLOAD_NOT_FOUND: "arr_download.not_received", + ARR_SOURCE_FETCH_FAILED: "arr_download.source_failed", + ARR_DOWNLOAD_INTERRUPTED: "arr_download.source_interrupted", + ARR_DATA_REVIEW_INVALID: "data_review.invalid", + ARR_DATA_REVIEW_CONFLICT: "data_review.conflict", + ARR_DATA_REVIEW_INCOMPLETE: "data_review.complete_first", + ARR_DATA_REVIEW_NOT_FOUND: "data_review.not_found", + ARR_DATA_REVIEW_FROZEN: "data_review.frozen", REPLAY_DATE_NOT_AVAILABLE: "arr_download.date_unavailable", LOGIN_FAILED: "error.login_failed", LOGIN_RATE_LIMITED: "error.login_rate_limited", diff --git a/arr_web/static/index.html b/arr_web/static/index.html index ef5a738..5f9c3d3 100644 --- a/arr_web/static/index.html +++ b/arr_web/static/index.html @@ -75,6 +75,7 @@ 正在连接下载服务…
+
@@ -127,6 +128,30 @@ + +