From 02ad9d83ff18e6802c3e2a39e93fc78f62f8b994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84GitHub=E7=94=A8=E6=88=B7=E5=90=8D?= <3457189498@qq.com> Date: Fri, 28 Aug 2026 16:34:15 +0800 Subject: [PATCH] Add CC multi-profile, V1.73 alignment, and parse/confirm gates. Enables clone-and-deploy with customer CC profiles, message-type dict, improved packing/work-order parse, and related settings UI. Co-authored-by: Cursor --- .cursor/rules/implementation-plan.mdc | 3 +- .gitignore | 2 + deploy/update-prebuilt-image.sh | 28 +- docs/邮件指令提取规则.md | 65 +- docs/邮箱项目.md | 17 +- docs/项目全貌与开发指南.md | 10 +- ...rriercentral客户端通用接口V1.73.md} | 168 ++- .../数据模版-新辰泽-WHSU8127240.xlsx | Bin 8906 -> 12014 bytes prisma/schema.prisma | 77 +- prisma/seed.ts | 44 +- scripts/backfill-import-log-meta.ts | 56 + scripts/backfill-instruction-import-logs.ts | 33 + scripts/inspect-mail.ts | 22 + scripts/reparse-mail.ts | 37 + src/app/(ops)/customers/page.tsx | 74 +- src/app/(ops)/logs/page.tsx | 252 +++-- src/app/(ops)/mails/[id]/page.tsx | 107 +- src/app/(ops)/settings/page.tsx | 23 +- src/app/api/auth/password/route.ts | 2 +- src/app/api/imports/route.ts | 139 ++- src/app/api/mails/[id]/do/confirm/route.ts | 4 + .../[id]/forecast/parse-packing/route.ts | 91 ++ src/app/api/mails/[id]/import/route.ts | 9 + src/app/api/mails/[id]/route.ts | 1 + .../api/mails/[id]/transfer/confirm/route.ts | 4 + src/app/api/mails/[id]/type/route.ts | 8 + .../mails/[id]/work-order/confirm/route.ts | 17 + .../settings/cc-message-types/[id]/route.ts | 92 ++ .../api/settings/cc-message-types/route.ts | 67 ++ .../api/settings/cc-profiles/[id]/route.ts | 122 ++ .../settings/cc-profiles/[id]/test/route.ts | 65 ++ src/app/api/settings/cc-profiles/route.ts | 138 +++ src/app/api/settings/mailboxes/route.ts | 8 +- src/app/api/shipping-lines/route.ts | 7 +- src/app/globals.css | 121 ++ src/components/CcCustomerProfileSelect.tsx | 123 ++ src/components/CcCustomerProfilesCard.tsx | 265 +++++ src/components/CcDoUploadPanel.tsx | 52 +- src/components/CcForecastFormOrder.tsx | 1001 +++++++++++------ src/components/CcMessageTypeDictCard.tsx | 242 ++++ src/components/CcSettingsCard.tsx | 83 +- src/components/CcWorkOrderForm.tsx | 173 ++- src/components/ConfirmCcCustomerModal.tsx | 37 + src/components/ConfirmImportModal.tsx | 80 +- src/components/ImapPullSettingsCard.tsx | 244 ++-- src/components/MailBusinessSummary.tsx | 116 +- src/components/MailInstructionUnitShell.tsx | 8 + src/components/OauthSettingsCard.tsx | 2 +- src/components/ShipmentVirtualTable.tsx | 34 +- src/components/TypeOverrideModal.tsx | 32 +- .../confirm/ForecastConfirmView.tsx | 266 ++++- .../confirm/OpsInstructionConfirmView.tsx | 288 ++++- src/constants/cabinet-types.ts | 17 + src/constants/cc-api-codes.ts | 157 +++ src/constants/cc-message-types.ts | 54 +- src/constants/error-copy.ts | 284 ++++- src/constants/ui-copy.ts | 2 +- src/lib/cc-owner.ts | 31 +- src/lib/client-api.ts | 179 ++- src/services/auth/customers.ts | 7 +- src/services/cc/auth.ts | 49 +- src/services/cc/batch-transfer.ts | 13 +- src/services/cc/conflict.ts | 61 +- src/services/cc/container-status.ts | 51 +- src/services/cc/customer-message.ts | 16 +- src/services/cc/customer-profiles.ts | 245 ++++ src/services/cc/do-confirm-gate.ts | 40 + src/services/cc/do-upload.ts | 23 +- src/services/cc/forecast-confirm-gate.ts | 24 +- src/services/cc/list-http-gate.ts | 37 + src/services/cc/message-type-dict.ts | 101 ++ src/services/cc/save-container.ts | 22 +- src/services/cc/settings-config.ts | 21 +- src/services/cc/shipping-lines.ts | 15 +- src/services/cc/write-capability.ts | 11 +- src/services/imap/mailbox-config.ts | 11 +- src/services/import/auto-exec.ts | 1 + src/services/import/confirm-batch-transfer.ts | 31 +- src/services/import/confirm-do-upload.ts | 34 +- src/services/import/confirm-work-order.ts | 58 +- src/services/import/confirm.ts | 89 +- src/services/import/import-log-meta.ts | 80 ++ src/services/import/instruction-import-log.ts | 227 ++++ .../import/instruction-import-state.ts | 35 +- src/services/parse/address-block.ts | 333 +++++- src/services/parse/bl-plus-template.ts | 42 +- src/services/parse/classify.ts | 30 + .../parse/container-header-from-sheet.ts | 165 +++ src/services/parse/do-upload-extract.ts | 2 +- src/services/parse/excel-cell-text.ts | 14 + src/services/parse/excel-sheet-grid.ts | 136 +++ .../parse/extract-container-header.ts | 150 ++- src/services/parse/header-service-hints.ts | 95 ++ .../parse/instruction-execution-state.ts | 43 +- src/services/parse/instruction-lexicon.ts | 12 + .../parse/match-segment-attachments.ts | 4 +- src/services/parse/packing-list.ts | 218 ++-- src/services/parse/pipeline.ts | 37 +- .../parse/resolve-confirm-instruction.ts | 199 +++- src/services/parse/revalidate-shipment-row.ts | 158 +++ src/services/parse/split-instructions.ts | 56 +- src/services/parse/us-ports.ts | 25 + src/services/parse/work-order-fields.ts | 510 +++++++++ src/services/parse/work-order-record.ts | 91 +- src/services/parse/work-order-snapshot.ts | 139 +++ src/services/state-machine.ts | 2 +- src/types/cc.ts | 2 +- src/types/index.ts | 38 + src/utils/mail-body-text.ts | 6 +- tests/unit/address-block.test.ts | 16 + tests/unit/cc-api-v173-errors.test.ts | 79 ++ tests/unit/cc-message-types.test.ts | 7 + tests/unit/cc-response-speed.test.ts | 33 +- tests/unit/classify-forecast-subject.test.ts | 24 + .../unit/container-header-from-sheet.test.ts | 84 ++ tests/unit/do-confirm-gate.test.ts | 34 + tests/unit/excel-sheet-grid.test.ts | 90 ++ tests/unit/extract-header.test.ts | 11 +- tests/unit/forecast-confirm-gate.test.ts | 40 +- tests/unit/four-business.test.ts | 34 +- tests/unit/header-service-hints.test.ts | 103 ++ tests/unit/import-log-meta.test.ts | 26 + tests/unit/instruction-import-log.test.ts | 59 + tests/unit/list-http-gate.test.ts | 39 + tests/unit/match-segment-attachments.test.ts | 13 + tests/unit/operator-error-copy.test.ts | 60 +- tests/unit/ops-subject-mail3.test.ts | 18 + tests/unit/packing-list.test.ts | 78 ++ .../unit/resolve-confirm-instruction.test.ts | 57 + tests/unit/revalidate-shipment-row.test.ts | 64 ++ tests/unit/split-instructions.test.ts | 26 + tests/unit/transfer-gate-rate-limit.test.ts | 1 + tests/unit/work-order-fields.test.ts | 239 ++++ tests/unit/work-order-record.test.ts | 25 + tests/unit/work-order-snapshot.test.ts | 108 ++ 135 files changed, 9462 insertions(+), 1398 deletions(-) rename docx/接口/{carriercentral客户端通用接口V1.72.md => carriercentral客户端通用接口V1.73.md} (89%) create mode 100644 scripts/backfill-import-log-meta.ts create mode 100644 scripts/backfill-instruction-import-logs.ts create mode 100644 scripts/inspect-mail.ts create mode 100644 scripts/reparse-mail.ts create mode 100644 src/app/api/mails/[id]/forecast/parse-packing/route.ts create mode 100644 src/app/api/settings/cc-message-types/[id]/route.ts create mode 100644 src/app/api/settings/cc-message-types/route.ts create mode 100644 src/app/api/settings/cc-profiles/[id]/route.ts create mode 100644 src/app/api/settings/cc-profiles/[id]/test/route.ts create mode 100644 src/app/api/settings/cc-profiles/route.ts create mode 100644 src/components/CcCustomerProfileSelect.tsx create mode 100644 src/components/CcCustomerProfilesCard.tsx create mode 100644 src/components/CcMessageTypeDictCard.tsx create mode 100644 src/components/ConfirmCcCustomerModal.tsx create mode 100644 src/constants/cabinet-types.ts create mode 100644 src/constants/cc-api-codes.ts create mode 100644 src/services/cc/customer-profiles.ts create mode 100644 src/services/cc/do-confirm-gate.ts create mode 100644 src/services/cc/list-http-gate.ts create mode 100644 src/services/cc/message-type-dict.ts create mode 100644 src/services/import/import-log-meta.ts create mode 100644 src/services/import/instruction-import-log.ts create mode 100644 src/services/parse/container-header-from-sheet.ts create mode 100644 src/services/parse/excel-sheet-grid.ts create mode 100644 src/services/parse/header-service-hints.ts create mode 100644 src/services/parse/revalidate-shipment-row.ts create mode 100644 src/services/parse/work-order-fields.ts create mode 100644 src/services/parse/work-order-snapshot.ts create mode 100644 tests/unit/cc-api-v173-errors.test.ts create mode 100644 tests/unit/classify-forecast-subject.test.ts create mode 100644 tests/unit/container-header-from-sheet.test.ts create mode 100644 tests/unit/do-confirm-gate.test.ts create mode 100644 tests/unit/excel-sheet-grid.test.ts create mode 100644 tests/unit/header-service-hints.test.ts create mode 100644 tests/unit/import-log-meta.test.ts create mode 100644 tests/unit/instruction-import-log.test.ts create mode 100644 tests/unit/list-http-gate.test.ts create mode 100644 tests/unit/revalidate-shipment-row.test.ts create mode 100644 tests/unit/work-order-fields.test.ts create mode 100644 tests/unit/work-order-snapshot.test.ts diff --git a/.cursor/rules/implementation-plan.mdc b/.cursor/rules/implementation-plan.mdc index 99f02d3..a2c8b02 100644 --- a/.cursor/rules/implementation-plan.mdc +++ b/.cursor/rules/implementation-plan.mdc @@ -1,9 +1,10 @@ # 邮件自动预报 — 实施计划 ## 当前步骤 -MailBusinessSummary forecastShippingId TDZ — Done +antd useForm 未连接 Form 告警 — Done ## 进度日志 +- Modal destroyOnClose 后 set/reset 改 afterOpenChange;IMAP Card loading→Spin — Done - forecastShippingId useState 前移到 forecastValue useMemo 之前 — Done - CC 客户端接口 PDF→MD 全文转换 — Done - extractForecastInstructionText + stripMobileMailChrome 去发件人/大小/iPhone 元数据 — Done diff --git a/.gitignore b/.gitignore index 28c6414..6771a03 100644 --- a/.gitignore +++ b/.gitignore @@ -34,12 +34,14 @@ node_modules/.vite # editor / OS / local AI scratch .DS_Store .idea +.vs/ .vscode/* !.vscode/extensions.json .mypy_cache .cursor/plans agent-transcripts *.tmp +*.bak _tmp* scripts/_dump-*.ts scripts/_check-*.ts diff --git a/deploy/update-prebuilt-image.sh b/deploy/update-prebuilt-image.sh index 8d6d2a0..8b8db11 100644 --- a/deploy/update-prebuilt-image.sh +++ b/deploy/update-prebuilt-image.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash # 预构建镜像更新 — 加载新 tar 并重建容器(不编译) -# 用法:bash deploy/update-prebuilt-image.sh [镜像tar路径] +# 用法: +# bash deploy/update-prebuilt-image.sh +# bash deploy/update-prebuilt-image.sh --db-push +# bash deploy/update-prebuilt-image.sh /path/to/yubao-app-image.tar --db-push set -euo pipefail @@ -8,7 +11,21 @@ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" COMPOSE_FILE="${ROOT_DIR}/deploy/docker-compose.prebuilt.yml" ENV_FILE="${ROOT_DIR}/.env" IMAGE_TAG="yubao-app:latest" -TAR_PATH="${1:-${ROOT_DIR}/deploy/yubao-app-image.tar}" +TAR_PATH="${ROOT_DIR}/deploy/yubao-app-image.tar" +DO_DB_PUSH=false + +for arg in "$@"; do + case "$arg" in + --db-push) DO_DB_PUSH=true ;; + -h|--help) + echo "用法: bash deploy/update-prebuilt-image.sh [镜像tar] [--db-push]" + exit 0 + ;; + *) + TAR_PATH="$arg" + ;; + esac +done RED='\033[0;31m' GREEN='\033[0;32m' @@ -39,5 +56,10 @@ docker load -i "$TAR_PATH" log "重建容器…" "${compose[@]}" up -d --force-recreate +if [[ "$DO_DB_PUSH" == "true" ]]; then + log "同步数据库结构(prisma db push,不删数据)…" + "${compose[@]}" run --rm --no-deps web pnpm exec prisma db push --skip-generate +fi + "${compose[@]}" ps -log "完成" +log "完成。登录仍用 .env 中 APP_ADMIN_USER / APP_ADMIN_PASS,勿覆盖 .env" diff --git a/docs/邮件指令提取规则.md b/docs/邮件指令提取规则.md index d6e7405..e4a6829 100644 --- a/docs/邮件指令提取规则.md +++ b/docs/邮件指令提取规则.md @@ -261,7 +261,70 @@ mail1:主题只有柜号+预约码、无标准词 → 分类工单,详情用 面板分流(管理端/客户端):拦截/改渠道 → 转渠道;数量拆分 → Split;出库/贴标文件 → 出库;其余 → 客户留言。 ---- +### 6.1 创建工单字段(SaveForm,对齐 AI 助手 / V1.74) + +实现:`work-order-fields.ts` + `buildCcWorkOrderFormValue`。对照 `carriercentral-ai-assistant` 的 `work-order-rules.mdc`。 + +| 弹窗字段 | 接口 | 提取规则 | +|----------|------|----------| +| 参考号 | `F_ReferCode` | 显式「参考号/柜号/原箱号」或正文首个 ISO 柜号 | +| 留言类型 | `F_MessageType` | 字典名称;可由邮件动作建议 | +| 紧急程度 | `F_UrgencyLevel` | 默认普通 `0` | +| Ref No | `F_RefNo` | 显式 `Ref No`/`唛头`,否则首个非柜号货件标识(FBA/WL…) | +| 留言内容 | `F_MessageContent` | **默认全部操作/货件原文**(必填,≤800) | +| 备注 | `F_Remark` | **选填可空**;仅见下节分界 | +| 附件 | `F_AttachFile` | 段附件匹配 | + +### 6.2 留言内容 / 备注分界(必须准确) + +1. **默认**:货件清单与操作/派送步骤进**留言内容**;无可拆内容时备注为空,**禁止**把「邮件动作:贴标」等系统词塞进备注。 +2. **仅当**出现 `注 / 注意 / 备注 / 特别说明 / 补充说明 / 温馨提示:` → 冒号后说明进备注。 +3. **特例**:`注意:` 后接 `1.`/`2.` 编号清单或货件清单(WL/FBA)→ 编号/清单段仍为留言内容;空「注意:」后的**说明性散文**进备注。 +4. **货件后的独立叮嘱行**(美时送达、尽量早些、记得拍照、今晚辛苦等)进备注;**禁止**把「安排贴标 / 卡转海 / 派送要求 / 编号内开箱回传」等操作句拆进备注。 +5. 编号分节内的「开箱拍照、回传」等操作说明留在留言内容;文末 `注意:` 说明才进备注。说明里即使夹带「分货/打托」也不应误判回正文。 +6. **噪声剥离**(留言内容与备注均适用):独立邮箱行、行末签名邮箱、QQ/Webmail「N个附件 / 普通附件 / 文件名.zip」附件栏、转发壳、「在…写道」引用头、「发自我的…」等,不得写入两字段。 + +**验收样例:** + +``` +FBA19DYHCDLS-XPB2-37件 +安排卡转海,麻烦安排贴标操作。 +注意:ups默认一件贴2张面单 +``` +→ 留言内容含 FBA 行 +「安排卡转海…」;备注 = `ups默认一件贴2张面单`;Ref No 含 FBA/WL + +``` +拆柜清单更新 以此为准 +注意: +1.WL103516 +以上几票需要拦截 +2. +这2票改自提 +``` +→ 全部进留言内容;备注为空 + +``` +注意: +WL103262-6件 +派送要求:分别按票打板 +注意: +柜子里的亚马逊卡派货物,是我们在国内换过标签……不能只根据WL唛头来打托。 +``` +→ 留言内容含货件清单与「派送要求」;备注含「柜子里的亚马逊…」 + +``` +TCNU4412465 +WL103262 6件 +这两票客户需要美时7/10 下午2点前送达 尽量早些哈 +``` +→ 留言内容含柜号/WL;备注 = 美时送达催促句 + +``` +TCNU4412465 +WL103262 6件 +这两票总共31件记得叫仓库拍一下照片过来哈 +``` +→ 留言内容含柜号/WL;备注 = 拍照叮嘱--- ## 7. 上传 DO diff --git a/docs/邮箱项目.md b/docs/邮箱项目.md index 21d88ec..6e299b8 100644 --- a/docs/邮箱项目.md +++ b/docs/邮箱项目.md @@ -43,7 +43,7 @@ | 运营 SOP | `docx/运营SOP-邮件自动预报.md` | | 产品规则(指令拆分) | `docx/产品规则-邮件指令识别与拆分.md` | | 指令提取规则(对照代码,含覆盖缺口) | `docs/邮件指令提取规则.md` | -| CC 接口 V1.72 | `docx/接口/carriercentral客户端通用接口V1.72.md` | +| CC 接口 V1.73 | `docx/接口/carriercentral客户端通用接口V1.73.md` | | 实施计划(Cursor) | `.cursor/rules/implementation-plan.mdc` | | 快速 README | `README.md` | @@ -202,9 +202,9 @@ docs/ # 协作入口文档(本文) | id | 默认 mode | 说明 | |---|---|---| -| `save_container` | live | V1.72 有正式接口;确认导入路径 | +| `save_container` | live | V1.73 有正式接口;确认导入路径 | | `do_upload` | live | `SaveFieldValue` + `annexes/upload`;list 验真接口文档未单列,为客户端同源 | -| `transfer` / `batch_transfer` / `hold_split` / `label` / `customer_message` | **mock** | 多数 **不在 V1.72 正式表**;切 live 前必须对方确认 endpoint + 联调 | +| `transfer` / `batch_transfer` / `hold_split` / `label` / `customer_message` | **mock** | 多数 **不在 V1.73 正式表**;切 live 前必须对方确认 endpoint + 联调 | `CC_MOCK=true` 或设置页 Mock:**所有写视为 mock,不能当 TEST/上线验收**。 @@ -272,8 +272,9 @@ docs/ # 协作入口文档(本文) | 角色 | 能力 | |---|---| -| ops | 列表/详情/确认导入/重新解析/附件下载 | -| admin | 上述 + 改类型、设置、立即拉取、审计、FORCE 导入(开关开启时)、忽略邮件 | +| ops | 列表/详情/确认导入/重新解析/附件下载;日志可看全站导入(含租户列) | +| admin | 上述 + 改类型、设置(公共邮箱)、立即拉取、审计、FORCE 导入、忽略邮件、**租户账号**管理 | +| 租户(customer) | 登录后配置自己的 CC 客户档案与邮箱;仅见本账号邮件与导入;无租户管理页 | **一期验收要点(摘要)** @@ -325,7 +326,7 @@ docs/ # 协作入口文档(本文) | 注意 | 说明 | |---|---| -| **以 V1.72 为契约底** | `customerLogin` / `GetShippingLineList`(GET) / `SaveContainer` / `SaveFieldValue` / `annexes/upload` | +| **以 V1.73 为契约底** | `customerLogin` / `GetShippingLineList`(GET) / `SaveContainer` / `SaveFieldValue` / `annexes/upload` | | **文档外 endpoint** | 转仓/工单等默认 mock;live 前写清路径与验收入库 | | **token 可出现在 query** | GET 船司列表等;日志/反向代理勿明文长期存 query log | | **CC 密码** | 协议要求 MD5;设置页传明文则服务端 md5 后存用 | @@ -383,7 +384,7 @@ pnpm cc:smoke -- --mode=live # 有真账号时 | 启动硬校验 | 生产弱密钥 **直接拒绝启动**(见 `src/lib/env.ts`) | | 路径安全 | 下载/解析只读 `data/` 下相对路径;防 `../` 与 `data` 前缀绕过 | | 登录/导入限流 | 单机有效;多副本需 LB 层限流 | -| 角色 | 仅 admin/ops 两账号模型;**不是多租户**;泄露一账号即全站业务数据 | +| 角色 | admin / ops / 租户(`AppUser.role=customer`)。租户仅见自有邮箱下的邮件与导入;admin 可跨租户查看导入。员工账号泄露仍可看全站业务数据 | | 审计 | 导入成功/失败、FORCE、邮箱绑定、CC 设置、OAuth 绑定须可查 | | 保留期 | `DATA_RETENTION_DAYS` + worker retention;合规要求则加大或关闭清理并外备 | | 第三方邮件 | 授权码/OAuth 属客户邮箱权限;SOP 告知运营勿绑个人箱当生产总线 | @@ -433,4 +434,4 @@ pnpm cc:smoke -- --mode=live # 有真账号时 - 需求/行为变更:先改 `docx/需求规格-*.md` 与韧性文档,再改代码。 - 协作入口文档:即本文件 `docs/邮箱项目.md`。 - 开发进度:`.cursor/rules/implementation-plan.mdc`。 -- CC 路径/字段:以 `docx/接口/carriercentral客户端通用接口V1.72.md` 为准;缺接口时先 mock + 书面确认。 +- CC 路径/字段:以 `docx/接口/carriercentral客户端通用接口V1.73.md` 为准;缺接口时先 mock + 书面确认。 diff --git a/docs/项目全貌与开发指南.md b/docs/项目全貌与开发指南.md index 89f869d..ef15942 100644 --- a/docs/项目全貌与开发指南.md +++ b/docs/项目全貌与开发指南.md @@ -18,12 +18,12 @@ 材料冲突时,优先级从高到低: 1. **正在运行的代码**:`src/app/**`、`src/services/**`、`src/worker/index.ts`、`prisma/schema.prisma` -2. **行为契约**:`docx/需求规格-邮件自动预报-v0.2.md`、`docx/接口/carriercentral客户端通用接口V1.72.md` +2. **行为契约**:`docx/需求规格-邮件自动预报-v0.2.md`、`docx/接口/carriercentral客户端通用接口V1.73.md` 3. **协作入口**:`docs/邮箱项目.md`(红线、排障、上市清单) 4. **指令规则**:`docx/产品规则-邮件指令识别与拆分.md`、`docs/邮件指令提取规则.md` 5. **速查**:`README.md` -改识别规则先改 `docx/产品规则-*` 再改 `src/services/parse/`。改 CC 路径以 V1.72 为准;文档外 endpoint 默认 mock。 +改识别规则先改 `docx/产品规则-*` 再改 `src/services/parse/`。改 CC 路径以 V1.73 为准;文档外 endpoint 默认 mock。 --- @@ -72,7 +72,7 @@ - Vue / 桌面 Tkinter / LangChain Agent - Redis / Kafka / RabbitMQ -- 多租户 SaaS 账号体系(只有 admin/ops 两角色) +- 独立 Tenant 表 / 一租户多登录用户(当前为一租户一 `AppUser.role=customer` 账号) - SMTP 自动回信、真实大模型指令识别(词表/规则) --- @@ -679,7 +679,7 @@ Windows:`.\docs\start-system.ps1`(`-Mode compose` 需 Docker Hub)。 |----|------| | AUTH-TABLE | `app_user` 被 seed,登录却只认 env。改密码易误改库表无效 | | MOCK≠TEST | `CC_MOCK` 或设置页 Mock 的成功 **不算** 上线验收 | -| CAP-MOCK | `transfer`/`hold_split`/`label`/`customer_message` 默认 mock,V1.72 无正式表 | +| CAP-MOCK | `transfer`/`hold_split`/`label`/`customer_message` 默认 mock,V1.73 无正式表 | | OCR-LOCAL | `OCR_PROVIDER=local` 是启发式,不是商用 OCR;aliyun 路径未当正式 SDK 宣传 | | RATE-LIMIT | 限流是进程内 Map,多副本不共享 | | LOCK | Prisma 池 GET_LOCK 会泄漏 → 永久 `lock_busy`;用 `scripts/clear-imap-lock.ts` | @@ -706,7 +706,7 @@ Windows:`.\docs\start-system.ps1`(`-Mode compose` 需 Docker Hub)。 |----|------| | 基址 | `CC_API_BASE` 或 `cc_settings.api_base`(默认 `https://test.saas.carriercentral.vip/api`) | | 头 | `Saas`、登录 token(`cc/auth.ts` `customerLogin`) | -| 契约 | `docx/接口/carriercentral客户端通用接口V1.72.md` | +| 契约 | `docx/接口/carriercentral客户端通用接口V1.73.md` | | 登录 | `customerLogin`;token 缓存 `cc_token_cache` | | 预报写入 | `POST /Container/SaveContainer`(`save-container.ts`) | | 船司 | `GET GetShippingLineList`(`shipping-line.ts`) | diff --git a/docx/接口/carriercentral客户端通用接口V1.72.md b/docx/接口/carriercentral客户端通用接口V1.73.md similarity index 89% rename from docx/接口/carriercentral客户端通用接口V1.72.md rename to docx/接口/carriercentral客户端通用接口V1.73.md index f74810f..fe72e48 100644 --- a/docx/接口/carriercentral客户端通用接口V1.72.md +++ b/docx/接口/carriercentral客户端通用接口V1.73.md @@ -1,9 +1,9 @@ -# 客户接口 V1.72 +# 客户接口 V1.74 -> 本文档由 `carriercentral客户端通用接口V1.72docx(4).pdf`(共 15 页)全文转换。 +> 本文档由 `carriercentral客户端通用接口V1.72docx(4).pdf`(共 15 页)全文转换,并按 **V1.73 / V1.74** 变更修订结构化参数说明。 > -> - **结构化正文**:接口 1–13 的协议、地址、参数表、示例、输出、限流均按 PDF 收录。 -> - **附录 A**:`pypdf` 逐页原文抽取,保证文本层零遗漏,可对照查漏。 +> - **结构化正文**:接口 1–13 的协议、地址、参数表、示例、输出、限流均按 PDF 收录;接口 14 按服务端 `CustomerMessageApi` 实现收录。V1.74 起以本表为准。 +> - **附录 A**:`pypdf` 逐页原文抽取(源 PDF 为 V1.72);与结构化表冲突时以结构化表(含 V1.73 / V1.74 更正)为准。 > - 字段名处 PDF 抽取产生的异常空格(如 `F_ TransMode`)在结构化表中还原为合法标识符;说明文字按原文语义收录(含原文笔误,如 `F_Email` 说明写作「邮编」)。 --- @@ -30,6 +30,8 @@ | V1.7 | 1. 集装箱列表接口访问次数限制:1 分钟 1 次
2. 集装箱货件列表接口访问次数限制:1 分钟 1 次
3. 集装箱状态变更日志接口访问次数限制:5 分钟 1 次
4. 集装箱货件信息接口访问次数限制:30 秒 1 次
5. 集装箱拆箱报告:1 分钟 1 次 | | V1.71 | 1. 集装箱数据导入接口添加字段:柜号id(F_ContainerId) | | V1.72 | 1. 集装箱列表接口添加字段:提柜供应商(F_SupplierId) | +| V1.73 | 1. 集装箱列表接口输出参数状态说明纠正:0-新单,20-清关结束,30-提柜直达,40-待提柜,60-已到仓,65-直送运输中,70-已拆柜,80-已还柜
2. 集装箱货件信息接口输出参数状态增加:22-已装车 | +| V1.74 | 1. 增加接口:创建工单(客户指令)SaveForm | --- @@ -48,6 +50,7 @@ 11. [上传文件](#11-上传文件) 12. [下载 POD 和 ClosePage](#12-下载-pod-和-closepage) 13. [下载拆柜图片](#13-下载拆柜图片) +14. [创建工单(客户指令)](#14-创建工单客户指令) --- @@ -389,7 +392,7 @@ | F_UnstowTime | datetime | 否 | 拆柜日期 | | F_GiveBackTime | datetime | 否 | 还柜日期 | | F_NotifyGiveBackTime | datetime | 否 | 通知还柜日期 | -| F_Status | Int | 是 | 0-新单 1-已导入 Transload 2-已通知 3-已提柜 4-已到仓 5-已还柜(已结束) 9-异常 | +| F_Status | Int | 是 | 状态:0-新单,20-清关结束,30-提柜直达,40-待提柜,60-已到仓,65-直送运输中,70-已拆柜,80-已还柜 | | F_CreateDate | datetime | 是 | 创建时间 | | F_LoadPort | string | 否 | 起运港 | | F_Dock | String | 否 | 目的港 | @@ -719,7 +722,7 @@ | ShipmentID | String(1000) | 否 | Customer Ref no. | | Ctns | Int | 是 | 箱数 | | Pallets | decimal? | 是 | 板数 | -| Status | decimal? | 是 | 状态:0-预到达 10-在库 15-在库 16-已排货 20-已出仓 26-运输中 30-已送达 90-已取消 | +| Status | decimal? | 是 | 状态:0-预到达 10-在库 15-在库 16-已排货 20-已出仓 22-已装车 26-运输中 30-已送达 90-已取消 | | PlanDate | datetime? | 否 | 计划发车时间 | | PlanETA | datetime? | 否 | 预计送达日期 | | ISATime | datetime? | 否 | ISA 时间 | @@ -940,6 +943,147 @@ --- +## 14. 创建工单(客户指令) + +对应客户端「我的工单 / 客户留言」页面的 **新增** 弹窗。创建后状态为未处理(`F_Status=0`),工单号由服务端按编码规则生成(示例:`WO20260827005`)。 + +本接口不在 V1.72/V1.73 源 PDF 中,V1.74 按服务端实现收录。 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/CustomerMessage/SaveForm | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| keyValue | string | 否 | 工单主键。新建不传或传空;修改时必填 | +| entity | SR_CustomerMessageEntity 对象 | 是 | 见 entity 表 | + +#### entity(SR_CustomerMessageEntity) + +对应新增弹窗字段: + +| 字段 | 类型 | 必须 | 对应弹窗 | 参数说明 | +| --- | --- | --- | --- | --- | +| F_ReferCode | string | 否 | 参考号 | 柜号、客户参考号等 | +| F_MessageType | string | 是 | 留言类型 | 数据字典 `CustomerMessageType` 的 **名称文本**(不是数字)。多选时用英文逗号拼接。示例:`客户新增集装箱`。实际可用值以客户端「留言类型」下拉为准 | +| F_UrgencyLevel | int | 否 | 紧急程度 | `0`-普通,`1`-紧急,`2`-特急 | +| F_RefNo | string(100) | 否 | Ref No | 唛头 / 第二参考号 | +| F_MessageContent | string(800) | 是 | 留言内容 | 工单正文 | +| F_Remark | string(800) | 否 | 备注 | 备注 | +| F_AttachFile | string | 否 | 附件上传 | 附件文件夹 Id。有附件时先调用 [11. 上传文件](#11-上传文件),再把同一文件夹 Id 填入本字段 | +| F_CustomerId | string | 否 | (客户账号页面不展示) | 客户 Id。**客户账号登录时服务端强制覆盖为当前客户,不必传**;非客户账号调用时需传入 | + +以下字段由服务端自动生成,**新建时不要传**(传了也会被覆盖或忽略): + +| 字段 | 说明 | +| --- | --- | +| F_MessageId | 工单主键 GUID | +| F_MessageCode | 工单号,按编码规则生成 | +| F_Status | 固定写入 `0`(未处理 / 待处理) | +| F_CreateDate / F_CreateUserId / F_CreateUserName | 当前登录用户 | + +#### 状态 F_Status(查询时使用,创建接口不必传) + +| 值 | 说明 | +| --- | --- | +| 0 | 未处理 / 待处理 | +| 10 | 处理中 | +| 15 | 已挂起 | +| 20 | 已处理 | +| 90 | 已删除 | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "{\"keyValue\":\"\",\"entity\":{\"F_ReferCode\":\"test1111\",\"F_MessageType\":\"客户新增集装箱\",\"F_UrgencyLevel\":0,\"F_RefNo\":\"\",\"F_MessageContent\":\"新增集装箱【test1111】,通知操作人员处理。\",\"F_Remark\":\"\",\"F_AttachFile\":\"\"}}" +} +``` + +`data` 展开后(便于对照,实际请求中 `data` 仍须是 JSON 字符串): + +```json +{ + "keyValue": "", + "entity": { + "F_ReferCode": "test1111", + "F_MessageType": "客户新增集装箱", + "F_UrgencyLevel": 0, + "F_RefNo": "", + "F_MessageContent": "新增集装箱【test1111】,通知操作人员处理。", + "F_Remark": "", + "F_AttachFile": "" + } +} +``` + +### 带附件调用顺序 + +1. 调用端自行生成一个文件夹 GUID,作为 `F_AttachFile`。 +2. 调用 [11. 上传文件](#11-上传文件):`data` = 该文件夹 GUID,`file` = 文件二进制。可多次上传到同一文件夹。 +3. 调用本接口,`entity.F_AttachFile` 填同一文件夹 GUID。 + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功时为「保存成功!」;失败为错误消息 | +| data | object | 是 | 成功时为空对象,**不返回工单号** | + +### 输出示例 JSON + +```json +{ + "code": 200, + "info": "保存成功!", + "data": {} +} +``` + +### 创建后如何核对工单号 + +本接口成功响应不返回 `F_MessageCode`。如需拿到刚创建的工单号,可再调工单列表: + +| 项 | 值 | +| --- | --- | +| 请求方式 | POST 或 GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/CustomerMessage/GetMessageList | + +`data` 结构与集装箱列表类似,含 `pagination` 与 `queryJson`。可用 `F_ReferCode`、`F_MessageType`、`F_MessageContent` 过滤,按 `F_CreateDate desc` 取最新一条。 + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "{\"pagination\":{\"rows\":10,\"page\":1,\"sidx\":\"F_CreateDate\",\"sord\":\"desc\"},\"queryJson\":\"{\\\"F_ReferCode\\\":\\\"test1111\\\",\\\"F_MessageType\\\":\\\"客户新增集装箱\\\"}\"}" +} +``` + +### 调用说明 + +1. 必须先调用 [1. 登录](#1-登录) 获取 `token`,`loginMark` 全程保持不变。 +2. `F_MessageType` 必须使用数据字典中的中文名称。名称不在字典中也能入库,但客户端「工单来源=人为工单」筛选可能查不到。 +3. 用测试客户账号(如 `fj`)调用时,不要传其他客户的 `F_CustomerId`,服务端会覆盖为当前客户。 +4. `keyValue` 有值时为修改:仅当原工单状态为 `0/10/15` 时可改,否则返回失败「工单状态已变更,不能修改」。 +5. 页面「确认」走的是 Web MVC(`/SR_Transfer/SRCustomerMessage/SaveForm`),需要浏览器登录态,外部系统请使用本 WebApi。 + +--- + ## 附录 B:接口清单与限流速查 | # | 名称 | 方法 | 路径 | @@ -957,6 +1101,7 @@ | 11 | 上传文件 | POST | /api/learun/adms/annexes/upload | | 12 | 下载 POD 和 ClosePage | GET | /api/learun/adms/annexes/down | | 13 | 下载拆柜图片 | GET | /api/Container/DownDevanFile | +| 14 | 创建工单(客户指令) | POST | /api/CustomerMessage/SaveForm | | 接口 | 限制 | | --- | --- | @@ -1009,6 +1154,8 @@ V1.7 1.集装箱列表接口访问次数限制: 1 分钟 1 次 5.集装箱拆箱报告: 1 分钟 1 次 V1.71 1.集装箱数据导入接口添加字段:柜号id(F_ContainerId) V1.72 1.集装箱列表接口添加字段:提柜供应商(F_SupplierId) +V1.73 1.集装箱列表接口输出参数状态说明纠正:0-新单,20-清关结束,30-提柜直达,40-待提柜,60-已到仓,65-直送运输中,70-已拆柜,80-已还柜 +2.集装箱货件信息接口输出参数状态增加:22-已装车 1. 登录 接口协议 HTTP/WebApi ``` @@ -1284,8 +1431,8 @@ F_ATA datetime 否 到港时间 F_ UnstowTime datetime 否 拆柜日期 F _ GiveBackTime datetime 否 还柜日期 F_NotifyGiveBackTime datetime 否 通知还柜日期 -F_Status Int 是 0-新单 1-已导入 Transload 2-已通知 3-已提 -柜 4-已到仓 5-已还柜(已结束) 9 - 异常 +F_Status Int 是 状态:0-新单,20-清关结束,30-提柜直达,40-待提柜,60-已到仓,65-直送运输中,70-已拆柜,80-已还柜 +(V1.73 纠正;原 V1.72 PDF 误写为 0/1/2/3/4/5/9) F_ CreateDate datetime 是 创建时间 F_LoadPort string 否 起运港 F_Dock String 否 目的港 @@ -1470,7 +1617,8 @@ ShipmentID String(1000) 否 Customer Ref no. Ctns Int 是 箱数 Pallets decimal? 是 板数 Status decimal? 是 状态:0-预到达 10-在库 15-在库 16-已排货 -20-已出仓 26-运输中 30-已送达 90-已取消 +20-已出仓 22-已装车 26-运输中 30-已送达 90-已取消 +(V1.73 增加 22-已装车) PlanDate datetime? 否 计划发车时间 PlanETA datetime? 否 预计送达日期 ISATime datetime? 否 ISA 时间 @@ -1625,4 +1773,4 @@ fileName string 是 文件名 --- -*来源 PDF:`carriercentral客户端通用接口V1.72docx(4).pdf`(15 页)* +*来源 PDF:`carriercentral客户端通用接口V1.72docx(4).pdf`(15 页);结构化说明已更新至 V1.74。* diff --git a/docx/邮件/邮件4/数据模版-新辰泽-WHSU8127240.xlsx b/docx/邮件/邮件4/数据模版-新辰泽-WHSU8127240.xlsx index 596c5dce9f805a9281073ad15bb8852d9ece9bcc..b95538781556c16e0b2f1c4ae3376105f87adea1 100644 GIT binary patch literal 12014 zcma)ibzGdi(l%PWxLa`+*P_Lt=;Ch0wNTvM-J!S?cXui7?xnc9yM6HVoJV@jdwyU3 z$nM@dlS!`0JxL~)j089YEXZpK0Iu=9e*U|_ynNBO)RVEc{9;2d^CE`!@&Nh|u@M0c zP)1M?5DqX95Tw70>3sP@=lIz?HL6`4oDs?Y*!vjCy38abFxEUcV@;SwrKR`49CuDi zz)(Xiy*mV|o$X{}f$|2+kS#;~bmKGm^C<7K^oHx?RIy*+Op~|t+ny9J=GJNp#gpXC z+nuk>q=({~E>MumU}p_KmSaM8z=bM3X@uEnZjva6eEF*UMb`PFHRY%XaivVcs8@>y zaM&-mT0uYRauN{2%pujI49MQR-QcjAneY&lu;k=v*0c}=Q#vE{{edG@kkeRA?tFn> zQ5EK;7Mw{*&*!j-`9nmZGDj88-&Nn3Q0@_6-7%{tj zUDrFBhY5&ITfVb0#vi$ok#ZKqu@BSa+Y;@dTb+P%-DlhQrzrc(9cX;(ItqorB^^0B>?_|L0K89S*+-Q~h z!LKa1;5AT+2d#v5EO7Rj+H5jfsHw%r+o9N;PO0`R=Xikl6q=WL;q)e12(==UUWBQaS&yuKFyCoBV1+~cLc82GNbWhlP@yssCmUEw<2(b|77Hc#p?6LEu2Uha*k zN^WW`OdhmQ#*@K;12%veQpGI|#uejWueTR3Xt!4<&MS`EASEPMyDije6CUo@KDtJn zS}qdbzu}^T?lsJ6dIPnoV{4ZxC5sdJ&bEIJAuH5(DuR+odhwj%x+%1YzX9=-p3&OJ zN`!b%TZR`83DL89)a{Ob!t$SPpq>0dAn@V_-xoKa{=*G+Hnx_ZUmZ~stuF1ufabX@ z^?;atJT@_ofbnUbQX&))C)I^DJGKO-I-7Q~`)2vD%gJf_#y-jB?2;?N_Vj1Xl9w4t z><}N;yA`W`1=4L^{ zX%2BP8fuM_-)xmtucx^3lSCMR=(wD8v|Z3rM7W`fsmmT~-}{mY4=kf0peYXe*H#`y9{I;dP!SJ|-`COI@PIg~jXCn% z;=V%u-{TMX3*?UGe?*@DF7Wo3fPeA1aKm zP7F(0v|bZM@jGVIUG`dWhlswl2{;U5tn~F2k~BXa(wbDM4SLh%{o~?b(sHktd=yZi zYti`GZA3xQJtwPhY_W{@x@pP9p40NY({0(#|53|+Zew(zbKmVb!UL*p=&r@4f`_}z z;g+cB_&F~1zK!SMoX{cR;o|Wj-auHI32)^3u`Y|<9UFcmxz4be z;>Wv-$KF#+G1u4;w}qqHZf!*eUYuC;S?+|v?gqTkk*%?60rbAqx0Sk2>lzy_9-77X zhz@aH%hEmpP1_|36$;B-2K3jD%RlLLvRGV&7t@v>g>!@I>>nBZuN1ZSIX&i`QWBWg?>htU*7v{SQk}@- zGuvwf!{RVB*ngwaq?{jcQei#wZ>CxC-yj1M)zbBx$|B=Civ}U32k`BUi3=X9$wkg& ziqkn1fjJJVD_ZfCDp-xX;p^oW>ItDwa~Xg46~`BN532@}t%CuYUE2$yiozEU5lIHB zFNR|;mQ>9vZQY=b!ne2MDkqj@f5+MNMIit_71{1XemgBXr>}moXeaWAdSt%fP}XK0 zVUU7U7`{E{!qq^L1@TTg;YvJn^}ha13t1{fDU6qN`R%scRu=h| zM5Nie^elMep%|c~s1^SH=-nGrs>v3%I8fGD(45lJmDbb&{obF2lE>Ln27CwxT8esw<;Q#PIgvC<2f`p2+}Y~|hrP2GkXL}8Qh1XXmIt4oDLDyK;{Aj%Fb4cVTaz%UVirhK z*!_BX*qAW;B8E9n+zwAUjx7tDB`@@=&9h6vFXE`%@~9h}1SJ^W{*HAlU;?6jewrYu z6JWGp0~LP`r9d)4v<5b=zqLsb`=A%$)vuRU-FD;h2%2pVGY>Y4x>RN(>}s*o*}{py zd`^``3$MCw(-IJ|dVh^X^{oYr%3Sp|pSz<_T&Dz?)VQJ>5VL=CqA0%IJ% z0Np*_!)QD;2ypkF<8x216xK7X~Cr#hqR-SQ-V zZY8JamEjFGmZ(b29o9q)iU3dP-AfmuMff=0*G}Zbkt-#EZBC!U=9{A9?6ZTNu0;B5 z)ql+Puef9IX=Az|G8Gp{_l-BSz@UKlB$C?=sr$Q#c=XKyz?M{gfi!NLlj;CH_dF48U;Yj~Q8ah!*I}8iYFKnAW^AGgZ})LG-??ZC|9(xO`=gVG5-k8h?=lKfu|FUK z+wJ`an36DLXq@lbOa75SyE3OPTR0;YM`AN`^Bf84Uvp~mv(kq`zv(iR+2aF(*)r8e zo)!zl?Ik*wsOiVCsIZ#?cj@}OhQ5etMQn)WGl8X$r<+45E$PvX>7`5(LOgmWE!PKP zON<1kHpA`+z2kZw!@RPE=l+G0O3DFgbtmK&=jVr3k0I%9lNQ>6*`R%W{QUI^e6%Z^t57$d6sb_3T6g?#1!QijO2Vc%MX32mN-D&!5m|4=$CsnDCdVU{ z0UbP5_B}aq@e;^wk%Gdo{xwsOm~>N&IbW+3B9N4ba6kzgr2@+(4WI7B{*0o>yd0nvxVA zj1NA60;>SfAQ{FsP?g!!$^Ri=I$Y5cD;`#h#V7a{PO0`Kat})e7C=^#HI&{CZZLVR zwihPKheCB{Pg|5&8RU&wf=CXpbEX2D3d3L4H z5CtaUBJ9327VD1hC}&Vtq`W084`e8}{Vy~`{k?Mwv76I%^8*uGl}Fp$E*}8Smk<5@ zokkRh9_&sRI~%LlN(m&6;1uhXD}!acw~vE}PDVuHW}#pu$U9L?zKKhg_M1@mJ`!i@W_2Jd>SEQ92*Rn69Mh z$PIq-^aQjvEU;S%EF@-+v7Tq!)?0q;Vzh}^6^4g0xMi{M*LQZxudg7te}4i>FmR6o7+ z0iJNx#1nNDVIC3}dE|I2{DOBAdL8V0@IkRWiCkb0w2+VF{_dLy_jJiUbaIr=E0a!e zHd-P_CqDMR>r(-TZT zIYu8K=Lg@C?R^e#U&!=%b`8){7{pvoFe<@ntX_(oYpI=g>V;!lA{kJTmcniz8(vcH z0Ao4m$cSeS0LtNYO+dTviTlIg{Z#ox;sc>fD1&}UoYK+Sf@4gkmsmg`5o-;zEzEo{ zU24YQO}lSvUGJWIt|zSk2p!d%@ui!VvVfC z=*!+CF?VN3slFKSe%m^u`3u4uxc53nKA(6YXv#V(w51WY@rU|k&Mov}1GB;1lMGljb%7EZgb!qg#Eqb2KX86C>|Cvb)zFRHHXE+C%k`xHY9{0J9hp zW3tS~Q_B0GuvS0dMZQehrGptNOH^Yr2e*TJ%WXe{p&qAC@1=Ylo)le0o zO0j_|EJTk(kO`U`=mx(E1+3u}q+0FhK&y85Twoopb=tg|DV~fyu7)28*NYcn-;cku zCof}%V|&alZ|9C10$*;f#oZn`4lcb3vCc{9d1h9=um)j+HWS46LZWkk6ls)80>z|S z8}pk8^w$L4Z{5X@r2QfYSLWqfulxQP&N~0Oj@8KSYo)(W#*aKbQc4;MDiq%Xes!jK z2meab+6$F98hL%DLj6+7l6i1zQ5Eg$?{R{KE`PEIt~K{Es2mu{)k6u~5vn}rLnl6JWnxb8EC(zFublgABJwL~ zPiP`AiL!~_n}OUDa2dyl6;)0dauE)zwW?^Jh}3+&Mw|apX|U!7tPGKghX) z{LtI13Yf0L5>0lm@4Iut&g+3DIfqiIEqX><$#2G(YV5a?bUd~KYv@R8syry*cxk>W z&@lv0+l2HU)07AD#Er)6=bbT>%qP$N zkDxu(xqF+zb`A^-X|vw!-{-=0;=)W9nV`U{a`Ee#FLo)wYY!QO%;tT(T*8^p$p(MO zUk@eeT+bRwhA>QCiVT10Gg_xJ8)cFFX1>fUm8WFjs89^TtZ1n=^2t-#7i6-U zYMDi9`r{W$29{6C{%=x<9GqskT2GV`)Gh0p2_u>}eMXL2>uh+wa|(i+vu$x6ozP#~ zmOS1SyVV}jl?rn14_#ds+{QjZ>PKJRlgFf|G7`x$6uOy`ioy8bhdl0fF&v`NCE(_M z79^xaDUHAHU|nx&6izglsACk$of(`eXfENfeB9jp>AG(|t?se<`E#f1Vqp6FCu)Ju zKY+vL3>-E|V$=7T?STedVOz0+{$O%TSMg0}6he#J(m$ZunvyXOb0GU_MwTLL`KP$% z8&b4$#u}<-7N)P3h=lPhwCHH$cTOQ2a+>Tmc#?ha3J91{{PLF}a&>^o2uwR$2t?V% zN4r$CIs5K*t#h1<^{%&j)<-Vdo2@Q8M^Dantq|uEw6}!14yUr)j-v~zJ9s?0O?Fxf zzE4NzkjIxiLLpXecs9*LA<%Ls3(HJ_cXg(zk3W|Z+c=j$^>lDv-x}fF4XC>a-u>YG z@Ca>3*lMi|{MkEr*z%1I8R$~Az;MSQhE02#ms&WaX4mI zB&lA`$GW;L)$0w={iiXBC@KaT)QFs{iWDH;yw=dB54h!_=KVwe-~ zw)vB-EpsFUG$V4AtXVt)xiZ_pNu$0YyV$7Vrlgin3SNhEb!?DevpP!PX<&l4DD>ID za441#iax1Uhk$6jAexO^p)1N}zNB~b)EXos!lJ7bT~QplKqRX5u7DM3s&9SPNwg=K zSQc8Vx-L_Z2(b)VIA&EtHE`N*%JU;rne9G_jNJ)-Z{+t)$Q`U;L%$neVCv|sewPAi zvJZ|bAe2J*GyGb;1GHpMaGt4y=C`bTG2wjF6Jx4`?J6XXWmQ*ZK~4$A@l~Br2-4+= zxls1vxctX1lMg{+d7qNTMKxwcHG@d9AMQheJ-s{FDAA0Jj7hY7!(F5RB#}V-d*e>M zuz}cip7pBE{N@&IPFDo)J;C9~Yki>#G!y|+-peu!t4zRsYZ2B#V3&q5g$ z%cerr?KM^*Rv%FzX4F@O$>kYwH&kcw9pyc6D9*TnvdlvrD>MCa_uV{B4vXg;mL}8c z&afnn_Fxkzc&tiKeE^gOnrX#0VN>E{+QPAl*yGYcW1$Ekjznwx(H)uZQo&@9Btb(M zyBS&Us1^gD)7IHR#m){1xJLntm}gheq%cen&{TSj#*r+K@iN!xCJWm9TRaKpY)SY2vB_gvjroqHd^}iEPm|k2?i%#du zA3xvKaAX$=Q8_Df-1#l7z7fokz4?+>M}7%*h<~MJbuBH;{>aA4%UUKgAi2NBxZ4km zkz@Xp*|1V$RIG||hPziR9A6-cK9MY77vw%(&m&DJC6lLx-#;uGEj#ocY$;&uP>6Oz zAXaeMGJvH8#-0iWwppyVLy+VEq)mv}eQ(gwS`$5bU{<%oupeOU|^rOban zs?1TOkQaZH9731GdJtfj;}1jeX`eeG1>ndtr~=wF11?mw=M|qjDeooAAE%WgDTuQc zB_@nu=@}8ltu!8mzQuyYK|p>ULK`tbI+Kp?qt>$eUYK>gAU|ET@EplywtY^tW)AaH zfJQREW7@I$hcn=}-9BShS-QU4 zHGgMX$v%#=Abv@ltZ7H_2BpZZAoNt=BKBCUz-R54a-Sre_O;n3)KT+3EvmXz2G4iz zT7Zkki)ISt1_V)xan-lke@~6f#mVOpuTnc#EqY&L>?%V4X$EY2hF61Ok>)e?_TDpc z`j9E+SPb5=Gm0hQ%z~Zt(WB}8I>(WOR`IvZEMAX_d74&B<>f}|K3)Ve!!5&x#{QTi z%d}_Ee=dEHP-ujpFFF2qKoAgwe=U8sPUZ%Gta;NKhY<^+*w5iz&z~B+5d+QF5}F+? zt8$VxB`ENBvN2#_WHzNziv94R7IqK4BVk)A9Rh-VVbOpTRzC}35pBJ|t){IGADN{Z z?}I#6(-JE_F!!@tNuani$*m_iEqR=CuP0VGloGg<+77-I=q0Q*FeoCIxGLYjPFc;M z^|0qJesVm>GAH<4%AF0TpkTYYytw7E&4ioZ6f7f&@LZwSzlRjZQe7O2-?-|s+% z-%l5XgiRUUjsikj627E2;VvXPd3Z5Rvo|(zxp|2C6jLD?*cv!cJOtSeT4yFxQVM7; zUxEX`ss&?D<1#nrv%5DAltJ-=G z!-gs|$!gOews{l5T-IuF&TcYiw{J^M5oTrhvr`$b5i);Da@hQRq>b`4WTj+?l{bXx zekaWOph|{QNrSe!35B+9X3%rVL=N0q9ml>_;myXu8XUY2XmaI`a#|!q+YFTAYD^lm znf$WlN_Guu$kx96=?*YNNdYWyj?(0}ZmbAl!7BqtZ{a&hxUiDG5O?>o0Yw%;|1(xlcMML?UWaZ^;B?x zr|fKxw3XI?;pS8cF^G7&oPM+YXgWZ=UQ+py|Mo6XT#I-c+7()98+~1(S*vIm`=Y?v z(T`FW{G9pY%SrgsZ)om6jp9dilBb&m%)ACiP zV=Kn2V_254MvyH)zohBYB|FuL_m3p7HmmnqMUN>#v&G{=e-K5b%C3NEq)A|kYLFu zAHluu?tXpCM`w3)H7@)C1;QHElE zF7W_GD5IK$?6eZ**qJ6S*Nd=}YSowL&6)hc|(Yj5FtiHFI}=Yt{F z9?6kJ`LaxBYG##TMDaFH#~))Tc%`9brU0Psv${$nA-p``M3ZfK0y2+zEH~X=s!1^4 z7Ou^BC9^Y=Bmt*d7pme}1cE+Pq&e-l$=k+HHrH!URl8+` z2O%L)M)XS__pb2-iYVd{b=o5XDNp-ln0d!!V6t*~5DbLiTs($=5yEGKv*hdCHr`bC zlRDqGIja8Q&BL7Eb}u?7Ln59cWmXyp{DpR&&OZ|RSWhlQIGPwUr6Vh2uK5JUWB~Yi z(~xu^V83CYf+pz_6OaTmm@^`?gatV|Qg+mL3yramubqMdq&Du1t|=VkZ7I3A-zghK zrntw8uOfp+pb|${@wTzML`I){^{i;|)W17D+)XC{9z9xEUyodLj>@YMU=}jSK%CiB z=U*`JEBU!O-;GZ09zK(=olGJlFgnRvW3q(kcp!8DS&!*Oq~p0;d03RDG;fCNzz$K8Ese=3gJtTIYGXM^^RE^HicOgFT2CNm$|MDq^UJ^S0{Pz|86Y@i!b7C8Rl1OLQeDTY2V;-@NP!dLT>z+YYKE{G2V3 zA#;Vm{K?m8zpuj=FN!3}n8a+8)UgOR0j%PI8eYmEx)sWv=!1NL^O9vU@3{^41%`fE zRsWY@#7|BUIyZ5zOgG=wH~!bqd3rA2l}C@%_1GGtmdpn)ZWeB8AOqch?n!OO6Y5}m zP9$ja(t`7{g8hV-M!f{RO8fREuE%RK-ov?kmgNdjvSy<2u!gG9aF$xc&9Y5ymdoY- zK37)WEH#~9e=KjZHUj-|nnzlRx$w=cFU|q9(#7GJU&L3)ii(P}+1;Ajp%kRcs4GR8 zof*lTIKb#ofI`73k9R#){pq`PS01aDQawL*h5V zQD*85g{V|J^GNhiAJ=blZAh2GZ{Bc>THC!)6dc3@ic1V>5rj4TzRCyx+|+2ZqDk!4 z01^h1Wenf)O-^oiv`3ArR}YGE`jTbsKa(=`c5TEv@vv%(TA$GtIsD&P{_7&=zropO zK5u>S0%PL~oH#F^FPW)dVz11be;?cag|3uoCj*k-wcir|P-}c@yqx%kxj50n%m>fN zp7x5v|JC1E-a3+2hvMUL6v1ti6iYumA4$Dxw4cwNWTlL zsdI)VK$jFPFS4|(wPZIQN$9(b60$$d{?6EI1n6hSV?`&*8cltlEo&2B5F?xHbM%U? zY;F$Jlu??lC3<#?8_2jMBgOME{*PL=8_I+_|(;RmW%i_?~|)Ez$Y+%uh3*M&coZb=Z6+}5o)twGNGxZ>0<3m6;Pma z&SJ44VNAkV8v0jX`3^@YR(0^o@sm4K234r<-i7!x#bN;NFX5G-)a7TOc*Bxz)x_fr zBaTScbcWw8U0S&4S|nEF64emi<8iku&`46HDPOp(lX~+jaa&;27AFNEg~k&{$+z0> zdCFP9pet^%&h2=z>HD01^R?e{_+EPymKCJN&xpa;0xR*p{>&QBjSuKw*rpghcQIKI z&xZpifvhlATg(^dMM^}Dz?=50FG09!eM9%H_D**VET%4j84)L5bxZPQ;ETBs#AO0?blTNXK3+)6Wz@`@r$s)D5Bxye0?%#BHX9JtEr^rB-rKzE^O5q- zYQW_H?BU4DHm$%9x5C|}%hsscHk#)1_H(`W4{u~7K*7-d$e{k>s{ZbomkA643i9ry z<^X_z{1-p{YT&<9&R!$VukuQJ%_9GIZ28^j?-A%#=ac^pGN=9KK)(&cb?>5>Ig3!zQ5G} zWKI6BLBF#8ewEijd%om|Uc$})QurrIM=#_3Gd}&^(cc5vtDRpcp;P`%?LWfW@9q4a zs{gAU7xX{c`H#f??=AeE@%XC+d%RyQ{F(pw{~tTq3nB7l?Ek)t|K|+({WktDi;{m= z`|EE0_b&gQw)?9A{g*}I#qQr>`(K@YrEUHyuO80)<>7yjIRDx6-)Wq$y1%?O{2#i1 Ykve50Apdw-kzW|pP%m=hRIhsf5A-ThEC2ui literal 8906 zcmai41zeO{(<2nQ&UmhsQdo&g z4#ErGd7)`6t2wcs#DG9h4653iutAv?D>udkFLPdm3nFsTS;t}sb4HAnIguf{dh{E; z4}G00p#Hcs2^b=*-Xr6wDi`Q4#72@UjgJgZA`(a+5PEtL71h3hMh+DQS~0CdN3?oo zDvcl|5{90`qZY4B^u!?dv#r0F1|?!M7-?@}B{S)w)?2K=2ImlQ@X~Hilt9}f++3?D zRT;|ip}8FI_hMf7FW>t3hYl4W$U9qt9YjutYT_PAiJxG?l0~N>obUQCCK{OlS8uHY zQ8kC`lk%&-3Ljvio_{yGP5)3QbYd4uvDKd|f;D})Rry$mDQpX1=pk0JM6C9gC!l_K z0%H7M10M31i3`ZimF*|Zy|)gtfHRwrdx|hnP{_Y%SnnuH6F#VPu%n3Y`^l_ar8>wz z$j)yf&zq;^7JX0cKwKM>+ZnRA{~Roi5@U@J>%6mW*}pv~9J3XpaY};1Tug)m*FO|7 z^uGVr+8Lb@89ssvfzv4uC2j4=jS`hYVQ)*EKCXNZyiff3Yxg=4kaQoli)JISmO;`(s3qGbK|29ngUU@A|2 zMDuITQ98a6Kd7G;`KQ%jAr|qn`(?6!5+L3a{F`M%sVjBvEZYFFEYaV|JRMzZA3HkQ z-u)7O8rr6V9YcEm2C4GQz{5a+A>NfC=#k+WY-mlLk{!^B&;I62n^>OYK{i5GXUWv7 zZu#xOGyZ3)O5+ISuNf6EHk2z`_Hq2UXRjknQ0spFM} z8Jq?VWE42jV?AMbrTXzWR^3%Z~ln}gj#R;{c?8XeLhPi0sNXqK8i zg*OQih~BtR2&K*pA7CTO5VU}kGqNB9PcG{!cDa7}=z5tYj8<%jf*z&w)lKqhh(YXI z^Ye>yyn*Eeb`y%-UW!A)74A!!!R|sr?}Oznvj1*qw0i^Hna0%$1akW`OrMy@mbIN|$ey6mmx675;9z$%7)5n`M|P4m;X_)1F*rd|A- z9sxIudKP}MJDvwdUl)6-NSPJchHkn8(5^n&8fQcu@>hq7RLWjBYdSCUpYp-khXiY%O!m>$tRZhkO zn7sS_F3kLKE%F>`jab z2FW6owKMyT?2FgjsEP+7T1Xwqf2}bbbpz*FH_7nkWI?{U3=`)qaD|zE z{gehAvlT4PThh;)bp4nTjJll)`G6U;!-S9BBTG*Kj!{z}C(f3Gxp7DyB2QLr5d+>mBm%T;7bhq* z9P8+u>r}3yoa>&YoNJ<-lS%TkAi5T-zXpX?T+WPKo%qor|8#xSe?O zs=n^bMe5UFiBlL0Hu2NoA+9V0d6SVhY#WpK6|xZR7?_d3tx%iDcQ4z7syS>V zd2G^`l6h=921UbC7Hs2Vv?lLaBwI$-j-EUlug&h*_W~RW89y5jlQ+8i+&^?vY03Ot zkb`h#Z+H3Tkbdu7FXBHg0*#s3Y+iOEpE6*Y0Z+B zimjk;ufWiTauxof52wQdiC$CuRr}Ty(J(NcLDTA05QXN(ZeF)Cl2w)R1)!xMNwuH` z)R7lR3&#z#daryM&BPBP%$m=^i?Rz~n*=-D%-2J*%Z}uq?n3zu|W| zN#&}G4AS5(FfF*CMi)&N_;xn;4rIzoWy(%P;_tf5XvUpeOv~FAh?gqgT5pg5ubdo8 zbi^bW_F$A?6fl}}+Nb8NC{Goh&kfVX4YS5AiNMpPipE~}N!w<=1HWwY^xVJ+NW`s; z)wk^6g@l>Mk%ySau$!EH_+mg71=${;WDkh84}GJ{@Q6mqp-j7O*b}U~#LwGC!3s<( zP?o$<0c6SmtWm6CtQCAgQ_VEpm>t0+(1|3_xg?98Y<{LGxl!Zpy+)UpRV$?a8}Vx9 z(;=5+5=2s|;;Ey?grtr@x6TTAx%WzP@73fsv?6@%@Gru<#Z8i~EwjRCIl0$#$D?pX z(klz4YyZ+MyP(&w{UHbQJSO`WO!fs#WqNaJ00lSUX)4N%c8K$V2R^<9pAL5t!H7pNv8T56hhXn8A+QXz;M!X}a;CX!A|a|w&l$rJ#+ z=QUm81z}RZ;iB(!Mv@M$QmWhkG}K43>5bxi$_O|SEcm;rfxzXb2rTluJ&q|$ow?@g z2%~k_m0gC!*O+|aOejnT#hzrdj=^qHj_ofzoHGIm4B_)O2uor_8*h?L$tF@T@=hR=I3oH!LH*OsN1nitWz@?^d}`= zrk$`qLV(e%b4+*Ei=;mDS`?(}ovN++@j!THy(vm~fk=B$bM0l@_MzK_qCa2jOLrsE zvhvI$yEI+L@^PhBrvA&z$8$ATmd0)AvLNlZY#&#ruV1+{7B}`q8sHY6eP_X)8dm){ z;9{{6n#m^hdC%7P^Tz_%^v`J*vyQ7a_N`1?j;jkF>z=j>*FW{XxOKWVMDN=Fp8oCI zuUvn>&FrbMNXvyB2-G2aOq~1t?P_J_0(JzSVq74%sNAf$sz6s8G$ipZ5H>Nn%Ak-@b&aIKuBTUkeIA?? zsileQJyc1%9ozkdFs_W4KuP;ppu5VfM7Z6#rI~q>WPJNNnLLJ zqK*D4yK!57M5vI-{Gz4zeKt}YYb4aF0u|vJH$@c4*rBdM&7>_`Cj`c>S5uA6l?Jmd zarWC+$hKE@PJT8I-9_Flo_x(sX?k}gHn=^1d!^FmYUQvuy;)PamWo@2cz`T~t*Z-S zZmOf4ecr5u5xk&VJaD-+%2yP;l9zDC!LF>v)KgmG zF*f5XWLI`3$YQ%6^I0?<@1WyW3R6;wZ>uUCv_iw2-MHhX^$E39o%gE>c`f?v+7Q{Z zQ?ZtvteYpKbI0|(JCDkbpkq-BUER4KIZ{|vk6le|Wu;SfUVLyeCzfDyG=2Hxs@-os zJ%PpOL8l365ls+ zKGNP;QrZ_Q6y%dZb|tr6Kksk%fewpnVh@3T(pM>qiAuHIb8)> zb_&AoPE%Sk++a#P7aswqlqabYBL@iA83&t=#p^l7VZJlHN;Ncq3-a)ooZmdw8|MQp^|g% zMW65{amgICk*Ok%wO;0B#9Nyh!=%rj40&ya1XeOff%!ml-lCyuPmOO&ufr3I849t1=CM3Hrh(HdM_hdg_e;iwO^_3lGc?nwgbuk+24_|7~z18fDop-RGA$Jx!mRGFl z499{?P+6KCAw@tTm_ThpnX->CGYrC;%D>{77PNaf(pI565y6mVlTJIYMlQflc_O@z zzi>ulZPMmU!s#X-nkT%acT&uQ9N)7_8gm`%9>D)@v<|Grf5^U!xv8f~SxTHnX4Fm0 zNKPRk9=8I&rx&Dg5N@xCUr^cHXO8uui36WPhaIPN7ts&!Sn3I%O)Za5CwG-vs1(@8 zQMiCrqC+~b_PNWZKRj7{LN(#b=l-4rn(KL1`>#c>*xpoi0|Yxu%!63t$WVJLt1vn= zK19W8r`_oDzTOpxDP(MveK>IIVxw|^M#P|fo*QgPRx?p} zy>c|sCQpv_I~lANGZFSZ*~JgIIUf&Q!1Bslj^L)onx~Poii`M~1%xAf&ZQ9!T|k3` z24y38s;wsZCFAiEfCo?@934=DOm(=nqTc)EMqCMphr$uT46&g@VB5v)bkie8<;)uE z0{8NxG{T;lpOw~vq2B5(b@*I?Y<5dvlF~e=;@cD*raHZMH~IsP15yox$bB?-xR-t< zBT|u#DXFkrc~qb$^EnjEBjZ+7B|Z$2Lg;EQxpIT;UG%~w!%UyxDCjYy@*PMoj@a>_ zy+UgV(Kh9F3t*hn_?jOqbB4J5b{~@=CeQb=0p|s8I4@`@@F6d1zgqT25T*};YnfF2 zg#_DsGrPJp*&wuAJyP+m$2I-Q(`d$JuPjiIgLAi%1H_*TnpZBod1|WiVr5X8z&7-o zON`1iK_dMEpsz(Z?~|dBQ0~YKo6wb{%vKF)#d9P;pfX>8re3GaT4lI*`$)+EzB;TsH@4LFi z_y==>5T#e# z^aIy#%^Mhx+{0#!j-R$rZf^0snYpOS)RV7`0fDhB_aoVSCD==D;1q|K8bb5!q_^K1&enTq={c4fqe5S>&X(&P$z?!%zKV@Hs_KI>6) zAYPkF9}{vl|9gFjnv*1X#Ld?gR1ddgXS79|t5pY)PkI}U85S;#__6s~rmK+=;DXc> z+8;(C<2qRxIg8H+1y;)xVeUw&2|l-eMq^S;$D1fsI-jpmwYPy_6L%@EW);tf5xP?} zuspW%nK<8w4?nrSpy4y;5UK16xWY_h!xqTSjI**!7pD;U$UuHpFO(|a8#GCx!G3ex z;OE+*y8OE1d8t{giDAcHo2I6M8ui7e9p3#ab;st|GF!LLe8;X|_13(!2x8!UfVUgZk@Lm5as^p^=;=u+ z8R03X2#_Ah!4=n`YlOETctJ1B#EJJP%KAgryl@O>N`V;2f{@*@_GqedV@~~1Z(<~U z>XF|GeOXQLK8}pLpAF8@)O6Q1b+^J-2rRjTB^--azSgJ=c( zf(2PZ7b(DZhU*C?hF0QIOYKPlxL}G4n8swp1xJ<4n_#e!`q0{@!)p2+Py;-2W=_eV z>qX8CIuOGE4!RophP~ja_3MYLkH`#*XMS-el#e=(EynX*^Oh+=u9yfMN2 zMxQZs(niq|obRr?1YRf}He!V%6&&V4huNaOhBb&-YePqW%Kx=cM89QzTk#yKfOAh@ zf+Vs%I80-p7vRR&!2cSbQG-M<2+F)i7n75V*lOnZ@hBif}?M!ScUnoNM(53j?_h{yd^P|(j{8k(V z0Z|7ummA6Vbl!@E7#Sn?Ua{;eN&Mr)$T?uad6X~lW69U{{b8$H=;1;79*L7r#~Zs{ z^x+6-imjM-*0@ua7;Xm?zVv-dI_WW~d01U={vksPqlu`b8a-y$aGWS}Da*4sF6zXH z)W2pd?$e(W02|e2` z5!yYK7ZnaDc5yB=@*&`GFCA9C-h{3q-3|~xI7Nr#iU--pRAhp9WUu|r5 zM-Lu@B4Vb(Cw5OohFQW!$Q!DFI1y#POjXp16SmhW5_ z_57g@-SFfGC-m~}w8@Vrl;1PgmkHB-6rN_*-@^X%>ix2_FPoZd0&&+eWa+{DySvOC zT|htlrO|)a*K0mwqFe~UR%3{i1kXfj#xvrM7x7IRzo)5W@wM(*nDhn*_7&D=WK183 zrn9QZkX{G6YZMDSm%~I9cV@|(8SH)1f?`SS)RSb|puX7|== zDIrf%kufepLVhsa9E_BGb&MdrQBjidiVZ2v8klF3^*CPWsXuHt?>sCSveTSG$gK*C zck;J&esOIZrgY14HGhWiim^JILTGO=R|HeQUMb)}54Ghsf7GFV7ljnrap zVUhBe)WmYj)&wG<%hQ6Xv`>!Ssg7sZL6R-h;tEs^`W32998i<9PYkFU1WE@^u+9xu^9+abhXH`)NUQIGGyXfmC*9C)xZ~nuQ2;Yktxv6*dG}& zA(pGu!P_;8!&t?u`P(JM!4CHLI?0CmZ_AAm#yu+?Wz6uUr;%OT&TyBozxyxq7p z^lTT`-vjf@_rTCS37N1XaomP>TQj3UK_p^!4Xfvzf@9`*!fDKx#34P0_-cyKFgQ^E zz26OS#oxxibiaQy_~&AI*QUK6ck5v!#JxXTi~lL~&$jJP6ckk1FU*hL;{PY?uDf|Z z?u5OE#L ({ + name, + enabled: true, + sortOrder: i, + })), }); } - console.log(`Seed users only: ${adminUser} (admin), ${opsUser} (ops); cc_write_capability seeded`); + console.log( + `Seed users only: ${adminUser} (admin), ${opsUser} (ops); cc_write_capability seeded; message types ensured`, + ); } main() diff --git a/scripts/backfill-import-log-meta.ts b/scripts/backfill-import-log-meta.ts new file mode 100644 index 0000000..d394988 --- /dev/null +++ b/scripts/backfill-import-log-meta.ts @@ -0,0 +1,56 @@ +/** + * 回填导入日志的 import_kind,并清理错误的 "[object Object]" external_id。 + * Usage: pnpm exec tsx scripts/backfill-import-log-meta.ts + */ +import { prisma } from "../src/services/db"; +import { + inferImportKindFromObjectKey, + normalizeExternalId, +} from "../src/services/import/import-log-meta"; + +async function main() { + const rows = await prisma.containerImport.findMany({ + select: { + id: true, + containerNo: true, + importKind: true, + externalId: true, + }, + }); + let kindUpdated = 0; + let externalFixed = 0; + for (const row of rows) { + const kind = row.importKind || inferImportKindFromObjectKey(row.containerNo); + const externalId = normalizeExternalId(row.externalId); + const needKind = !row.importKind; + const needExt = + row.externalId === "[object Object]" || + (row.externalId != null && externalId !== row.externalId); + if (!needKind && !needExt) continue; + await prisma.containerImport.update({ + where: { id: row.id }, + data: { + ...(needKind ? { importKind: kind } : {}), + ...(needExt ? { externalId } : {}), + }, + }); + if (needKind) kindUpdated += 1; + if (needExt) externalFixed += 1; + } + console.log( + JSON.stringify( + { scanned: rows.length, kindUpdated, externalFixed }, + null, + 2, + ), + ); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/backfill-instruction-import-logs.ts b/scripts/backfill-instruction-import-logs.ts new file mode 100644 index 0000000..0f8fc07 --- /dev/null +++ b/scripts/backfill-instruction-import-logs.ts @@ -0,0 +1,33 @@ +/** + * 将历史已成功导入的工单/DO/转仓指令补写入 /logs 导入日志。 + * Usage: pnpm exec tsx scripts/backfill-instruction-import-logs.ts + * pnpm exec tsx scripts/backfill-instruction-import-logs.ts --dry-run + */ +import { backfillInstructionImportLogs } from "../src/services/import/instruction-import-log"; +import { prisma } from "../src/services/db"; + +async function main() { + const dryRun = process.argv.includes("--dry-run"); + const result = await backfillInstructionImportLogs({ dryRun }); + console.log( + JSON.stringify( + { + dryRun, + scannedMails: result.scannedMails, + created: result.created, + skipped: result.skipped, + }, + null, + 2, + ), + ); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/inspect-mail.ts b/scripts/inspect-mail.ts new file mode 100644 index 0000000..3d70f7c --- /dev/null +++ b/scripts/inspect-mail.ts @@ -0,0 +1,22 @@ +import { prisma } from "../src/services/db"; + +async function main() { + const id = BigInt(process.argv[2] || "258"); + const mail = await prisma.mailMessage.findUnique({ + where: { id }, + include: { attachments: true, parseResult: true }, + }); + console.log( + JSON.stringify( + mail, + (_k, v) => (typeof v === "bigint" ? Number(v) : v), + 2, + ), + ); + await prisma.$disconnect(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/reparse-mail.ts b/scripts/reparse-mail.ts new file mode 100644 index 0000000..a40abe2 --- /dev/null +++ b/scripts/reparse-mail.ts @@ -0,0 +1,37 @@ +import { prisma } from "../src/services/db"; +import { ParsePipeline } from "../src/services/parse/pipeline"; + +async function main() { + const id = BigInt(process.argv[2] || "258"); + await prisma.mailMessage.update({ + where: { id }, + data: { status: "PARSING", version: { increment: 1 } }, + }); + await ParsePipeline.run(id); + const mail = await prisma.mailMessage.findUnique({ + where: { id }, + include: { parseResult: true }, + }); + const shipments = (mail?.parseResult?.shipments as Array<{ row_status?: string }>) || []; + console.log( + JSON.stringify( + { + id: Number(id), + status: mail?.status, + mailType: mail?.mailType, + lastError: mail?.lastError, + validRows: shipments.filter((s) => s.row_status === "VALID").length, + totalRows: shipments.length, + header: mail?.parseResult?.containerHeader, + }, + null, + 2, + ), + ); + await prisma.$disconnect(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/(ops)/customers/page.tsx b/src/app/(ops)/customers/page.tsx index 54e10ec..e8cdcd6 100644 --- a/src/app/(ops)/customers/page.tsx +++ b/src/app/(ops)/customers/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { App, Button, @@ -12,10 +13,10 @@ import { Space, Switch, Table, - Tag, Typography, } from "antd"; import { ErrorBanner } from "@/components/ErrorBanner"; +import { useAuthSession } from "@/components/providers/AuthSessionProvider"; import { IMPORT_STATUS_LABEL, MAIL_STATUS_LABEL, MAIL_TYPE_LABEL } from "@/constants/ui-copy"; import { createCustomerApi, @@ -27,6 +28,8 @@ import { } from "@/lib/client-api"; export default function CustomersPage() { + const router = useRouter(); + const { user } = useAuthSession(); const { message } = App.useApp(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); @@ -46,6 +49,12 @@ export default function CustomersPage() { const [importsLoading, setImportsLoading] = useState(false); const [importPage, setImportPage] = useState(1); + useEffect(() => { + if (user.role !== "admin") { + router.replace("/mails"); + } + }, [user.role, router]); + const reload = useCallback(async () => { setLoading(true); const res = await listCustomersApi(); @@ -59,25 +68,34 @@ export default function CustomersPage() { }, []); useEffect(() => { + if (user.role !== "admin") return; void reload(); - }, [reload]); + }, [reload, user.role]); const openCreate = () => { setEditing(null); - form.resetFields(); setModalOpen(true); }; const openEdit = (row: CustomerAccount) => { setEditing(row); - form.setFieldsValue({ - username: row.username, - password: row.password, - display_name: row.display_name, - }); setModalOpen(true); }; + /** Form mounts only after Modal opens (destroyOnClose); fill then. */ + const fillCustomerForm = (visible: boolean) => { + if (!visible) return; + if (editing) { + form.setFieldsValue({ + username: editing.username, + password: editing.password, + display_name: editing.display_name, + }); + return; + } + form.resetFields(); + }; + const onSave = async () => { const values = await form.validateFields(); setSaving(true); @@ -91,7 +109,7 @@ export default function CustomersPage() { message.error(res.error.message); return; } - message.success("已更新客户账密"); + message.success("已更新租户账密"); } else { const res = await createCustomerApi({ username: values.username, @@ -102,7 +120,7 @@ export default function CustomersPage() { message.error(res.error.message); return; } - message.success("已创建客户账号"); + message.success("已创建租户账号"); } setModalOpen(false); await reload(); @@ -152,21 +170,26 @@ export default function CustomersPage() { setImportsTotal(res.data.total); }; + if (user.role !== "admin") { + return null; + } + return (
- 客户账号 + 租户账号 - 配置客户登录账密。客户登录后自行绑定 CarrierCentral 与邮箱,即可只看到自己的邮件与导入。 + 配置租户登录账密。租户登录后自行配置 CarrierCentral + 客户档案与邮箱,即可只看到自己的邮件与导入;租户端无本管理页。
{error ? : null} - 新增客户 + 新增租户 } > @@ -192,18 +215,10 @@ export default function CustomersPage() { width: 80, }, { - title: "CC 账号", - dataIndex: "cc_username", - width: 140, - render: (v: string, r: CustomerAccount) => - v ? ( - - {v} - {r.cc_mock ? mock : live} - - ) : ( - "未绑定" - ), + title: "CC 客户档案", + dataIndex: "cc_profile_count", + width: 120, + render: (v: number | undefined) => v ?? 0, }, { title: "启用", @@ -232,12 +247,13 @@ export default function CustomersPage() { setModalOpen(false)} onOk={() => void onSave()} confirmLoading={saving} destroyOnClose + afterOpenChange={fillCustomerForm} >
setImportsOpen(false)} width={920} diff --git a/src/app/(ops)/logs/page.tsx b/src/app/(ops)/logs/page.tsx index 39d6425..3756ac6 100644 --- a/src/app/(ops)/logs/page.tsx +++ b/src/app/(ops)/logs/page.tsx @@ -44,7 +44,12 @@ function ImportLogsPanel() { const { message } = App.useApp(); const { user } = useAuthSession(); const isAdmin = user.role === "admin"; + const isStaff = user.role === "admin" || user.role === "ops"; const [status, setStatus] = useState(""); + const [tenantId, setTenantId] = useState(""); + const [tenants, setTenants] = useState< + Array<{ id: string; username: string; display_name: string }> + >([]); const [q, setQ] = useState(""); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); @@ -57,9 +62,11 @@ function ImportLogsPanel() { status: status || undefined, mail_id: mailId, q: q || undefined, + tenant_id: isStaff && tenantId ? tenantId : undefined, }); if (res.ok) { setItems(res.data.items); + if (res.data.tenants) setTenants(res.data.tenants); setError(null); } else { setError(res.error.message); @@ -70,7 +77,7 @@ function ImportLogsPanel() { useEffect(() => { void reload(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status, mailId]); + }, [status, mailId, tenantId]); useEffect(() => { if (error) message.error(error); @@ -96,11 +103,30 @@ function ImportLogsPanel() { }, ]} /> + {isStaff ? ( + setQ(e.target.value)} + onPressEnter={() => void reload()} /> - ); - } - - if (!canRetry) return "-"; - return ( - + ); } - }} - > - {UI_COPY.btnRetry} - - ); - }, - }, + + if (!canRetry) return "-"; + return ( + + ); + }, + }, + ] + : []), ]} /> )} diff --git a/src/app/(ops)/mails/[id]/page.tsx b/src/app/(ops)/mails/[id]/page.tsx index 3e95b4f..89189eb 100644 --- a/src/app/(ops)/mails/[id]/page.tsx +++ b/src/app/(ops)/mails/[id]/page.tsx @@ -17,28 +17,29 @@ import { import { LeftOutlined } from "@ant-design/icons"; import { ErrorBanner } from "@/components/ErrorBanner"; import { MailBusinessSummary } from "@/components/MailBusinessSummary"; -import { CcUiModeSwitch } from "@/components/CcUiModeSwitch"; +import { + CcCustomerProfileSelect, + useCcCustomerProfileSelect, +} from "@/components/CcCustomerProfileSelect"; import { StatusPill } from "@/components/StatusPill"; -import { TypeOverrideModal } from "@/components/TypeOverrideModal"; import { TypeTag } from "@/components/TypeTag"; import { useAuthSession } from "@/components/providers/AuthSessionProvider"; import { UI_COPY, formatMailLastError } from "@/constants/ui-copy"; import { useMailDetail } from "@/hooks/useMailDetail"; -import { useCcUiMode } from "@/hooks/useCcUiMode"; import { healthApi, ignoreMailApi, deleteMailsApi, - overrideTypeApi, reparseMailApi, } from "@/lib/client-api"; import { buildMailIntent } from "@/services/parse/mail-intent"; import { sanitizeOcrDisplayText } from "@/services/ocr/sanitize"; import { canTransition } from "@/services/state-machine"; import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate"; -import { extractMailInstructions } from "@/services/parse/split-instructions"; -import { confirmHref } from "@/services/parse/resolve-confirm-instruction"; -import type { MailType } from "@/types/mail"; +import { + confirmHref, + extractInstructionsFromMail, +} from "@/services/parse/resolve-confirm-instruction"; export default function MailDetailPage() { const params = useParams<{ id: string }>(); @@ -47,22 +48,13 @@ export default function MailDetailPage() { const { message, modal } = App.useApp(); const { user } = useAuthSession(); const { mail, loading, error, reload } = useMailDetail(id); - const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode(); + const ccProfile = useCcCustomerProfileSelect({ mailId: id }); const [reparseLoading, setReparseLoading] = useState(false); const [ignoreLoading, setIgnoreLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); - const [overrideOpen, setOverrideOpen] = useState(false); - const [overrideLoading, setOverrideLoading] = useState(false); - const [enableOverride, setEnableOverride] = useState(false); const isAdmin = user.role === "admin"; - useEffect(() => { - void healthApi().then((res) => { - if (res.ok) setEnableOverride(Boolean(res.data.enable_type_override)); - }); - }, []); - useEffect(() => { if (!id) return; router.prefetch(`/mails/${id}/confirm`); @@ -81,11 +73,10 @@ export default function MailDetailPage() { const forecastUnit = useMemo(() => { if (!mail) return null; - return extractMailInstructions({ - subject: mail.subject, - body: mail.body_text, - filenames: mail.attachments.map((a) => a.filename), - }).find((u) => u.uiKind === "forecast") || null; + return ( + extractInstructionsFromMail(mail).find((u) => u.uiKind === "forecast") || + null + ); }, [mail]); const canImport = mail && forecastUnit @@ -215,28 +206,25 @@ export default function MailDetailPage() { {mail.subject}
- {mail.from_addr} - {mail.ingest_source_label ? ( - {mail.ingest_source_label} - ) : null} - {new Date(mail.received_at).toLocaleString("zh-CN")} - #{mail.id} - {mail.trace_id ? ( - - trace {mail.trace_id.slice(0, 8)} - - ) : null} + 发件人:{mail.from_addr || "—"} + 收件人:{mail.mailbox_addr || "—"} + + 时间: + {new Date(mail.received_at).toLocaleString("zh-CN")} + + 邮件编码:{mail.id}
- + {showReparse ? ( - ) : null} {showIgnore ? (
- setOverrideOpen(false)} - onSubmit={async (values) => { - setOverrideLoading(true); - const res = await overrideTypeApi( - mail.id, - values.mail_type as MailType, - values.reason.trim(), - mail.version, - ); - setOverrideLoading(false); - if (res.ok) { - message.success("改类型成功"); - setOverrideOpen(false); - reload(); - } else { - message.error(res.error.message); - } - }} - /> ); } diff --git a/src/app/(ops)/settings/page.tsx b/src/app/(ops)/settings/page.tsx index 57c1dc6..f938db8 100644 --- a/src/app/(ops)/settings/page.tsx +++ b/src/app/(ops)/settings/page.tsx @@ -17,7 +17,8 @@ import { Typography, } from "antd"; import { ErrorBanner } from "@/components/ErrorBanner"; -import { CcSettingsCard } from "@/components/CcSettingsCard"; +import { CcCustomerProfilesCard } from "@/components/CcCustomerProfilesCard"; +import { CcMessageTypeDictCard } from "@/components/CcMessageTypeDictCard"; import { ChangePasswordCard } from "@/components/ChangePasswordCard"; import { ImapPullSettingsCard } from "@/components/ImapPullSettingsCard"; import { MailboxBindHelpIcon } from "@/components/MailboxBindHelp"; @@ -114,10 +115,8 @@ export default function SettingsPage() { }; const fillMailboxForm = (open: boolean) => { - if (!open) { - form.resetFields(); - return; - } + // destroyOnClose: Form is gone when closed — do not resetFields on close + if (!open) return; if (editing) { form.setFieldsValue({ name: editing.name, @@ -303,22 +302,30 @@ export default function SettingsPage() { } if (!allowed) return null; + const isTenant = user.role === "customer"; + return (
{UI_COPY.navSettings} + + {isTenant + ? "配置本租户账号的 CarrierCentral 客户档案与邮箱;仅能看到本账号的邮件与导入。" + : "管理端公共邮箱与共享 CC 客户档案。各租户的邮箱与档案由租户登录后自行配置。"} +
{error ? : null} - - {user.role === "customer" ? : null} + + {user.role === "admin" ? : null} + {isTenant ? : null} {user.role === "admin" ? : null} {user.role === "admin" ? ( diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 8aa9a37..67062d0 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -15,7 +15,7 @@ export async function PUT(req: Request) { const guard = await requireSession(); if (guard.response) return guard.response; if (guard.session.role !== "customer" || !guard.session.userId) { - return fail("FORBIDDEN", "仅客户账号可在此修改密码", 403); + return fail("FORBIDDEN", "仅租户账号可在此修改密码", 403); } let body: unknown; diff --git a/src/app/api/imports/route.ts b/src/app/api/imports/route.ts index 835ecea..8900a8e 100644 --- a/src/app/api/imports/route.ts +++ b/src/app/api/imports/route.ts @@ -2,13 +2,24 @@ import { z } from "zod"; import { Prisma } from "@prisma/client"; import { prisma } from "@/services/db"; import { fail, ok, parseBigIntId, requireSession } from "@/lib/api"; -import { importWhereForSession } from "@/services/tenant/access"; +import { + importWhereForSession, + isStaffRole, +} from "@/services/tenant/access"; +import { + importKindLabel, + inferImportKindFromObjectKey, + normalizeExternalId, +} from "@/services/import/import-log-meta"; const querySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(20), status: z.string().optional(), mail_id: z.string().optional(), + /** 管理端按租户筛选;空=全部;`public`=无归属(公共邮箱) */ + tenant_id: z.string().optional(), + q: z.string().optional(), }); export async function GET(req: Request) { @@ -21,20 +32,52 @@ export async function GET(req: Request) { return fail("VALIDATION", "Invalid query", 400, parsed.error.flatten()); } - const { page, pageSize, status, mail_id } = parsed.data; - const where: Prisma.ContainerImportWhereInput = {}; - if (status) where.status = status; + const { page, pageSize, status, mail_id, tenant_id, q } = parsed.data; + const parts: Prisma.ContainerImportWhereInput[] = []; + if (status) parts.push({ status }); if (mail_id) { const mailId = parseBigIntId(mail_id); if (!mailId) return fail("VALIDATION", "Invalid mail_id", 400); - where.mailId = mailId; + parts.push({ mailId }); } + const keyword = (q || "").trim(); + if (keyword) { + parts.push({ + OR: [ + { containerNo: { contains: keyword } }, + { ccCustomerName: { contains: keyword } }, + { mail: { subject: { contains: keyword } } }, + ], + }); + } + + const staff = isStaffRole(guard.session.role); + if (staff && tenant_id) { + if (tenant_id === "public") { + parts.push({ + mail: { + OR: [ + { mailboxAccountId: null }, + { mailboxAccount: { ownerUserId: null } }, + ], + }, + }); + } else { + const tid = parseBigIntId(tenant_id); + if (!tid) return fail("VALIDATION", "Invalid tenant_id", 400); + parts.push({ mail: { mailboxAccount: { ownerUserId: tid } } }); + } + } + + const baseWhere: Prisma.ContainerImportWhereInput = + parts.length === 0 ? {} : parts.length === 1 ? parts[0]! : { AND: parts }; + const scope = importWhereForSession(guard.session); const scopedWhere: Prisma.ContainerImportWhereInput = scope - ? { AND: [where, scope] } - : where; + ? { AND: [baseWhere, scope] } + : baseWhere; - const [total, rows] = await Promise.all([ + const [total, rows, tenantRows] = await Promise.all([ prisma.containerImport.count({ where: scopedWhere }), prisma.containerImport.findMany({ where: scopedWhere, @@ -42,7 +85,25 @@ export async function GET(req: Request) { skip: (page - 1) * pageSize, take: pageSize, include: { - mail: { select: { id: true, subject: true, status: true } }, + mail: { + select: { + id: true, + subject: true, + status: true, + mailboxAccount: { + select: { + ownerUserId: true, + owner: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + }, + }, + }, compensations: { select: { id: true, @@ -53,27 +114,51 @@ export async function GET(req: Request) { }, }, }), + staff + ? prisma.appUser.findMany({ + where: { role: "customer" }, + orderBy: { username: "asc" }, + select: { id: true, username: true, displayName: true, enabled: true }, + }) + : Promise.resolve([]), ]); return ok({ - items: rows.map((r) => ({ - id: r.id.toString(), - mail_id: r.mailId.toString(), - mail_subject: r.mail.subject, - mail_status: r.mail.status, - container_no: r.containerNo, - external_id: r.externalId, - status: r.status, - last_error: r.lastError, - shipments_hash: r.shipmentsHash, - created_at: r.createdAt, - updated_at: r.updatedAt, - compensations: r.compensations.map((c) => ({ - id: c.id.toString(), - status: c.status, - retry_count: c.retryCount, - max_retry: c.maxRetry, - })), + items: rows.map((r) => { + const owner = r.mail.mailboxAccount?.owner ?? null; + const kind = + r.importKind || inferImportKindFromObjectKey(r.containerNo); + return { + id: r.id.toString(), + mail_id: r.mailId.toString(), + mail_subject: r.mail.subject, + mail_status: r.mail.status, + container_no: r.containerNo, + external_id: normalizeExternalId(r.externalId), + status: r.status, + last_error: r.lastError, + shipments_hash: r.shipmentsHash, + created_at: r.createdAt, + updated_at: r.updatedAt, + tenant_id: owner ? owner.id.toString() : null, + tenant_username: owner?.username ?? null, + tenant_display_name: owner?.displayName || null, + import_kind: kind, + import_kind_label: importKindLabel(kind), + cc_customer_name: r.ccCustomerName || null, + compensations: r.compensations.map((c) => ({ + id: c.id.toString(), + status: c.status, + retry_count: c.retryCount, + max_retry: c.maxRetry, + })), + }; + }), + tenants: tenantRows.map((t) => ({ + id: t.id.toString(), + username: t.username, + display_name: t.displayName, + enabled: t.enabled, })), total, page, diff --git a/src/app/api/mails/[id]/do/confirm/route.ts b/src/app/api/mails/[id]/do/confirm/route.ts index bdff574..39e2164 100644 --- a/src/app/api/mails/[id]/do/confirm/route.ts +++ b/src/app/api/mails/[id]/do/confirm/route.ts @@ -13,6 +13,7 @@ const bodySchema = z.object({ container_no: z.string().min(1), container_task_id: z.string().optional(), filenames: z.array(z.string()).optional(), + cc_profile_id: z.string().min(1).optional(), }); export async function POST(req: Request, ctx: RouteContext) { @@ -38,6 +39,9 @@ export async function POST(req: Request, ctx: RouteContext) { containerNo: parsed.data.container_no, containerTaskId: parsed.data.container_task_id, filenames: parsed.data.filenames, + ccProfileId: parsed.data.cc_profile_id + ? parseBigIntId(parsed.data.cc_profile_id) || undefined + : undefined, }); return ok(result); } catch (err) { diff --git a/src/app/api/mails/[id]/forecast/parse-packing/route.ts b/src/app/api/mails/[id]/forecast/parse-packing/route.ts new file mode 100644 index 0000000..6677c90 --- /dev/null +++ b/src/app/api/mails/[id]/forecast/parse-packing/route.ts @@ -0,0 +1,91 @@ +import { parsePackingListBuffer, formatPackingParseLineageMessage } from "@/services/parse/packing-list"; +import { + reindexShipments, + revalidateShipmentRow, +} from "@/services/parse/revalidate-shipment-row"; +import { + fail, + ok, + parseBigIntId, + requireSession, +} from "@/lib/api"; +import { assertMailReadable } from "@/services/tenant/access"; + +const MAX_BYTES = 12 * 1024 * 1024; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function POST(req: Request, ctx: RouteContext) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const mailId = parseBigIntId(id); + if (!mailId) return fail("VALIDATION", "Invalid mail id", 400); + if (!(await assertMailReadable(guard.session, mailId))) { + return fail("NOT_FOUND", "Mail not found", 404); + } + + let form: FormData; + try { + form = await req.formData(); + } catch { + return fail("VALIDATION", "请上传装箱表文件(xlsx)", 400); + } + + const file = form.get("file"); + if (!file || !(file instanceof File)) { + return fail("VALIDATION", "请选择卡派清单等表格附件(xlsx)", 400); + } + + const name = file.name || "packing.xlsx"; + if (!/\.(xlsx|xls)$/i.test(name)) { + return fail("VALIDATION", "仅支持 xlsx / xls 表格附件", 400); + } + if (file.size <= 0) { + return fail("VALIDATION", "文件为空", 400); + } + if (file.size > MAX_BYTES) { + return fail("VALIDATION", "文件过大(上限 12MB)", 400); + } + + const hintContainerNo = + typeof form.get("container_no") === "string" + ? String(form.get("container_no")).trim() + : ""; + + try { + const buf = Buffer.from(await file.arrayBuffer()); + const parsed = await parsePackingListBuffer(buf, { + hintContainerNo: hintContainerNo || undefined, + }); + if (parsed.errorCode === "PARSE_FAILED" && !parsed.shipments.length) { + return fail( + "PARSE_FAILED", + formatPackingParseLineageMessage(parsed.lineage), + 400, + { lineage: parsed.lineage }, + ); + } + const shipments = reindexShipments( + parsed.shipments.map((s, i) => + revalidateShipmentRow({ ...s, row_index: i }), + ), + ); + return ok({ + shipments, + container_no: parsed.container_header?.F_ContainerNo || null, + row_count: shipments.length, + valid_count: shipments.filter((s) => s.row_status === "VALID").length, + lineage: parsed.lineage, + file_name: name, + }); + } catch (err) { + console.error("parse-packing failed", err); + const detail = + err instanceof Error && /MergeValue|cell\.text/i.test(err.message) + ? "表格含异常合并单元格,系统无法读取部分单元格。请用 Excel 重新保存该文件,或改用标准数据模版" + : "解析装箱表时发生异常,请确认文件为有效的 Excel(xlsx/xls)且未损坏"; + return fail("PARSE_FAILED", detail, 400); + } +} diff --git a/src/app/api/mails/[id]/import/route.ts b/src/app/api/mails/[id]/import/route.ts index 2003c67..9f1041d 100644 --- a/src/app/api/mails/[id]/import/route.ts +++ b/src/app/api/mails/[id]/import/route.ts @@ -26,6 +26,9 @@ const bodySchema = z.object({ selected_row_indexes: z.array(z.number().int().min(0)).default([]), ack_unmapped_channels: z.boolean().optional(), force_skip_conflict: z.boolean().optional(), + cc_profile_id: z.string().min(1).optional(), + /** 确认页增改后的货件;有则覆盖库内解析结果 */ + shipments: z.array(z.record(z.unknown())).max(2000).optional(), }); const RATE_LIMIT = 10; @@ -90,6 +93,12 @@ export async function POST(req: Request, ctx: RouteContext) { selectedRowIndexes: parsed.data.selected_row_indexes, ackUnmappedChannels: parsed.data.ack_unmapped_channels, forceSkipConflict: parsed.data.force_skip_conflict, + ccProfileId: parsed.data.cc_profile_id + ? parseBigIntId(parsed.data.cc_profile_id) || undefined + : undefined, + shipments: parsed.data.shipments as + | import("@/types/mail").ShipmentRow[] + | undefined, }); return ok({ mail_status: result.mailStatus, diff --git a/src/app/api/mails/[id]/route.ts b/src/app/api/mails/[id]/route.ts index 0a57ec8..1daf1ad 100644 --- a/src/app/api/mails/[id]/route.ts +++ b/src/app/api/mails/[id]/route.ts @@ -44,6 +44,7 @@ export async function GET(req: Request, ctx: RouteContext) { folder: mail.folder, subject: mail.subject, from_addr: mail.fromAddr, + mailbox_addr: mailboxHint, ingest_source, ingest_source_label: formatIngestSourceLabel(ingest_source, mailboxHint), received_at: mail.receivedAt, diff --git a/src/app/api/mails/[id]/transfer/confirm/route.ts b/src/app/api/mails/[id]/transfer/confirm/route.ts index d3c31f9..92beb7b 100644 --- a/src/app/api/mails/[id]/transfer/confirm/route.ts +++ b/src/app/api/mails/[id]/transfer/confirm/route.ts @@ -23,6 +23,7 @@ const bodySchema = z.object({ F_ConvertChargeMode: z.string(), F_RemovePlanDetail: z.string(), }), + cc_profile_id: z.string().min(1).optional(), }); export async function POST(req: Request, ctx: RouteContext) { @@ -47,6 +48,9 @@ export async function POST(req: Request, ctx: RouteContext) { instructionId: parsed.data.instruction_id, containerNo: parsed.data.container_no, payload: parsed.data.payload, + ccProfileId: parsed.data.cc_profile_id + ? parseBigIntId(parsed.data.cc_profile_id) || undefined + : undefined, }); return ok(result); } catch (err) { diff --git a/src/app/api/mails/[id]/type/route.ts b/src/app/api/mails/[id]/type/route.ts index 54e8387..29d6630 100644 --- a/src/app/api/mails/[id]/type/route.ts +++ b/src/app/api/mails/[id]/type/route.ts @@ -46,6 +46,14 @@ export async function POST(req: Request, ctx: RouteContext) { return fail("VALIDATION", "Invalid body", 400, parsed.error.flatten()); } + if (parsed.data.mail_type === "NEW_CONTAINER") { + return fail( + "VALIDATION", + "不允许手工改为新增预报,请通过解析/确认导入处理预报", + 400, + ); + } + const mail = await prisma.mailMessage.findUnique({ where: { id: mailId }, include: { parseResult: true }, diff --git a/src/app/api/mails/[id]/work-order/confirm/route.ts b/src/app/api/mails/[id]/work-order/confirm/route.ts index d4e1dd5..8b79228 100644 --- a/src/app/api/mails/[id]/work-order/confirm/route.ts +++ b/src/app/api/mails/[id]/work-order/confirm/route.ts @@ -20,6 +20,19 @@ const bodySchema = z.object({ F_CustomerName: z.string().optional(), F_UrgencyLevel: z.string().optional(), }), + baseline: z + .object({ + F_ReferCode: z.string().optional(), + F_MessageType: z.string().optional(), + F_RefNo: z.string().optional(), + F_MessageContent: z.string().optional(), + F_Remark: z.string().optional(), + F_AttachFile: z.string().optional(), + F_CustomerName: z.string().optional(), + F_UrgencyLevel: z.string().optional(), + }) + .optional(), + cc_profile_id: z.string().min(1).optional(), }); export async function POST(req: Request, ctx: RouteContext) { @@ -43,6 +56,10 @@ export async function POST(req: Request, ctx: RouteContext) { actor: guard.session.username, instructionId: parsed.data.instruction_id, entity: parsed.data.entity, + baseline: parsed.data.baseline, + ccProfileId: parsed.data.cc_profile_id + ? parseBigIntId(parsed.data.cc_profile_id) || undefined + : undefined, }); return ok(result); } catch (err) { diff --git a/src/app/api/settings/cc-message-types/[id]/route.ts b/src/app/api/settings/cc-message-types/[id]/route.ts new file mode 100644 index 0000000..08cf5c2 --- /dev/null +++ b/src/app/api/settings/cc-message-types/[id]/route.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { fail, ok, parseBigIntId, requireAdmin } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { + deleteCcMessageTypeDict, + updateCcMessageTypeDict, +} from "@/services/cc/message-type-dict"; + +const PatchSchema = z.object({ + name: z.string().trim().min(1).max(64).optional(), + enabled: z.boolean().optional(), + sort_order: z.number().int().optional(), +}); + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function PATCH(req: Request, ctx: RouteContext) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const rowId = parseBigIntId(id); + if (!rowId) return fail("VALIDATION", "Invalid id", 400); + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + + const parsed = PatchSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + if ( + parsed.data.name === undefined && + parsed.data.enabled === undefined && + parsed.data.sort_order === undefined + ) { + return fail("VALIDATION", "无变更字段", 400); + } + + try { + const row = await updateCcMessageTypeDict(rowId, { + name: parsed.data.name, + enabled: parsed.data.enabled, + sortOrder: parsed.data.sort_order, + }); + if (!row) return fail("NOT_FOUND", "留言类型不存在", 404); + + await writeAudit({ + actor: guard.session.username!, + action: "CC_MESSAGE_TYPE_UPDATE", + payload: { + id: row.id, + name: row.name, + enabled: row.enabled, + sort_order: row.sort_order, + }, + }); + return ok(row); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return fail("CONFLICT", "留言类型名称已存在", 409); + } + throw err; + } +} + +export async function DELETE(_req: Request, ctx: RouteContext) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const rowId = parseBigIntId(id); + if (!rowId) return fail("VALIDATION", "Invalid id", 400); + + const deleted = await deleteCcMessageTypeDict(rowId); + if (!deleted) return fail("NOT_FOUND", "留言类型不存在", 404); + + await writeAudit({ + actor: guard.session.username!, + action: "CC_MESSAGE_TYPE_DELETE", + payload: { id }, + }); + return ok({ id, deleted: true }); +} diff --git a/src/app/api/settings/cc-message-types/route.ts b/src/app/api/settings/cc-message-types/route.ts new file mode 100644 index 0000000..ac1d9f5 --- /dev/null +++ b/src/app/api/settings/cc-message-types/route.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { fail, ok, requireAdmin, requireSession } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { + createCcMessageTypeDict, + listCcMessageTypeDict, +} from "@/services/cc/message-type-dict"; + +const CreateSchema = z.object({ + name: z.string().trim().min(1).max(64), + enabled: z.boolean().optional().default(true), + sort_order: z.number().int().optional(), +}); + +export async function GET(req: Request) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const url = new URL(req.url); + const enabledOnly = url.searchParams.get("enabled_only") === "1"; + // 非管理员只能看已启用项(表单下拉) + const forForm = + enabledOnly || guard.session.role !== "admin"; + + const items = await listCcMessageTypeDict({ enabledOnly: forForm }); + return ok({ items }); +} + +export async function POST(req: Request) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + + const parsed = CreateSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + + try { + const row = await createCcMessageTypeDict({ + name: parsed.data.name, + enabled: parsed.data.enabled, + sortOrder: parsed.data.sort_order, + }); + await writeAudit({ + actor: guard.session.username!, + action: "CC_MESSAGE_TYPE_CREATE", + payload: { id: row.id, name: row.name }, + }); + return ok(row, 201); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return fail("CONFLICT", "留言类型名称已存在", 409); + } + throw err; + } +} diff --git a/src/app/api/settings/cc-profiles/[id]/route.ts b/src/app/api/settings/cc-profiles/[id]/route.ts new file mode 100644 index 0000000..4446ea0 --- /dev/null +++ b/src/app/api/settings/cc-profiles/[id]/route.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; +import { fail, ok, parseBigIntId, requireAdminOrCustomer } from "@/lib/api"; +import { assertSafeCcApiBase } from "@/lib/cc-api-base"; +import { writeAudit } from "@/services/audit"; +import { + deleteCcCustomerProfile, + getCcCustomerProfile, + updateCcCustomerProfile, +} from "@/services/cc/customer-profiles"; +import { sessionUserId } from "@/services/tenant/access"; + +const PutSchema = z.object({ + name: z.string().min(1).max(128).optional(), + mock: z.boolean().optional(), + api_base: z.string().url().max(512).optional(), + saas_header: z.string().min(1).max(64).optional(), + login_mark: z.string().uuid().optional(), + username: z.string().max(128).optional(), + password: z.string().max(256).optional(), + enabled: z.boolean().optional(), + sort_order: z.number().int().optional(), +}); + +type RouteContext = { params: Promise<{ id: string }> }; + +async function assertCanManageProfile( + session: { role: string; userId?: string | null }, + profileId: bigint, +) { + const profile = await getCcCustomerProfile(profileId); + if (!profile) return { error: fail("NOT_FOUND", "客户档案不存在", 404) as Response }; + if (session.role === "customer") { + const uid = sessionUserId(session as never); + if (!uid || profile.owner_user_id !== uid.toString()) { + return { error: fail("FORBIDDEN", "无权操作该客户档案", 403) as Response }; + } + } else if (profile.owner_user_id != null && session.role !== "admin") { + return { error: fail("FORBIDDEN", "无权操作该客户档案", 403) as Response }; + } + return { profile }; +} + +export async function PUT(req: Request, ctx: RouteContext) { + const guard = await requireAdminOrCustomer(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const profileId = parseBigIntId(id); + if (!profileId) return fail("VALIDATION", "Invalid id", 400); + + const access = await assertCanManageProfile(guard.session, profileId); + if (access.error) return access.error; + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + const parsed = PutSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + const d = parsed.data; + + let apiBase: string | undefined; + if (d.api_base) { + try { + apiBase = assertSafeCcApiBase(d.api_base); + } catch (err) { + return fail( + "VALIDATION", + err instanceof Error ? err.message : "非法 CC API 地址", + 400, + ); + } + } + + const item = await updateCcCustomerProfile(profileId, { + name: d.name, + mock: d.mock, + apiBase, + saasHeader: d.saas_header, + loginMark: d.login_mark, + username: d.username, + password: d.password, + enabled: d.enabled, + sortOrder: d.sort_order, + }); + if (!item) return fail("NOT_FOUND", "客户档案不存在", 404); + + await writeAudit({ + actor: guard.session.username!, + action: "CC_PROFILE_UPDATE", + payload: { id: item.id, name: item.name }, + }); + + return ok(item); +} + +export async function DELETE(_req: Request, ctx: RouteContext) { + const guard = await requireAdminOrCustomer(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const profileId = parseBigIntId(id); + if (!profileId) return fail("VALIDATION", "Invalid id", 400); + + const access = await assertCanManageProfile(guard.session, profileId); + if (access.error) return access.error; + + const okDel = await deleteCcCustomerProfile(profileId); + if (!okDel) return fail("NOT_FOUND", "客户档案不存在", 404); + + await writeAudit({ + actor: guard.session.username!, + action: "CC_PROFILE_DELETE", + payload: { id: profileId.toString() }, + }); + + return ok({ deleted: true }); +} diff --git a/src/app/api/settings/cc-profiles/[id]/test/route.ts b/src/app/api/settings/cc-profiles/[id]/test/route.ts new file mode 100644 index 0000000..6d8ab56 --- /dev/null +++ b/src/app/api/settings/cc-profiles/[id]/test/route.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import { fail, ok, parseBigIntId, requireAdminOrCustomer } from "@/lib/api"; +import { runWithCcProfile } from "@/lib/cc-owner"; +import { + getCcCustomerProfile, + loadCcCustomerProfileRuntime, +} from "@/services/cc/customer-profiles"; +import { testCcLogin } from "@/services/cc/settings-config"; +import { sessionUserId } from "@/services/tenant/access"; + +const BodySchema = z.object({ + password: z.string().max(256).optional(), +}); + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function POST(req: Request, ctx: RouteContext) { + const guard = await requireAdminOrCustomer(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const profileId = parseBigIntId(id); + if (!profileId) return fail("VALIDATION", "Invalid id", 400); + + const profile = await getCcCustomerProfile(profileId); + if (!profile) return fail("NOT_FOUND", "客户档案不存在", 404); + + if (guard.session.role === "customer") { + const uid = sessionUserId(guard.session); + if (!uid || profile.owner_user_id !== uid.toString()) { + return fail("FORBIDDEN", "无权测试该客户档案", 403); + } + } + + let body: unknown = {}; + try { + body = await req.json(); + } catch { + body = {}; + } + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + + const ownerUserId = profile.owner_user_id + ? BigInt(profile.owner_user_id) + : null; + + const result = await runWithCcProfile(profileId, ownerUserId, async () => { + // 确保运行时能加载到档案 + const rt = await loadCcCustomerProfileRuntime(profileId); + if (!rt) { + return { ok: false as const, message: "客户档案不可用或已禁用" }; + } + if (parsed.data.password?.trim()) { + // testCcLogin 会读 ALS 中的 runtime;覆盖密码走参数 + return testCcLogin(parsed.data.password.trim()); + } + return testCcLogin(); + }); + + if (!result.ok) return fail("CC_TEST_FAILED", result.message, 400); + return ok({ message: result.message }); +} diff --git a/src/app/api/settings/cc-profiles/route.ts b/src/app/api/settings/cc-profiles/route.ts new file mode 100644 index 0000000..45eefbf --- /dev/null +++ b/src/app/api/settings/cc-profiles/route.ts @@ -0,0 +1,138 @@ +import { z } from "zod"; +import { fail, ok, requireAdminOrCustomer } from "@/lib/api"; +import { assertSafeCcApiBase } from "@/lib/cc-api-base"; +import { writeAudit } from "@/services/audit"; +import { + createCcCustomerProfile, + ensureCcProfilesMigratedFromGlobalSettings, + listCcCustomerProfiles, + listSelectableCcCustomerProfiles, +} from "@/services/cc/customer-profiles"; +import { sessionUserId } from "@/services/tenant/access"; +import { prisma } from "@/services/db"; + +const CreateSchema = z.object({ + name: z.string().min(1).max(128), + mock: z.boolean().optional().default(false), + api_base: z.string().url().max(512), + saas_header: z.string().min(1).max(64), + login_mark: z.string().uuid(), + username: z.string().max(128).optional().default(""), + password: z.string().max(256).optional(), + enabled: z.boolean().optional().default(true), + sort_order: z.number().int().optional(), +}); + +export async function GET(req: Request) { + const guard = await requireAdminOrCustomer(); + if (guard.response) return guard.response; + + await ensureCcProfilesMigratedFromGlobalSettings(); + + const url = new URL(req.url); + const selectable = url.searchParams.get("selectable") === "1"; + const mailIdRaw = url.searchParams.get("mail_id"); + + if (selectable) { + let mailOwnerUserId: bigint | null | undefined; + if (mailIdRaw) { + try { + const mailId = BigInt(mailIdRaw); + const mail = await prisma.mailMessage.findUnique({ + where: { id: mailId }, + select: { mailboxAccount: { select: { ownerUserId: true } } }, + }); + mailOwnerUserId = mail?.mailboxAccount?.ownerUserId ?? null; + } catch { + mailOwnerUserId = null; + } + } + // 租户角色:只看自己的档案 + if (guard.session.role === "customer") { + const uid = sessionUserId(guard.session); + const items = await listCcCustomerProfiles({ + ownerUserId: uid, + includeDisabled: false, + }); + return ok({ items }); + } + const items = await listSelectableCcCustomerProfiles({ + mailOwnerUserId: mailOwnerUserId ?? null, + }); + return ok({ items }); + } + + if (guard.session.role === "customer") { + const uid = sessionUserId(guard.session); + const items = await listCcCustomerProfiles({ + ownerUserId: uid, + includeDisabled: true, + }); + return ok({ items }); + } + + // admin/ops:管理共享档案 + const items = await listCcCustomerProfiles({ + ownerUserId: null, + includeDisabled: true, + }); + return ok({ items }); +} + +export async function POST(req: Request) { + const guard = await requireAdminOrCustomer(); + if (guard.response) return guard.response; + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + const parsed = CreateSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + const d = parsed.data; + if (!d.mock && !d.username.trim()) { + return fail("VALIDATION", "live 模式请填写 CC 用户名", 400); + } + if (!d.mock && !d.password?.trim()) { + return fail("VALIDATION", "请填写密码", 400); + } + + let apiBase: string; + try { + apiBase = assertSafeCcApiBase(d.api_base); + } catch (err) { + return fail( + "VALIDATION", + err instanceof Error ? err.message : "非法 CC API 地址", + 400, + ); + } + + const ownerUserId = + guard.session.role === "customer" ? sessionUserId(guard.session) : null; + + const item = await createCcCustomerProfile({ + name: d.name, + mock: d.mock, + apiBase, + saasHeader: d.saas_header, + loginMark: d.login_mark, + username: d.username, + password: d.password, + enabled: d.enabled, + sortOrder: d.sort_order, + ownerUserId, + }); + + await writeAudit({ + actor: guard.session.username!, + action: "CC_PROFILE_CREATE", + payload: { id: item.id, name: item.name, username: item.username }, + }); + + return ok(item); +} diff --git a/src/app/api/settings/mailboxes/route.ts b/src/app/api/settings/mailboxes/route.ts index e8b596a..76a1db1 100644 --- a/src/app/api/settings/mailboxes/route.ts +++ b/src/app/api/settings/mailboxes/route.ts @@ -26,8 +26,12 @@ export async function GET() { const guard = await requireAdminOrCustomer(); if (guard.response) return guard.response; const ownerUserId = - guard.session.role === "customer" ? sessionUserId(guard.session) : undefined; - const items = await listMailboxes(ownerUserId ?? undefined); + guard.session.role === "customer" ? sessionUserId(guard.session) : null; + if (guard.session.role === "customer" && ownerUserId == null) { + return fail("FORBIDDEN", "租户会话无效", 403); + } + // admin:仅公共邮箱;租户:仅自己的邮箱 + const items = await listMailboxes(ownerUserId); return ok({ items }); } diff --git a/src/app/api/shipping-lines/route.ts b/src/app/api/shipping-lines/route.ts index 7ffcb3e..8363aea 100644 --- a/src/app/api/shipping-lines/route.ts +++ b/src/app/api/shipping-lines/route.ts @@ -1,12 +1,15 @@ -import { listShippingLines } from "@/services/cc/shipping-lines"; +import { listShippingLines, invalidateShippingLineListCache } from "@/services/cc/shipping-lines"; import { ok, requireSession } from "@/lib/api"; import { runWithCcOwner } from "@/lib/cc-owner"; import { sessionUserId } from "@/services/tenant/access"; -export async function GET() { +export async function GET(req: Request) { const guard = await requireSession(); if (guard.response) return guard.response; + const refresh = new URL(req.url).searchParams.get("refresh") === "1"; + if (refresh) invalidateShippingLineListCache(); + const ownerUserId = guard.session.role === "customer" ? sessionUserId(guard.session) : null; const items = await runWithCcOwner(ownerUserId, () => listShippingLines()); diff --git a/src/app/globals.css b/src/app/globals.css index 8c29ca7..9b797bf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -350,6 +350,28 @@ a:hover { background: #f3f4f6; } +.mail-unit-badge-supplemented { + color: #1d4ed8; + background: #eff6ff; + border: 1px solid #bfdbfe; +} + +.cc-fo-field-mark { + border-radius: 6px; + padding: 6px 8px; + margin: -6px -8px; +} + +.cc-fo-field-mark-supplemented { + background: #f8fbff; + box-shadow: inset 3px 0 0 #3b82f6; +} + +.cc-fo-field-mark-modified { + background: #fffaf5; + box-shadow: inset 3px 0 0 #f97316; +} + .mail-unit-source { font-size: 12px; color: #9ca3af; @@ -551,6 +573,37 @@ a:hover { min-height: 0; } +.cc-fo-field.is-invalid .cc-fo-control > .ant-select, +.cc-fo-field.is-invalid .cc-fo-control > .ant-input, +.cc-fo-field.is-invalid .cc-fo-control > .ant-input-affix-wrapper, +.cc-fo-field.is-invalid .cc-fo-control > .ant-picker, +.cc-fo-field.is-invalid .cc-fo-control > textarea.ant-input { + border-color: #ff4d4f !important; + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.15); +} + +.cc-fo-grid-wrap.is-invalid { + border: 1px solid #ff4d4f; + border-radius: 6px; + padding: 4px; + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.12); +} + +.cc-fo-do-files.is-invalid { + border: 1px solid #ff4d4f; + border-radius: 6px; + padding: 8px 10px; + background: #fff2f0; + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.12); +} + +.cc-fo-ack.is-invalid { + border: 1px solid #ff4d4f; + border-radius: 6px; + padding: 8px 10px; + background: #fff2f0; +} + .cc-fo-label { flex: none; padding-right: 0; @@ -732,6 +785,55 @@ a:hover { max-width: 100%; } +.cc-ship-edit.ant-input, +.cc-ship-edit.ant-input-sm { + padding: 2px 6px; + height: 30px; + font-size: 13px; + width: 100%; + border: 1px solid #d9d9d9 !important; + border-radius: 4px; + background: #fff !important; +} + +.cc-ship-edit.ant-input:hover, +.cc-ship-edit.ant-input-sm:hover { + border-color: #69b1ff !important; +} + +.cc-ship-edit.ant-input:focus, +.cc-ship-edit.ant-input-sm:focus { + border-color: #1677ff !important; + box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.12); +} + +.cc-ship-edit.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.cc-fo-ship-hint { + margin: 0 0 10px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.5; + color: #1f2937; + background: #f0f7ff; + border: 1px solid #91caff; + border-radius: 6px; +} + +.cc-fo-rows-alert { + margin: 0 0 10px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.5; + color: #a8071a; + background: #fff2f0; + border: 1px solid #ffccc7; + border-radius: 6px; +} + /* CC IndexTruck 转仓界面复刻 */ .cc-tr-root { border: 1px solid #d9d9d9; @@ -905,6 +1007,25 @@ a:hover { white-space: nowrap; } +.cc-wo-attach-list { + border: 1px solid #e5e7eb; + border-radius: 4px; + background: #fff; + overflow: hidden; +} + +.cc-wo-attach-item { + padding: 8px 12px; + font-size: 13px; + line-height: 1.4; + border-bottom: 1px solid #f0f0f0; + word-break: break-all; +} + +.cc-wo-attach-item:last-child { + border-bottom: none; +} + .cc-fo-root.cc-portal-admin { border-left: 3px solid #f97316; padding-left: 8px; diff --git a/src/components/CcCustomerProfileSelect.tsx b/src/components/CcCustomerProfileSelect.tsx new file mode 100644 index 0000000..6fa54c1 --- /dev/null +++ b/src/components/CcCustomerProfileSelect.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Select, Typography } from "antd"; +import { listCcCustomerProfilesApi } from "@/lib/client-api"; +import type { CcCustomerProfile } from "@/types"; + +const STORAGE_KEY = "yx.ccCustomerProfileId"; + +export function readStoredCcProfileId(): string | null { + if (typeof window === "undefined") return null; + try { + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; + } +} + +export function writeStoredCcProfileId(id: string | null) { + if (typeof window === "undefined") return; + try { + if (id) localStorage.setItem(STORAGE_KEY, id); + else localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } +} + +export function useCcCustomerProfileSelect(opts?: { mailId?: string }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [profileId, setProfileIdState] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + const res = await listCcCustomerProfilesApi({ + selectable: true, + mailId: opts?.mailId, + }); + setLoading(false); + if (!res.ok) { + setItems([]); + return; + } + setItems(res.data.items); + const stored = readStoredCcProfileId(); + const valid = + stored && res.data.items.some((i) => i.id === stored) ? stored : null; + const next = valid || res.data.items[0]?.id || null; + setProfileIdState(next); + if (next) writeStoredCcProfileId(next); + }, [opts?.mailId]); + + useEffect(() => { + void reload(); + }, [reload]); + + const setProfileId = useCallback((id: string | null) => { + setProfileIdState(id); + writeStoredCcProfileId(id); + }, []); + + const selected = useMemo( + () => items.find((i) => i.id === profileId) || null, + [items, profileId], + ); + + return { + items, + loading, + profileId, + setProfileId, + selected, + reload, + }; +} + +export function CcCustomerProfileSelect({ + mailId, + value, + onChange, + items, + loading, + style, +}: { + mailId?: string; + value?: string | null; + onChange?: (id: string | null) => void; + items?: CcCustomerProfile[]; + loading?: boolean; + style?: React.CSSProperties; +}) { + const internal = useCcCustomerProfileSelect( + items ? undefined : { mailId }, + ); + const list = items ?? internal.items; + const busy = loading ?? internal.loading; + const current = value !== undefined ? value : internal.profileId; + + return ( + +
+ + + + + + + + + + + + +
+
+ + ); +} diff --git a/src/components/CcDoUploadPanel.tsx b/src/components/CcDoUploadPanel.tsx index 09b6369..50ec51d 100644 --- a/src/components/CcDoUploadPanel.tsx +++ b/src/components/CcDoUploadPanel.tsx @@ -18,18 +18,23 @@ function Field({ label, children, required, + error, }: { label: ReactNode; children: ReactNode; required?: boolean; + error?: string; }) { return ( -
+
{label} {required ? * : null}
-
{children}
+
+ {children} + {error ?
{error}
: null} +
); } @@ -45,12 +50,14 @@ export type CcDoUploadPanelProps = { mode: "readonly" | "edit"; value: CcDoUploadFormValue; onChange?: (patch: Partial) => void; + errors?: Record; }; export function CcDoUploadPanel({ mode, value, onChange, + errors = {}, }: CcDoUploadPanelProps) { const readonly = mode === "readonly"; const patch = (p: Partial) => { @@ -63,12 +70,15 @@ export function CcDoUploadPanel({
上传 DO(F_DOFile)
- + patch({ F_ContainerNo: e.target.value })} + onChange={(e) => + patch({ F_ContainerNo: e.target.value.toUpperCase() }) + } /> @@ -93,19 +103,27 @@ export function CcDoUploadPanel({
- {value.filenames.length === 0 ? ( - - 未识别到 DO 附件(期望如 WHSU8127240-DO.pdf) - - ) : ( - ({ name }))} - columns={[{ title: "待上传 DO 附件", dataIndex: "name" }]} - /> - )} +
+ {value.filenames.length === 0 ? ( + + {errors.filenames || + "未识别到 DO 附件(期望如 WHSU8127240-DO.pdf)"} + + ) : ( +
({ name }))} + columns={[{ title: "待上传 DO 附件", dataIndex: "name" }]} + /> + )} + {errors.filenames && value.filenames.length > 0 ? ( +
{errors.filenames}
+ ) : null} + {!readonly && !value.F_ContainerTaskId.trim() ? ( DO 必须挂到已创建的集装箱。若尚未确认导入预报,确认上传会被拒绝。 diff --git a/src/components/CcForecastFormOrder.tsx b/src/components/CcForecastFormOrder.tsx index d9441ca..6e21bb8 100644 --- a/src/components/CcForecastFormOrder.tsx +++ b/src/components/CcForecastFormOrder.tsx @@ -1,17 +1,23 @@ "use client"; -import { useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { + App, Button, Checkbox, + Form, Input, + InputNumber, + Modal, Select, Space, Tooltip, + Upload, } from "antd"; import { CC_SHIPMENT_COLUMNS, ShipmentVirtualTable, + type ShipmentColumn, } from "@/components/ShipmentVirtualTable"; import { OPERATION_TYPE_OPTIONS, @@ -22,19 +28,18 @@ import { normalizeDestinationPort, sanitizeCustomerName, } from "@/services/parse/bl-plus-template"; +import { + applyServiceFromModules, + normalizeCabinetType, +} from "@/services/parse/header-service-hints"; +import { + reindexShipments, + revalidateShipmentRow, +} from "@/services/parse/revalidate-shipment-row"; import type { OperationType, ShipmentRow, TransMode } from "@/types/mail"; import type { CcUiMode } from "@/constants/cc-ui-mode"; - -const CABINET_OPTIONS = [ - "20GP", - "20HQ", - "40GP", - "40HQ", - "40HC", - "45HQ", - "45HC", - "45GP", -].map((v) => ({ value: v, label: v })); +import { CC_CABINET_TYPE_OPTIONS } from "@/constants/cabinet-types"; +import { normalizeFbaCodeForCc } from "@/services/parse/normalize-fba-code"; export type CcForecastFormValue = { customerName: string; @@ -61,9 +66,17 @@ export type CcForecastFormOrderProps = { value: CcForecastFormValue; shipments: ShipmentRow[]; shippingLines?: Array<{ value: string; label: string }>; + /** 点击「获取船司」:拉取 CC 列表并按提单匹配 */ + onFetchShippingLine?: () => void | Promise; + fetchShippingLineLoading?: boolean; selected?: Set; onSelectedChange?: (next: Set) => void; onChange?: (patch: Partial) => void; + /** 确认页货件增改 */ + onShipmentsChange?: (next: ShipmentRow[]) => void; + /** 导入装箱表(xlsx)加载中 */ + packingImportLoading?: boolean; + onImportPackingFile?: (file: File) => void | Promise; errors?: Record; step?: number; onStepChange?: (step: number) => void; @@ -81,13 +94,6 @@ export type CcForecastFormOrderProps = { portal?: CcUiMode; }; -const fieldRow: CSSProperties = { - display: "grid", - gridTemplateColumns: "repeat(3, minmax(0, 1fr))", - gap: "10px 16px", - marginBottom: 12, -}; - const fieldRow2: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", @@ -113,7 +119,10 @@ function Field({ style?: CSSProperties; }) { return ( -
+
{label} {required ? ( @@ -128,14 +137,6 @@ function Field({ ); } -/** 只读时不展示没有解析到的可选格;编辑页仍留空格供补录,但不预填编造值 */ -function showOptional(readonly: boolean, value: unknown): boolean { - if (!readonly) return true; - if (value === undefined || value === null) return false; - if (typeof value === "string") return value.trim() !== ""; - return true; -} - function ChevronSteps({ step, onChange, @@ -165,14 +166,336 @@ function ChevronSteps({ ); } +function ShipEditCellSync({ + value, + disabled, + onCommit, + mono, + placeholder, +}: { + value: string; + disabled?: boolean; + onCommit: (next: string) => void; + mono?: boolean; + placeholder?: string; +}) { + const [draft, setDraft] = useState(value); + const [focused, setFocused] = useState(false); + useEffect(() => { + if (!focused) setDraft(value); + }, [value, focused]); + return ( + setFocused(true)} + onChange={(e) => setDraft(e.target.value)} + onBlur={() => { + setFocused(false); + if (draft !== value) onCommit(draft); + }} + onPressEnter={(e) => (e.target as HTMLInputElement).blur()} + /> + ); +} + +function buildEditableCcShipmentColumns( + onPatch: (rowIndex: number, patch: Partial) => void, + onOpenEdit: (rowIndex: number) => void, +): ShipmentColumn[] { + const text = ( + key: keyof ShipmentRow, + title: string, + width: number, + opts?: { mono?: boolean; channel?: boolean; placeholder?: string }, + ): ShipmentColumn => ({ + key: String(key), + title, + width, + render: (row) => { + const raw = + opts?.channel + ? row.channel_raw || row.F_Transporter || "" + : key === "F_FBACode" + ? normalizeFbaCodeForCc(row.F_FBACode, row.F_Transporter || "") || + row.F_FBACode || + "" + : row[key] == null + ? "" + : String(row[key]); + return ( + { + if (opts?.channel) { + onPatch(row.row_index, { + channel_raw: next, + F_Transporter: next, + }); + return; + } + if ( + key === "F_CTNS" || + key === "F_CBM" || + key === "F_Weight" || + key === "F_Pcs" + ) { + const n = next.trim() === "" ? null : Number(next.replace(/,/g, "")); + onPatch(row.row_index, { + [key]: + n != null && Number.isFinite(n) + ? key === "F_CTNS" + ? Math.trunc(n) + : n + : key === "F_CTNS" + ? 0 + : null, + } as Partial); + return; + } + onPatch(row.row_index, { [key]: next } as Partial); + }} + /> + ); + }, + }); + + return [ + { key: "idx", title: "#", width: 44, render: (row) => row.row_index + 1 }, + { key: "ck", title: "选", width: 44, render: () => null }, + text("F_Transporter", "Move Type", 110, { + channel: true, + placeholder: "派送方式*", + }), + text("F_FBACode", "FBACode", 88, { placeholder: "仓库代码" }), + text("F_ShipmentID", "Custmer Ref No.", 120, { + mono: true, + placeholder: "客户参考号", + }), + text("F_FBAID", "FBAId(Amazon)", 130, { mono: true, placeholder: "FBA ID" }), + text("F_PO", "PO(Amazon)", 100, { mono: true, placeholder: "PO" }), + text("F_Address", "Address", 200, { placeholder: "地址" }), + text("F_CTNS", "Qty.(CTNS)", 88, { placeholder: "件数*" }), + text("F_CBM", "Vol(CBM)", 88, { placeholder: "体积*" }), + text("F_Weight", "G.W.(KGS)", 88, { placeholder: "毛重*" }), + text("F_Pcs", "PCS", 64, { placeholder: "PCS" }), + text("F_City", "City", 90, { placeholder: "城市" }), + text("F_State", "State", 70, { placeholder: "州" }), + text("F_ZipCode", "ZipCode", 80, { placeholder: "邮编" }), + text("F_Contact", "Contact", 90, { placeholder: "联系人" }), + text("F_Mobile", "Mobile", 100, { placeholder: "电话" }), + text("F_Email", "Email", 140, { placeholder: "邮箱" }), + text("F_EnglishName", "英文名称", 120, { placeholder: "公司/英文名" }), + text("F_Remark", "行备注", 160, { placeholder: "备注" }), + text("F_ReferenceId", "ReferenceId", 110, { + mono: true, + placeholder: "Reference", + }), + { + key: "st", + title: "状态", + width: 64, + render: (row) => (row.row_status === "VALID" ? "有效" : "无效"), + }, + { + key: "act", + title: "操作", + width: 72, + render: (row) => ( + + ), + }, + ]; +} + +type ShipmentEditFormValues = { + F_Transporter: string; + F_FBACode: string; + F_ShipmentID: string; + F_FBAID: string; + F_PO: string; + F_Address: string; + F_CTNS: number | null; + F_CBM: number | null; + F_Weight: number | null; + F_Pcs: number | null; + F_City: string; + F_State: string; + F_ZipCode: string; + F_Contact: string; + F_Mobile: string; + F_Email: string; + F_EnglishName: string; + F_Remark: string; +}; + +function shipmentRowToEditForm(row: ShipmentRow): ShipmentEditFormValues { + return { + F_Transporter: row.channel_raw || row.F_Transporter || "", + F_FBACode: row.F_FBACode || "", + F_ShipmentID: row.F_ShipmentID || "", + F_FBAID: row.F_FBAID || "", + F_PO: row.F_PO || "", + F_Address: row.F_Address || "", + F_CTNS: row.F_CTNS ?? null, + F_CBM: row.F_CBM ?? null, + F_Weight: row.F_Weight ?? null, + F_Pcs: row.F_Pcs ?? null, + F_City: row.F_City || "", + F_State: row.F_State || "", + F_ZipCode: row.F_ZipCode || "", + F_Contact: row.F_Contact || "", + F_Mobile: row.F_Mobile || "", + F_Email: row.F_Email || "", + F_EnglishName: row.F_EnglishName || "", + F_Remark: row.F_Remark || "", + }; +} + +function ShipmentEditModal({ + rowIndex, + row, + onCancel, + onSave, +}: { + rowIndex: number; + row: ShipmentRow; + onCancel: () => void; + onSave: (values: ShipmentEditFormValues) => void; +}) { + const [form] = Form.useForm(); + + const onOk = async () => { + const values = await form.validateFields().catch(() => null); + if (!values) return; + onSave(values); + }; + + return ( + void onOk()} + okText="保存" + cancelText="取消" + width={640} + > +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ ); +} + export function CcForecastFormOrder({ mode, value, shipments, shippingLines = [], + onFetchShippingLine, + fetchShippingLineLoading, selected, onSelectedChange, onChange, + onShipmentsChange, + packingImportLoading, + onImportPackingFile, errors = {}, step: controlledStep, onStepChange, @@ -186,11 +509,13 @@ export function CcForecastFormOrder({ finishLabel = "确认导入", portal = "admin", }: CcForecastFormOrderProps) { + const { message } = App.useApp(); const readonly = mode === "readonly"; const isAdmin = portal === "admin"; const [innerStep, setInnerStep] = useState(0); const [queryDraft, setQueryDraft] = useState(""); const [query, setQuery] = useState(""); + const [editRowIndex, setEditRowIndex] = useState(null); const step = controlledStep ?? innerStep; const setStep = (s: number) => { onStepChange?.(s); @@ -230,6 +555,96 @@ export function CcForecastFormOrder({ }); }, [shipments, query]); + const canEditShipments = !readonly && Boolean(onShipmentsChange); + const shipmentsRef = useRef(shipments); + shipmentsRef.current = shipments; + + const patchShipment = useCallback( + (rowIndex: number, patch: Partial) => { + if (!onShipmentsChange) return; + onShipmentsChange( + shipmentsRef.current.map((row) => + row.row_index === rowIndex + ? revalidateShipmentRow({ ...row, ...patch, row_index: rowIndex }) + : row, + ), + ); + }, + [onShipmentsChange], + ); + + const openEditRow = useCallback((rowIndex: number) => { + setEditRowIndex(rowIndex); + }, []); + + const shipmentColumns = useMemo( + () => + canEditShipments + ? buildEditableCcShipmentColumns(patchShipment, openEditRow) + : CC_SHIPMENT_COLUMNS, + [canEditShipments, patchShipment, openEditRow], + ); + + const onAddShipment = () => { + if (!onShipmentsChange) return; + const cur = shipmentsRef.current; + const nextIndex = cur.reduce((m, r) => Math.max(m, r.row_index), -1) + 1; + const row = revalidateShipmentRow({ row_index: nextIndex }); + const next = [...cur, row]; + shipmentsRef.current = next; + onShipmentsChange(next); + onSelectedChange?.(new Set(selectedSet).add(nextIndex)); + message.success("已新增一行,请填写派送方式、件数、体积、毛重(可点「编辑」弹窗填写)"); + openEditRow(nextIndex); + }; + + const onSaveEditRow = (values: ShipmentEditFormValues) => { + if (editRowIndex == null) return; + const transporter = (values.F_Transporter || "").trim(); + patchShipment(editRowIndex, { + channel_raw: transporter, + F_Transporter: transporter, + F_FBACode: (values.F_FBACode || "").trim() || undefined, + F_ShipmentID: (values.F_ShipmentID || "").trim() || null, + F_FBAID: (values.F_FBAID || "").trim() || null, + F_PO: (values.F_PO || "").trim() || null, + F_Address: (values.F_Address || "").trim() || null, + F_CTNS: values.F_CTNS ?? 0, + F_CBM: values.F_CBM, + F_Weight: values.F_Weight, + F_Pcs: values.F_Pcs, + F_City: (values.F_City || "").trim() || null, + F_State: (values.F_State || "").trim() || null, + F_ZipCode: (values.F_ZipCode || "").trim() || null, + F_Contact: (values.F_Contact || "").trim() || null, + F_Mobile: (values.F_Mobile || "").trim() || null, + F_Email: (values.F_Email || "").trim() || null, + F_EnglishName: (values.F_EnglishName || "").trim() || null, + F_Remark: (values.F_Remark || "").trim() || null, + }); + setEditRowIndex(null); + message.success("货件已更新"); + }; + + const editRow = + editRowIndex == null + ? null + : shipments.find((r) => r.row_index === editRowIndex) ?? null; + + const onDeleteSelected = () => { + if (!onShipmentsChange) return; + const keep = shipmentsRef.current.filter( + (r) => !selectedSet.has(r.row_index), + ); + const next = reindexShipments(keep); + onShipmentsChange(next); + onSelectedChange?.( + new Set( + next.filter((r) => r.row_status === "VALID").map((r) => r.row_index), + ), + ); + }; + const selectedRows = useMemo( () => shipments.filter((s) => selectedSet.has(s.row_index)), [shipments, selectedSet], @@ -254,7 +669,6 @@ export function CcForecastFormOrder({ }, [selectedRows, shipments]); const ph = "==请选择=="; - const opt = (v: unknown) => showOptional(readonly, v); const transModeValue = value.F_TransMode === 0 || value.F_TransMode === 1 || value.F_TransMode === 3 ? value.F_TransMode @@ -291,9 +705,9 @@ export function CcForecastFormOrder({ {isAdmin ? ( 修改 · 基础信息 ) : ( - 基础信息(客户端字段序) + 基础信息 )} - {/* 客户端顺序:运输方式、业务类型、柜号…;管理端带客户行 */} + {/* 对齐 CC「修改·基础信息」:运输方式/业务类型/柜号/柜型* + 柜号Id/提单/ETD/ETA/港/船司/客服/自带车架/Instruction/备注 */} {isAdmin ? (
@@ -306,294 +720,188 @@ export function CcForecastFormOrder({ } /> - - patch({ F_TransMode: v as TransMode })} - /> - - - - patch({ F_OperationType: v as OperationType }) - } - /> - - - - patch({ F_ContainerNo: e.target.value.toUpperCase() }) - } - /> - - {opt(value.F_CabinetType) ? ( - - - patch({ F_BLCopyCode: e.target.value.toUpperCase() }) - } - /> - - +
+ + + patch({ F_OperationType: v as OperationType }) + } + /> + +
+ +
+ + + patch({ F_ContainerNo: e.target.value.toUpperCase() }) + } + /> + + + + + + + patch({ F_BLCopyCode: e.target.value.toUpperCase() }) + } + /> + +
+ +
+ + patch({ F_ETD: e.target.value })} + /> + + + patch({ F_ETA: e.target.value })} + /> + +
+ +
+ + patch({ F_LoadPort: e.target.value })} + /> + + + patch({ F_Dock: e.target.value })} + /> + +
+ +
+ + + patch({ F_CustomerService: e.target.value }) + } + /> + +
- {isAdmin ? ( - <> - {opt(value.F_ETD) || opt(value.F_ETA) || opt(value.F_LoadPort) ? ( -
- {opt(value.F_ETD) ? ( - - patch({ F_ETD: e.target.value })} - /> - - ) : null} - {opt(value.F_ETA) ? ( - - patch({ F_ETA: e.target.value })} - /> - - ) : null} - {opt(value.F_LoadPort) ? ( - - patch({ F_LoadPort: e.target.value })} - /> - - ) : null} -
- ) : null} - - {opt(value.F_Dock) || - opt(value.F_BLCopyCode) || - opt(value.F_ContainerId) ? ( -
- {opt(value.F_Dock) ? ( - - patch({ F_Dock: e.target.value })} - /> - - ) : null} - {opt(value.F_BLCopyCode) ? ( - - - patch({ F_BLCopyCode: e.target.value.toUpperCase() }) - } - /> - - ) : null} - {opt(value.F_ContainerId) ? ( - - - - ) : null} -
- ) : null} - - {opt(value.F_ShippingLineId) || - opt(value.F_CustomerService) || - opt(value.F_Classis) ? ( -
- {opt(value.F_ShippingLineId) ? ( - - - patch({ F_CustomerService: e.target.value }) - } - /> - - ) : null} - {opt(value.F_Classis) ? ( -
- - patch({ F_Classis: e.target.checked ? 1 : 0 }) - } - > - 自带车架 - -
- ) : null} -
- ) : null} - - ) : ( -
- - - patch({ F_CustomerService: e.target.value }) - } - /> - -
- )} +
+ + patch({ F_Classis: e.target.checked ? 1 : 0 }) + } + > + 自带车架 + +
- {isAdmin ? ( - opt(value.F_Instruction) || opt(value.F_MemoRemark) ? ( -
- {opt(value.F_Instruction) ? ( - - - patch({ F_Instruction: e.target.value }) - } - /> - - ) : null} - {opt(value.F_MemoRemark) ? ( - - - patch({ F_MemoRemark: e.target.value }) - } - /> - - ) : null} -
- ) : null - ) : ( -
- - patch({ F_Instruction: e.target.value })} - /> - - - patch({ F_MemoRemark: e.target.value })} - /> - -
- )} +
+ + patch({ F_Instruction: e.target.value })} + /> + + + patch({ F_MemoRemark: e.target.value })} + /> + +
) : null} @@ -602,6 +910,16 @@ export function CcForecastFormOrder({ {isAdmin ? ( 货件列表 ) : null} + {canEditShipments ? ( +
+ 表格单元格可直接填写;也可点行末「编辑」弹窗修改。有效行需至少填写:派送方式、件数、体积、毛重。 +
+ ) : null} + {errors.rows ? ( +
+ {errors.rows} +
+ ) : null}
查询 + {canEditShipments ? ( + <> + + + {onImportPackingFile ? ( + { + void onImportPackingFile(file); + return false; + }} + > + + + ) : null} + + ) : null}
-
+
{ if (readonly || !onSelectedChange) return; const next = new Set(selectedSet); @@ -635,8 +986,9 @@ export function CcForecastFormOrder({ /> {shipments.length === 0 ? (
- 货件列表来自卡派清单等表格附件;当前无解析出行。请检查是否附带 - xlsx/xls,或重新解析。 + {canEditShipments + ? "暂无货件行。可点击「新增货件」手工录入,或「导入装箱表」上传卡派清单(xlsx)。" + : "货件列表来自卡派清单等表格附件;当前无解析出行。请检查是否附带 xlsx/xls,或重新解析。"}
) : null}
@@ -732,6 +1084,16 @@ export function CcForecastFormOrder({
) : null} + + {editRowIndex != null && editRow ? ( + setEditRowIndex(null)} + onSave={onSaveEditRow} + /> + ) : null}
); } @@ -765,6 +1127,7 @@ export function buildCcForecastFormValue(input: { vessel_voyage?: string; service?: string; classis?: 0 | 1; + ops_hints?: string[]; } | null; }): CcForecastFormValue { const h = input.header || {}; @@ -779,14 +1142,22 @@ export function buildCcForecastFormValue(input: { const memoRaw = h.F_MemoRemark || ""; // 备注若残留「回复:柜号:…」等脏客户前缀,剥掉已占用柜号包装 const memo = sanitizeMemoRemark(memoRaw, { containerNo, blNo, customer }); - const transMode = h.F_TransMode; - const operationType = h.F_OperationType; + const fromHints = applyServiceFromModules({ + ops_hints: m.ops_hints, + service: m.service, + }); + const transMode = + h.F_TransMode ?? + (m.container_no || m.bl_no || m.cabinet_type || m.vessel_voyage + ? (0 as const) + : undefined); + const operationType = h.F_OperationType ?? fromHints.F_OperationType; const classis = h.F_Classis === 0 || h.F_Classis === 1 ? h.F_Classis : m.classis === 0 || m.classis === 1 ? m.classis - : undefined; + : fromHints.F_Classis; return { customerName: customer, F_TransMode: @@ -798,7 +1169,9 @@ export function buildCcForecastFormValue(input: { ? operationType : undefined, F_ContainerNo: containerNo, - F_CabinetType: h.F_CabinetType || m.cabinet_type || "", + F_CabinetType: normalizeCabinetType( + h.F_CabinetType || m.cabinet_type || "", + ), F_ETD: h.F_ETD || m.etd || "", F_ETA: h.F_ETA || m.eta || "", F_LoadPort: h.F_LoadPort || "", @@ -811,7 +1184,7 @@ export function buildCcForecastFormValue(input: { F_OverWeight: false, F_Classis: classis, F_Instruction: stripMobileMailChrome( - h.F_Instruction || vesselInst || "", + h.F_Instruction || vesselInst || fromHints.F_Instruction || "", ), F_MemoRemark: memo, }; diff --git a/src/components/CcMessageTypeDictCard.tsx b/src/components/CcMessageTypeDictCard.tsx new file mode 100644 index 0000000..4141a23 --- /dev/null +++ b/src/components/CcMessageTypeDictCard.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + App, + Button, + Card, + Form, + Input, + InputNumber, + Popconfirm, + Space, + Spin, + Switch, + Table, + Typography, +} from "antd"; +import { DownOutlined, UpOutlined } from "@ant-design/icons"; +import { + createCcMessageTypeApi, + deleteCcMessageTypeApi, + listCcMessageTypesApi, + updateCcMessageTypeApi, +} from "@/lib/client-api"; +import type { CcMessageTypeDictItem } from "@/types"; + +export function CcMessageTypeDictCard() { + const { message } = App.useApp(); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(false); + const [items, setItems] = useState([]); + const [adding, setAdding] = useState(false); + const [form] = Form.useForm<{ name: string; sort_order?: number }>(); + + const enabledCount = useMemo( + () => items.filter((i) => i.enabled).length, + [items], + ); + + const reload = useCallback(async () => { + setLoading(true); + const res = await listCcMessageTypesApi(); + setLoading(false); + if (!res.ok) { + message.error(res.error.message); + return; + } + setItems(res.data.items); + }, [message]); + + useEffect(() => { + void reload(); + }, [reload]); + + const onAdd = async () => { + const values = await form.validateFields().catch(() => null); + if (!values) return; + setAdding(true); + const res = await createCcMessageTypeApi({ + name: values.name, + sort_order: values.sort_order, + enabled: true, + }); + setAdding(false); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success("已添加留言类型"); + form.resetFields(); + await reload(); + }; + + const onToggle = async (row: CcMessageTypeDictItem, enabled: boolean) => { + const res = await updateCcMessageTypeApi(row.id, { enabled }); + if (!res.ok) { + message.error(res.error.message); + return; + } + setItems((prev) => + prev.map((x) => (x.id === row.id ? { ...x, enabled } : x)), + ); + }; + + const onRename = async (row: CcMessageTypeDictItem, name: string) => { + const next = name.trim(); + if (!next || next === row.name) return; + const res = await updateCcMessageTypeApi(row.id, { name: next }); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success("已更新名称"); + await reload(); + }; + + const onSort = async (row: CcMessageTypeDictItem, sort_order: number | null) => { + if (sort_order == null || sort_order === row.sort_order) return; + const res = await updateCcMessageTypeApi(row.id, { sort_order }); + if (!res.ok) { + message.error(res.error.message); + return; + } + await reload(); + }; + + const onDelete = async (row: CcMessageTypeDictItem) => { + const res = await deleteCcMessageTypeApi(row.id); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success("已删除"); + await reload(); + }; + + return ( + + + {loading + ? "加载中…" + : `共 ${items.length} 项 · 启用 ${enabledCount}`} + + + + } + > + {!expanded ? ( + + 折叠中。展开后可维护工单 / 客户留言表单的留言类型下拉选项。 + + ) : ( + <> + + 在此维护写入 CarrierCentral 的「留言类型」名称。启用项会出现在确认页工单表单中;首次空库会自动写入默认种子。 + + + +
8 ? { y: 320 } : undefined} + columns={[ + { + title: "名称", + dataIndex: "name", + render: (name: string, row) => ( + void onRename(row, e.target.value)} + onPressEnter={(e) => { + (e.target as HTMLInputElement).blur(); + }} + /> + ), + }, + { + title: "排序", + dataIndex: "sort_order", + width: 100, + render: (v: number, row) => ( + void onSort(row, n)} + /> + ), + }, + { + title: "启用", + dataIndex: "enabled", + width: 80, + render: (enabled: boolean, row) => ( + void onToggle(row, checked)} + /> + ), + }, + { + title: "操作", + width: 80, + render: (_, row) => ( + void onDelete(row)} + > + + + ), + }, + ]} + /> + +
void onAdd()} + > + + + + + + + + + + + + + + + )} + + ); +} diff --git a/src/components/CcSettingsCard.tsx b/src/components/CcSettingsCard.tsx index 6db6816..091c8fc 100644 --- a/src/components/CcSettingsCard.tsx +++ b/src/components/CcSettingsCard.tsx @@ -9,20 +9,15 @@ import { Input, Space, Spin, - Tag, } from "antd"; import { getCcSettingsApi, testCcSettingsApi, updateCcSettingsApi, } from "@/lib/client-api"; -import type { CcSettings } from "@/types"; type FormValues = { - mock: boolean; api_base: string; - saas_header: string; - login_mark: string; username: string; password?: string; }; @@ -32,11 +27,9 @@ export function CcSettingsCard() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); - const [meta, setMeta] = useState | null>(null); - const [mockMode, setMockMode] = useState(false); + const [hasPassword, setHasPassword] = useState(false); + const [saasHeader, setSaasHeader] = useState("TEST"); + const [loginMark, setLoginMark] = useState(""); const [form] = Form.useForm(); const reload = useCallback(async () => { @@ -47,17 +40,11 @@ export function CcSettingsCard() { message.error(res.error.message); return; } - setMeta({ - source: res.data.source, - has_password: res.data.has_password, - updated_at: res.data.updated_at, - }); - setMockMode(res.data.mock); + setHasPassword(res.data.has_password); + setSaasHeader(res.data.saas_header || "TEST"); + setLoginMark(res.data.login_mark || crypto.randomUUID()); form.setFieldsValue({ - mock: res.data.mock, api_base: res.data.api_base, - saas_header: res.data.saas_header, - login_mark: res.data.login_mark, username: res.data.username, password: undefined, }); @@ -69,13 +56,13 @@ export function CcSettingsCard() { const onSave = async () => { const values = await form.validateFields(); - // mock 开关已从设置页移除;保存时沿用当前运行时配置,不在此页改 mock - const mock = mockMode; - if (!mock && !values.username.trim()) { + // 工单等业务需真写 CC;保存时强制关闭全局 mock + const mock = false; + if (!values.username.trim()) { message.error("请填写用户名"); return; } - if (!mock && !meta?.has_password && !values.password?.trim()) { + if (!hasPassword && !values.password?.trim()) { message.error("请填写密码(首次或尚未配置)"); return; } @@ -84,8 +71,8 @@ export function CcSettingsCard() { const res = await updateCcSettingsApi({ mock, api_base: values.api_base, - saas_header: values.saas_header, - login_mark: values.login_mark, + saas_header: saasHeader || "TEST", + login_mark: loginMark || crypto.randomUUID(), username: values.username, ...(values.password?.trim() ? { password: values.password.trim() } @@ -120,21 +107,7 @@ export function CcSettingsCard() { }; return ( - - {meta ? ( - - 来源 {meta.source === "db" ? "数据库" : ".env"} - - ) : null} - - {mockMode ? "mock" : "live"} - - - } - > +
- - - - - - - - diff --git a/src/components/CcWorkOrderForm.tsx b/src/components/CcWorkOrderForm.tsx index b096967..fc297f9 100644 --- a/src/components/CcWorkOrderForm.tsx +++ b/src/components/CcWorkOrderForm.tsx @@ -1,12 +1,11 @@ "use client"; import type { CSSProperties, ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Button, Input, Select, Space, Tag, Typography } from "antd"; -import { - CC_CUSTOMER_MESSAGE_TYPE_OPTIONS, - suggestCcMessageType, -} from "@/constants/cc-message-types"; +import { suggestCcMessageType } from "@/constants/cc-message-types"; import type { CcUiMode } from "@/constants/cc-ui-mode"; +import { listCcMessageTypesApi } from "@/lib/client-api"; import { collectSoftOpsHints, collectWorkOrderActions, @@ -14,6 +13,12 @@ import { extractWorkOrderMessageBody, matchWorkOrderAttachments, } from "@/services/parse/work-order-record"; +import { extractWorkOrderFormFields } from "@/services/parse/work-order-fields"; +import type { + WorkOrderFieldMark, + WorkOrderFieldMarks, + WorkOrderSnapshotField, +} from "@/services/parse/work-order-snapshot"; const fieldRow: CSSProperties = { display: "grid", @@ -29,25 +34,46 @@ const fieldRow3: CSSProperties = { marginBottom: 10, }; +const MARK_TAG: Record = { + supplemented: { color: "blue", text: "补充" }, + modified: { color: "orange", text: "已改" }, +}; + function Field({ label, children, required, full, + mark, }: { label: ReactNode; children: ReactNode; required?: boolean; full?: boolean; + mark?: WorkOrderFieldMark; }) { + const markMeta = mark ? MARK_TAG[mark] : null; return (
{label} {required ? * : null} + {markMeta ? ( + + {markMeta.text} + + ) : null}
{children}
@@ -74,6 +100,8 @@ export type CcWorkOrderFormProps = { onChange?: (patch: Partial) => void; /** 管理端 Form.cshtml vs 客户端 SaveForm 布局完全不同 */ portal?: CcUiMode; + /** 相对邮件基线的字段标记(只读回显人工补充/修改) */ + fieldMarks?: WorkOrderFieldMarks; }; const URGENCY_OPTIONS = [ @@ -90,6 +118,13 @@ function uniq(list: string[]): string[] { return out; } +function splitAttachFiles(raw: string): string[] { + return String(raw || "") + .split(/[;;\n]+/) + .map((s) => s.trim()) + .filter(Boolean); +} + /** * 客户留言 / 工单表单 * - admin:对齐 ccnew SRCustomerMessage/Form(客户、紧急程度、附件上下载) @@ -100,14 +135,45 @@ export function CcWorkOrderForm({ value, onChange, portal = "admin", + fieldMarks, }: CcWorkOrderFormProps) { const readonly = mode === "readonly"; + const markOf = (key: WorkOrderSnapshotField): WorkOrderFieldMark | undefined => + fieldMarks?.[key]; const patch = (p: Partial) => { if (readonly || !onChange) return; onChange(p); }; const actions = value.mailActions || []; const isAdmin = portal === "admin"; + const attachFiles = splitAttachFiles(value.F_AttachFile); + const [messageTypeOptions, setMessageTypeOptions] = useState< + Array<{ value: string; label: string }> + >([]); + + useEffect(() => { + let cancelled = false; + void (async () => { + const res = await listCcMessageTypesApi({ enabled_only: true }); + if (cancelled || !res.ok) return; + setMessageTypeOptions( + res.data.items.map((i) => ({ value: i.name, label: i.name })), + ); + })(); + return () => { + cancelled = true; + }; + }, []); + + /** 字典未加载完或历史值不在启用列表时,仍要显示已提交值 */ + const typeSelectOptions = useMemo(() => { + const cur = (value.F_MessageType || "").trim(); + if (!cur) return messageTypeOptions; + if (messageTypeOptions.some((o) => o.value === cur)) { + return messageTypeOptions; + } + return [{ value: cur, label: cur }, ...messageTypeOptions]; + }, [messageTypeOptions, value.F_MessageType]); return (
- + patch({ F_CustomerName: e.target.value })} /> - +
- + patch({ F_MessageType: v })} showSearch @@ -186,7 +252,7 @@ export function CcWorkOrderForm({
- +
- +
- +
- +
- + patch({ F_ReferCode: e.target.value })} /> - + patch({ F_RefNo: e.target.value })} /> - - patch({ F_AttachFile: e.target.value })} - /> + + {attachFiles.length ? ( +
+ {attachFiles.map((name) => ( +
+ {name} +
+ ))} + {!readonly ? ( + patch({ F_AttachFile: e.target.value })} + /> + ) : null} +
+ ) : ( + patch({ F_AttachFile: e.target.value })} + /> + )}
- +
- + void; + onOk: () => void; +}) { + return ( + +

+ 是否为【{customerName || "未命名客户"}】进行【 + {operationLabel}】? +

+
+ ); +} diff --git a/src/components/ConfirmImportModal.tsx b/src/components/ConfirmImportModal.tsx index c829531..6184fa9 100644 --- a/src/components/ConfirmImportModal.tsx +++ b/src/components/ConfirmImportModal.tsx @@ -1,6 +1,7 @@ "use client"; -import { Modal } from "antd"; +import { Alert, Modal, Typography } from "antd"; +import { ccFailureKindLabel } from "@/constants/error-copy"; import { UI_COPY } from "@/constants/ui-copy"; export function ConfirmImportModal({ @@ -34,27 +35,49 @@ export function ConfirmImportModal({ ); } +const FAILURE_MODAL_TITLE: Record = { + CONFLICT_CHECK_FAILED: "柜号查重失败", + CONFLICT_CONTAINER: "柜号冲突", + IMPORT_FAILED: "导入失败", +}; + export function ConflictModal({ open, reason, + message: messageText, + failureKind, + detailError, canForceSkip, loading, onClose, onForceSkip, }: { open: boolean; - reason: "CONFLICT_CONTAINER" | "CONFLICT_CHECK_FAILED" | string; + reason: "CONFLICT_CONTAINER" | "CONFLICT_CHECK_FAILED" | "IMPORT_FAILED" | string; + /** 服务端返回的可读主文案(优先于固定模板) */ + message?: string; + failureKind?: string; + detailError?: string; canForceSkip: boolean; loading?: boolean; onClose: () => void; onForceSkip?: () => void; }) { const isCheckFailed = reason === "CONFLICT_CHECK_FAILED"; + const isImportFailed = reason === "IMPORT_FAILED"; + const kindText = ccFailureKindLabel(failureKind); + const body = + messageText || + (isCheckFailed + ? "无法在 CarrierCentral 完成柜号查重(接口超时/限流/登录失败),并非表示该柜已在 CC 存在。" + : isImportFailed + ? "向 CarrierCentral 保存集装箱失败,请根据下方原因修正后重试。" + : UI_COPY.conflictBody); + const showDetail = Boolean(detailError?.trim()); + return ( -

- {isCheckFailed - ? "无法校验柜号是否已在 CC 存在(列表超时/失败)。默认阻断导入。" - : UI_COPY.conflictBody} -

+ {failureKind ? ( + + ) : null} + + {body} + + {showDetail ? ( + + + CC 返回详情 + + {detailError} + + ) : null} + {isCheckFailed ? ( + + 说明:查重失败不代表柜号已在 CC 存在;换用可登录的客户档案或等待约 1 分钟后再试。 + + ) : null} {canForceSkip ? ( -

+ Admin 且 ENABLE_FORCE_IMPORT=true:可强制跳过冲突检测。 -

+ ) : null}
); diff --git a/src/components/ImapPullSettingsCard.tsx b/src/components/ImapPullSettingsCard.tsx index e8a1cf1..6f54535 100644 --- a/src/components/ImapPullSettingsCard.tsx +++ b/src/components/ImapPullSettingsCard.tsx @@ -10,6 +10,7 @@ import { Popconfirm, Select, Space, + Spin, Tag, Typography, } from "antd"; @@ -68,7 +69,6 @@ export function ImapPullSettingsCard() { const [savingInterval, setSavingInterval] = useState(false); const [adding, setAdding] = useState(false); const [intervalMs, setIntervalMs] = useState(30 * 60_000); - const [source, setSource] = useState<"db" | "env">("env"); const [filters, setFilters] = useState([]); const [form] = Form.useForm<{ kind: ImapFilterKind; value: string }>(); @@ -97,7 +97,6 @@ export function ImapPullSettingsCard() { return; } setIntervalMs(res.data.poll_interval_ms); - setSource(res.data.source); setFilters(res.data.filters || []); }, [message]); @@ -114,7 +113,6 @@ export function ImapPullSettingsCard() { return; } message.success("拉取间隔已保存(worker 下一轮生效)"); - setSource(res.data.source); setIntervalMs(res.data.poll_interval_ms); }; @@ -152,138 +150,134 @@ export function ImapPullSettingsCard() { }; return ( - - 来源:{source === "db" ? "数据库" : ".env 回退"} - - } - > - - 拉取间隔 - setIntervalMs(v)} + /> + + - - 过滤规则 - - - - - - - - - - - {filters.length === 0 ? ( - - 暂无规则(将使用业务相关兜底) - - ) : ( -
- {grouped.map((g) => ( -
+ ({ + options={OVERRIDE_TYPE_OPTIONS.map((k) => ({ value: k, label: MAIL_TYPE_LABEL[k], }))} @@ -61,7 +73,7 @@ export function TypeOverrideModal({ label="原因(必填)" rules={[{ required: true, message: "请填写改类型原因" }]} > - + diff --git a/src/components/confirm/ForecastConfirmView.tsx b/src/components/confirm/ForecastConfirmView.tsx index afd65b4..86faafc 100644 --- a/src/components/confirm/ForecastConfirmView.tsx +++ b/src/components/confirm/ForecastConfirmView.tsx @@ -19,25 +19,34 @@ import { type CcForecastFormValue, } from "@/components/CcForecastFormOrder"; import { ConfirmImportModal, ConflictModal } from "@/components/ConfirmImportModal"; -import { CcUiModeSwitch } from "@/components/CcUiModeSwitch"; +import { ConfirmCcCustomerModal } from "@/components/ConfirmCcCustomerModal"; +import { + CcCustomerProfileSelect, + useCcCustomerProfileSelect, +} from "@/components/CcCustomerProfileSelect"; import { useAuthSession } from "@/components/providers/AuthSessionProvider"; import { UI_COPY } from "@/constants/ui-copy"; -import { formatMailLastError } from "@/constants/error-copy"; +import { describeCcFailureForOperator } from "@/constants/error-copy"; import { buildImportIdempotencyKey } from "@/utils/idempotency-client"; import { withVersionConflictRetry } from "@/utils/mail-submit-version"; -import { ccUiModeLabel } from "@/constants/cc-ui-mode"; import { useImport } from "@/hooks/useImport"; -import { useCcUiMode } from "@/hooks/useCcUiMode"; -import { healthApi, listShippingLinesApi } from "@/lib/client-api"; +import { healthApi, listShippingLinesApi, parseForecastPackingApi } from "@/lib/client-api"; import { collectForecastImportIssues } from "@/services/cc/forecast-confirm-gate"; import { isInstructionImported } from "@/services/parse/instruction-execution-state"; import { matchShippingLineId, shippingLineHintFromBl, } from "@/services/parse/extract-container-header"; +import { extractBlPlusFromMail } from "@/services/parse/bl-plus-template"; +import { extractOpsSubjectFromMail } from "@/services/parse/ops-subject-template"; +import { + normalizeParseShipments, + reindexShipments, + revalidateShipmentRow, +} from "@/services/parse/revalidate-shipment-row"; import type { MailInstructionUnit } from "@/services/parse/split-instructions"; import type { MailMessage } from "@/types"; -import type { MailRecordModules } from "@/types/mail"; +import type { MailRecordModules, ShipmentRow } from "@/types/mail"; export function ForecastConfirmView({ mail, @@ -47,20 +56,23 @@ export function ForecastConfirmView({ unit: MailInstructionUnit; }) { const router = useRouter(); - const { message } = App.useApp(); + const { message, modal } = App.useApp(); const { user } = useAuthSession(); const { submitting, submit } = useImport(mail.id); - const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode(); + const ccProfile = useCcCustomerProfileSelect({ mailId: mail.id }); const instructionId = unit.id; const [submitVersion, setSubmitVersion] = useState(mail.version); useEffect(() => { setSubmitVersion(mail.version); }, [mail.version]); - const shipments = useMemo( - () => mail.parse_result?.shipments ?? [], - [mail.parse_result?.shipments], + const [shipments, setShipments] = useState(() => + normalizeParseShipments(mail.parse_result?.shipments), ); + useEffect(() => { + setShipments(normalizeParseShipments(mail.parse_result?.shipments)); + }, [mail.id, mail.version, mail.parse_result?.shipments]); + const customerName = mail.parse_result?.mail_record?.modules?.customer_name || ( @@ -76,9 +88,13 @@ export function ForecastConfirmView({ const [step, setStep] = useState(0); const [selected, setSelected] = useState>(new Set()); const [ackUnmapped, setAckUnmapped] = useState(false); + const [customerConfirmOpen, setCustomerConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [conflictOpen, setConflictOpen] = useState(false); const [conflictReason, setConflictReason] = useState("CONFLICT_CONTAINER"); + const [conflictMessage, setConflictMessage] = useState(""); + const [conflictKind, setConflictKind] = useState(); + const [conflictDetail, setConflictDetail] = useState(); const [containerSource, setContainerSource] = useState< "body" | "attachment" | null >(null); @@ -113,16 +129,126 @@ export function ForecastConfirmView({ }); }, []); - const loadShippingLines = async () => { - const res = await listShippingLinesApi(); + const [shippingFetchLoading, setShippingFetchLoading] = useState(false); + const [packingImportLoading, setPackingImportLoading] = useState(false); + + const loadShippingLines = async (opts?: { refresh?: boolean }) => { + const res = await listShippingLinesApi( + opts?.refresh ? { refresh: true } : undefined, + ); if (!res.ok) { message.error(res.error.message); - return; + return null; } setShippingLineRows(res.data.items); setShippingLines( res.data.items.map((item) => ({ value: item.id, label: item.name })), ); + return res.data.items; + }; + + const matchShippingLineFromBl = ( + bl: string, + rows: typeof shippingLineRows, + ): string | null => { + const hint = shippingLineHintFromBl(bl); + if (!hint && !bl.trim()) return null; + return matchShippingLineId( + hint, + rows.map((l) => ({ + F_Id: l.id, + F_FullName: l.name, + F_EnCode: l.code, + F_Prefix: l.prefix, + })), + { blNo: bl }, + ); + }; + + const onFetchShippingLine = async () => { + setShippingFetchLoading(true); + try { + const items = await loadShippingLines({ refresh: true }); + if (!items) return; + const bl = form.F_BLCopyCode || ""; + if (!bl.trim()) { + message.warning("请先填写提单号,再按提单前缀匹配船司"); + return; + } + const id = matchShippingLineFromBl(bl, items); + if (!id) { + message.warning( + `船司列表已刷新(${items.length} 条),但未能根据提单「${bl}」自动匹配,请手工选择`, + ); + return; + } + setForm((p) => ({ ...p, F_ShippingLineId: id })); + const name = items.find((x) => x.id === id)?.name || id; + message.success(`已匹配船司:${name}`); + } finally { + setShippingFetchLoading(false); + } + }; + + const onImportPackingFile = async (file: File) => { + if (pageReadonly) return; + setPackingImportLoading(true); + try { + const res = await parseForecastPackingApi(mail.id, file, { + containerNo: form.F_ContainerNo, + }); + if (!res.ok) { + modal.error({ + title: "导入装箱表失败", + content: + res.error.message || + "无法解析该文件。请上传卡派清单等含「渠道 / 件数 / 仓库」表头的 Excel(xlsx/xls)。", + }); + return; + } + if (!res.data.shipments.length) { + modal.warning({ + title: "未识别到货件行", + content: + "文件已上传,但没有解析出货件。请确认表头包含渠道/派送方式、件数、体积、毛重等列。", + }); + return; + } + const imported = reindexShipments( + res.data.shipments.map((s, i) => + revalidateShipmentRow({ ...s, row_index: i }), + ), + ); + setShipments(imported); + setSelected( + new Set( + imported + .filter((s) => s.row_status === "VALID") + .map((s) => s.row_index), + ), + ); + if ( + res.data.container_no && + !form.F_ContainerNo.trim() && + !hasContainerConflict + ) { + setForm((p) => ({ + ...p, + F_ContainerNo: res.data.container_no!.toUpperCase(), + })); + } + message.success( + `已导入 ${res.data.row_count} 行货件(有效 ${res.data.valid_count})`, + ); + if (!isAdmin && step === 0) setStep(1); + } catch { + modal.error({ + title: "导入装箱表失败", + content: "上传或解析时出错,请确认文件为有效的 Excel(xlsx/xls)后重试。", + }); + } finally { + setPackingImportLoading(false); + } }; useEffect(() => { @@ -134,18 +260,7 @@ export function ForecastConfirmView({ if (!form.F_BLCopyCode || !shippingLineRows.length || form.F_ShippingLineId) { return; } - const hint = shippingLineHintFromBl(form.F_BLCopyCode); - if (!hint) return; - const id = matchShippingLineId( - hint, - shippingLineRows.map((l) => ({ - F_Id: l.id, - F_FullName: l.name, - F_EnCode: l.code, - F_Prefix: l.prefix, - })), - { blNo: form.F_BLCopyCode }, - ); + const id = matchShippingLineFromBl(form.F_BLCopyCode, shippingLineRows); if (id) { setForm((p) => p.F_ShippingLineId === id ? p : { ...p, F_ShippingLineId: id }, @@ -161,11 +276,20 @@ export function ForecastConfirmView({ | { mail_record?: { modules?: MailRecordModules } } | undefined )?.mail_record; + const ops = extractOpsSubjectFromMail(mail.subject); + const bl = ops + ? null + : extractBlPlusFromMail(mail.subject, mail.body_text || ""); + const modules: MailRecordModules = { + ...(bl?.modules || {}), + ...(ops?.modules || {}), + ...(record?.modules || {}), + }; setForm( buildCcForecastFormValue({ customerName, header: mail.parse_result?.container_header, - modules: record?.modules, + modules, }), ); }, [mail, customerName]); @@ -240,6 +364,7 @@ export function ForecastConfirmView({ transMode: form.F_TransMode, operationType: form.F_OperationType, containerNo: form.F_ContainerNo, + cabinetType: form.F_CabinetType, hasContainerConflict, containerSource, validRowCount: validRows.length, @@ -249,13 +374,14 @@ export function ForecastConfirmView({ hasUnmappedSelected, ackUnmapped, softLimitExceeded, - portal: ccUiMode, + portal: "client", step, }), [ form.F_TransMode, form.F_OperationType, form.F_ContainerNo, + form.F_CabinetType, hasContainerConflict, containerSource, validRows.length, @@ -264,7 +390,6 @@ export function ForecastConfirmView({ hasUnmappedSelected, ackUnmapped, softLimitExceeded, - ccUiMode, step, ], ); @@ -273,6 +398,7 @@ export function ForecastConfirmView({ !errors.transMode && !errors.operationType && !errors.containerNo && + !errors.cabinetType && !errors.containerSource; const onStepChange = (s: number) => { @@ -293,6 +419,15 @@ export function ForecastConfirmView({ message.error(importIssues.blockers[0]); return; } + if (!ccProfile.profileId || !ccProfile.selected) { + message.error("请先选择 CC 客户"); + return; + } + setCustomerConfirmOpen(true); + }; + + const onCustomerConfirmOk = () => { + setCustomerConfirmOpen(false); setConfirmOpen(true); }; @@ -303,6 +438,10 @@ export function ForecastConfirmView({ if (operationType !== 0 && operationType !== 2 && operationType !== 4) { return; } + if (!ccProfile.profileId) { + message.error("请先选择 CC 客户"); + return; + } const containerNo = form.F_ContainerNo.toUpperCase(); const basePayload = { instruction_id: instructionId, @@ -330,6 +469,8 @@ export function ForecastConfirmView({ }, selected_row_indexes: selectedValid, ack_unmapped_channels: ackUnmapped, + cc_profile_id: ccProfile.profileId, + shipments, ...(forceSkip ? { force_skip_conflict: true } : {}), }; @@ -353,7 +494,16 @@ export function ForecastConfirmView({ res.error.code === "CONFLICT_CHECK_FAILED" ) { setConfirmOpen(false); + const details = res.error.details as + | { kind?: string; error?: string } + | undefined; + const rawDetail = + details?.error || res.error.message || undefined; + const described = describeCcFailureForOperator(rawDetail); setConflictReason(res.error.code); + setConflictMessage(described.message || res.error.message || ""); + setConflictKind(details?.kind || described.kind); + setConflictDetail(described.detail || details?.error); setConflictOpen(true); return; } @@ -372,12 +522,19 @@ export function ForecastConfirmView({ message.success("本条预报已导入,请继续确认其余指令"); router.push(`/mails/${mail.id}`); } else { - const importErr = formatMailLastError( + const rawErr = res.data.imports?.[0]?.last_error || - mail.last_error || - "导入失败,可修改后重试", - ); - message.error(importErr); + mail.last_error || + "导入失败,可修改后重试"; + const described = describeCcFailureForOperator(rawErr); + setConflictReason("IMPORT_FAILED"); + setConflictMessage(described.message); + setConflictKind(described.kind); + setConflictDetail(described.detail ?? undefined); + if (/柜型/.test(rawErr) || /柜型/.test(described.message)) { + setStep(0); + } + setConflictOpen(true); } } finally { window.clearTimeout(slowHint); @@ -426,9 +583,15 @@ export function ForecastConfirmView({ - + - {ccUiModeLabel(ccUiMode)} · SaveContainer + 客户端 · SaveContainer @@ -503,7 +666,7 @@ export function ForecastConfirmView({ ) : null} {hasUnmappedSelected ? ( -
+
{errors.ack ? ( -
+
{errors.ack}
) : null} @@ -527,13 +684,18 @@ export function ForecastConfirmView({ setForm((p) => ({ ...p, ...patch }))} + onShipmentsChange={pageReadonly ? undefined : setShipments} + packingImportLoading={packingImportLoading} + onImportPackingFile={pageReadonly ? undefined : onImportPackingFile} errors={errors} step={step} onStepChange={onStepChange} @@ -541,7 +703,9 @@ export function ForecastConfirmView({ containerNoLocked={hasContainerConflict} onFinish={onPrimary} finishLoading={submitting} - finishDisabled={importIssues.blockers.length > 0} + finishDisabled={ + importIssues.blockers.length > 0 || !ccProfile.profileId + } finishBlockers={pageReadonly ? [] : importIssues.blockers} finishNotes={pageReadonly ? [] : importIssues.notes} /> @@ -560,6 +724,13 @@ export function ForecastConfirmView({
+ setCustomerConfirmOpen(false)} + onOk={onCustomerConfirmOk} + /> setConflictOpen(false)} diff --git a/src/components/confirm/OpsInstructionConfirmView.tsx b/src/components/confirm/OpsInstructionConfirmView.tsx index c5e4492..6df20c3 100644 --- a/src/components/confirm/OpsInstructionConfirmView.tsx +++ b/src/components/confirm/OpsInstructionConfirmView.tsx @@ -23,10 +23,13 @@ import { buildCcWorkOrderFormValue, type CcWorkOrderFormValue, } from "@/components/CcWorkOrderForm"; -import { CcUiModeSwitch } from "@/components/CcUiModeSwitch"; +import { ConfirmCcCustomerModal } from "@/components/ConfirmCcCustomerModal"; +import { + CcCustomerProfileSelect, + useCcCustomerProfileSelect, +} from "@/components/CcCustomerProfileSelect"; +import { collectDoImportIssues } from "@/services/cc/do-confirm-gate"; import { UI_COPY } from "@/constants/ui-copy"; -import { ccUiModeLabel } from "@/constants/cc-ui-mode"; -import { useCcUiMode } from "@/hooks/useCcUiMode"; import { confirmDoUploadApi, confirmTransferApi, @@ -35,7 +38,17 @@ import { } from "@/lib/client-api"; import { toCcCustomerMessageEntity } from "@/services/cc/customer-message-entity"; import { resolveWorkOrderPanelKind } from "@/services/cc/work-order-panel"; -import { isInstructionImported } from "@/services/parse/instruction-execution-state"; +import { + isInstructionImported, + isInstructionImportedLive, + isInstructionImportedMock, + getInstructionExecutionState, +} from "@/services/parse/instruction-execution-state"; +import { + describeWorkOrderFieldMarks, + toWorkOrderSubmittedSnapshot, + workOrderFormFromSubmitted, +} from "@/services/parse/work-order-snapshot"; import { transferSegmentContextsFromLineage } from "@/services/parse/match-transfer-segment-ocr"; import { buildMailIntent, @@ -73,14 +86,12 @@ function asMailRecord(mail: MailMessage): MailRecord | null { function ConfirmChrome({ mail, unit, - ccUiMode, - setCcUiMode, + profileSelect, children, }: { mail: MailMessage; unit: MailInstructionUnit; - ccUiMode: ReturnType["mode"]; - setCcUiMode: ReturnType["setMode"]; + profileSelect: ReactNode; children: ReactNode; }) { return ( @@ -109,9 +120,9 @@ function ConfirmChrome({ {mail.subject} - + {profileSelect} - {ccUiModeLabel(ccUiMode)} · {instructionUiKindLabel(unit.uiKind)} + 客户端 · {instructionUiKindLabel(unit.uiKind)} @@ -133,7 +144,8 @@ export function TransferConfirmView({ }) { const router = useRouter(); const { message } = App.useApp(); - const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode(); + const ccProfile = useCcCustomerProfileSelect({ mailId: mail.id }); + const [customerConfirmOpen, setCustomerConfirmOpen] = useState(false); const { version: submitVersion, setVersion: setSubmitVersion } = useSubmitVersion(mail.version); const header = asHeader(mail); @@ -230,7 +242,9 @@ export function TransferConfirmView({ confirmWorkOrderApi(mail.id, { version, instruction_id: unit.id, + cc_profile_id: ccProfile.profileId || undefined, entity, + baseline: toWorkOrderSubmittedSnapshot(woDraft), }), }); if (!res.ok) { @@ -273,6 +287,7 @@ export function TransferConfirmView({ confirmTransferApi(mail.id, { version, instruction_id: unit.id, + cc_profile_id: ccProfile.profileId || undefined, container_no: form.F_ContainerNo || header?.F_ContainerNo || @@ -312,8 +327,15 @@ export function TransferConfirmView({ + } > {unitImported ? ( @@ -353,13 +375,30 @@ export function TransferConfirmView({
+ setCustomerConfirmOpen(false)} + onOk={() => { + setCustomerConfirmOpen(false); + void onSubmit(); + }} + /> ); } @@ -373,7 +412,8 @@ export function DoUploadConfirmView({ }) { const router = useRouter(); const { message } = App.useApp(); - const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode(); + const ccProfile = useCcCustomerProfileSelect({ mailId: mail.id }); + const [customerConfirmOpen, setCustomerConfirmOpen] = useState(false); const { version: submitVersion, setVersion: setSubmitVersion } = useSubmitVersion(mail.version); const header = asHeader(mail); @@ -404,7 +444,24 @@ export function DoUploadConfirmView({ ); const [submitting, setSubmitting] = useState(false); + const importIssues = useMemo( + () => + collectDoImportIssues({ + containerNo: value.F_ContainerNo, + filenames: value.filenames, + }), + [value.F_ContainerNo, value.filenames], + ); + const finishDisabled = + !canSubmit || + !ccProfile.profileId || + importIssues.blockers.length > 0; + const onSubmit = async () => { + if (importIssues.blockers.length) { + message.error(importIssues.blockers[0]); + return; + } setSubmitting(true); const res = await withVersionConflictRetry({ version: submitVersion, @@ -413,6 +470,7 @@ export function DoUploadConfirmView({ confirmDoUploadApi(mail.id, { version, instruction_id: unit.id, + cc_profile_id: ccProfile.profileId || undefined, container_no: value.F_ContainerNo, container_task_id: value.F_ContainerTaskId || undefined, filenames: value.filenames, @@ -433,8 +491,15 @@ export function DoUploadConfirmView({ + } > {unitImported ? ( @@ -442,8 +507,19 @@ export function DoUploadConfirmView({ setValue((v) => ({ ...v, ...p }))} /> + {canSubmit && importIssues.blockers.length ? ( +
+ 无法确认上传 +
    + {importIssues.blockers.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) : null}
@@ -453,13 +529,41 @@ export function DoUploadConfirmView({
+ setCustomerConfirmOpen(false)} + onOk={() => { + setCustomerConfirmOpen(false); + void onSubmit(); + }} + />
); } @@ -473,35 +577,71 @@ export function WorkOrderConfirmView({ }) { const router = useRouter(); const { message } = App.useApp(); - const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode(); + const ccProfile = useCcCustomerProfileSelect({ mailId: mail.id }); + const [customerConfirmOpen, setCustomerConfirmOpen] = useState(false); const { version: submitVersion, setVersion: setSubmitVersion } = useSubmitVersion(mail.version); const header = asHeader(mail); const record = asMailRecord(mail); const shipments = mail.parse_result?.shipments ?? []; - const filenames = mail.attachments.map((a) => a.filename); const containerNo = header?.F_ContainerNo || mail.container_no || ""; - const unitImported = isInstructionImported( + const unitImportedLive = isInstructionImportedLive( + mail.parse_result?.lineage, + unit.id, + ); + const unitImportedMock = isInstructionImportedMock( + mail.parse_result?.lineage, + unit.id, + ); + const importState = getInstructionExecutionState( mail.parse_result?.lineage, unit.id, ); const canSubmit = - !unitImported && + !unitImportedLive && (mail.status === "PENDING_CONFIRM" || mail.status === "FAILED" || - mail.status === "PARTIAL_SUCCESS"); + mail.status === "PARTIAL_SUCCESS" || + (mail.status === "SUCCESS" && unitImportedMock)); - const [draft, setDraft] = useState(() => - buildCcWorkOrderFormValue({ - subject: unit.segmentSubject || mail.subject, - body: unit.segmentText || mail.body_text || "", + const filenamesKey = mail.attachments.map((a) => a.filename).join("?"); + const filenames = useMemo( + () => (filenamesKey ? filenamesKey.split("?") : []), + [filenamesKey], + ); + const mailBaseline = useMemo( + () => + buildCcWorkOrderFormValue({ + subject: unit.segmentSubject || mail.subject, + body: unit.segmentText || mail.body_text || "", + filenames, + containerNo, + actions: unit.keywords, + customerName: record?.modules.customer_name, + isCurrent: unit.isCurrent, + }), + [ + unit.segmentSubject, + unit.segmentText, + unit.keywords, + unit.isCurrent, + mail.subject, + mail.body_text, filenames, containerNo, - actions: unit.keywords, - customerName: record?.modules.customer_name, - isCurrent: true, - }), + record?.modules.customer_name, + ], ); + + const [draft, setDraft] = useState(() => { + if (importState?.submitted) { + return { + ...workOrderFormFromSubmitted(importState.submitted), + mailActions: unit.keywords, + }; + } + return mailBaseline; + }); const holdBundle = useMemo( () => buildCcHoldFormValue({ @@ -532,7 +672,7 @@ export function WorkOrderConfirmView({ const [submitting, setSubmitting] = useState(false); const panelKind = resolveWorkOrderPanelKind( - ccUiMode, + "client", Array.from(new Set([...(unit.keywords || []), ...(draft.mailActions || [])])), [unit.segmentSubject, unit.segmentText, unit.summary] .filter(Boolean) @@ -558,7 +698,9 @@ export function WorkOrderConfirmView({ confirmWorkOrderApi(mail.id, { version, instruction_id: unit.id, + cc_profile_id: ccProfile.profileId || undefined, entity, + baseline: toWorkOrderSubmittedSnapshot(mailBaseline), }), }); setSubmitting(false); @@ -567,7 +709,9 @@ export function WorkOrderConfirmView({ return; } message.success( - res.data.mode === "mock" ? "工单已提交(mock)" : "工单已提交", + res.data.mode === "mock" + ? "工单仅在模拟模式提交(未写入 CarrierCentral)。请将写入能力改为 live 后重新提交" + : "工单已提交到 CarrierCentral", ); afterConfirm(router, mail.id); }; @@ -576,11 +720,49 @@ export function WorkOrderConfirmView({ + } > - {unitImported ? ( - + {unitImportedLive ? ( + { + const lines = describeWorkOrderFieldMarks( + importState?.fieldMarks, + importState?.submitted, + ); + if (!lines.length) return undefined; + return ( +
+ {lines.map((line) => ( +
{line}
+ ))} +
+ ); + })() + } + /> + ) : null} + {unitImportedMock ? ( + ) : null} {panelKind === "hold" ? ( ) : ( setDraft((v) => ({ ...v, ...p }))} /> )} @@ -619,8 +806,14 @@ export function WorkOrderConfirmView({ @@ -631,6 +824,17 @@ export function WorkOrderConfirmView({ )}
+ setCustomerConfirmOpen(false)} + onOk={() => { + setCustomerConfirmOpen(false); + void onSubmit(); + }} + /> ); } diff --git a/src/constants/cabinet-types.ts b/src/constants/cabinet-types.ts new file mode 100644 index 0000000..10c7c35 --- /dev/null +++ b/src/constants/cabinet-types.ts @@ -0,0 +1,17 @@ +/** 对齐 ClientContainerTemplate.xlsx / V1.72 SaveContainer 柜型枚举 */ +export const CC_CABINET_TYPES = [ + "20GP", + "40GP", + "40HQ", + "40HR", + "40HC", + "45HQ", + "53HQ", +] as const; + +export type CcCabinetType = (typeof CC_CABINET_TYPES)[number]; + +export const CC_CABINET_TYPE_OPTIONS = CC_CABINET_TYPES.map((v) => ({ + value: v, + label: v, +})); diff --git a/src/constants/cc-api-codes.ts b/src/constants/cc-api-codes.ts new file mode 100644 index 0000000..665adf1 --- /dev/null +++ b/src/constants/cc-api-codes.ts @@ -0,0 +1,157 @@ +/** + * CarrierCentral 客户端接口 V1.73:状态枚举与统一返回码说明。 + * 操作员可见文案以本表为准;与旧 PDF 冲突时以 V1.73 为准。 + */ + +/** 统一业务返回码(各接口 code 字段) */ +export const CC_RESPONSE_CODE_LABEL: Record = { + 200: "成功", + 400: "业务失败", + 410: "没有登录信息", + 500: "服务异常", +}; + +/** GetContainerList 输出 F_Status(V1.73 纠正) */ +export const CC_CONTAINER_STATUS_LABEL: Record = { + 0: "新单", + 20: "清关结束", + 30: "提柜直达", + 40: "待提柜", + 60: "已到仓", + 65: "直送运输中", + 70: "已拆柜", + 80: "已还柜", +}; + +/** GetShipmentInfo 输出 Status(V1.73 增加 22-已装车) */ +export const CC_SHIPMENT_STATUS_LABEL: Record = { + 0: "预到达", + 10: "在库", + 15: "在库", + 16: "已排货", + 20: "已出仓", + 22: "已装车", + 26: "运输中", + 30: "已送达", + 90: "已取消", +}; + +/** GetStateTrack TypeCode */ +export const CC_STATE_TRACK_LABEL: Record = { + 0: "已到港", + 10: "已提柜", + 20: "已到仓", + 30: "已拆柜", + 40: "已还空", +}; + +/** 接口路径/名称 → 操作员可读接口名 */ +export const CC_API_DISPLAY_NAME: Record = { + customerLogin: "登录", + GetShippingLineList: "获取船司列表", + SaveContainer: "集装箱数据导入", + SaveTruckInfo: "卡派数据导入", + GetContainerList: "集装箱列表", + GetShipmentList: "集装箱货件列表", + GetStateTrack: "集装箱状态变更日志", + GetShipmentInfo: "集装箱货件信息", + GetDevanReport: "集装箱拆箱报告", + SaveFieldValue: "修改集装箱字段值", + upload: "上传文件", + DownPOD: "下载 POD/ClosePage", + DownDevanFile: "下载拆柜图片", + SaveStockBatchTransfer: "批量转仓", + SaveForm: "创建工单(客户指令)", +}; + +export type CcApiKey = keyof typeof CC_API_DISPLAY_NAME | string; + +export function ccApiDisplayName(api: string | null | undefined): string { + const key = (api || "").trim(); + if (!key) return "CarrierCentral 接口"; + if (CC_API_DISPLAY_NAME[key]) return CC_API_DISPLAY_NAME[key]; + const hit = Object.keys(CC_API_DISPLAY_NAME).find((k) => + key.toLowerCase().includes(k.toLowerCase()), + ); + return hit ? CC_API_DISPLAY_NAME[hit] : key; +} + +export function ccResponseCodeLabel(code: number | null | undefined): string { + if (code == null || !Number.isFinite(Number(code))) return ""; + const n = Number(code); + return CC_RESPONSE_CODE_LABEL[n] || `状态码 ${n}`; +} + +export function ccContainerStatusLabel( + status: number | string | null | undefined, +): string { + if (status == null || status === "") return ""; + const n = Number(status); + if (!Number.isFinite(n)) return String(status); + const label = CC_CONTAINER_STATUS_LABEL[n]; + return label ? `${n}-${label}` : `状态 ${n}`; +} + +export function ccShipmentStatusLabel( + status: number | string | null | undefined, +): string { + if (status == null || status === "") return ""; + const n = Number(status); + if (!Number.isFinite(n)) return String(status); + const label = CC_SHIPMENT_STATUS_LABEL[n]; + return label ? `${n}-${label}` : `货件状态 ${n}`; +} + +/** V1.73:柜已到仓及之后(不可批量转仓) */ +export function isContainerArrivedOrLater(status: number | null): boolean { + if (status == null) return false; + return status >= 60; +} + +/** + * 组装「哪个接口 + 返回码含义 + 原因」给操作员。 + * info 优先保留 CC 原文(中文业务说明)。 + */ +export function buildCcApiFailureMessage(input: { + api: string; + code?: number | null; + info?: string | null; + fallback?: string; +}): string { + const apiName = ccApiDisplayName(input.api); + const info = (input.info || "").trim(); + const code = input.code != null ? Number(input.code) : null; + const codeLabel = + code != null && Number.isFinite(code) ? ccResponseCodeLabel(code) : ""; + + if (code === 410) { + return info + ? `${apiName}失败(410 没有登录信息):${info}` + : `${apiName}失败:没有登录信息(410),请检查客户档案账号或重新登录`; + } + if (code === 401) { + return info + ? `${apiName}失败(登录/鉴权):${info}` + : `${apiName}失败:登录鉴权失败,请检查客户档案用户名和密码`; + } + if (code === 400) { + return info + ? `${apiName}被拒绝(400 业务失败):${info}` + : `${apiName}被拒绝(400 业务失败),请核对必填项后重试`; + } + if (code != null && code >= 500) { + return info + ? `${apiName}异常(${code} ${codeLabel || "服务异常"}):${info}` + : `${apiName}异常(${code}),请稍后重试`; + } + if (info) { + return code != null && Number.isFinite(code) + ? `${apiName}失败(${code}${codeLabel ? ` ${codeLabel}` : ""}):${info}` + : `${apiName}失败:${info}`; + } + if (input.fallback) return `${apiName}失败:${input.fallback}`; + if (code != null && Number.isFinite(code)) { + return `${apiName}失败(${code}${codeLabel ? ` ${codeLabel}` : ""})`; + } + return `${apiName}失败,CarrierCentral 未返回具体原因`; +} diff --git a/src/constants/cc-message-types.ts b/src/constants/cc-message-types.ts index 30e99b4..079e6af 100644 --- a/src/constants/cc-message-types.ts +++ b/src/constants/cc-message-types.ts @@ -1,34 +1,40 @@ /** - * CC 客户留言类型(CustomerMessageType 字典 F_ItemName)。 - * 邮件识别词(贴标/拦截/拍照/转仓/快递单号)≠ 留言类型。 + * CC 客户留言类型字典。 + * 默认种子仅用于首次空表初始化;运行时选项一律读库。 */ -export const CC_CUSTOMER_MESSAGE_TYPE_OPTIONS = [ - { value: "操作指令", label: "操作指令" }, - { value: "货件拦截", label: "货件拦截" }, - { value: "拆柜时自动转仓", label: "拆柜时自动转仓" }, - { value: "客户拆分指令", label: "客户拆分指令" }, - { value: "扣货", label: "扣货" }, - { value: "扣货问题", label: "扣货问题" }, - { value: "收费问题", label: "收费问题" }, - { value: "查验柜", label: "查验柜" }, - { value: "PO查验报错", label: "PO查验报错" }, - { value: "天灾", label: "天灾" }, - { value: "客户新增集装箱", label: "客户新增集装箱" }, +export const DEFAULT_CC_MESSAGE_TYPE_NAMES = [ + "操作指令", + "货件拦截", + "拆柜时自动转仓", + "客户拆分指令", + "扣货", + "扣货问题", + "收费问题", + "查验柜", + "PO查验报错", + "天灾", + "客户新增集装箱", ] as const; -/** 邮件动作关键词 → 最接近的 CC 留言类型;贴标/拍照走「操作指令」以便提交 */ -export function suggestCcMessageType(mailActions: string[]): string { - if (mailActions.includes("拦截")) return "货件拦截"; - if (mailActions.includes("转仓")) return "拆柜时自动转仓"; - if (mailActions.some((a) => a.includes("拆分") && !a.includes("拆柜"))) { - return "客户拆分指令"; - } - if ( +/** 邮件动作关键词 → 建议留言类型名(须存在于已启用字典) */ +export function suggestCcMessageType( + mailActions: string[], + enabledNames?: string[], +): string { + let suggested = ""; + if (mailActions.includes("拦截")) suggested = "货件拦截"; + else if (mailActions.includes("转仓")) suggested = "拆柜时自动转仓"; + else if (mailActions.some((a) => a.includes("拆分") && !a.includes("拆柜"))) { + suggested = "客户拆分指令"; + } else if ( mailActions.some((a) => /贴标|换标|拍照|快递单号|拆柜清单|操作指令/.test(a), ) ) { - return "操作指令"; + suggested = "操作指令"; } - return ""; + + if (!suggested) return ""; + if (!enabledNames || enabledNames.length === 0) return suggested; + return enabledNames.includes(suggested) ? suggested : ""; } diff --git a/src/constants/error-copy.ts b/src/constants/error-copy.ts index f4e5476..487117b 100644 --- a/src/constants/error-copy.ts +++ b/src/constants/error-copy.ts @@ -1,8 +1,15 @@ /** * 操作员可见报错。禁止把英文短句、机器码、CarrierCentral 原文直接展示。 * 内部仍可存机器码;展示与 API message 必须走这里。 + * CC 业务返回码 / 柜态 / 货件态说明对齐 V1.73。 */ +import { + buildCcApiFailureMessage, + ccContainerStatusLabel, + ccShipmentStatusLabel, +} from "@/constants/cc-api-codes"; + const CJK = /[\u4e00-\u9fff]/; const CJK_ALL = /[\u4e00-\u9fff]/g; @@ -45,6 +52,7 @@ const API_CODE_MESSAGE: Record = { NO_CONTAINER: "CarrierCentral 中还没有该柜,请先确认导入预报", NO_DO_FILE: "本地没有可上传的 DO 附件", CC_WRITE_OFF: "当前未开启向 CarrierCentral 写入,请到设置检查 CC 模式", + PARSE_FAILED: "文件解析失败,请确认上传的是有效的 Excel 装箱表(xlsx/xls)", }; const LAST_ERROR_EXACT: Record = { @@ -66,6 +74,12 @@ const LAST_ERROR_EXACT: Record = { AUTO_EXEC_DISABLED: "自动执行已关闭,需人工确认", AUTO_EXEC_FAILED: "自动执行失败,请打开邮件详情查看并手动确认", AUTO_EXEC_MOCK_OK: "自动执行已在模拟模式完成(未写入真实 CarrierCentral)", + WO_MOCK_OK: + "工单仅在模拟模式提交成功,未写入 CarrierCentral。请将「客户留言/工单」写入能力改为 live 后重新提交", + XFER_MOCK_OK: + "转仓仅在模拟模式提交成功,未写入 CarrierCentral。请将写入能力改为 live 后重新提交", + DO_MOCK_OK: + "上传 DO 仅在模拟模式完成,未写入 CarrierCentral。请将写入能力改为 live 后重新提交", DO_FAILED: "上传 DO 到 CarrierCentral 失败", XFER_FAILED: "批量转仓失败", WO_FAILED: "工单提交失败", @@ -88,27 +102,39 @@ const LAST_ERROR_EXACT: Record = { /** 英文 / 机器短语 → 操作员说明(按长词优先) */ const PHRASE_REPLACEMENTS: Array<[RegExp, string]> = [ - [/CC response missing external id/gi, "CarrierCentral 未返回柜任务编号,请到 CC 核对是否已创建"], - [/SaveContainer request failed/gi, "保存柜信息时无法连接 CarrierCentral"], - [/CC save failed with code\s*401/gi, "CarrierCentral 登录失败或账号无权限,请到「设置」检查用户名和密码"], - [/CC save failed with code\s*400/gi, "CarrierCentral 拒绝保存,请核对柜号、运输方式和货件必填项"], - [/\bBad Request\b/gi, "CarrierCentral 拒绝了请求,请核对填写内容"], - [/CC save failed with code\s*(\d+)/gi, "CarrierCentral 保存失败(状态 $1),请稍后重试或到 CC 核对"], - [/CC invalid JSON:[^\n]*/gi, "CarrierCentral 返回了无法解析的数据"], - [/CC upload invalid JSON:[^\n]*/gi, "CarrierCentral 上传接口返回异常"], - [/SaveFieldValue failed:\s*(\d+)/gi, "绑定 DO 文件夹失败(状态 $1)"], - [/SaveFieldValue failed/gi, "绑定 DO 文件夹失败"], - [/annexes upload failed:\s*(\d+)/gi, "上传 DO 附件失败(状态 $1)"], - [/annexes upload failed/gi, "上传 DO 附件失败"], - [/annexes upload error/gi, "上传 DO 附件时无法连接 CarrierCentral"], - [/annex list error/gi, "读取 DO 附件列表失败"], + [/GetContainerList timeout/gi, "集装箱列表查询超时,请稍后重试"], + [/GetContainerList failed:\s*410/gi, "集装箱列表查询失败:没有登录信息(410),请检查客户档案"], + [/GetContainerList failed:\s*400/gi, "集装箱列表查询被拒绝(400),请核对查询条件"], + [/GetContainerList failed:\s*(\d+)/gi, "集装箱列表查询失败(状态 $1)"], + [/GetContainerList failed/gi, "集装箱列表查询失败"], + [/GetShipmentInfo failed:\s*(\d+)/gi, "集装箱货件信息查询失败(状态 $1)"], + [/GetShipmentInfo failed/gi, "集装箱货件信息查询失败"], + [/GetShipmentList failed:\s*(\d+)/gi, "集装箱货件列表查询失败(状态 $1)"], + [/GetShipmentList failed/gi, "集装箱货件列表查询失败"], + [/GetStateTrack failed:\s*(\d+)/gi, "集装箱状态变更日志查询失败(状态 $1)"], + [/GetStateTrack failed/gi, "集装箱状态变更日志查询失败"], + [/SaveContainer request failed/gi, "集装箱数据导入时无法连接 CarrierCentral"], + [/CC save failed with code\s*410/gi, "集装箱数据导入失败:没有登录信息(410),请检查客户档案或重新登录"], + [/CC save failed with code\s*401/gi, "集装箱数据导入失败:登录失败或账号无权限,请到「设置」检查用户名和密码"], + [/CC save failed with code\s*400/gi, "集装箱数据导入被拒绝(400),请核对柜号、柜型、运输方式和货件必填项"], + [/CC save failed with code\s*(\d+)/gi, "集装箱数据导入失败(状态 $1),请稍后重试或到 CC 核对"], + [/SaveFieldValue failed:\s*410/gi, "修改集装箱字段失败:没有登录信息(410)"], + [/SaveFieldValue failed:\s*400/gi, "修改集装箱字段被拒绝(400),请核对字段名与取值"], + [/SaveFieldValue failed:\s*(\d+)/gi, "修改集装箱字段失败(状态 $1)"], + [/SaveFieldValue failed/gi, "修改集装箱字段失败"], + [/annexes upload failed:\s*410/gi, "上传文件失败:没有登录信息(410)"], + [/annexes upload failed:\s*400/gi, "上传文件被拒绝(400),请核对文件类型与柜任务"], + [/annexes upload failed:\s*(\d+)/gi, "上传文件失败(状态 $1)"], + [/annexes upload failed/gi, "上传文件失败"], + [/annexes upload error/gi, "上传文件时无法连接 CarrierCentral"], + [/annex list error/gi, "读取附件列表失败"], [/annex list invalid JSON/gi, "CarrierCentral 附件列表返回异常"], [/CC request timeout/gi, "连接 CarrierCentral 超时,请稍后重试"], [/CC request failed/gi, "无法连接 CarrierCentral,请检查网络或 CC 地址"], - [/GetContainerList timeout/gi, "查询柜号超时,请稍后重试"], - [/GetContainerList failed:\s*(\d+)/gi, "查询柜号失败(状态 $1)"], - [/GetContainerList failed/gi, "查询柜号失败"], - [/AUTHENTICATIONFAILED[:\s]*Invalid credentials/gi, "邮箱登录失败:账号或授权码不正确"], + [/CC invalid JSON:[^\n]*/gi, "CarrierCentral 返回了无法解析的数据"], + [/CC upload invalid JSON:[^\n]*/gi, "上传文件接口返回异常"], + [/\bBad Request\b/gi, "CarrierCentral 拒绝了请求(400 业务失败),请核对填写内容"], + [/CC response missing external id/gi, "CarrierCentral 未返回柜任务编号,请到 CC 核对是否已创建"], [/AUTHENTICATIONFAILED[^\n]*/gi, "邮箱登录失败,请检查账号或授权码"], [/Invalid credentials/gi, "账号或密码不正确"], [/Invalid username or password/gi, "用户名或密码不正确"], @@ -199,11 +225,11 @@ function looksLikeMachineCode(s: string): boolean { function leftoverLatin(s: string): boolean { if (!s) return false; const allowed = - /CarrierCentral|OAuth|JSON|PDF|\bDO\b|\bCC\b|ISO|IMAP|FBA|xlsx|xls|csv|GUID/gi; + /CarrierCentral|OAuth|JSON|PDF|Excel|\bDO\b|\bCC\b|ISO|IMAP|FBA|Move\s*Type|xlsx|xls|csv|GUID/gi; const rest = s .replace(CJK_ALL, "") .replace(allowed, "") - .replace(/[0-9\s.,::()「」《》\-_/]/g, ""); + .replace(/[0-9\s.,::()「」《》\[\]\-_/]/g, ""); return /[A-Za-z]{3,}/.test(rest); } @@ -249,15 +275,15 @@ export function formatOperatorError( const rewritten = applyPhraseReplacements(s); if (LAST_ERROR_EXACT[rewritten]) return LAST_ERROR_EXACT[rewritten]; + // 已有可读中文:保留给操作员(可夹带 xlsx / Excel 等术语),勿盖成万能句 if (hasCjk(rewritten)) { - if (leftoverLatin(rewritten)) { - const again = applyPhraseReplacements(rewritten); - if (hasCjk(again) && !leftoverLatin(again)) return again; - } if (!leftoverLatin(rewritten)) return rewritten; + const again = applyPhraseReplacements(rewritten); + if (hasCjk(again) && !leftoverLatin(again)) return again; + return again; } - if (apiCode && API_CODE_MESSAGE[apiCode] && leftoverLatin(rewritten)) { + if (apiCode && API_CODE_MESSAGE[apiCode]) { return API_CODE_MESSAGE[apiCode]; } @@ -273,20 +299,212 @@ export function formatMailLastError(code: string | null | undefined): string { return formatOperatorError(code); } +export type CcFailureKind = + | "login" + | "rate_limit" + | "timeout" + | "network" + | "validation" + | "unknown"; + +export const CC_FAILURE_KIND_LABEL: Record = { + login: "登录失败", + rate_limit: "访问限流", + timeout: "连接超时", + network: "网络异常", + validation: "业务校验失败", + unknown: "未知原因", +}; + +export function ccFailureKindLabel(kind: CcFailureKind | string | undefined): string { + if (!kind) return CC_FAILURE_KIND_LABEL.unknown; + return CC_FAILURE_KIND_LABEL[kind as CcFailureKind] || kind; +} + +/** 操作员弹窗:归类 + 主文案 + 可展示的 CC 原文 */ +export function describeCcFailureForOperator(raw?: string | null): { + kind: CcFailureKind; + kindLabel: string; + message: string; + detail: string | null; +} { + const detail = (raw || "").trim() || null; + const kind = classifyCcFailure(detail); + const kindLabel = ccFailureKindLabel(kind); + let message: string; + switch (kind) { + case "login": + case "rate_limit": + case "timeout": + case "network": + message = conflictCheckOperatorMessage(kind, detail); + break; + case "validation": + if (detail && /柜型/.test(detail)) { + message = "请选择柜型后再导入(如 40HQ / 40GP)"; + } else { + message = detail + ? formatCcCause(detail) + : "CarrierCentral 拒绝了本次保存,请核对必填项"; + } + break; + default: + message = detail ? formatCcCause(detail) : "CarrierCentral 处理失败,请稍后重试"; + } + return { + kind, + kindLabel, + message, + detail: detail && detail !== message ? detail : null, + }; +} + +/** 归类 CC 列表/写入失败,供冲突弹窗与日志使用 */ +export function classifyCcFailure(raw: string | null | undefined): CcFailureKind { + const s = (raw || "").trim(); + if (!s) return "unknown"; + const lower = s.toLowerCase(); + if ( + /cc_login_failed|cc_credentials_missing|unauthorized|\b401\b|\b410\b|invalid credentials|账户或密码|登录失败|鉴权|密码错误|没有登录信息/.test( + lower, + ) || + /账户或密码|登录失败|鉴权|密码错误|没有登录信息/.test(s) + ) { + return "login"; + } + if ( + /访问时长限制|访问时间间隔|访问次数|时间间隔\s*\[\d+\]|过于频繁|达上限|rate.?limit/.test( + s, + ) + ) { + return "rate_limit"; + } + if (/timeout|超时|etimedout|abort/.test(lower)) return "timeout"; + if ( + /econnreset|econnrefused|enotfound|无法连接|cc request failed|fetch failed/.test( + lower, + ) + ) { + return "network"; + } + if ( + /code\s*400|拒绝保存|validation|必填|格式不正确|格式错误|invalid|未填写|不能保存/.test( + lower, + ) || + /必填|格式不正确|格式错误|未填写|不能保存/.test(s) + ) { + return "validation"; + } + return "unknown"; +} + +/** 冲突检测(GetContainerList)失败时对操作员的主文案 */ +export function conflictCheckOperatorMessage( + kind: CcFailureKind, + raw?: string | null, +): string { + const detail = raw ? formatCcCause(raw) : ""; + switch (kind) { + case "login": + return detail.includes("账号") || detail.includes("密码") + ? detail + : "CarrierCentral 登录失败,无法校验柜号是否已存在。请检查当前客户档案的账号和密码。"; + case "rate_limit": + return "集装箱列表接口访问过于频繁(V1.73:约每分钟 1 次),暂时无法校验柜号是否已存在。请等待约 1 分钟后重试。"; + case "timeout": + return detail + ? `连接 CarrierCentral 超时,无法完成柜号查重:${detail}` + : "连接 CarrierCentral 超时,无法完成柜号查重。请检查网络后重试。"; + case "network": + return detail + ? `无法连接 CarrierCentral,柜号查重未完成:${detail}` + : "无法连接 CarrierCentral,柜号查重未完成。请检查接口地址与网络。"; + case "validation": + return detail || "CarrierCentral 拒绝了查柜请求,请核对客户档案配置。"; + default: + return ( + detail || + "无法校验柜号是否已在 CarrierCentral 存在,请稍后重试或联系管理员。" + ); + } +} + /** CarrierCentral 失败原因(不含「操作失败」万能句,便于拼到具体动作前) */ export function formatCcCause(raw: string | null | undefined): string { const s = (raw || "").trim(); if (!s) return "CarrierCentral 未返回具体原因,请稍后重试或到 CC 核对"; + if (s === "CC_CREDENTIALS_MISSING") { + return "未配置 CarrierCentral 账号或密码,请到客户档案补全后重试"; + } + if (s.startsWith("CC_LOGIN_FAILED:")) { + const rest = s.slice("CC_LOGIN_FAILED:".length).trim(); + if (/401|unauthorized|410|没有登录/i.test(rest)) { + return "CarrierCentral 账号或密码错误,或登录已失效(410),请检查当前客户档案"; + } + return formatCcCause(rest); + } + // 结构化:API|code|info —— 各接口写入失败时统一格式 + const structured = s.match(/^CC_API:([^|]+)\|code=([^|]*)\|([\s\S]*)$/); + if (structured) { + const api = structured[1].trim(); + const codeRaw = structured[2].trim(); + const info = structured[3].trim(); + const code = codeRaw === "" ? null : Number(codeRaw); + return polishCcChinese( + buildCcApiFailureMessage({ + api, + code: Number.isFinite(code as number) ? (code as number) : null, + info, + }), + ); + } if (LAST_ERROR_EXACT[s]) return LAST_ERROR_EXACT[s]; const rewritten = polishCcChinese(applyPhraseReplacements(s)); if (LAST_ERROR_EXACT[rewritten]) return LAST_ERROR_EXACT[rewritten]; - // V1.72:失败看 code + info。info 常是中文并夹着 FBACode 等字段名,必须原文给操作员。 + // V1.73:失败看 code + info。info 常是中文并夹着 FBACode 等字段名,必须原文给操作员。 if (hasCjk(rewritten)) return rewritten; if (looksLikeMachineCode(s) && LAST_ERROR_EXACT[s]) return LAST_ERROR_EXACT[s]; - if (leftoverLatin(rewritten) || leftoverLatin(s)) { + if (rewritten && rewritten !== s && !leftoverLatin(rewritten)) { + return rewritten; + } + if (rewritten && rewritten.length >= 8 && classifyCcFailure(rewritten) !== "unknown") { + return rewritten; + } + if ( + (leftoverLatin(rewritten) || leftoverLatin(s)) && + classifyCcFailure(s) === "unknown" + ) { return "CarrierCentral 处理失败,请稍后重试或到 CC 核对该柜状态"; } - return "CarrierCentral 处理失败,请稍后重试或到 CC 核对该柜状态"; + return rewritten || "CarrierCentral 处理失败,请稍后重试或到 CC 核对该柜状态"; +} + +/** 按接口名 + 返回码 + info 生成操作员可读原因(写入/查询统一入口) */ +export function formatCcApiFailure(input: { + api: string; + code?: number | null; + info?: string | null; + fallback?: string; +}): string { + return formatCcCause( + `CC_API:${input.api}|code=${input.code ?? ""}|${input.info || input.fallback || ""}`, + ); +} + +export function formatCcContainerStatusReason( + status: number | string | null | undefined, + action: string, +): string { + const label = ccContainerStatusLabel(status); + if (!label) return `当前柜状态不允许${action}`; + return `当前柜状态为「${label}」,不允许${action}`; +} + +export function formatCcShipmentStatusHint( + status: number | string | null | undefined, +): string { + const label = ccShipmentStatusLabel(status); + return label ? `货件状态:${label}` : ""; } function polishCcChinese(s: string): string { @@ -299,6 +517,16 @@ function polishCcChinese(s: string): string { .replace(/\b(?:F_)?CabinetType\b/gi, "柜型") .replace(/\b(?:F_)?BLCopyCode\b/gi, "提单号") .replace(/\b(?:F_)?CTNS\b/gi, "件数") + .replace(/\b(?:F_)?Email\b/gi, "邮箱") + .replace(/\b(?:F_)?Mobile\b/gi, "手机号") + .replace(/\b(?:F_)?Pcs\b/gi, "PCS") + .replace(/\b(?:F_)?Weight\b/gi, "毛重") + .replace(/\b(?:F_)?CBM\b/gi, "体积") + .replace(/\b(?:F_)?Status\b/gi, "状态") + .replace(/\bGetContainerList\b/gi, "集装箱列表") + .replace(/\bSaveContainer\b/gi, "集装箱数据导入") + .replace(/\bGetShipmentInfo\b/gi, "集装箱货件信息") + .replace(/\bSaveFieldValue\b/gi, "修改集装箱字段") .replace(/\s{2,}/g, " ") .trim(); } diff --git a/src/constants/ui-copy.ts b/src/constants/ui-copy.ts index 6452aba..f2f36ba 100644 --- a/src/constants/ui-copy.ts +++ b/src/constants/ui-copy.ts @@ -28,7 +28,7 @@ export const UI_COPY = { navMails: "邮件列表", navLogs: "日志", navSettings: "设置", - navCustomers: "客户账号", + navCustomers: "租户账号", emptyMails: "暂无邮件,等待 IMAP 拉取", emptyImports: "暂无导入记录", btnConfirmImport: "确认导入", diff --git a/src/lib/cc-owner.ts b/src/lib/cc-owner.ts index f5b4559..5084a77 100644 --- a/src/lib/cc-owner.ts +++ b/src/lib/cc-owner.ts @@ -1,26 +1,51 @@ import { AsyncLocalStorage } from "async_hooks"; import { prisma } from "@/services/db"; -const als = new AsyncLocalStorage<{ ownerUserId: bigint | null }>(); +type CcOwnerStore = { + ownerUserId: bigint | null; + /** 选中的 CC 客户档案;优先于 ownerUserId 对应的 CcSettings */ + profileId?: bigint | null; +}; + +const als = new AsyncLocalStorage(); export function getCcOwnerUserId(): bigint | null | undefined { return als.getStore()?.ownerUserId; } +export function getCcProfileId(): bigint | null | undefined { + const store = als.getStore(); + if (!store) return undefined; + return store.profileId ?? null; +} + export function runWithCcOwner( ownerUserId: bigint | null, fn: () => T, ): T { - return als.run({ ownerUserId }, fn); + return als.run({ ownerUserId, profileId: null }, fn); +} + +export function runWithCcProfile( + profileId: bigint, + ownerUserId: bigint | null, + fn: () => T, +): T { + return als.run({ ownerUserId, profileId }, fn); } export async function runWithMailCcOwner( mailId: bigint, fn: () => Promise, + opts?: { profileId?: bigint | null }, ): Promise { const mail = await prisma.mailMessage.findUnique({ where: { id: mailId }, select: { mailboxAccount: { select: { ownerUserId: true } } }, }); - return runWithCcOwner(mail?.mailboxAccount?.ownerUserId ?? null, fn); + const ownerUserId = mail?.mailboxAccount?.ownerUserId ?? null; + if (opts?.profileId != null) { + return runWithCcProfile(opts.profileId, ownerUserId, fn); + } + return runWithCcOwner(ownerUserId, fn); } diff --git a/src/lib/client-api.ts b/src/lib/client-api.ts index 8414924..f705d09 100644 --- a/src/lib/client-api.ts +++ b/src/lib/client-api.ts @@ -1,5 +1,7 @@ import type { ApiResponse, + CcCustomerProfile, + CcMessageTypeDictItem, CcSettings, ContainerImport, HealthStatus, @@ -245,11 +247,37 @@ export async function importMailApi( }); } +/** 确认页上传卡派清单等表格,解析为货件行(不写库) */ +export async function parseForecastPackingApi( + mailId: string, + file: File, + opts?: { containerNo?: string }, +): Promise< + ApiResponse<{ + shipments: import("@/types/mail").ShipmentRow[]; + container_no: string | null; + row_count: number; + valid_count: number; + file_name: string; + }> +> { + const form = new FormData(); + form.append("file", file); + if (opts?.containerNo) form.append("container_no", opts.containerNo); + const res = await fetch(`/api/mails/${mailId}/forecast/parse-packing`, { + method: "POST", + body: form, + credentials: "include", + }); + return parseJson(res); +} + export async function confirmWorkOrderApi( id: string, payload: { version: number; instruction_id?: string; + cc_profile_id?: string; entity: { F_ReferCode?: string; F_MessageType: string; @@ -260,6 +288,16 @@ export async function confirmWorkOrderApi( F_CustomerName?: string; F_UrgencyLevel?: string; }; + baseline?: { + F_ReferCode?: string; + F_MessageType?: string; + F_RefNo?: string; + F_MessageContent?: string; + F_Remark?: string; + F_AttachFile?: string; + F_CustomerName?: string; + F_UrgencyLevel?: string; + }; }, ): Promise> { return request(`/api/mails/${id}/work-order/confirm`, { @@ -276,6 +314,7 @@ export async function confirmDoUploadApi( container_no: string; container_task_id?: string; filenames?: string[]; + cc_profile_id?: string; }, ): Promise< ApiResponse<{ @@ -296,6 +335,7 @@ export async function confirmTransferApi( version: number; instruction_id?: string; container_no: string; + cc_profile_id?: string; payload: { keyValue: string; strDetialList: Array<{ @@ -343,16 +383,36 @@ export async function listImportsApi(params?: { mail_id?: string; q?: string; page?: number; -}): Promise> { + tenant_id?: string; +}): Promise< + ApiResponse<{ + items: ContainerImport[]; + total: number; + tenants?: Array<{ + id: string; + username: string; + display_name: string; + enabled: boolean; + }>; + }> +> { const qs = new URLSearchParams(); if (params?.status) qs.set("status", params.status); if (params?.mail_id) qs.set("mail_id", params.mail_id); if (params?.q) qs.set("q", params.q); if (params?.page) qs.set("page", String(params.page)); + if (params?.tenant_id) qs.set("tenant_id", params.tenant_id); const suffix = qs.toString() ? `?${qs.toString()}` : ""; - return request<{ items: ContainerImport[]; total: number }>( - `/api/imports${suffix}`, - ); + return request<{ + items: ContainerImport[]; + total: number; + tenants?: Array<{ + id: string; + username: string; + display_name: string; + enabled: boolean; + }>; + }>(`/api/imports${suffix}`); } export async function retryImportApi( @@ -380,11 +440,14 @@ export async function retryCompensationApi( }); } -export async function listShippingLinesApi(): Promise< - ApiResponse<{ items: ShippingLine[] }> -> { +export async function listShippingLinesApi(opts?: { + /** 跳过客户端缓存并请求服务端刷新 CC 船司列表 */ + refresh?: boolean; +}): Promise> { + if (opts?.refresh) shippingLinesMemo.current = null; + const qs = opts?.refresh ? "?refresh=1" : ""; return memoGet(shippingLinesMemo, 10 * 60 * 1000, () => - request<{ items: ShippingLine[] }>("/api/shipping-lines"), + request<{ items: ShippingLine[] }>(`/api/shipping-lines${qs}`), ); } @@ -454,6 +517,40 @@ export async function deleteImapFilterApi( return request(`/api/settings/imap/filters/${id}`, { method: "DELETE" }); } +export async function listCcMessageTypesApi(params?: { + enabled_only?: boolean; +}): Promise> { + const qs = params?.enabled_only ? "?enabled_only=1" : ""; + return request(`/api/settings/cc-message-types${qs}`); +} + +export async function createCcMessageTypeApi(body: { + name: string; + enabled?: boolean; + sort_order?: number; +}): Promise> { + return request("/api/settings/cc-message-types", { + method: "POST", + body: JSON.stringify(body), + }); +} + +export async function updateCcMessageTypeApi( + id: string, + body: { name?: string; enabled?: boolean; sort_order?: number }, +): Promise> { + return request(`/api/settings/cc-message-types/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }); +} + +export async function deleteCcMessageTypeApi( + id: string, +): Promise> { + return request(`/api/settings/cc-message-types/${id}`, { method: "DELETE" }); +} + export async function listImapPullLogsApi(params?: { page?: number; pageSize?: number; @@ -584,6 +681,70 @@ export async function testCcSettingsApi(body?: { }); } +export async function listCcCustomerProfilesApi(opts?: { + selectable?: boolean; + mailId?: string; +}): Promise> { + const q = new URLSearchParams(); + if (opts?.selectable) q.set("selectable", "1"); + if (opts?.mailId) q.set("mail_id", opts.mailId); + const qs = q.toString(); + return request(`/api/settings/cc-profiles${qs ? `?${qs}` : ""}`); +} + +export async function createCcCustomerProfileApi(body: { + name: string; + mock?: boolean; + api_base: string; + saas_header: string; + login_mark: string; + username?: string; + password?: string; + enabled?: boolean; + sort_order?: number; +}): Promise> { + return request("/api/settings/cc-profiles", { + method: "POST", + body: JSON.stringify(body), + }); +} + +export async function updateCcCustomerProfileApi( + id: string, + body: { + name?: string; + mock?: boolean; + api_base?: string; + saas_header?: string; + login_mark?: string; + username?: string; + password?: string; + enabled?: boolean; + sort_order?: number; + }, +): Promise> { + return request(`/api/settings/cc-profiles/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }); +} + +export async function deleteCcCustomerProfileApi( + id: string, +): Promise> { + return request(`/api/settings/cc-profiles/${id}`, { method: "DELETE" }); +} + +export async function testCcCustomerProfileApi( + id: string, + body?: { password?: string }, +): Promise> { + return request(`/api/settings/cc-profiles/${id}/test`, { + method: "POST", + body: JSON.stringify(body ?? {}), + }); +} + export async function getOAuthSettingsApi(): Promise> { return request("/api/settings/oauth"); } @@ -699,6 +860,8 @@ export type CustomerAccount = { display_name: string; enabled: boolean; mailbox_count?: number; + /** 租户自建的 CC 客户档案数 */ + cc_profile_count?: number; cc_username?: string; cc_mock?: boolean; created_at?: string; diff --git a/src/services/auth/customers.ts b/src/services/auth/customers.ts index 4abb9e6..e34e9bf 100644 --- a/src/services/auth/customers.ts +++ b/src/services/auth/customers.ts @@ -54,7 +54,9 @@ export async function listCustomers() { where: { role: "customer" }, orderBy: { id: "asc" }, include: { - _count: { select: { mailboxes: true } }, + _count: { + select: { mailboxes: true, ccCustomerProfiles: true }, + }, ccSettings: { select: { username: true, mock: true, updatedAt: true } }, }, }); @@ -65,6 +67,7 @@ export async function listCustomers() { display_name: r.displayName, enabled: r.enabled, mailbox_count: r._count.mailboxes, + cc_profile_count: r._count.ccCustomerProfiles, cc_username: r.ccSettings?.username || "", cc_mock: r.ccSettings?.mock ?? true, created_at: r.createdAt.toISOString(), @@ -115,7 +118,7 @@ export async function updateCustomer( where: { id, role: "customer" }, }); if (!row) { - throw new CustomerAccountError("NOT_FOUND", "客户不存在", 404); + throw new CustomerAccountError("NOT_FOUND", "租户不存在", 404); } const data: { passwordHash?: string; diff --git a/src/services/cc/auth.ts b/src/services/cc/auth.ts index da59e45..8a4e956 100644 --- a/src/services/cc/auth.ts +++ b/src/services/cc/auth.ts @@ -41,6 +41,7 @@ export function extractLoginToken(data: unknown): string { type MemoryToken = { token: string; loginMark: string; + username: string; expireAt: Date; }; @@ -49,14 +50,23 @@ export const CC_TOKEN_MEMORY_TRUST_MS = 30_000; let memoryToken: MemoryToken | null = null; let mockToken: MemoryToken | null = null; +/** 与 ccTokenCache 配套:多客户档案可能共用 loginMark,须按 username 隔离 token */ +let cachedLoginUsername = ""; export function shouldReuseCcMemoryToken( - memory: { loginMark: string; expireAt: Date } | null, + memory: { loginMark: string; username: string; expireAt: Date } | null, loginMark: string, + username: string, nowMs: number, minRemainingMs = CC_TOKEN_MEMORY_TRUST_MS, ): boolean { - if (!memory || memory.loginMark !== loginMark) return false; + if ( + !memory || + memory.loginMark !== loginMark || + memory.username !== username + ) { + return false; + } return memory.expireAt.getTime() - nowMs > minRemainingMs; } @@ -72,20 +82,35 @@ export class CcAuth { } const loginMark = rt.loginMark; + const username = rt.username.trim(); const now = new Date(); + if (cachedLoginUsername && cachedLoginUsername !== username) { + memoryToken = null; + await prisma.ccTokenCache + .deleteMany({ where: { loginMark } }) + .catch(() => undefined); + } + // 内存未近过期则跳过 DB;临近过期仍以 DB expireAt 为准(韧性 §7.1) - if (shouldReuseCcMemoryToken(memoryToken, loginMark, now.getTime())) { + if ( + shouldReuseCcMemoryToken(memoryToken, loginMark, username, now.getTime()) + ) { return { token: memoryToken!.token, loginMark }; } const cached = await prisma.ccTokenCache.findUnique({ where: { loginMark }, }); - if (cached && cached.expireAt > now) { + if ( + cached && + cached.expireAt > now && + cachedLoginUsername === username + ) { memoryToken = { token: cached.token, loginMark: cached.loginMark, + username, expireAt: cached.expireAt, }; return { token: cached.token, loginMark }; @@ -95,12 +120,13 @@ export class CcAuth { memoryToken = null; } - return this.loginWithLock(); + return this.loginWithLock(username); } async invalidateToken(): Promise { memoryToken = null; mockToken = null; + cachedLoginUsername = ""; if (isTestMode()) return; const rt = await resolveCcRuntime(); if (rt.mock) return; @@ -119,12 +145,13 @@ export class CcAuth { mockToken = { token: `mock-cc-token-${Date.now()}`, loginMark, + username: "mock", expireAt: new Date(now.getTime() + TOKEN_TTL_MS), }; return { token: mockToken.token, loginMark }; } - private async loginWithLock(): Promise<{ token: string; loginMark: string }> { + private async loginWithLock(username: string): Promise<{ token: string; loginMark: string }> { const rt = await resolveCcRuntime(); const loginMark = rt.loginMark; @@ -133,10 +160,15 @@ export class CcAuth { const cached = await prisma.ccTokenCache.findUnique({ where: { loginMark }, }); - if (cached && cached.expireAt > now) { + if ( + cached && + cached.expireAt > now && + cachedLoginUsername === username + ) { memoryToken = { token: cached.token, loginMark: cached.loginMark, + username, expireAt: cached.expireAt, }; return { token: cached.token, loginMark }; @@ -151,7 +183,8 @@ export class CcAuth { update: { token, expireAt }, }); - memoryToken = { token, loginMark, expireAt }; + memoryToken = { token, loginMark, username, expireAt }; + cachedLoginUsername = username; return { token, loginMark }; }); diff --git a/src/services/cc/batch-transfer.ts b/src/services/cc/batch-transfer.ts index 06bd869..23fe103 100644 --- a/src/services/cc/batch-transfer.ts +++ b/src/services/cc/batch-transfer.ts @@ -6,7 +6,7 @@ import { ccAuth } from "@/services/cc/auth"; import { CcHttpClient } from "@/services/cc/http"; import { normalizeCcCode } from "@/services/cc/code"; import { getCcWriteCapability, resolveWriteMode } from "@/services/cc/write-capability"; -import { formatCcCause } from "@/constants/error-copy"; +import { formatCcApiFailure, formatCcCause } from "@/constants/error-copy"; const ccHttp = new CcHttpClient(ccAuth); @@ -73,13 +73,16 @@ export async function saveStockBatchTransfer( response: res, error: ok ? undefined - : formatCcCause( - String( + : formatCcApiFailure({ + api: "SaveStockBatchTransfer", + code, + info: String( (res as { info?: string; message?: string }).info || (res as { message?: string }).message || - "CC_XFER_FAILED", + "", ), - ), + fallback: "CC_XFER_FAILED", + }), }; } catch (err) { return { diff --git a/src/services/cc/conflict.ts b/src/services/cc/conflict.ts index a8cf640..5c0c70a 100644 --- a/src/services/cc/conflict.ts +++ b/src/services/cc/conflict.ts @@ -1,4 +1,5 @@ import { getEnv } from "@/lib/env"; +import { getCcOwnerUserId, getCcProfileId } from "@/lib/cc-owner"; import { logger } from "@/lib/logger"; import { prisma } from "@/services/db"; import { ccAuth } from "@/services/cc/auth"; @@ -10,12 +11,18 @@ import { } from "@/services/cc/mock-state"; import { isCcMock } from "@/services/cc/settings-config"; import { toShanghaiDateString } from "@/utils/dates"; -import { formatCcCause } from "@/constants/error-copy"; +import { + classifyCcFailure, + conflictCheckOperatorMessage, + formatCcApiFailure, + formatCcCause, +} from "@/constants/error-copy"; +import { runWithListHttpGate } from "@/services/cc/list-http-gate"; const LIST_PATH = "/Container/GetContainerList"; const ccHttp = new CcHttpClient(ccAuth); -/** V1.72:GetContainerList 1 分钟 1 次;成功结果缓存,避免转仓闸门把限流当成失败 */ +/** V1.73:GetContainerList 1 分钟 1 次;成功结果缓存,避免转仓闸门把限流当成失败 */ const LIST_CACHE_TTL_MS = 70_000; const listCache = new Map< string, @@ -26,8 +33,20 @@ const listInflight = new Map< Promise<{ ok: true; items: CcContainerRow[] } | { ok: false; error: string }> >(); +function listProfileKey(): string { + const profileId = getCcProfileId(); + if (profileId != null) return `profile:${profileId}`; + const ownerUserId = getCcOwnerUserId(); + if (ownerUserId != null) return `owner:${ownerUserId}`; + return "global"; +} + +function listCacheKey(profileKey: string, containerNo: string): string { + return `${profileKey}:${containerNo}`; +} + export function isCcRateLimitedMessage(msg: string): boolean { - return /访问时长限制|访问时间间隔|时间间隔\s*\[\d+\]\s*秒/.test(msg || ""); + return classifyCcFailure(msg) === "rate_limit"; } export type ConflictCheckResult = @@ -74,6 +93,8 @@ export async function getContainerListFromCc( containerNo: string, ): Promise<{ ok: true; items: CcContainerRow[] } | { ok: false; error: string }> { const normalized = containerNo.toUpperCase(); + const profileKey = listProfileKey(); + const cacheKey = listCacheKey(profileKey, normalized); if (await isCcMock()) { if (mockContainerExists(normalized)) { @@ -85,12 +106,12 @@ export async function getContainerListFromCc( return { ok: true, items: [] }; } - const cached = listCache.get(normalized); + const cached = listCache.get(cacheKey); if (cached && Date.now() - cached.at < LIST_CACHE_TTL_MS) { return cached.result; } - const pending = listInflight.get(normalized); + const pending = listInflight.get(cacheKey); if (pending) return pending; const { startTime, endTime } = listDateRange(); @@ -120,20 +141,27 @@ export async function getContainerListFromCc( > => { try { const env = getEnv(); - const response = await ccHttp.request(LIST_PATH, queryPayload, { - timeoutMs: env.CC_READ_TIMEOUT_MS, - }); + const response = await runWithListHttpGate(profileKey, () => + ccHttp.request(LIST_PATH, queryPayload, { + timeoutMs: env.CC_READ_TIMEOUT_MS, + }), + ); const code = normalizeCcCode(response.code); if (code !== 200) { - const error = response.info || `GetContainerList failed: ${code}`; - if (isCcRateLimitedMessage(error) && cached?.result.ok) { + const error = formatCcApiFailure({ + api: "GetContainerList", + code, + info: response.info, + fallback: `GetContainerList failed: ${code}`, + }); + if (isCcRateLimitedMessage(String(response.info || error)) && cached?.result.ok) { return cached.result; } - return { ok: false, error: formatCcCause(error) }; + return { ok: false, error }; } const items = parseContainerListRows(response.data); const okResult = { ok: true as const, items }; - listCache.set(normalized, { at: Date.now(), result: okResult }); + listCache.set(cacheKey, { at: Date.now(), result: okResult }); return okResult; } catch (err) { const msg = @@ -151,9 +179,9 @@ export async function getContainerListFromCc( }; const promise = run().finally(() => { - listInflight.delete(normalized); + listInflight.delete(cacheKey); }); - listInflight.set(normalized, promise); + listInflight.set(cacheKey, promise); return promise; } @@ -255,11 +283,12 @@ export async function checkContainerConflict( if (env.ENABLE_FORCE_IMPORT && input.forceSkipConflict) { return { ok: true }; } + const kind = classifyCcFailure(listResult.error); return { ok: false, code: "CONFLICT_CHECK_FAILED", - message: "无法校验柜号是否已存在,请稍后重试", - details: { error: listResult.error }, + message: conflictCheckOperatorMessage(kind, listResult.error), + details: { kind, error: listResult.error }, }; } diff --git a/src/services/cc/container-status.ts b/src/services/cc/container-status.ts index a484ed9..710ff82 100644 --- a/src/services/cc/container-status.ts +++ b/src/services/cc/container-status.ts @@ -1,11 +1,18 @@ /** - * 到仓闸门:决定转仓邮件走批量转仓还是降级工单 + * 到仓闸门:决定转仓邮件走批量转仓还是降级工单。 + * 柜态以 V1.73 GetContainerList.F_Status 为准: + * 0-新单,20-清关结束,30-提柜直达,40-待提柜,60-已到仓,65-直送运输中,70-已拆柜,80-已还柜 */ import { getContainerListFromCc, isCcRateLimitedMessage, } from "@/services/cc/conflict"; import { isCcMock } from "@/services/cc/settings-config"; +import { + ccContainerStatusLabel, + isContainerArrivedOrLater, +} from "@/constants/cc-api-codes"; +import { formatCcContainerStatusReason } from "@/constants/error-copy"; export type TransferGateDecision = | { @@ -28,6 +35,9 @@ type CcRow = { F_ContainerNo?: string; F_Id?: string; F_ContainerTaskId?: string; + /** V1.73 柜头状态 */ + F_Status?: number | string; + /** 兼容旧字段;优先使用 F_Status */ F_ShipStatus?: number | string; F_ToWhDate?: string | null; F_OperationType?: number | string; @@ -42,8 +52,8 @@ function toInt(v: unknown): number | null { } /** - * 未到仓(F_ShipStatus < 10 且无到仓日)且非直送/未拆柜中 → 可批量转仓 - * 已到仓或不可转 → 降级工单 + * 未到仓(F_Status < 60 且无到仓日)且非直送/未拆柜中 → 可批量转仓 + * 已到仓(60)及之后 → 降级工单 * 查询失败 → 默认降级工单 */ export async function evaluateTransferGate( @@ -60,19 +70,19 @@ export async function evaluateTransferGate( } if (await isCcMock()) { - // mock:柜号含 ARRIVED 模拟已到仓,否则可转 + // mock:柜号含 ARRIVED 模拟已到仓(60),否则可转 if (/ARRIVED|TOWH/i.test(cn)) { return { canTransfer: false, - reason: "mock:已到仓,转仓须下工单", - shipStatus: 10, + reason: `mock:${formatCcContainerStatusReason(60, "批量转仓")},须下工单`, + shipStatus: 60, fallback: "work_order", containerTaskId: `mock-task-${cn}`, }; } return { canTransfer: true, - reason: "mock:未到仓,可批量转仓", + reason: `mock:柜状态「${ccContainerStatusLabel(0)}」,可批量转仓`, shipStatus: 0, containerTaskId: `mock-task-${cn}`, }; @@ -85,7 +95,7 @@ export async function evaluateTransferGate( return { canTransfer: true, reason: - "查柜接口限流(GetContainerList 间隔 60 秒)。未改走工单;确认提交调用转仓接口 SaveStockBatchTransfer。", + "集装箱列表接口限流(GetContainerList 间隔约 60 秒,V1.73)。未改走工单;确认提交调用转仓接口 SaveStockBatchTransfer。", shipStatus: null, lookupRateLimited: true, }; @@ -100,25 +110,26 @@ export async function evaluateTransferGate( if (!list.items.length) { return { canTransfer: false, - reason: "CC 无此柜,无法批量转仓,请下工单", + reason: "CarrierCentral 中无此柜,无法批量转仓,请下工单", shipStatus: null, fallback: "work_order", }; } const row = list.items[0] as CcRow; - const shipStatus = toInt(row.F_ShipStatus); + const status = + toInt(row.F_Status) ?? toInt(row.F_ShipStatus); const toWh = row.F_ToWhDate; const opType = toInt(row.F_OperationType); const dismantle = toInt(row.F_DismantleStatus); const report = toInt(row.F_Report); const taskId = row.F_ContainerTaskId || row.F_Id || undefined; - if (shipStatus != null && shipStatus >= 10) { + if (isContainerArrivedOrLater(status)) { return { canTransfer: false, - reason: "已到仓,转仓必须下工单", - shipStatus, + reason: `${formatCcContainerStatusReason(status, "批量转仓")},须下工单`, + shipStatus: status, fallback: "work_order", containerTaskId: taskId, }; @@ -127,7 +138,7 @@ export async function evaluateTransferGate( return { canTransfer: false, reason: "已有到仓日期,转仓必须下工单", - shipStatus, + shipStatus: status, fallback: "work_order", containerTaskId: taskId, }; @@ -136,7 +147,7 @@ export async function evaluateTransferGate( return { canTransfer: false, reason: "直送柜不可批量转仓,请下工单", - shipStatus, + shipStatus: status, fallback: "work_order", containerTaskId: taskId, }; @@ -145,7 +156,7 @@ export async function evaluateTransferGate( return { canTransfer: false, reason: "正在拆柜,不可转仓", - shipStatus, + shipStatus: status, fallback: "work_order", containerTaskId: taskId, }; @@ -154,7 +165,7 @@ export async function evaluateTransferGate( return { canTransfer: false, reason: "已做拆柜报告,不可转仓", - shipStatus, + shipStatus: status, fallback: "work_order", containerTaskId: taskId, }; @@ -162,8 +173,10 @@ export async function evaluateTransferGate( return { canTransfer: true, - reason: "未到仓,可确认批量转仓", - shipStatus: shipStatus ?? 0, + reason: status != null + ? `柜状态「${ccContainerStatusLabel(status)}」,可确认批量转仓` + : "未到仓,可确认批量转仓", + shipStatus: status ?? 0, containerTaskId: taskId, }; } diff --git a/src/services/cc/customer-message.ts b/src/services/cc/customer-message.ts index b8286ff..8618e7f 100644 --- a/src/services/cc/customer-message.ts +++ b/src/services/cc/customer-message.ts @@ -7,8 +7,9 @@ import { ccAuth } from "@/services/cc/auth"; import { CcHttpClient } from "@/services/cc/http"; import { normalizeCcCode } from "@/services/cc/code"; import { resolveWriteMode } from "@/services/cc/write-capability"; -import { formatCcCause } from "@/constants/error-copy"; +import { formatCcApiFailure, formatCcCause } from "@/constants/error-copy"; import type { CustomerMessageEntity } from "@/services/cc/customer-message-entity"; +import { normalizeExternalId } from "@/services/import/import-log-meta"; export type { CustomerMessageEntity } from "@/services/cc/customer-message-entity"; export { toCcCustomerMessageEntity } from "@/services/cc/customer-message-entity"; @@ -68,18 +69,21 @@ export async function saveCustomerMessage( return { ok, mode: "live", - externalId: ok ? String(res.data ?? "") : null, + externalId: ok ? normalizeExternalId(res.data) : null, request, response: res, error: ok ? undefined - : formatCcCause( - String( + : formatCcApiFailure({ + api: "SaveForm", + code, + info: String( (res as { info?: string; message?: string }).info || (res as { message?: string }).message || - "CC_WO_FAILED", + "", ), - ), + fallback: "CC_WO_FAILED", + }), }; } catch (err) { return { diff --git a/src/services/cc/customer-profiles.ts b/src/services/cc/customer-profiles.ts new file mode 100644 index 0000000..1c830fb --- /dev/null +++ b/src/services/cc/customer-profiles.ts @@ -0,0 +1,245 @@ +import { getEnv } from "@/lib/env"; +import { decryptSecret, encryptSecret, maskUsername } from "@/lib/secret-crypto"; +import { prisma } from "@/services/db"; +import { md5 } from "@/utils/hash"; +import { invalidateCcSettingsCache } from "@/services/cc/settings-config"; +import type { CcRuntimeConfig } from "@/services/cc/settings-config"; + +export type CcCustomerProfilePublic = { + id: string; + name: string; + mock: boolean; + api_base: string; + saas_header: string; + login_mark: string; + username: string; + username_masked: string; + has_password: boolean; + enabled: boolean; + sort_order: number; + owner_user_id: string | null; + updated_at: string; +}; + +function toPublic(row: { + id: bigint; + name: string; + mock: boolean; + apiBase: string; + saasHeader: string; + loginMark: string; + username: string; + passwordEnc: string | null; + enabled: boolean; + sortOrder: number; + ownerUserId: bigint | null; + updatedAt: Date; +}): CcCustomerProfilePublic { + return { + id: row.id.toString(), + name: row.name, + mock: row.mock, + api_base: row.apiBase, + saas_header: row.saasHeader, + login_mark: row.loginMark, + username: row.username, + username_masked: row.username ? maskUsername(row.username) : "", + has_password: Boolean(row.passwordEnc), + enabled: row.enabled, + sort_order: row.sortOrder, + owner_user_id: row.ownerUserId != null ? row.ownerUserId.toString() : null, + updated_at: row.updatedAt.toISOString(), + }; +} + +export async function listCcCustomerProfiles(opts?: { + ownerUserId?: bigint | null; + includeDisabled?: boolean; +}): Promise { + const where: { + ownerUserId?: bigint | null; + enabled?: boolean; + } = {}; + if (opts && "ownerUserId" in (opts || {})) { + where.ownerUserId = opts!.ownerUserId ?? null; + } else { + // 默认 staff 共享档案 + where.ownerUserId = null; + } + if (!opts?.includeDisabled) where.enabled = true; + + const rows = await prisma.ccCustomerProfile.findMany({ + where, + orderBy: [{ sortOrder: "asc" }, { id: "asc" }], + }); + return rows.map(toPublic); +} + +/** 操作下拉:staff 共享 + 可选邮件归属租户的档案 */ +export async function listSelectableCcCustomerProfiles(opts?: { + mailOwnerUserId?: bigint | null; +}): Promise { + const or: Array<{ ownerUserId: bigint | null }> = [{ ownerUserId: null }]; + if (opts?.mailOwnerUserId != null) { + or.push({ ownerUserId: opts.mailOwnerUserId }); + } + const rows = await prisma.ccCustomerProfile.findMany({ + where: { enabled: true, OR: or }, + orderBy: [{ sortOrder: "asc" }, { id: "asc" }], + }); + return rows.map(toPublic); +} + +export async function getCcCustomerProfile( + id: bigint, +): Promise { + const row = await prisma.ccCustomerProfile.findUnique({ where: { id } }); + return row ? toPublic(row) : null; +} + +export async function loadCcCustomerProfileRuntime( + id: bigint, +): Promise { + const row = await prisma.ccCustomerProfile.findUnique({ where: { id } }); + if (!row || !row.enabled) return null; + const env = getEnv(); + let passwordMd5 = ""; + let hasPassword = false; + if (row.passwordEnc) { + try { + passwordMd5 = md5(decryptSecret(row.passwordEnc)); + hasPassword = true; + } catch { + hasPassword = false; + } + } + const username = row.username.trim(); + return { + mock: row.mock, + apiBase: row.apiBase.trim() || env.CC_API_BASE, + saasHeader: row.saasHeader.trim() || env.CC_SAAS_HEADER, + loginMark: row.loginMark.trim() || env.CC_LOGIN_MARK, + username, + passwordMd5, + source: "db", + hasPassword: Boolean(username && passwordMd5), + }; +} + +export async function createCcCustomerProfile(input: { + name: string; + mock: boolean; + apiBase: string; + saasHeader: string; + loginMark: string; + username: string; + password?: string; + enabled?: boolean; + sortOrder?: number; + ownerUserId?: bigint | null; +}): Promise { + const env = getEnv(); + const row = await prisma.ccCustomerProfile.create({ + data: { + ownerUserId: input.ownerUserId ?? null, + name: input.name.trim(), + mock: input.mock, + apiBase: input.apiBase.trim() || env.CC_API_BASE, + saasHeader: input.saasHeader.trim() || env.CC_SAAS_HEADER, + loginMark: input.loginMark.trim() || env.CC_LOGIN_MARK, + username: input.username.trim(), + passwordEnc: input.password?.trim() + ? encryptSecret(input.password.trim()) + : null, + enabled: input.enabled ?? true, + sortOrder: input.sortOrder ?? 0, + }, + }); + invalidateCcSettingsCache(); + return toPublic(row); +} + +export async function updateCcCustomerProfile( + id: bigint, + input: { + name?: string; + mock?: boolean; + apiBase?: string; + saasHeader?: string; + loginMark?: string; + username?: string; + password?: string; + enabled?: boolean; + sortOrder?: number; + }, +): Promise { + const existing = await prisma.ccCustomerProfile.findUnique({ where: { id } }); + if (!existing) return null; + const env = getEnv(); + const row = await prisma.ccCustomerProfile.update({ + where: { id }, + data: { + ...(input.name !== undefined ? { name: input.name.trim() } : {}), + ...(input.mock !== undefined ? { mock: input.mock } : {}), + ...(input.apiBase !== undefined + ? { apiBase: input.apiBase.trim() || env.CC_API_BASE } + : {}), + ...(input.saasHeader !== undefined + ? { saasHeader: input.saasHeader.trim() || env.CC_SAAS_HEADER } + : {}), + ...(input.loginMark !== undefined + ? { loginMark: input.loginMark.trim() || env.CC_LOGIN_MARK } + : {}), + ...(input.username !== undefined + ? { username: input.username.trim() } + : {}), + ...(input.password !== undefined + ? { + passwordEnc: input.password.trim() + ? encryptSecret(input.password.trim()) + : existing.passwordEnc, + } + : {}), + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + }, + }); + invalidateCcSettingsCache(); + return toPublic(row); +} + +export async function deleteCcCustomerProfile(id: bigint): Promise { + const existing = await prisma.ccCustomerProfile.findUnique({ where: { id } }); + if (!existing) return false; + await prisma.ccCustomerProfile.delete({ where: { id } }); + invalidateCcSettingsCache(); + return true; +} + +/** 把全局 CcSettings 迁成第一条共享档案(幂等) */ +export async function ensureCcProfilesMigratedFromGlobalSettings(): Promise { + const count = await prisma.ccCustomerProfile.count({ + where: { ownerUserId: null }, + }); + if (count > 0) return; + + const global = + (await prisma.ccSettings.findFirst({ where: { userId: null } })) ?? + (await prisma.ccSettings.findUnique({ where: { id: 1 } })); + if (!global) return; + + await prisma.ccCustomerProfile.create({ + data: { + ownerUserId: null, + name: global.username.trim() || "默认客户", + mock: global.mock, + apiBase: global.apiBase, + saasHeader: global.saasHeader, + loginMark: global.loginMark, + username: global.username, + passwordEnc: global.passwordEnc, + enabled: true, + sortOrder: 0, + }, + }); +} diff --git a/src/services/cc/do-confirm-gate.ts b/src/services/cc/do-confirm-gate.ts new file mode 100644 index 0000000..0e3cb52 --- /dev/null +++ b/src/services/cc/do-confirm-gate.ts @@ -0,0 +1,40 @@ +import { isValidContainerNo } from "@/utils/iso6346"; + +export type DoImportIssues = { + errors: Record; + blockers: string[]; + notes: string[]; +}; + +/** + * DO 上传确认页缺项:柜号必填且 ISO;至少一份 DO 附件。 + * 对齐 assistant:柜须可定位,口语「DO请查收」不算已有文件。 + */ +export function collectDoImportIssues(input: { + containerNo: string; + filenames: string[]; +}): DoImportIssues { + const errors: Record = {}; + const blockers: string[] = []; + const notes: string[] = []; + + const containerNo = (input.containerNo || "").trim(); + if (!containerNo) { + const msg = "请填写柜号(4 位字母 + 7 位数字)"; + errors.containerNo = msg; + blockers.push(msg); + } else if (!isValidContainerNo(containerNo)) { + const msg = `柜号「${containerNo.toUpperCase()}」不符合 ISO 6346 校验位,请核对后改正`; + errors.containerNo = msg; + blockers.push(msg); + } + + if (!input.filenames.length) { + const msg = + "未识别到 DO 附件(如 WHSU8127240-DO.pdf)。请确认邮件含 DO 文件后再上传"; + errors.filenames = msg; + blockers.push(msg); + } + + return { errors, blockers, notes }; +} diff --git a/src/services/cc/do-upload.ts b/src/services/cc/do-upload.ts index 75047ba..dfc69b6 100644 --- a/src/services/cc/do-upload.ts +++ b/src/services/cc/do-upload.ts @@ -21,7 +21,7 @@ import { resolveWriteMode } from "@/services/cc/write-capability"; import { getContainerListFromCc } from "@/services/cc/conflict"; import { getMockTargetAPI } from "@/services/cc/mock-target-api"; import { resolveCcRuntime } from "@/services/cc/settings-config"; -import { formatCcCause } from "@/constants/error-copy"; +import { formatCcApiFailure, formatCcCause } from "@/constants/error-copy"; import type { CcApiResponse } from "@/types/cc"; const SAVE_FIELD_PATH = "/Container/SaveFieldValue"; @@ -188,7 +188,12 @@ async function uploadAnnexFile( error: code === 200 ? undefined - : formatCcCause(body.info || `annexes upload failed: ${code}`), + : formatCcApiFailure({ + api: "upload", + code, + info: body.info, + fallback: `annexes upload failed: ${code}`, + }), }; } catch (err) { return { @@ -264,7 +269,12 @@ async function uploadAnnexFile( error: code === 200 ? undefined - : formatCcCause(body.info || `annexes upload failed: ${code}`), + : formatCcApiFailure({ + api: "upload", + code, + info: body.info, + fallback: `annexes upload failed: ${code}`, + }), }; } catch (err) { return { @@ -384,7 +394,12 @@ export async function saveContainerDoFile(input: { folderId: null, request: { ...requestBase, fieldValue: folderId }, response: bind, - error: formatCcCause(bind.info || `SaveFieldValue failed: ${bindCode}`), + error: formatCcApiFailure({ + api: "SaveFieldValue", + code: bindCode, + info: bind.info, + fallback: `SaveFieldValue failed: ${bindCode}`, + }), }; } diff --git a/src/services/cc/forecast-confirm-gate.ts b/src/services/cc/forecast-confirm-gate.ts index 7dd905e..eddf810 100644 --- a/src/services/cc/forecast-confirm-gate.ts +++ b/src/services/cc/forecast-confirm-gate.ts @@ -60,17 +60,20 @@ function summarizeInvalidRows( const parts = [...counts.entries()].map(([reason, n]) => n > 1 ? `${reason}(${n}行)` : reason, ); - return `货件有 ${rows.length} 行未通过校验:${parts.join("、")}。请在附件中补全后重新解析,或仅导入柜头。`; + return `货件有 ${rows.length} 行未通过校验:${parts.join("、")}。请在本页补全必填项或导入装箱表。`; } /** * 实时收集确认导入页的缺项 / 错项,供灰按钮旁提示与字段红字共用。 - * 无有效货件但柜号可用时允许仅柜头导入,不把「未选货件」当作阻断。 + * 必填以 SaveContainer 为准:柜头 F_TransMode / F_OperationType / F_ContainerNo / F_CabinetType; + * 货件行 F_FBACode / F_Transporter / F_CTNS(由 VALID 行保证)。无有效货件不可导入。 */ export function collectForecastImportIssues(input: { transMode: number | null | undefined; operationType: number | null | undefined; containerNo: string; + /** 柜型(SaveContainer 必填) */ + cabinetType?: string | null; hasContainerConflict: boolean; containerSource: "body" | "attachment" | null; validRowCount: number; @@ -115,13 +118,26 @@ export function collectForecastImportIssues(input: { blockers.push(msg); } + if (!(input.cabinetType || "").trim()) { + const msg = "请选择柜型(标准:20GP / 40GP / 40HQ / 40HR / 45HQ / 53HQ 等)"; + errors.cabinetType = msg; + blockers.push(msg); + } + if (input.hasContainerConflict && !input.containerSource) { const msg = "正文与附件柜号不一致,请先选定使用哪一个"; errors.containerSource = msg; blockers.push(msg); } - if (input.validRowCount > 0 && input.selectedValidCount === 0) { + if (input.validRowCount <= 0) { + const msg = + shipmentCount > 0 || invalidRows.length > 0 + ? "没有可导入的有效货件行(必填:派送方式、件数、体积、毛重)。请在本页修改或导入装箱表" + : "未解析到货件信息。请在本页新增货件,或导入卡派清单(xlsx)后再确认导入"; + errors.rows = msg; + blockers.push(msg); + } else if (input.selectedValidCount === 0) { const msg = input.softLimitExceeded ? "货件超过软限,默认未全选;请筛选后至少勾选一行有效货件" : "请至少勾选一行有效货件"; @@ -138,8 +154,6 @@ export function collectForecastImportIssues(input: { const invalidSummary = summarizeInvalidRows(invalidRows); if (input.validRowCount === 0 && invalidSummary) { notes.push(invalidSummary); - } else if (input.validRowCount === 0 && shipmentCount === 0) { - notes.push("未解析到货件行,确认导入将只提交柜头信息。"); } return { errors, blockers, notes }; diff --git a/src/services/cc/list-http-gate.ts b/src/services/cc/list-http-gate.ts new file mode 100644 index 0000000..d25b8dd --- /dev/null +++ b/src/services/cc/list-http-gate.ts @@ -0,0 +1,37 @@ +/** V1.73:GetContainerList 真网调用串行 + 60 秒间隔 */ + +const LIST_HTTP_MIN_INTERVAL_MS = 60_000; +const listHttpLastAt = new Map(); +const listHttpChains = new Map>(); + +export function resetListHttpGateForTests(): void { + listHttpLastAt.clear(); + listHttpChains.clear(); +} + +export async function runWithListHttpGate( + profileKey: string, + fn: () => Promise, +): Promise { + const prev = listHttpChains.get(profileKey) ?? Promise.resolve(); + const run = prev.then(async () => { + const last = listHttpLastAt.get(profileKey) ?? 0; + const waitMs = LIST_HTTP_MIN_INTERVAL_MS - (Date.now() - last); + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + try { + return await fn(); + } finally { + listHttpLastAt.set(profileKey, Date.now()); + } + }); + listHttpChains.set( + profileKey, + run.then( + () => undefined, + () => undefined, + ), + ); + return run; +} diff --git a/src/services/cc/message-type-dict.ts b/src/services/cc/message-type-dict.ts new file mode 100644 index 0000000..351690e --- /dev/null +++ b/src/services/cc/message-type-dict.ts @@ -0,0 +1,101 @@ +import { prisma } from "@/services/db"; +import { DEFAULT_CC_MESSAGE_TYPE_NAMES } from "@/constants/cc-message-types"; + +export type CcMessageTypeDictPublic = { + id: string; + name: string; + enabled: boolean; + sort_order: number; + updated_at: string; +}; + +function toPublic(row: { + id: bigint; + name: string; + enabled: boolean; + sortOrder: number; + updatedAt: Date; +}): CcMessageTypeDictPublic { + return { + id: row.id.toString(), + name: row.name, + enabled: row.enabled, + sort_order: row.sortOrder, + updated_at: row.updatedAt.toISOString(), + }; +} + +/** 空表时写入默认种子(幂等) */ +export async function ensureCcMessageTypeDictSeeded(): Promise { + const count = await prisma.ccMessageTypeDict.count(); + if (count > 0) return; + await prisma.ccMessageTypeDict.createMany({ + data: DEFAULT_CC_MESSAGE_TYPE_NAMES.map((name, i) => ({ + name, + enabled: true, + sortOrder: i, + })), + }); +} + +export async function listCcMessageTypeDict(opts?: { + enabledOnly?: boolean; +}): Promise { + await ensureCcMessageTypeDictSeeded(); + const rows = await prisma.ccMessageTypeDict.findMany({ + where: opts?.enabledOnly ? { enabled: true } : undefined, + orderBy: [{ sortOrder: "asc" }, { id: "asc" }], + }); + return rows.map(toPublic); +} + +export async function listEnabledCcMessageTypeOptions(): Promise< + Array<{ value: string; label: string }> +> { + const items = await listCcMessageTypeDict({ enabledOnly: true }); + return items.map((i) => ({ value: i.name, label: i.name })); +} + +export async function createCcMessageTypeDict(input: { + name: string; + enabled?: boolean; + sortOrder?: number; +}): Promise { + await ensureCcMessageTypeDictSeeded(); + const name = input.name.trim(); + const maxSort = await prisma.ccMessageTypeDict.aggregate({ + _max: { sortOrder: true }, + }); + const row = await prisma.ccMessageTypeDict.create({ + data: { + name, + enabled: input.enabled ?? true, + sortOrder: input.sortOrder ?? (maxSort._max.sortOrder ?? 0) + 1, + }, + }); + return toPublic(row); +} + +export async function updateCcMessageTypeDict( + id: bigint, + input: { name?: string; enabled?: boolean; sortOrder?: number }, +): Promise { + const existing = await prisma.ccMessageTypeDict.findUnique({ where: { id } }); + if (!existing) return null; + const row = await prisma.ccMessageTypeDict.update({ + where: { id }, + data: { + ...(input.name !== undefined ? { name: input.name.trim() } : {}), + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + }, + }); + return toPublic(row); +} + +export async function deleteCcMessageTypeDict(id: bigint): Promise { + const existing = await prisma.ccMessageTypeDict.findUnique({ where: { id } }); + if (!existing) return false; + await prisma.ccMessageTypeDict.delete({ where: { id } }); + return true; +} diff --git a/src/services/cc/save-container.ts b/src/services/cc/save-container.ts index ab0e128..fdef05d 100644 --- a/src/services/cc/save-container.ts +++ b/src/services/cc/save-container.ts @@ -13,7 +13,7 @@ import type { CcShipment, } from "@/types/cc"; import type { ContainerHeader, ShipmentRow } from "@/types/mail"; -import { formatCcCause } from "@/constants/error-copy"; +import { formatCcApiFailure, formatCcCause } from "@/constants/error-copy"; import { normalizeFbaCodeForCc } from "@/services/parse/normalize-fba-code"; export { normalizeFbaCodeForCc } from "@/services/parse/normalize-fba-code"; @@ -58,7 +58,7 @@ export function buildSaveContainerEntity( F_TransMode: merged.F_TransMode ?? overrides.F_TransMode, F_OperationType: merged.F_OperationType ?? overrides.F_OperationType, F_ContainerNo: merged.F_ContainerNo.toUpperCase(), - F_CabinetType: cabinet || undefined, + F_CabinetType: cabinet ? cabinet : null, F_BLCopyCode: bl, F_ETD: merged.F_ETD ?? null, F_ETA: merged.F_ETA ?? null, @@ -190,7 +190,7 @@ export async function saveContainer( return { ok: false, status: "FAILED", - error: formatCcCause(ccApiErrorText(response, code)), + error: ccApiErrorText(response, code), requestBody, responseBody, response, @@ -214,15 +214,19 @@ function ccApiErrorText( code = normalizeCcCode(response.code), ): string { const info = String(response.info || "").trim(); - if (info) return info; const message = String( (response as { message?: string }).message || "", ).trim(); - if (message) return message; - if (typeof response.data === "string" && response.data.trim()) { - return response.data.trim(); - } - return `CC save failed with code ${code}`; + const dataHint = + typeof response.data === "string" && response.data.trim() + ? response.data.trim() + : ""; + return formatCcApiFailure({ + api: "SaveContainer", + code, + info: info || message || dataHint || null, + fallback: `CC save failed with code ${code}`, + }); } function extractExternalId(data: unknown): string | null { diff --git a/src/services/cc/settings-config.ts b/src/services/cc/settings-config.ts index 688b2a4..14e48e4 100644 --- a/src/services/cc/settings-config.ts +++ b/src/services/cc/settings-config.ts @@ -1,5 +1,5 @@ import { getEnv } from "@/lib/env"; -import { getCcOwnerUserId } from "@/lib/cc-owner"; +import { getCcOwnerUserId, getCcProfileId } from "@/lib/cc-owner"; import { isTestMode } from "@/lib/test-mode"; import { decryptSecret, encryptSecret, maskUsername } from "@/lib/secret-crypto"; import { prisma } from "@/services/db"; @@ -8,6 +8,7 @@ import { normalizeCcCode } from "@/services/cc/code"; import type { CcApiResponse } from "@/types/cc"; import { fetch } from "undici"; import { getMockTargetAPI } from "@/services/cc/mock-target-api"; +import { loadCcCustomerProfileRuntime } from "@/services/cc/customer-profiles"; const SINGLETON_ID = 1; const LOGIN_PATH = "/learun/adms/user/customerLogin"; @@ -41,7 +42,11 @@ type CacheEntry = { at: number; value: CcRuntimeConfig }; const cacheByOwner = new Map(); -function cacheKey(ownerUserId: bigint | null | undefined): string { +function cacheKey( + ownerUserId: bigint | null | undefined, + profileId?: bigint | null, +): string { + if (profileId != null) return `profile:${profileId}`; return ownerUserId != null ? String(ownerUserId) : "global"; } @@ -153,12 +158,20 @@ async function loadRuntime( } export async function resolveCcRuntime(): Promise { + const profileId = getCcProfileId(); const ownerUserId = getCcOwnerUserId(); - const key = cacheKey(ownerUserId); + const key = cacheKey(ownerUserId, profileId); const now = Date.now(); const hit = cacheByOwner.get(key); if (hit && now - hit.at < CACHE_TTL_MS) return hit.value; - const value = await loadRuntime(ownerUserId); + + let value: CcRuntimeConfig; + if (profileId != null) { + const fromProfile = await loadCcCustomerProfileRuntime(profileId); + value = fromProfile ?? (await loadRuntime(ownerUserId)); + } else { + value = await loadRuntime(ownerUserId); + } cacheByOwner.set(key, { at: now, value }); return value; } diff --git a/src/services/cc/shipping-lines.ts b/src/services/cc/shipping-lines.ts index eb41e0f..8448242 100644 --- a/src/services/cc/shipping-lines.ts +++ b/src/services/cc/shipping-lines.ts @@ -1,7 +1,18 @@ export type { ShippingLineItem } from "./shipping-line"; -export { getShippingLineList } from "./shipping-line"; +export { + getShippingLineList, + invalidateShippingLineCache, +} from "./shipping-line"; -import { getShippingLineList } from "./shipping-line"; +import { + getShippingLineList, + invalidateShippingLineCache, +} from "./shipping-line"; + +/** API 强制刷新船司列表时清服务端缓存 */ +export function invalidateShippingLineListCache(): void { + invalidateShippingLineCache(); +} export async function listShippingLines(): Promise< Array<{ diff --git a/src/services/cc/write-capability.ts b/src/services/cc/write-capability.ts index 0736faf..6014fb7 100644 --- a/src/services/cc/write-capability.ts +++ b/src/services/cc/write-capability.ts @@ -34,7 +34,7 @@ const DEFAULTS: Record(["customer_message"]); + export async function ensureCcWriteCapabilities(): Promise { for (const [id, def] of Object.entries(DEFAULTS) as Array< [CcWriteCapabilityId, (typeof DEFAULTS)[CcWriteCapabilityId]] >) { + const forceLive = FORCE_LIVE_IDS.has(id); await prisma.ccWriteCapability.upsert({ where: { id }, create: { id, mode: def.mode, endpoint: def.endpoint }, - update: {}, + update: forceLive + ? { mode: "live", endpoint: def.endpoint } + : {}, }); } } @@ -82,6 +88,7 @@ export async function resolveWriteMode( ): Promise { if (isTestMode()) return "live"; if (await isCcMock()) return "mock"; + if (FORCE_LIVE_IDS.has(id)) return "live"; const cap = await getCcWriteCapability(id); return cap.mode; } diff --git a/src/services/imap/mailbox-config.ts b/src/services/imap/mailbox-config.ts index b023050..5656e68 100644 --- a/src/services/imap/mailbox-config.ts +++ b/src/services/imap/mailbox-config.ts @@ -142,9 +142,16 @@ function rowToConfig( }; } -export async function listMailboxes(ownerUserId?: bigint | null): Promise { +/** + * 列出邮箱。 + * - bigint:该租户名下邮箱 + * - null:仅管理端公共邮箱(owner_user_id IS NULL) + */ +export async function listMailboxes( + ownerUserId: bigint | null, +): Promise { const rows = await prisma.mailboxAccount.findMany({ - where: ownerUserId != null ? { ownerUserId } : undefined, + where: { ownerUserId }, orderBy: [{ enabled: "desc" }, { id: "asc" }], }); return rows.map(toPublic); diff --git a/src/services/import/auto-exec.ts b/src/services/import/auto-exec.ts index 470514c..0b92f53 100644 --- a/src/services/import/auto-exec.ts +++ b/src/services/import/auto-exec.ts @@ -131,6 +131,7 @@ async function runAutoExecInner(mailId: bigint): Promise<{ responseBody: JSON.stringify(result.response), status: "SUCCESS", shipmentsHash: "auto-exec", + importKind: "forecast", }, }).catch(() => undefined); } diff --git a/src/services/import/confirm-batch-transfer.ts b/src/services/import/confirm-batch-transfer.ts index fb04563..36dac52 100644 --- a/src/services/import/confirm-batch-transfer.ts +++ b/src/services/import/confirm-batch-transfer.ts @@ -13,6 +13,7 @@ import { markInstructionImportedAndResolveMail, validateInstructionImportable, } from "@/services/import/instruction-import-state"; +import { recordInstructionImportLog } from "@/services/import/instruction-import-log"; export class TransferConfirmError extends Error { constructor( @@ -33,9 +34,12 @@ export async function confirmBatchTransfer(input: { instructionId?: string; containerNo: string; payload: BatchTransferPayload; + ccProfileId?: bigint; }): Promise<{ externalId: string | null; mode: string }> { - return runWithMailCcOwner(input.mailId, () => - runWithMailTrace(input.mailId, "submit", async () => { + return runWithMailCcOwner( + input.mailId, + () => + runWithMailTrace(input.mailId, "submit", async () => { const checked = await validateInstructionImportable({ mailId: input.mailId, instructionId: input.instructionId, @@ -113,9 +117,31 @@ export async function confirmBatchTransfer(input: { externalId: result.externalId || null, successLastError: result.mode === "mock" ? "XFER_MOCK_OK" : null, }); + await recordInstructionImportLog({ + mailId: input.mailId, + instructionId: checked.unit.id, + kind: "transfer", + display: input.containerNo, + status: "SUCCESS", + externalId: result.externalId || null, + lastError: result.mode === "mock" ? "XFER_MOCK_OK" : null, + requestBody: result.request ? JSON.stringify(result.request) : null, + responseBody: result.response ? JSON.stringify(result.response) : null, + }); return { externalId: result.externalId || null, mode: result.mode }; } + await recordInstructionImportLog({ + mailId: input.mailId, + instructionId: checked.unit.id, + kind: "transfer", + display: input.containerNo, + status: "FAILED", + lastError: result.error || "XFER_FAILED", + requestBody: result.request ? JSON.stringify(result.request) : null, + responseBody: result.response ? JSON.stringify(result.response) : null, + }); + assertTransition("IMPORTING", "FAILED"); const failed = await prisma.mailMessage.update({ where: { id: input.mailId }, @@ -133,5 +159,6 @@ export async function confirmBatchTransfer(input: { { server_version: failed.version }, ); }), + { profileId: input.ccProfileId }, ); } diff --git a/src/services/import/confirm-do-upload.ts b/src/services/import/confirm-do-upload.ts index d5d45a7..68c461a 100644 --- a/src/services/import/confirm-do-upload.ts +++ b/src/services/import/confirm-do-upload.ts @@ -16,6 +16,7 @@ import { markInstructionImportedAndResolveMail, validateInstructionImportable, } from "@/services/import/instruction-import-state"; +import { recordInstructionImportLog } from "@/services/import/instruction-import-log"; export class DoConfirmError extends Error { constructor( @@ -136,14 +137,17 @@ export async function confirmDoUpload(input: { containerNo: string; containerTaskId?: string; filenames?: string[]; + ccProfileId?: bigint; }): Promise<{ folderId: string | null; mode: string; containerTaskId: string; uploadedFileIds?: string[]; }> { - return runWithMailCcOwner(input.mailId, () => - runWithMailTrace(input.mailId, "submit", async () => { + return runWithMailCcOwner( + input.mailId, + () => + runWithMailTrace(input.mailId, "submit", async () => { const checked = await validateInstructionImportable({ mailId: input.mailId, instructionId: input.instructionId, @@ -272,6 +276,20 @@ export async function confirmDoUpload(input: { ? "DO_MOCK_OK" : `DO 已上传: ${(result.uploadedFileIds || []).join(",") || result.folderId || "ok"}`, }); + await recordInstructionImportLog({ + mailId: input.mailId, + instructionId: checked.unit.id, + kind: "do_upload", + display: cn, + status: "SUCCESS", + externalId: taskId, + lastError: + result.mode === "mock" + ? "DO_MOCK_OK" + : `DO 已上传: ${(result.uploadedFileIds || []).join(",") || result.folderId || "ok"}`, + requestBody: result.request ? JSON.stringify(result.request) : null, + responseBody: result.response ? JSON.stringify(result.response) : null, + }); return { folderId: result.folderId || null, mode: result.mode, @@ -280,6 +298,17 @@ export async function confirmDoUpload(input: { }; } + await recordInstructionImportLog({ + mailId: input.mailId, + instructionId: checked.unit.id, + kind: "do_upload", + display: cn, + status: "FAILED", + lastError: result.error || "DO_FAILED", + requestBody: result.request ? JSON.stringify(result.request) : null, + responseBody: result.response ? JSON.stringify(result.response) : null, + }); + if (wasSuccess) { const failed = await prisma.mailMessage.update({ where: { id: input.mailId }, @@ -308,5 +337,6 @@ export async function confirmDoUpload(input: { server_version: failed.version, }); }), + { profileId: input.ccProfileId }, ); } diff --git a/src/services/import/confirm-work-order.ts b/src/services/import/confirm-work-order.ts index 50184c7..7ef9e79 100644 --- a/src/services/import/confirm-work-order.ts +++ b/src/services/import/confirm-work-order.ts @@ -12,6 +12,12 @@ import { markInstructionImportedAndResolveMail, validateInstructionImportable, } from "@/services/import/instruction-import-state"; +import { recordInstructionImportLog } from "@/services/import/instruction-import-log"; +import { + diffWorkOrderSupplementedFields, + toWorkOrderSubmittedSnapshot, + type WorkOrderSnapshotValue, +} from "@/services/parse/work-order-snapshot"; export class WorkOrderConfirmError extends Error { constructor( @@ -31,14 +37,19 @@ export async function confirmWorkOrder(input: { actor: string; instructionId?: string; entity: CustomerMessageEntity; + /** 邮件抽取基线,用于标记人工补充/修改字段 */ + baseline?: WorkOrderSnapshotValue | null; + ccProfileId?: bigint; }): Promise<{ externalId: string | null; mode: string }> { - return runWithMailCcOwner(input.mailId, () => - runWithMailTrace(input.mailId, "submit", async () => { + return runWithMailCcOwner( + input.mailId, + () => + runWithMailTrace(input.mailId, "submit", async () => { const checked = await validateInstructionImportable({ mailId: input.mailId, instructionId: input.instructionId, allowedKinds: ["work_order", "transfer"], - allowedStatuses: ["PENDING_CONFIRM", "FAILED", "PARTIAL_SUCCESS"], + allowedStatuses: ["PENDING_CONFIRM", "FAILED", "PARTIAL_SUCCESS", "SUCCESS"], }); if (checked.error === "NOT_FOUND") { throw new WorkOrderConfirmError("NOT_FOUND", "邮件不存在", 404); @@ -99,7 +110,20 @@ export async function confirmWorkOrder(input: { }, }); + const logDisplay = + input.entity.F_ReferCode || + input.entity.F_RefNo || + checked.unit.label || + checked.unit.id; + if (result.ok) { + const submitted = toWorkOrderSubmittedSnapshot( + input.entity as unknown as Record, + ); + const diff = diffWorkOrderSupplementedFields( + input.baseline || null, + submitted, + ); await markInstructionImportedAndResolveMail({ mailId: input.mailId, instructionId: checked.unit.id, @@ -107,10 +131,37 @@ export async function confirmWorkOrder(input: { mode: result.mode, externalId: result.externalId || null, successLastError: result.mode === "mock" ? "WO_MOCK_OK" : null, + submitted, + supplementedFields: diff.supplementedFields, + fieldMarks: diff.fieldMarks, + userSupplemented: diff.userSupplemented, + }); + // 按指令落导入日志(不等整封邮件全部指令完成) + await recordInstructionImportLog({ + mailId: input.mailId, + instructionId: checked.unit.id, + kind: "work_order", + display: logDisplay, + status: "SUCCESS", + externalId: result.externalId || null, + lastError: result.mode === "mock" ? "WO_MOCK_OK" : null, + requestBody: result.request ? JSON.stringify(result.request) : null, + responseBody: result.response ? JSON.stringify(result.response) : null, }); return { externalId: result.externalId || null, mode: result.mode }; } + await recordInstructionImportLog({ + mailId: input.mailId, + instructionId: checked.unit.id, + kind: "work_order", + display: logDisplay, + status: "FAILED", + lastError: result.error || "WO_FAILED", + requestBody: result.request ? JSON.stringify(result.request) : null, + responseBody: result.response ? JSON.stringify(result.response) : null, + }); + assertTransition("IMPORTING", "FAILED"); const failed = await prisma.mailMessage.update({ where: { id: input.mailId }, @@ -128,5 +179,6 @@ export async function confirmWorkOrder(input: { { server_version: failed.version }, ); }), + { profileId: input.ccProfileId }, ); } diff --git a/src/services/import/confirm.ts b/src/services/import/confirm.ts index f21ff25..4ab28d6 100644 --- a/src/services/import/confirm.ts +++ b/src/services/import/confirm.ts @@ -33,7 +33,12 @@ import { markInstructionImportedAndResolveMail, validateInstructionImportable, } from "@/services/import/instruction-import-state"; +import { + normalizeExternalId, + resolveImportCcCustomerMeta, +} from "@/services/import/import-log-meta"; import { fillShipmentFbaCodes } from "@/services/parse/normalize-fba-code"; +import { sanitizeClientShipments } from "@/services/parse/revalidate-shipment-row"; /** 仅允许白名单柜头字段从客户端覆盖,防止 mass assignment 窜改 CC payload */ function pickContainerHeaderPatch( @@ -125,6 +130,13 @@ export interface ConfirmImportInput { selectedRowIndexes: number[]; ackUnmappedChannels?: boolean; forceSkipConflict?: boolean; + /** 选中的 CC 客户档案,写 CC 时使用该账密 */ + ccProfileId?: bigint; + /** + * 确认页手工增改 / 导入装箱表后的货件覆盖。 + * 传入时(含空数组)以客户端货件为准,不再只用库内 parseResult。 + */ + shipments?: ShipmentRow[]; } export interface ConfirmImportResult { @@ -169,10 +181,13 @@ async function createCompensation( export class ConfirmImport { static async execute(input: ConfirmImportInput): Promise { - return runWithMailCcOwner(input.mailId, () => - runWithMailTrace(input.mailId, "submit", () => - ConfirmImport.executeUntraced(input), - ), + return runWithMailCcOwner( + input.mailId, + () => + runWithMailTrace(input.mailId, "submit", () => + ConfirmImport.executeUntraced(input), + ), + { profileId: input.ccProfileId }, ); } @@ -232,9 +247,18 @@ export class ConfirmImport { mail.parseResult.shipments, ) as unknown as ShipmentRow[]) : []; - const validShipmentCount = shipmentRows.filter( + let validShipmentCount = shipmentRows.filter( (s) => s.row_status === "VALID", ).length; + if (input.shipments !== undefined) { + try { + validShipmentCount = sanitizeClientShipments(input.shipments).filter( + (s) => s.row_status === "VALID", + ).length; + } catch { + validShipmentCount = 0; + } + } const checked = await validateInstructionImportable({ mailId: input.mailId, @@ -258,9 +282,13 @@ export class ConfirmImport { ); } - const containerNoForGate = ( - mail.parseResult?.containerHeader as { F_ContainerNo?: string } | null - )?.F_ContainerNo || ""; + const containerNoForGate = + (typeof input.containerHeader?.F_ContainerNo === "string" && + input.containerHeader.F_ContainerNo.trim()) || + ( + mail.parseResult?.containerHeader as { F_ContainerNo?: string } | null + )?.F_ContainerNo || + ""; if (validShipmentCount < 1 && !containerNoForGate.trim()) { throw new ConfirmImportError( @@ -300,21 +328,43 @@ export class ConfirmImport { ); } + let clientShipments: ShipmentRow[] | undefined; + if (input.shipments !== undefined) { + try { + clientShipments = sanitizeClientShipments(input.shipments); + } catch (err) { + throw new ConfirmImportError( + "INVALID_ROWS", + err instanceof Error ? err.message : "货件数据无效", + ); + } + } + const parseResult = mail.parseResult ? { container_header: mail.parseResult .containerHeader as unknown as ContainerHeader, shipments: fillShipmentFbaCodes( - mail.parseResult.shipments, - ) as unknown as ShipmentRow[], + clientShipments ?? + (mail.parseResult.shipments as unknown as ShipmentRow[]), + ), } - : null; + : clientShipments + ? { + container_header: { + F_ContainerNo: "", + container_no_valid: false, + } as ContainerHeader, + shipments: fillShipmentFbaCodes(clientShipments), + } + : null; if (!parseResult) { throw new ConfirmImportError("INVALID_ROWS", "缺少解析结果"); } - const selected = parseResult.shipments.filter((s) => + const allShipments = parseResult.shipments as ShipmentRow[]; + const selected = allShipments.filter((s) => input.selectedRowIndexes.includes(s.row_index), ); @@ -493,6 +543,10 @@ export class ConfirmImport { F_ContainerNo: containerNo, }); + const customer = await resolveImportCcCustomerMeta({ + ccProfileId: input.ccProfileId ?? null, + }); + let importRecord = await prisma.containerImport.upsert({ where: { mailId_containerNo_shipmentsHash: { @@ -506,10 +560,16 @@ export class ConfirmImport { containerNo, shipmentsHash, status: "IMPORTING", + importKind: "forecast", + ccCustomerName: customer.ccCustomerName, + ccProfileId: customer.ccProfileId, }, update: { status: "IMPORTING", lastError: null, + importKind: "forecast", + ccCustomerName: customer.ccCustomerName ?? undefined, + ccProfileId: customer.ccProfileId ?? undefined, }, }); @@ -529,10 +589,13 @@ export class ConfirmImport { where: { id: importRecord.id }, data: { status: "SUCCESS", - externalId: saveResult.externalId, + externalId: normalizeExternalId(saveResult.externalId), requestBody: saveResult.requestBody, responseBody: saveResult.responseBody, lastError: null, + importKind: "forecast", + ccCustomerName: customer.ccCustomerName ?? undefined, + ccProfileId: customer.ccProfileId ?? undefined, }, }); diff --git a/src/services/import/import-log-meta.ts b/src/services/import/import-log-meta.ts new file mode 100644 index 0000000..b1820fe --- /dev/null +++ b/src/services/import/import-log-meta.ts @@ -0,0 +1,80 @@ +/** + * 导入日志展示用:类型标签、外部 ID 规范化、当前 CC 客户名解析。 + */ +import { getCcProfileId } from "@/lib/cc-owner"; +import { getCcCustomerProfile } from "@/services/cc/customer-profiles"; + +export const IMPORT_KIND_LABEL: Record = { + forecast: "新增预报", + work_order: "工单", + do_upload: "上传DO", + transfer: "批量转仓", +}; + +export function importKindLabel(kind: string | null | undefined): string { + const k = (kind || "").trim(); + if (!k) return "其他"; + return IMPORT_KIND_LABEL[k] || k; +} + +/** 历史记录无 import_kind 时,从对象列前缀推断 */ +export function inferImportKindFromObjectKey( + containerNo: string | null | undefined, +): string { + const s = String(containerNo || ""); + if (s.startsWith("WO/")) return "work_order"; + if (s.startsWith("DO/")) return "do_upload"; + if (s.startsWith("XF/")) return "transfer"; + return "forecast"; +} + +/** 避免 res.data 为对象时写入 "[object Object]" */ +export function normalizeExternalId(value: unknown): string | null { + if (value == null || value === "") return null; + if (typeof value === "string") { + const t = value.trim(); + if (!t || t === "[object Object]") return null; + return t.slice(0, 64); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value).slice(0, 64); + } + if (typeof value === "object") { + const o = value as Record; + for (const key of ["F_Id", "id", "Id", "data", "externalId", "taskId"]) { + const inner = o[key]; + if (typeof inner === "string" && inner.trim()) { + return inner.trim().slice(0, 64); + } + if (typeof inner === "number") return String(inner).slice(0, 64); + } + try { + return JSON.stringify(value).slice(0, 64); + } catch { + return null; + } + } + return null; +} + +export async function resolveImportCcCustomerMeta(explicit?: { + ccProfileId?: bigint | null; + ccCustomerName?: string | null; +}): Promise<{ ccProfileId: bigint | null; ccCustomerName: string | null }> { + if (explicit?.ccCustomerName?.trim()) { + return { + ccProfileId: explicit.ccProfileId ?? null, + ccCustomerName: explicit.ccCustomerName.trim().slice(0, 128), + }; + } + const pid = + explicit?.ccProfileId != null ? explicit.ccProfileId : getCcProfileId(); + if (pid == null) { + return { ccProfileId: null, ccCustomerName: null }; + } + const profile = await getCcCustomerProfile(pid); + return { + ccProfileId: pid, + ccCustomerName: (profile?.name || "").trim().slice(0, 128) || null, + }; +} diff --git a/src/services/import/instruction-import-log.ts b/src/services/import/instruction-import-log.ts new file mode 100644 index 0000000..aa7dd7b --- /dev/null +++ b/src/services/import/instruction-import-log.ts @@ -0,0 +1,227 @@ +/** + * 指令级导入日志:一封邮件可有多条指令,每条成功/失败各写一条 container_import, + * 供 /logs「导入日志」按指令展示(非等整封邮件全部成功才落库)。 + * + * container_no 带 WO/DO/XF 前缀,避免与真实柜号 SUCCESS 冲突检测撞车。 + */ +import { prisma } from "@/services/db"; +import { + getInstructionExecutionStateMap, + listActionableInstructionUnits, +} from "@/services/parse/instruction-execution-state"; +import { + normalizeExternalId, + resolveImportCcCustomerMeta, +} from "@/services/import/import-log-meta"; +import { sha256 } from "@/utils/hash"; + +export type InstructionImportLogKind = + | "work_order" + | "do_upload" + | "transfer" + | string; + +const KIND_PREFIX: Record = { + work_order: "WO", + do_upload: "DO", + transfer: "XF", +}; + +/** 回填时只补这些;预报本身已有柜级 container_import */ +const BACKFILL_KINDS = new Set(["work_order", "do_upload", "transfer"]); + +export function instructionImportShipmentsHash(instructionId: string): string { + return sha256(`instruction:${instructionId}`); +} + +/** 列表「对象」列:最长 50(与 schema VarChar(50) 对齐) */ +export function instructionImportObjectKey(input: { + kind: InstructionImportLogKind; + instructionId: string; + display?: string | null; +}): string { + const prefix = KIND_PREFIX[input.kind] || "IN"; + const disp = String(input.display || "") + .trim() + .toUpperCase() + .replace(/\s+/g, ""); + const core = (disp || input.instructionId.replace(/[^A-Za-z0-9_-]/g, "")).slice( + 0, + 40, + ); + return `${prefix}/${core || "ITEM"}`.slice(0, 50); +} + +export async function recordInstructionImportLog(input: { + mailId: bigint; + instructionId: string; + kind: InstructionImportLogKind; + /** 柜号 / 参考号 / 指令摘要,用于对象列 */ + display?: string | null; + status: "SUCCESS" | "FAILED" | "IMPORTING"; + externalId?: unknown; + lastError?: string | null; + requestBody?: string | null; + responseBody?: string | null; + /** 历史回填:用指令导入时间作为日志时间 */ + importedAt?: string | Date | null; + ccProfileId?: bigint | null; + ccCustomerName?: string | null; +}): Promise<"created" | "updated"> { + const containerNo = instructionImportObjectKey({ + kind: input.kind, + instructionId: input.instructionId, + display: input.display, + }); + const shipmentsHash = instructionImportShipmentsHash(input.instructionId); + const lastError = input.lastError + ? String(input.lastError).slice(0, 1000) + : null; + const at = input.importedAt ? new Date(input.importedAt) : null; + const stamp = at && !Number.isNaN(at.getTime()) ? at : undefined; + const externalId = normalizeExternalId(input.externalId); + const customer = await resolveImportCcCustomerMeta({ + ccProfileId: input.ccProfileId, + ccCustomerName: input.ccCustomerName, + }); + const importKind = String(input.kind || "work_order").slice(0, 32); + + const existing = await prisma.containerImport.findUnique({ + where: { + mailId_containerNo_shipmentsHash: { + mailId: input.mailId, + containerNo, + shipmentsHash, + }, + }, + select: { id: true }, + }); + + if (!existing) { + await prisma.containerImport.create({ + data: { + mailId: input.mailId, + containerNo, + shipmentsHash, + status: input.status, + externalId, + lastError, + requestBody: input.requestBody ?? null, + responseBody: input.responseBody ?? null, + importKind, + ccCustomerName: customer.ccCustomerName, + ccProfileId: customer.ccProfileId, + ...(stamp ? { createdAt: stamp, updatedAt: stamp } : {}), + }, + }); + return "created"; + } + + await prisma.containerImport.update({ + where: { id: existing.id }, + data: { + status: input.status, + externalId, + lastError, + requestBody: input.requestBody ?? undefined, + responseBody: input.responseBody ?? undefined, + importKind, + ccCustomerName: customer.ccCustomerName ?? undefined, + ccProfileId: customer.ccProfileId ?? undefined, + }, + }); + return "updated"; +} + +/** + * 扫描 lineage.instruction_state 中已 IMPORTED 的工单/DO/转仓,补写导入日志。 + * 幂等:已有同 mail+instruction 记录则跳过创建。 + */ +export async function backfillInstructionImportLogs(options?: { + dryRun?: boolean; +}): Promise<{ scannedMails: number; created: number; skipped: number }> { + const dryRun = !!options?.dryRun; + const rows = await prisma.parseResult.findMany({ + select: { + mailId: true, + lineage: true, + mail: { + select: { + subject: true, + bodyText: true, + attachments: { select: { filename: true } }, + }, + }, + }, + }); + + let created = 0; + let skipped = 0; + + for (const row of rows) { + const stateMap = getInstructionExecutionStateMap(row.lineage); + const ids = Object.keys(stateMap); + if (!ids.length) continue; + + const units = listActionableInstructionUnits({ + subject: row.mail.subject, + body: row.mail.bodyText, + filenames: row.mail.attachments.map((a) => a.filename), + lineage: row.lineage, + }); + const byId = new Map(units.map((u) => [u.id, u])); + + for (const instructionId of ids) { + const entry = stateMap[instructionId]; + if (!entry || entry.status !== "IMPORTED") { + skipped += 1; + continue; + } + const unit = byId.get(instructionId); + const kind = unit?.uiKind || "work_order"; + if (!BACKFILL_KINDS.has(kind)) { + skipped += 1; + continue; + } + + const hash = instructionImportShipmentsHash(instructionId); + const already = await prisma.containerImport.findFirst({ + where: { mailId: row.mailId, shipmentsHash: hash }, + select: { id: true }, + }); + if (already) { + skipped += 1; + continue; + } + + const submitted = entry.submitted || {}; + const display = + submitted.F_ReferCode || + submitted.F_RefNo || + unit?.summary || + unit?.label || + instructionId; + + if (dryRun) { + created += 1; + continue; + } + + const action = await recordInstructionImportLog({ + mailId: row.mailId, + instructionId, + kind, + display, + status: "SUCCESS", + externalId: entry.externalId || null, + lastError: + entry.mode === "mock" ? `${String(kind).toUpperCase()}_MOCK_OK` : null, + importedAt: entry.importedAt, + }); + if (action === "created") created += 1; + else skipped += 1; + } + } + + return { scannedMails: rows.length, created, skipped }; +} diff --git a/src/services/import/instruction-import-state.ts b/src/services/import/instruction-import-state.ts index 4e108f2..b6fb504 100644 --- a/src/services/import/instruction-import-state.ts +++ b/src/services/import/instruction-import-state.ts @@ -5,8 +5,13 @@ import { listActionableInstructionUnits, markInstructionImported, resolveMailStatusAfterInstructionImport, + type InstructionExecutionStateEntry, } from "@/services/parse/instruction-execution-state"; import type { MailStatus } from "@/types/mail"; +import type { + WorkOrderFieldMarks, + WorkOrderSnapshotField, +} from "@/services/parse/work-order-snapshot"; export async function validateInstructionImportable(input: { mailId: bigint; @@ -19,10 +24,21 @@ export async function validateInstructionImportable(input: { include: { attachments: true, parseResult: true }, }); if (!mail) return { error: "NOT_FOUND" as const }; + const lineage = mail.parseResult?.lineage || {}; if (!input.allowedStatuses.includes(mail.status as MailStatus)) { - return { error: "BAD_STATUS" as const, mail }; + // 历史:mock 误标 SUCCESS 时仍允许对该 mock 指令重提 + const mockRetry = + mail.status === "SUCCESS" && + !!input.instructionId && + ( + lineage as { + instruction_state?: Record; + } + )?.instruction_state?.[input.instructionId]?.mode === "mock"; + if (!mockRetry) { + return { error: "BAD_STATUS" as const, mail }; + } } - const lineage = mail.parseResult?.lineage || {}; const units = listActionableInstructionUnits({ subject: mail.subject, body: mail.bodyText, @@ -39,10 +55,11 @@ export async function validateInstructionImportable(input: { return { error: "FORBIDDEN_KIND" as const, mail, unit }; } const state = ( - (lineage as { instruction_state?: Record }) + (lineage as { instruction_state?: Record }) ?.instruction_state || {} )[unit.id]; - if (state?.status === "IMPORTED") { + // mock 成功未写真实 CC,允许改 live 后再次提交 + if (state?.status === "IMPORTED" && state.mode !== "mock") { return { error: "ALREADY_IMPORTED" as const, mail, unit }; } return { mail, unit, lineage }; @@ -55,6 +72,10 @@ export async function markInstructionImportedAndResolveMail(input: { mode: string; externalId?: string | null; successLastError?: string | null; + submitted?: Record; + supplementedFields?: WorkOrderSnapshotField[]; + fieldMarks?: WorkOrderFieldMarks; + userSupplemented?: boolean; }): Promise { const mail = await prisma.mailMessage.findUnique({ where: { id: input.mailId }, @@ -67,7 +88,11 @@ export async function markInstructionImportedAndResolveMail(input: { importedBy: input.actor, mode: input.mode, externalId: input.externalId || null, - }); + submitted: input.submitted, + supplementedFields: input.supplementedFields, + fieldMarks: input.fieldMarks, + userSupplemented: input.userSupplemented, + } satisfies InstructionExecutionStateEntry); const status = resolveMailStatusAfterInstructionImport({ currentStatus: mail.status as MailStatus, subject: mail.subject, diff --git a/src/services/parse/address-block.ts b/src/services/parse/address-block.ts index f26b22d..a3c0114 100644 --- a/src/services/parse/address-block.ts +++ b/src/services/parse/address-block.ts @@ -30,30 +30,86 @@ export type LabeledField = | "F_Expected_DeliveryDateB" | "F_Expected_DeliveryDateE"; -const LABELS: Array<{ keys: string[]; field: LabeledField }> = [ - { keys: ["仓库代码", "仓库ID", "仓库id", "目的代码", "FBACode", "FBA Code"], field: "F_FBACode" }, +type BlockField = LabeledField | "_RegionCombo" | "_CompanyName"; + +const LABELS: Array<{ keys: string[]; field: BlockField }> = [ + { + keys: [ + "仓库代码", + "仓库编码", + "仓库ID", + "仓库id", + "目的代码", + "FBACode", + "FBA Code", + "Warehouse Code", + "Warehouse", + "Mark", + ], + field: "F_FBACode", + }, { keys: ["渠道", "Move Type", "Transporter", "运输方式"], field: "F_Transporter" }, { keys: ["件数", "箱数", "Qty.(CTNS)", "CTNS"], field: "F_CTNS" }, { keys: ["总体积", "体积(m³)", "Vol(CBM)", "CBM"], field: "F_CBM" }, { keys: ["毛重", "G.W.(KGS)", "Weight"], field: "F_Weight" }, { keys: ["件数(PCS)", "PCS", "Pcs"], field: "F_Pcs" }, - { keys: ["FBA ID", "FBAID", "FBAId(Amazon)", "Shipment ID(FBA)"], field: "F_FBAID" }, + { + keys: ["FBA ID", "FBAID", "FBAId(Amazon)", "Shipment ID(FBA)"], + field: "F_FBAID", + }, { keys: ["PO(Amazon)", "Amazon PO", "FBA PO"], field: "F_PO" }, - { keys: ["Amazon reference ID", "ReferenceId", "Reference ID"], field: "F_ReferenceId" }, { - keys: ["分货标识", "箱唛", "Customer Ref No.", "Customer Ref No", "Custmer Ref No.", "ShipmentID"], + keys: ["Amazon reference ID", "ReferenceId", "Reference ID"], + field: "F_ReferenceId", + }, + { + keys: [ + "分货标识", + "箱唛", + "Customer Ref No.", + "Customer Ref No", + "Custmer Ref No.", + "ShipmentID", + ], field: "F_ShipmentID", }, - { keys: ["收货地址", "派送地址", "详细地址", "Address"], field: "F_Address" }, + { + keys: [ + "收货地址", + "详细地址", + "派送地址", + "地址", + "街道", + "Address", + "ADDR", + "Street", + ], + field: "F_Address", + }, { keys: ["城市", "City"], field: "F_City" }, - { keys: ["州省", "State"], field: "F_State" }, - { keys: ["省/市/区", "省市/区", "省市区"], field: "F_State" }, - { keys: ["邮政编码", "邮编", "ZipCode", "Zip Code", "ZIP"], field: "F_ZipCode" }, - { keys: ["收件人", "收货人", "联系人", "Contact"], field: "F_Contact" }, - { keys: ["联系电话", "手机号", "电话", "Mobile"], field: "F_Mobile" }, - { keys: ["邮箱", "Email"], field: "F_Email" }, - { keys: ["公司名称", "公司名", "Company Name", "Company"], field: "F_Company" }, - { keys: ["行备注", "备注", "Remark"], field: "F_Remark" }, + { keys: ["州省", "State", "省州"], field: "F_State" }, + { keys: ["省/市/区", "省市/区", "省市区", "州/城市", "State/City"], field: "_RegionCombo" }, + { + keys: ["邮政编码", "邮编", "ZipCode", "Zip Code", "ZIP", "Postcode"], + field: "F_ZipCode", + }, + { + keys: ["收件人", "收货人", "联系人", "Contact", "Consignee", "Receiver", "Attn"], + field: "F_Contact", + }, + { + keys: ["联系电话", "手机号", "电话", "手机", "Tel", "Phone", "Mobile", "Telephone"], + field: "F_Mobile", + }, + { keys: ["邮箱", "Email", "E-mail"], field: "F_Email" }, + { + keys: ["公司名称", "公司名", "Company Name", "Company"], + field: "_CompanyName", + }, + { + keys: ["行备注", "备注", "Remark", "Note", "客户拆柜备注", "客户预约备注"], + field: "F_Remark", + }, { keys: ["SKU"], field: "F_SKU" }, { keys: ["品名", "货物描述", "Description"], field: "F_Description" }, { keys: ["英文名称", "English Name", "EnglishName"], field: "F_EnglishName" }, @@ -72,20 +128,79 @@ function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function normHeader(h: string): string { + return (h || "").replace(/\s+/g, "").toLowerCase(); +} + const LABEL_ALT = [...new Set(LABELS.flatMap((x) => x.keys))] .sort((a, b) => b.length - a.length) .map(escapeRe) .join("|"); -const LABEL_RE = new RegExp(`(${LABEL_ALT})\\s*[::]`, "gi"); +/** 标签 + 多种分隔符(对齐 AI 助手 packing_parse._split_kv_segments) */ +const LABEL_SPLIT_RE = new RegExp( + `(${LABEL_ALT})\\s*(?:[::]|[-–—→]|=>|[·・])\\s*`, + "gi", +); -function fieldForLabel(label: string): LabeledField | null { - const n = label.replace(/\s+/g, "").toLowerCase(); +function fieldForLabel(label: string): BlockField | null { + const n = normHeader(label); for (const row of LABELS) { - if (row.keys.some((k) => k.replace(/\s+/g, "").toLowerCase() === n)) { - return row.field; + if (row.keys.some((k) => normHeader(k) === n)) return row.field; + } + return mapBlockLabelFuzzy(label); +} + +function mapBlockLabelFuzzy(label: string): BlockField | null { + const lab = (label || "").trim(); + if (!lab) return null; + const key = normHeader(lab); + const low = lab.toLowerCase().replace(/\s+/g, ""); + if (/省\s*\/\s*市\s*\/\s*区|省市区/.test(lab)) return "_RegionCombo"; + + const rules: Array<[BlockField, string[]]> = [ + ["F_Mobile", ["联系电话", "手机号", "电话", "手机", "tel", "phone", "mobile", "telephone"]], + ["F_ZipCode", ["邮政编码", "邮编", "zipcode", "zip", "postcode", "postal"]], + [ + "F_Address", + ["收货地址", "详细地址", "派送地址", "街道地址", "街道", "address", "addr", "street"], + ], + ["F_Contact", ["收件人", "联系人", "consignee", "receiver", "contact", "attn"]], + ["_CompanyName", ["公司名称", "公司名", "company"]], + [ + "F_FBACode", + ["仓库代码", "仓库编码", "仓库id", "目的代码", "warehouse", "fbacode", "mark"], + ], + ["_RegionCombo", ["州/城市", "state/city", "州省市区"]], + ["F_City", ["城市", "city"]], + ["F_State", ["州", "state", "省州"]], + ["F_Remark", ["行备注", "备注", "remark", "note", "客户拆柜备注", "客户预约备注"]], + ["F_Email", ["邮箱", "email", "e-mail"]], + ]; + + for (const [field, kws] of rules) { + for (const kw of kws) { + const nk = /[\u4e00-\u9fff]/.test(kw) + ? normHeader(kw) + : kw.toLowerCase().replace(/\s+/g, ""); + if (!nk) continue; + if (nk.includes(key) || key.includes(nk) || low.includes(nk)) { + if (field === "F_State" && key.length > 6 && !lab.includes("州") && !low.includes("state")) { + continue; + } + if ( + field === "F_Address" && + (lab.includes("仓库") || low.includes("warehouse")) && + !lab.includes("收货") + ) { + continue; + } + return field; + } } } + if (key === "地址" || low === "addr" || low === "address") return "F_Address"; + if (key === "电话" || low === "tel" || low === "phone") return "F_Mobile"; return null; } @@ -93,6 +208,34 @@ function placeholderEmpty(t: string): boolean { return !t || t === "/" || t === "/" || t === "-" || t === "—"; } +function cleanBlockFieldValue(field: BlockField, val: string): string { + const text = (val || "").trim(); + if (!text) return ""; + if (field === "F_Mobile") { + const base = text.replace(/[((][^))]*[))]/g, " "); + const digits = base.replace(/\D/g, ""); + return digits.length >= 7 ? digits : base.replace(/\s+/g, " ").trim(); + } + if (field === "F_ZipCode") { + const base = text.replace(/[((][^))]*[))]/g, " "); + const us = base.match(/\b(\d{5}(?:-\d{4})?)\b/); + if (us) return us[1]; + const ca = base.match(/\b([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)\b/); + if (ca) return ca[0].replace(/\s+/g, "").toUpperCase(); + return base.split(/\s+(?:ext\.?|extension)\b/i)[0]?.trim() || base.trim(); + } + if ( + field === "F_Address" || + field === "F_Contact" || + field === "F_Email" || + field === "F_Remark" || + field === "_CompanyName" + ) { + return text.replace(/[ \t]+\n/g, "\n").trim(); + } + return text.replace(/\s+/g, " ").trim(); +} + /** 「NV / HENDERSON /」或「NV/Henderson」→ 州 + 城市 */ export function parseRegionComposite(raw: unknown): { state: string | null; @@ -124,7 +267,7 @@ function normalizeCellText(text: string): string { function labelHitCount(src: string): number { let n = 0; - const re = new RegExp(LABEL_RE.source, "gi"); + const re = new RegExp(LABEL_SPLIT_RE.source, "gi"); while (re.exec(src)) n += 1; return n; } @@ -133,19 +276,38 @@ export function looksLikeLabeledBlock(text: string): boolean { return labelHitCount(normalizeCellText(text)) >= 1; } -function emptyLabeled(): LabeledShipmentFields { - return {}; +function assignBlockField( + out: LabeledShipmentFields, + field: BlockField, + value: string, +): void { + const cleaned = cleanBlockFieldValue(field, value); + if (placeholderEmpty(cleaned)) return; + if (field === "_RegionCombo") { + const region = parseRegionComposite(cleaned); + if (region.state && !out.F_State) out.F_State = region.state; + if (region.city && !out.F_City) out.F_City = region.city; + return; + } + if (field === "_CompanyName") { + if (!out.F_Company) out.F_Company = cleaned; + if (!out.F_EnglishName) out.F_EnglishName = cleaned; + return; + } + const key = field as LabeledField; + if (!out[key]) out[key] = cleaned; } /** - * 任意单元格:「标签:值」可多项。一项归本列,其余归对应货件字段。 + * 任意单元格:「标签:值」可多项,顺序/个数/文案可不标准。 + * 一项归本列,其余归对应货件字段。 */ export function parseLabeledCell(text: string): LabeledShipmentFields | null { const src = normalizeCellText(text); if (!src || labelHitCount(src) < 1) return null; - const hits: Array<{ field: LabeledField; start: number; valueStart: number }> = []; - const re = new RegExp(LABEL_RE.source, "gi"); + const hits: Array<{ field: BlockField; start: number; valueStart: number }> = []; + const re = new RegExp(LABEL_SPLIT_RE.source, "gi"); let m: RegExpExecArray | null; while ((m = re.exec(src))) { const field = fieldForLabel(m[1]); @@ -154,19 +316,20 @@ export function parseLabeledCell(text: string): LabeledShipmentFields | null { } if (!hits.length) return null; - const out = emptyLabeled(); + if (hits.length === 1) { + const hasStrongSep = + /[::]/.test(src) || + src.includes("\n") || + /\s[-–—→=>·・]\s/.test(src); + if (!hasStrongSep) return null; + } + + const out: LabeledShipmentFields = {}; for (let i = 0; i < hits.length; i++) { const cur = hits[i]; const end = i + 1 < hits.length ? hits[i + 1].start : src.length; const value = src.slice(cur.valueStart, end).replace(/^[\s;;]+|[\s;;]+$/g, ""); - if (placeholderEmpty(value)) continue; - if (cur.field === "F_State" && /[//]/.test(value)) { - const region = parseRegionComposite(value); - if (region.state && !out.F_State) out.F_State = region.state; - if (region.city && !out.F_City) out.F_City = region.city; - continue; - } - if (!out[cur.field]) out[cur.field] = value; + assignBlockField(out, cur.field, value); } return Object.keys(out).length ? out : null; } @@ -188,12 +351,112 @@ export function mergeLabeledHarvest( block: LabeledShipmentFields, homeField?: string, ): void { - for (const [key, value] of Object.entries(block) as Array<[LabeledField, string | undefined]>) { + for (const [key, value] of Object.entries(block) as Array< + [LabeledField, string | undefined] + >) { if (!value) continue; if (!target[key] || key === homeField) target[key] = value; } } +const SHIPMENT_LABELED_SCAN_KEYS = [ + "F_FBACode", + "F_Transporter", + "F_CTNS", + "F_CBM", + "F_Weight", + "F_Pcs", + "F_FBAID", + "F_PO", + "F_ReferenceId", + "F_ShipmentID", + "F_Address", + "F_City", + "F_State", + "F_ZipCode", + "F_Contact", + "F_Mobile", + "F_Email", + "F_Company", + "F_EnglishName", + "F_Remark", + "F_SKU", + "F_Description", + "F_ContainerNo", + "F_Expected_DeliveryDateB", + "F_Expected_DeliveryDateE", + "channel_raw", +] as const; + +function strEmpty(v: unknown): boolean { + const t = String(v ?? "").trim(); + return !t || t === "/" || t === "—"; +} + +/** + * 对齐 AI 助手 apply_labeled_blocks_to_row: + * 任意字段若含「标签:值」多段文本,拆到对应货件字段;本列只保留归属值。 + */ +export function applyLabeledBlocksToShipmentRow< + T extends Record, +>(row: T): T { + const out: Record = { ...row }; + let changed = false; + + for (const hostField of SHIPMENT_LABELED_SCAN_KEYS) { + const val = out[hostField]; + if (typeof val !== "string" || !looksLikeLabeledBlock(val)) continue; + const block = parseLabeledCell(val); + if (!block || !Object.keys(block).length) continue; + + changed = true; + for (const [key, piece] of Object.entries(block) as Array< + [LabeledField, string | undefined] + >) { + if (!piece) continue; + if (key === hostField) { + out[hostField] = piece; + continue; + } + if (strEmpty(out[key])) out[key] = piece; + } + if (hostField === "F_Address") { + if (block.F_Address) out.F_Address = block.F_Address; + else if ( + typeof out.F_Address === "string" && + looksLikeLabeledBlock(out.F_Address) + ) { + const home = columnValueFromCell("F_Address", out.F_Address); + out.F_Address = home || out.F_Address; + } + } else if (!block[hostField as LabeledField]) { + const home = columnValueFromCell(hostField, val); + if (home) out[hostField] = home; + } + } + + // Address 列仍含标签时强制再拆(防换行/粘连漏拆) + const addr = out.F_Address; + if (typeof addr === "string" && looksLikeLabeledBlock(addr)) { + const block = parseLabeledCell(addr); + if (block && Object.keys(block).length) { + changed = true; + for (const [key, piece] of Object.entries(block) as Array< + [LabeledField, string | undefined] + >) { + if (!piece) continue; + if (key === "F_Address") out.F_Address = piece; + else if (strEmpty(out[key])) out[key] = piece; + } + if (!block.F_Address && typeof out.F_Address === "string") { + out.F_Address = columnValueFromCell("F_Address", addr) || out.F_Address; + } + } + } + + return (changed ? out : row) as T; +} + export function firstFilled(...vals: Array): string | null { for (const v of vals) { const t = (v || "").trim(); diff --git a/src/services/parse/bl-plus-template.ts b/src/services/parse/bl-plus-template.ts index d393bb4..4d24a4b 100644 --- a/src/services/parse/bl-plus-template.ts +++ b/src/services/parse/bl-plus-template.ts @@ -7,6 +7,10 @@ import { isKnownUsDestinationPort, matchUsDestinationPort, } from "@/services/parse/us-ports"; +import { + applyServiceFromModules, + normalizeCabinetType, +} from "@/services/parse/header-service-hints"; const ISO_CN = /\b([A-Z]{4}\d{7})\b/; const ISO_CN_EXACT = /^[A-Z]{4}\d{7}$/; @@ -840,14 +844,20 @@ export function mapBlModulesToHeader( opts?: { enableIsoCheck?: boolean }, ): ContainerHeader { const enableIso = opts?.enableIsoCheck ?? true; - const opFromService = - modules.ops_hints?.includes("直送") - ? (2 as const) - : modules.ops_hints?.includes("拆柜") || - modules.ops_hints?.includes("提柜") || - modules.ops_hints?.includes("派送") - ? (0 as const) - : undefined; + const fromHints = applyServiceFromModules({ + ops_hints: modules.ops_hints, + service: modules.service, + }); + const classis = + header.F_Classis ?? + (modules.classis !== undefined ? modules.classis : undefined) ?? + fromHints.F_Classis; + const opType = header.F_OperationType ?? fromHints.F_OperationType; + const instruction = + header.F_Instruction || + (modules.vessel_voyage + ? `船名航次 ${modules.vessel_voyage}` + : fromHints.F_Instruction); return { ...header, F_ContainerNo: header.F_ContainerNo || modules.container_no || "", @@ -856,7 +866,9 @@ export function mapBlModulesToHeader( (modules.container_no ? isValidContainerNo(modules.container_no, enableIso) : header.container_no_valid), - F_CabinetType: header.F_CabinetType || modules.cabinet_type, + F_CabinetType: normalizeCabinetType( + header.F_CabinetType || modules.cabinet_type || "", + ), F_ETA: header.F_ETA || modules.eta || null, F_ETD: header.F_ETD || modules.etd || null, F_BLCopyCode: header.F_BLCopyCode || modules.bl_no || "", @@ -864,20 +876,14 @@ export function mapBlModulesToHeader( normalizeDestinationPort(header.F_Dock) || normalizeDestinationPort(modules.port) || "", - F_Classis: - header.F_Classis ?? - (modules.classis !== undefined ? modules.classis : undefined), - F_OperationType: header.F_OperationType ?? opFromService, + F_Classis: classis as 0 | 1 | undefined, + F_OperationType: opType, F_TransMode: header.F_TransMode ?? (modules.container_no || modules.bl_no || modules.cabinet_type ? 0 : undefined), - F_Instruction: - header.F_Instruction || - (modules.vessel_voyage - ? `船名航次 ${modules.vessel_voyage}` - : undefined), + F_Instruction: instruction, F_MemoRemark: header.F_MemoRemark || (modules.memo_bits?.length ? modules.memo_bits.join(" / ") : undefined), diff --git a/src/services/parse/classify.ts b/src/services/parse/classify.ts index f1c3d6e..f6a1d21 100644 --- a/src/services/parse/classify.ts +++ b/src/services/parse/classify.ts @@ -132,6 +132,36 @@ export function classifyMail(input: { scores.NEW_CONTAINER += 50; } + // 主题仅「预报」+ 表格附件(信息全在 ClientContainerTemplate / 测试AI建单 等) + const subjectForecastOnly = /^预报$/.test(subject.trim()); + const hasSpreadsheet = filenames.some((f) => SPREADSHEET_RE.test(f)); + if (subjectForecastOnly && hasSpreadsheet) { + signals.push({ + signal: "主题预报+表格", + score: 45, + matched: "预报", + source: "subject", + }); + scores.NEW_CONTAINER += 45; + } + + const aiBuildSheet = filenames.some((f) => + /测试AI建单|ClientContainer|AI建单|集装箱导入/i.test(f), + ); + if (aiBuildSheet) { + const add = scores.NEW_CONTAINER < 40 ? 45 : 15; + signals.push({ + signal: "整表建单附件", + score: add, + matched: + filenames.find((f) => + /测试AI建单|ClientContainer|AI建单|集装箱导入/i.test(f), + ) || "xlsx", + source: "attachment", + }); + scores.NEW_CONTAINER += add; + } + // 金样:数据模版 / 明确预报清单附件 → NEW_CONTAINER(不把单纯「卡派资料」当预报) const forecastPacking = filenames.some( (f) => diff --git a/src/services/parse/container-header-from-sheet.ts b/src/services/parse/container-header-from-sheet.ts new file mode 100644 index 0000000..998f130 --- /dev/null +++ b/src/services/parse/container-header-from-sheet.ts @@ -0,0 +1,165 @@ +import { normalizeCabinetType } from "./header-service-hints"; +import type { ContainerHeader, OperationType, TransMode } from "@/types/mail"; +import { isValidContainerNo, normalizeContainerNo } from "@/utils/iso6346"; +import { parseFlexibleDate, toShanghaiDateString } from "@/utils/dates"; + +/** 官方 ClientContainerTemplate 柜头列(与货件列同表头行左侧);仅精确匹配 */ +export const CONTAINER_HEADER_ALIASES: Record = { + F_ContainerNo: ["柜号", "集装箱号", "ContainerNo", "Container No", "SORTBY", "SortBy"], + F_CabinetType: ["柜型", "CabinetType", "Cabinet Type"], + F_TransModeText: ["运输方式"], + F_OperationTypeText: ["业务类型", "操作类型"], + F_BLCopyCode: ["提单号", "提单", "BL", "B/L"], + F_ETD: ["ETD"], + F_ETA: ["ETA"], + F_LoadPort: ["起始港", "起运港", "装货港"], + F_Dock: ["目的港", "卸货港"], + shipping_line_hint: ["船司", "船公司"], + F_Instruction: ["instruction", "Instruction"], + F_MemoRemark: ["备注"], +}; + +function normHeader(h: string): string { + return (h || "").replace(/\s+/g, "").toLowerCase(); +} + +function cleanCellText(raw: unknown): string { + const t = String(raw ?? "").trim(); + if (!t || t === "/" || t === "/" || t === "-" || t === "—") return ""; + return t; +} + +function fmtSheetCell(raw: unknown): string { + if (raw == null || raw === "") return ""; + if (raw instanceof Date && !Number.isNaN(raw.getTime())) { + return toShanghaiDateString(raw) || ""; + } + const text = cleanCellText(raw); + const m = text.match(/^(\d{4}-\d{1,2}-\d{1,2})\b/); + if (m) return m[1]; + const parsed = parseFlexibleDate(text); + return parsed ? toShanghaiDateString(parsed) || text : text; +} + +export function matchContainerHeaderColumn(header: string): string | null { + const n = normHeader(header); + if (!n) return null; + for (const [field, aliases] of Object.entries(CONTAINER_HEADER_ALIASES)) { + if (aliases.some((a) => normHeader(a) === n)) return field; + } + return null; +} + +export function mapTransModeText(raw: string): TransMode | null { + const t = cleanCellText(raw); + if (!t) return null; + if (/^全部|运输方式)|运输方式\)|集装箱状态/.test(t)) return null; + if (/空运|\bAIR\b/i.test(t)) return 1; + if (/自送/.test(t)) return 3; + if (/海运|\bSEA\b|\bOCEAN\b/i.test(t)) return 0; + return null; +} + +export function mapOperationTypeText(raw: string): OperationType | null { + const t = cleanCellText(raw); + if (!t) return null; + if (/^全部/.test(t)) return null; + if (/只清关|仅清关/.test(t)) return 4; + if (/直送/.test(t) && !/拆柜|提拆派/.test(t)) return 2; + if (/拆柜|提拆派|派拆/.test(t)) return 0; + return null; +} + +export function inferTransModeFromCabinet(meta: Record): void { + if (meta.F_TransMode != null) return; + const cab = String(meta.F_CabinetType || ""); + if ( + /(?:20|40|45)\s*(?:HQ|HC|GP)|40HC|40HQ|45HC|45HQ|20GP|53HQ/i.test(cab) + ) { + meta.F_TransMode = 0; + } +} + +/** 从 ClientContainerTemplate 类表格首行数据提取柜头 */ +export function containerHeaderFromSheet( + grid: string[][], + headerIdx: number, + extractedContainerNo: string, + opts: { enableIsoCheck?: boolean } = {}, +): ContainerHeader { + const meta: Record = {}; + const hdr = grid[headerIdx] || []; + const ccol: Record = {}; + hdr.forEach((cellText, idx) => { + const field = matchContainerHeaderColumn(cellText); + if (field) ccol[idx] = field; + }); + + if (Object.keys(ccol).length) { + for (const row of grid.slice(headerIdx + 1)) { + if (!row.some((c) => cleanCellText(c))) continue; + for (const [idxStr, field] of Object.entries(ccol)) { + const idx = Number(idxStr); + const raw = row[idx] ?? ""; + if (field === "F_TransModeText") { + const tm = mapTransModeText(String(raw)); + if (tm != null) meta.F_TransMode = tm; + } else if (field === "F_OperationTypeText") { + const ot = mapOperationTypeText(String(raw)); + if (ot != null) { + meta.F_OperationType = ot; + const tip = cleanCellText(raw); + if (tip && !meta.F_Instruction) meta.F_Instruction = tip; + } + } else if (field === "F_ETD" || field === "F_ETA") { + const val = fmtSheetCell(raw); + if (val) meta[field] = val; + } else if (field === "F_ContainerNo") { + const cn = normalizeContainerNo(String(raw)); + if (cn) meta.F_ContainerNo = cn; + } else if (field === "F_CabinetType") { + const cab = normalizeCabinetType(String(raw)); + if (cab) meta.F_CabinetType = cab; + } else { + const val = cleanCellText(raw); + if (val && !meta[field]) meta[field] = val; + } + } + break; + } + } + + inferTransModeFromCabinet(meta); + + const containerNo = normalizeContainerNo( + extractedContainerNo || String(meta.F_ContainerNo || ""), + ); + const out: ContainerHeader = { + F_ContainerNo: containerNo, + container_no_valid: + !!containerNo && + isValidContainerNo(containerNo, opts.enableIsoCheck !== false), + }; + + for (const key of [ + "F_ETA", + "F_ETD", + "F_BLCopyCode", + "F_CabinetType", + "F_Dock", + "F_LoadPort", + "shipping_line_hint", + "F_OperationType", + "F_TransMode", + "F_Classis", + "F_MemoRemark", + "F_Instruction", + ] as const) { + const val = meta[key]; + if (val != null && val !== "") { + (out as unknown as Record)[key] = val; + } + } + + return out; +} diff --git a/src/services/parse/do-upload-extract.ts b/src/services/parse/do-upload-extract.ts index 19d0305..91bb4e5 100644 --- a/src/services/parse/do-upload-extract.ts +++ b/src/services/parse/do-upload-extract.ts @@ -8,7 +8,7 @@ const ISO_CN = /\b([A-Z]{4}\d{7})\b/i; export function isDoAttachmentFilename(filename: string): boolean { const f = filename.trim(); if (!f) return false; - if (/DO请查收|提货单|delivery\s*order/i.test(f)) return true; + if (/DO请查收|提货单|派车单|delivery\s*order/i.test(f)) return true; // 独立 DO 词:xxx-DO.pdf / DO.pdf / 柜号_DO.pdf if (/\.pdf$/i.test(f) && /(^|[^A-Za-z])DO([^A-Za-z]|$)/i.test(f)) { return true; diff --git a/src/services/parse/excel-cell-text.ts b/src/services/parse/excel-cell-text.ts index 8aece03..27e7085 100644 --- a/src/services/parse/excel-cell-text.ts +++ b/src/services/parse/excel-cell-text.ts @@ -20,3 +20,17 @@ export function flattenExcelCellValue(raw: unknown): string { if ("result" in o) return flattenExcelCellValue(o.result); return ""; } + +/** 安全读取 ExcelJS 单元格展示文本(合并单元格 master 为空时 cell.text 可能抛错) */ +export function readExcelCellText(cell: { + value?: unknown; + text?: string; +}): string { + const fromValue = flattenExcelCellValue(cell.value); + if (fromValue) return fromValue; + try { + return cell.text || ""; + } catch { + return ""; + } +} diff --git a/src/services/parse/excel-sheet-grid.ts b/src/services/parse/excel-sheet-grid.ts new file mode 100644 index 0000000..c0d8fde --- /dev/null +++ b/src/services/parse/excel-sheet-grid.ts @@ -0,0 +1,136 @@ +import type ExcelJS from "exceljs"; +import { readExcelCellText } from "./excel-cell-text"; + +/** 合并单元格的从属格视为空,只保留左上角 master 的值 */ +export function isMergedSlaveCell(cell: ExcelJS.Cell): boolean { + if (!cell.isMerged) return false; + const master = cell.master as ExcelJS.Cell | undefined; + if (!master) return false; + return master.address !== cell.address; +} + +/** 按值读取整张表为二维网格(合并从属格为空,避免重复/崩溃) */ +export function readWorksheetGrid(sheet: ExcelJS.Worksheet): string[][] { + const rowCount = sheet.rowCount || 0; + const colCount = sheet.columnCount || 0; + if (rowCount <= 0 || colCount <= 0) return []; + + const grid: string[][] = []; + for (let r = 1; r <= rowCount; r++) { + const row: string[] = []; + for (let c = 1; c <= colCount; c++) { + const cell = sheet.getCell(r, c); + if (isMergedSlaveCell(cell)) { + row.push(""); + continue; + } + row.push(readExcelCellText(cell)); + } + grid.push(row); + } + return grid; +} + +/** + * 在顶部若干行中找「字段名最完整」的表头行(中英双表头时优先中文行)。 + * 对齐 AI 助手 packing_parse 评分逻辑。 + */ +export function findPackingHeaderRowIndex( + grid: string[][], + maxScan = 15, +): number { + let bestIdx = 0; + let bestScore = -1; + const limit = Math.min(grid.length, maxScan); + for (let i = 0; i < limit; i++) { + const joined = grid[i].join("|"); + let score = 0; + if (/渠道/.test(joined)) score += 3; + if (/件数/.test(joined)) score += 3; + if (/仓库/.test(joined)) score += 3; + if (/FBACode/i.test(joined)) score += 2; + if (/Qty\.\(CTNS\)|QTY\(CTNS\)|\bCTNS\b/i.test(joined)) score += 2; + if (/Address/i.test(joined) || /派送地址|收货地址/.test(joined)) score += 2; + if (/Move Type|Transporter/i.test(joined)) score += 3; + if (/QTY\(CTNS\)|GW\(KGS\)|Vol\(CBM\)/i.test(joined)) score += 2; + if (/柜型/.test(joined) && /柜号/.test(joined)) score += 3; + if (/仓库ID|派送地址|分货标识/.test(joined)) score += 2; + if (/集装箱信息基础|货件详情/.test(joined)) score -= 10; + if (score > bestScore && score >= 5) { + bestScore = score; + bestIdx = i; + } + } + return bestIdx; +} + +/** 顶部合并标题行(如「卡派操作数据表格」) */ +export function isPackingTitleNoiseRow(cells: string[]): boolean { + const parts = cells.map((v) => v.trim()).filter(Boolean); + if (!parts.length) return false; + const unique = new Set(parts); + if (unique.size === 1 && /^卡派操作数据表格$/i.test([...unique][0])) return true; + if (parts.every((p) => p === parts[0]) && /操作数据表格/.test(parts[0])) return true; + return false; +} + +/** 表尾合计/汇总行 */ +export function isPackingSummaryRow(raw: Record): boolean { + const blob = Object.values(raw) + .map((v) => String(v ?? "").trim()) + .filter(Boolean) + .join(" "); + if (/合计|总计|小计|汇总|合计行|grand\s*total|\btotal\b|\bsum\b/i.test(blob)) { + return true; + } + const channel = String(raw.F_Transporter ?? "").trim(); + const warehouse = String(raw.F_FBACode ?? "").trim(); + const shipment = String(raw.F_ShipmentID ?? "").trim(); + const fbaId = String(raw.F_FBAID ?? "").trim(); + const po = String(raw.F_PO ?? "").trim(); + if (channel) return false; + if (warehouse && !/^(UPS|FEDEX|USPS|DHL)$/i.test(warehouse)) return false; + if (shipment && shipment !== "/") return false; + if (fbaId || po) return false; + const ctns = Number(raw.F_CTNS); + const cbm = raw.F_CBM; + const weight = raw.F_Weight; + const hasMetric = + (Number.isFinite(ctns) && ctns > 0) || + (cbm != null && cbm !== "") || + (weight != null && weight !== ""); + return hasMetric; +} + +/** 表尾合并备注 / 填写说明行 */ +export function isPackingFooterNoiseRow( + raw: Record, + cells: string[], +): boolean { + const parts = cells.map((v) => v.trim()).filter(Boolean); + const blob = parts.join(" "); + if (!blob) return false; + if (/^注意\s*[::]/.test(blob)) return true; + if (parts.some((p) => /^注意\s*[::]/.test(p))) return true; + if (/必须填写|单元格格式|请勿修改表头|填写规范/.test(blob) && blob.length > 40) { + return true; + } + const nonEmpty = parts.filter(Boolean); + if ( + nonEmpty.length >= 3 && + new Set(nonEmpty).size === 1 && + /^注意/.test(nonEmpty[0]) + ) { + return true; + } + const channel = String(raw.F_Transporter ?? "").trim(); + const fba = String(raw.F_FBACode ?? "").trim(); + const addr = String(raw.F_Address ?? "").trim(); + if ( + (channel.length > 40 || fba.length > 40 || addr.length > 80) && + /注意|填写|单元格|必须填写/.test(blob) + ) { + return true; + } + return false; +} diff --git a/src/services/parse/extract-container-header.ts b/src/services/parse/extract-container-header.ts index 4a52fad..ce24b17 100644 --- a/src/services/parse/extract-container-header.ts +++ b/src/services/parse/extract-container-header.ts @@ -6,12 +6,20 @@ import { isSeaBlPrefix, } from "@/services/parse/carrier-aliases"; import { normalizeDestinationPort } from "@/services/parse/bl-plus-template"; -import { matchUsDestinationPort } from "@/services/parse/us-ports"; +import { + applyServiceFromText, + normalizeCabinetType, + stripOrChoiceNoise, +} from "@/services/parse/header-service-hints"; +import { + matchUsDestinationPort, + scanUsDestinationPortInText, +} from "@/services/parse/us-ports"; const CABINET_RE = /\b(45HC|45HQ|40HC|40HQ|40GP|20GP|20HQ|45GP)\b/i; const LOOSE_DATE = String.raw`(?:\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{1,2}[-/.]\d{1,2})`; const ETA_RE = new RegExp( - String.raw`(?:ETA|预计抵港|抵港日|到港)\s*[::\s]*(${LOOSE_DATE})`, + String.raw`(?:ETA|预计抵港|预计到达(?:时间)?|抵港日|到港(?:日|时间)?)\s*[::\s]*(${LOOSE_DATE})`, "i", ); const ETD_RE = new RegExp( @@ -20,16 +28,14 @@ const ETD_RE = new RegExp( ); const BL_RE = /(?:提单(?:号)?|B\/?L(?:\s*(?:No\.?|号))?|BLCopy)\s*[::#\s]*([A-Z0-9][A-Z0-9\-_/]{3,31})/i; -/** 无标签提单号(常见船司前缀+数字,如 WHLC027G597465) */ -const BARE_BL_RE = /\b([A-Z]{4}[A-Z0-9]{6,28})\b/; +/** 无标签提单号(WHLC… / WHL063… / EGLV…);须含数字且非 ISO 柜号 */ +const BARE_BL_RE = /\b([A-Z]{2,4}[A-Z0-9]{6,28})\b/; const LOAD_PORT_RE = /(?:装货港|起运港|POL)\s*[::\s]*([^\n\r,,;;]{1,40})/i; const DOCK_RE = /(?:目的港|卸货港|码头|卸货码头|POD(?:\s*码头)?)\s*[::\s]*([^\n\r,,;;+]{1,40})/i; const SHIP_LINE_RE = /(?:船司|船公司|Shipping\s*Line|Carrier)\s*[::\s]*([^\n\r,,;;]{1,60})/i; -const VESSEL_RE = /船名航次\s*([^\n\r+]{2,60})/i; -const CLASSIS_RE = /(?:带车架|带托架)/; -const NO_CLASSIS_RE = /(?:不带车架|不带托架|无车架)/; +const VESSEL_RE = /船名航次\s*[::]?\s*([^\n\r+]{2,80})/i; const SEAL_NO_RE = /(?:封号|铅封(?:号)?|封条号?)\s*[::]\s*([A-Z0-9][A-Z0-9\-_/]{3,31})/gi; const ISO_CN_RE = /^[A-Z]{4}\d{7}$/; @@ -92,51 +98,54 @@ function findShippingLineAliasInText(text: string): string | null { /** * 从主题+正文抽取柜头字段(卡派表通常无这些列)。 - * 不覆盖已有非空字段(由 mergeContainerHeader 控制)。 + * 对齐 AI 助手 header_extract._extract_labeled + _apply_service + _infer_trans_mode。 */ export function extractContainerHeaderFromText( text: string, ): MailHeaderExtract { + const cleaned = stripOrChoiceNoise(text || ""); const out: MailHeaderExtract = {}; - if (!text.trim()) return out; + if (!cleaned.trim()) return out; - const cab = text.match(CABINET_RE); - if (cab) out.F_CabinetType = cab[1].toUpperCase(); + const cab = cleaned.match(CABINET_RE); + if (cab) out.F_CabinetType = normalizeCabinetType(cab[1]); - const eta = text.match(ETA_RE); + const eta = cleaned.match(ETA_RE); const etaDate = normDate(eta?.[1]); if (etaDate) out.F_ETA = etaDate; - const etd = text.match(ETD_RE); + const etd = cleaned.match(ETD_RE); const etdDate = normDate(etd?.[1]); if (etdDate) out.F_ETD = etdDate; - const bl = text.match(BL_RE); + const bl = cleaned.match(BL_RE); if (bl?.[1]) { out.F_BLCopyCode = bl[1].trim().toUpperCase(); } else { - // 跳过 ISO 柜号,取首个像提单的裸串 - for (const m of text.matchAll(new RegExp(BARE_BL_RE.source, "g"))) { + for (const m of cleaned.matchAll(new RegExp(BARE_BL_RE.source, "gi"))) { const cand = m[1].toUpperCase(); if (ISO_CN_RE.test(cand)) continue; if (CABINET_RE.test(cand)) continue; + if (!/\d/.test(cand)) continue; out.F_BLCopyCode = cand; break; } } - const port = text.match(LOAD_PORT_RE); + const port = cleaned.match(LOAD_PORT_RE); if (port?.[1]) out.F_LoadPort = port[1].trim(); - const dock = text.match(DOCK_RE); + const dock = cleaned.match(DOCK_RE); const dockMatched = dock?.[1] ? matchUsDestinationPort(dock[1]) : null; - // 仅「目的港/卸货港/POD」显式标签 + 白名单;不扫全文乱截 if (dockMatched) { out.F_Dock = dockMatched; + } else { + const scanned = scanUsDestinationPortInText(cleaned); + if (scanned) out.F_Dock = scanned; } - // 船司线索优先级:显式「船司」> 提单前缀 > 正文已知船司简称 > 船名首词 - const line = text.match(SHIP_LINE_RE); + // 船司:显式「船司」> 提单前缀 > 正文别名;禁止船名航次首词(WAN HAI→WAN) + const line = cleaned.match(SHIP_LINE_RE); if (line?.[1]) { out.shipping_line_hint = line[1].trim(); } else { @@ -144,53 +153,72 @@ export function extractContainerHeaderFromText( if (blHint) { out.shipping_line_hint = blHint; } else { - const aliasHint = findShippingLineAliasInText(text); - if (aliasHint) { - out.shipping_line_hint = aliasHint; - } else { - const vessel = text.match(VESSEL_RE); - if (vessel?.[1]) { - out.shipping_line_hint = vessel[1].trim().split(/\s+/)[0]; - } - } + const aliasHint = findShippingLineAliasInText(cleaned); + if (aliasHint) out.shipping_line_hint = aliasHint; + } + } + if (!out.shipping_line_hint) { + const vessel = cleaned.match(VESSEL_RE); + if (vessel?.[1]) { + const fromVessel = findShippingLineAliasInText(vessel[1]); + if (fromVessel) out.shipping_line_hint = fromVessel; } } - if (NO_CLASSIS_RE.test(text)) out.F_Classis = 0; - else if (CLASSIS_RE.test(text)) out.F_Classis = 1; + const service = applyServiceFromText(cleaned); + if (service.F_Classis === 0 || service.F_Classis === 1) { + out.F_Classis = service.F_Classis; + } + if ( + service.F_OperationType === 0 || + service.F_OperationType === 2 || + service.F_OperationType === 4 + ) { + out.F_OperationType = service.F_OperationType; + } + const vesselInst = cleaned.match(VESSEL_RE); + if (vesselInst?.[1]) { + out.F_Instruction = `船名航次 ${vesselInst[1].trim().replace(/\s+/g, " ")}`; + } else if (service.F_Instruction) { + out.F_Instruction = service.F_Instruction; + } const sealBits: string[] = []; - for (const m of text.matchAll(SEAL_NO_RE)) { - const label = (m[0].match(/^(封号|铅封(?:号)?|封条号?)/i)?.[1] || "封号").trim(); + for (const m of cleaned.matchAll(SEAL_NO_RE)) { + const label = + (m[0].match(/^(封号|铅封(?:号)?|封条号?)/i)?.[1] || "封号").trim(); const value = (m[1] || "").trim().toUpperCase(); if (value) sealBits.push(`${label}:${value}`); } + if (service.F_Classis === 0) sealBits.push("不带车架"); + else if (service.F_Classis === 1) sealBits.push("带车架"); if (sealBits.length) { out.F_MemoRemark = [...new Set(sealBits)].join(" / "); } - // 提拆派优先于直送(二者可同时出现在说明文案中) - if (/提拆派/.test(text)) { + if (out.F_OperationType === undefined && /拆柜清单|拆柜表/.test(cleaned)) { out.F_OperationType = 0 as OperationType; - } else if (/直送/.test(text)) { - out.F_OperationType = 2 as OperationType; + out.F_Instruction = out.F_Instruction || "拆柜清单"; } - const hasAirHint = containsAirlineHint(text); + const hasAirHint = + containsAirlineHint(cleaned) && !/海运|柜型|船名|航次/.test(cleaned); const blPrefix = shippingLineHintFromBl(out.F_BLCopyCode); - const seaFromBlPrefix = isSeaBlPrefix(blPrefix) || isSeaBlPrefix(out.shipping_line_hint); + const seaFromBlPrefix = + isSeaBlPrefix(blPrefix) || isSeaBlPrefix(out.shipping_line_hint); + const seaFromText = + /海运|船名航次/.test(cleaned) || + Boolean(findShippingLineAliasInText(cleaned)); - // 有柜型/船名航次/已知海运提单前缀(EGLV/COSU…)→ 海运 - // 仅裸号时不要压过已识别出的空运航司线索 if ( out.F_CabinetType || - VESSEL_RE.test(text) || - /船名航次/.test(text) || + VESSEL_RE.test(cleaned) || seaFromBlPrefix || + seaFromText || (out.F_BLCopyCode && !hasAirHint) ) { out.F_TransMode = 0 as TransMode; - } else if (hasAirHint) { + } else if (hasAirHint || /空运|\bAIR\b/i.test(cleaned)) { out.F_TransMode = 1 as TransMode; } @@ -213,7 +241,9 @@ export function mergeContainerHeader( return { ...base, - F_CabinetType: pick("F_CabinetType", fromMail.F_CabinetType), + F_CabinetType: normalizeCabinetType( + pick("F_CabinetType", fromMail.F_CabinetType) || "", + ), F_ETA: pick("F_ETA", fromMail.F_ETA ?? null) ?? null, F_ETD: pick("F_ETD", fromMail.F_ETD ?? null) ?? null, F_BLCopyCode: pick("F_BLCopyCode", fromMail.F_BLCopyCode) ?? "", @@ -257,13 +287,17 @@ function normalizeShippingLineHints(hint: string | undefined | null): string[] { const raw = (hint || "").trim(); if (!raw) return []; const upper = raw.toUpperCase(); - const out = new Set([raw, raw.toLowerCase(), upper, upper.toLowerCase()]); + const out = new Set([ + raw, + raw.toLowerCase(), + upper, + upper.toLowerCase(), + ]); const seed = [upper, upper.slice(0, 4)].filter((x) => x.length >= 2); for (const key of seed) { for (const alias of SHIPPING_LINE_ALIASES[key] || []) { out.add(alias); out.add(alias.toLowerCase()); - // 二级展开:EGLV → EMC → 长荣海运 const aliasKey = alias.toUpperCase(); for (const nested of SHIPPING_LINE_ALIASES[aliasKey] || []) { out.add(nested); @@ -288,7 +322,6 @@ export function matchShippingLineId( const bl = (options?.blNo || "").trim().toUpperCase(); if ((!q && !bl) || lines.length === 0) return null; - // 1) F_Prefix:BL 以 prefix 开头,或 hint 等于 prefix const byPrefix = lines .map((l) => { const tokens = prefixTokens(l.F_Prefix); @@ -307,29 +340,24 @@ export function matchShippingLineId( if (!q) return null; - const exact = lines.find( - (l) => - hints.some( - (h) => - (l.F_FullName || "").toLowerCase() === h.toLowerCase() || - (l.F_EnCode || "").toLowerCase() === h.toLowerCase(), - ), + const exact = lines.find((l) => + hints.some( + (h) => + (l.F_FullName || "").toLowerCase() === h.toLowerCase() || + (l.F_EnCode || "").toLowerCase() === h.toLowerCase(), + ), ); if (exact) return exact.F_Id; - // 2) 代码:WHLC 精确或前缀;较长 code 优先 const byCode = lines .filter((l) => { const code = (l.F_EnCode || "").toLowerCase(); if (!code) return false; return q === code || q.startsWith(code) || code.startsWith(q); }) - .sort( - (a, b) => (b.F_EnCode || "").length - (a.F_EnCode || "").length, - ); + .sort((a, b) => (b.F_EnCode || "").length - (a.F_EnCode || "").length); if (byCode[0]) return byCode[0].F_Id; - // 3) 名称/编码包含 const partial = lines.find((l) => { const name = (l.F_FullName || "").toLowerCase(); const code = (l.F_EnCode || "").toLowerCase(); diff --git a/src/services/parse/header-service-hints.ts b/src/services/parse/header-service-hints.ts new file mode 100644 index 0000000..23b66c3 --- /dev/null +++ b/src/services/parse/header-service-hints.ts @@ -0,0 +1,95 @@ +/** + * 对齐 carriercentral-ai-assistant header_extract._apply_service / + * _strip_or_choice_noise / 柜型规范化。 + * 供 extract-container-header、mapBlModules、预报表单初值共用。 + */ +import type { OperationType } from "@/types/mail"; + +/** 技能卡/话术里的「A或B」未决选项,不得当成已选值 */ +const OR_CHOICE_NOISE_RE = + /(?:运输方式|操作类型|业务类型)?\s*[((]?\s*(?:海运\s*[或/、]\s*空运|空运\s*[或/、]\s*海运|提拆派\s*[或/、]\s*直送|直送\s*[或/、]\s*提拆派|拆柜\s*[或/、]\s*直送|直送\s*[或/、]\s*拆柜|拆柜\s*[或/、]\s*直送\s*[或/、]\s*只清关)\s*[))]?/g; + +const DEVANNING_RE = + /提拆派|拆柜清单|拆柜表|派拆柜派送|派拆柜|派拆|组合柜|拖合拖|拆柜派送/; + +export function stripOrChoiceNoise(text: string): string { + return (text || "").replace(OR_CHOICE_NOISE_RE, " "); +} + +/** 助手惯例:40HC→40HQ(CC 侧柜型统一) */ +export function normalizeCabinetType(raw: string | null | undefined): string { + const t = (raw || "").trim().toUpperCase(); + if (!t) return ""; + return t.replace(/HC\b/g, "HQ"); +} + +export type ServiceHintResult = { + F_OperationType?: OperationType; + F_Classis?: 0 | 1; + /** 业务短指引;有船名航次 Instruction 时勿覆盖 */ + F_Instruction?: string; +}; + +/** + * 对照助手 _apply_service:车架 + 操作类型(拆柜/直送/只清关)。 + * 直送不得压过拆柜类词;拆柜清单 → 0。 + */ +export function applyServiceFromText(raw: string): ServiceHintResult { + const text = stripOrChoiceNoise(raw || "").trim(); + if (!text) return {}; + + const out: ServiceHintResult = {}; + + if (/不带车架|不带托架|无车架/.test(text)) out.F_Classis = 0; + else if (/带车架|带托架/.test(text)) out.F_Classis = 1; + + if ( + /直送/.test(text) && + !DEVANNING_RE.test(text) && + !/拆柜/.test(text) + ) { + out.F_OperationType = 2; + out.F_Instruction = "直送"; + return out; + } + if (/只清关|仅清关/.test(text)) { + out.F_OperationType = 4; + out.F_Instruction = "只清关"; + return out; + } + if ( + DEVANNING_RE.test(text) || + /拆柜/.test(text) || + (/提柜/.test(text) && /拆柜|派送/.test(text)) + ) { + out.F_OperationType = 0; + out.F_Instruction = /提拆派/.test(text) + ? "提拆派" + : /拆柜清单/.test(text) + ? "拆柜清单" + : /拆柜/.test(text) + ? "拆柜" + : text.slice(0, 40); + return out; + } + return out; +} + +/** 从 ops_hints / service 片段汇总业务类型与车架(空缺才填) */ +export function applyServiceFromModules(input: { + ops_hints?: string[] | null; + service?: string | null; +}): ServiceHintResult { + const blob = [ + ...(input.ops_hints || []), + input.service || "", + ] + .filter(Boolean) + .join(" "); + if (!blob.trim()) return {}; + // 直送 hint 单独优先于泛「派送」 + if ((input.ops_hints || []).includes("直送") && !/拆柜|提拆派/.test(blob)) { + return { F_OperationType: 2, F_Instruction: "直送" }; + } + return applyServiceFromText(blob); +} diff --git a/src/services/parse/instruction-execution-state.ts b/src/services/parse/instruction-execution-state.ts index a6e6908..5384f12 100644 --- a/src/services/parse/instruction-execution-state.ts +++ b/src/services/parse/instruction-execution-state.ts @@ -1,5 +1,9 @@ import { extractMailInstructions, type MailInstructionUnit } from "@/services/parse/split-instructions"; import type { MailStatus } from "@/types/mail"; +import type { + WorkOrderFieldMarks, + WorkOrderSnapshotField, +} from "@/services/parse/work-order-snapshot"; export type InstructionExecutionStateEntry = { status: "IMPORTED"; @@ -7,6 +11,14 @@ export type InstructionExecutionStateEntry = { importedBy?: string; mode?: string; externalId?: string | null; + /** 最终提交到 CC 的工单表单快照 */ + submitted?: Record; + /** 相对邮件抽取基线被补充或修改的字段名 */ + supplementedFields?: WorkOrderSnapshotField[]; + /** 字段级标记:补充 / 已改 */ + fieldMarks?: WorkOrderFieldMarks; + /** 是否含人工补充或修改 */ + userSupplemented?: boolean; }; export type InstructionExecutionStateMap = Record< @@ -42,6 +54,23 @@ export function isInstructionImported( return getInstructionExecutionState(lineage, unitId)?.status === "IMPORTED"; } +/** 已写入真实 CC(mock 不算锁定,允许改 live 后重提) */ +export function isInstructionImportedLive( + lineage: unknown, + unitId: string, +): boolean { + const e = getInstructionExecutionState(lineage, unitId); + return e?.status === "IMPORTED" && e.mode !== "mock"; +} + +export function isInstructionImportedMock( + lineage: unknown, + unitId: string, +): boolean { + const e = getInstructionExecutionState(lineage, unitId); + return e?.status === "IMPORTED" && e.mode === "mock"; +} + export function markInstructionImported( lineage: unknown, unitId: string, @@ -89,10 +118,16 @@ export function resolveMailStatusAfterInstructionImport(input: { }): MailStatus { const units = listActionableInstructionUnits(input); if (!units.length) return input.currentStatus; - const importedCount = units.filter((u) => + const imported = units.filter((u) => isInstructionImported(input.lineage, u.id), - ).length; - if (importedCount <= 0) return "PENDING_CONFIRM"; - if (importedCount >= units.length) return "SUCCESS"; + ); + if (imported.length <= 0) return "PENDING_CONFIRM"; + if (imported.length >= units.length) { + // 全部只是 mock 成功 → PARTIAL,允许再进 IMPORTING 写真实 CC + const allMock = imported.every((u) => + isInstructionImportedMock(input.lineage, u.id), + ); + return allMock ? "PARTIAL_SUCCESS" : "SUCCESS"; + } return "PARTIAL_SUCCESS"; } diff --git a/src/services/parse/instruction-lexicon.ts b/src/services/parse/instruction-lexicon.ts index 8057c5f..949ca09 100644 --- a/src/services/parse/instruction-lexicon.ts +++ b/src/services/parse/instruction-lexicon.ts @@ -91,6 +91,18 @@ export const KEYWORD_RULES: KeywordRule[] = [ re: /新增预报|请查收新增预报/i, where: ["subject", "body"], }, + { + uiKind: "forecast", + keyword: "主题预报", + re: /^预报$/, + where: ["subject"], + }, + { + uiKind: "forecast", + keyword: "整表建单", + re: /测试AI建单|ClientContainer|AI建单|集装箱导入/i, + where: ["attachment"], + }, { uiKind: "forecast", keyword: "派送单请查收", diff --git a/src/services/parse/match-segment-attachments.ts b/src/services/parse/match-segment-attachments.ts index f36476c..0d5c5ba 100644 --- a/src/services/parse/match-segment-attachments.ts +++ b/src/services/parse/match-segment-attachments.ts @@ -55,7 +55,9 @@ export type MatchSegmentAttachmentsInput = { /** * 本段应挂的业务附件(工单 F_AttachFile / 出库 F_LabelFile)。 - * 1) 主键交叉 2) 正文点名文件名 3) 当前段角色兜底(卡转海/换标) + * 优先按文件名与指令正文的主键对应(WL 唛头 / FBA / YT / FNSKU), + * 例如指令里的「WL103276 SBD1」对应附件「WL103276-SBD1(1).pdf」。 + * 其次正文点名文件名;再才是当前段角色兜底(卡转海.zip / 换标指令)。 * 跳过截图、DO;卡派 xlsx 仅当正文点名才进工单。 */ export function matchSegmentAttachments( diff --git a/src/services/parse/packing-list.ts b/src/services/parse/packing-list.ts index b0740ec..08a2556 100644 --- a/src/services/parse/packing-list.ts +++ b/src/services/parse/packing-list.ts @@ -11,10 +11,20 @@ import { type LabeledShipmentFields, } from "./address-block"; import { flattenExcelCellValue } from "./excel-cell-text"; +import { containerHeaderFromSheet } from "./container-header-from-sheet"; +import { + findPackingHeaderRowIndex, + isPackingFooterNoiseRow, + isPackingSummaryRow, + isPackingTitleNoiseRow, + readWorksheetGrid, +} from "./excel-sheet-grid"; import type { ContainerHeader, ShipmentRow } from "@/types/mail"; -import { isValidContainerNo, normalizeContainerNo } from "@/utils/iso6346"; +import { normalizeContainerNo } from "@/utils/iso6346"; import { parseFlexibleDate, round4, toShanghaiDateString } from "@/utils/dates"; +const NO_FUZZY_FIELDS = new Set(["F_ContainerNo"]); + const HEADER_ALIASES: Record = { F_FBACode: [ "仓库代码", @@ -23,13 +33,24 @@ const HEADER_ALIASES: Record = { "目的代码", "FBACode", "FBA Code", - "SORTBY", "FBA_CODE", + "贴标目的地", ], - F_Transporter: ["渠道", "Transporter", "运输方式", "Move Type"], + // 货件渠道 = Move Type;禁止把柜头「运输方式」(海运/空运) 映射到本字段 + F_Transporter: ["渠道", "Transporter", "Move Type", "MoveType"], F_CTNS: ["件数", "CTNS", "箱数", "Qty.(CTNS)", "Qty.(CTN)", "QTY(CTNS)"], - F_CBM: ["总体积", "体积(m³)", "体积m3", "Val(CBM)", "Vol(CBM)", "CBM"], - F_Weight: ["毛重", "毛重(kg)", "G.W.(KGS)", "G.W.OKGS)", "Weight"], + F_CBM: ["总体积", "体积", "体积(m³)", "体积m3", "Val(CBM)", "Vol(CBM)", "CBM"], + F_Weight: [ + "毛重", + "毛重(kg)", + "G.W.(KGS)", + "G.W.OKGS)", + "GW(KGS)", + "GW(KG)", + "G.W.(KG)", + "Weight", + "重量", + ], F_Pcs: ["PCS", "件数(PCS)", "Pcs"], F_FBAID: [ "FBA ID", @@ -58,13 +79,13 @@ const HEADER_ALIASES: Record = { "Customer Ref No", "MarkingNo", ], - F_Address: ["收货地址", "派送地址", "详细地址", "Address"], - F_City: ["City", "城市"], - F_State: ["State", "州省", "州"], - F_ZipCode: ["邮政编码", "ZipCode", "Zip Code", "邮编", "ZIP"], - F_Contact: ["收件人", "收货人", "Contact", "联系人"], - F_Mobile: ["联系电话", "Mobile", "电话", "手机"], - F_Email: ["Email", "邮箱"], + F_Address: ["收货地址", "派送地址", "详细地址", "Address", "客户地址"], + F_City: ["City", "城市", "City/城市"], + F_State: ["State", "州省", "州", "State/州"], + F_ZipCode: ["邮政编码", "ZipCode", "Zip Code", "邮编", "ZIP", "ZipCode/邮编"], + F_Contact: ["收件人", "收货人", "Contact", "联系人", "Contact/联系人"], + F_Mobile: ["联系电话", "Mobile", "电话", "手机", "手机号", "Mobile/手机"], + F_Email: ["Email", "邮箱", "Email/邮箱"], /** 内部列:拆成 State/City,不直接提交 CC */ F_Region: ["省/市/区", "省市/区", "省市区", "省市"], F_Company: ["公司名称", "公司名", "Company Name", "Company"], @@ -87,17 +108,28 @@ const HEADER_ALIASES: Record = { "Instruction", "配送时段", "配送时段(亚马逊仓库必填)", + "客户拆柜备注", + "客户预约备注", ], F_SKU: ["SKU"], - F_Description: ["品名", "Description", "货物描述"], + F_Description: ["品名", "Description", "货物描述", "中文名称"], F_EnglishName: ["英文名称", "English Name", "EnglishName"], - F_ContainerNo: ["柜号", "集装箱号", "ContainerNo", "Container No"], + F_ContainerNo: [ + "柜号", + "集装箱号", + "ContainerNo", + "Container No", + "SORTBY", + "SortBy", + ], }; -/** 模板占位符「/」「-」视为未填 */ +/** 模板占位符「/」「-」、CC 列表筛选文案视为未填 */ function cleanCellText(raw: unknown): string { const t = flattenExcelCellValue(raw).trim(); if (!t || t === "/" || t === "/" || t === "-" || t === "—") return ""; + if (/^全部[((]/.test(t) || /运输方式[))]/.test(t)) return ""; + if (/^全部\(集装箱状态\)/.test(t)) return ""; return t; } @@ -117,6 +149,7 @@ function matchColumn(header: string): string | null { // 2) 较长别名的包含匹配(别名长度 ≥ 2,且按长度降序) const ranked: Array<{ field: string; alias: string }> = []; for (const [field, aliases] of Object.entries(HEADER_ALIASES)) { + if (NO_FUZZY_FIELDS.has(field)) continue; for (const a of aliases) { const na = normHeader(a); if (na.length >= 2) ranked.push({ field, alias: na }); @@ -129,7 +162,31 @@ function matchColumn(header: string): string | null { return null; } -export { parseRegionComposite } from "./address-block"; +const COLUMN_LABEL: Record = { + F_FBACode: "仓库代码/FBACode", + F_Transporter: "渠道/派送方式", + F_CTNS: "件数", + sheet: "工作表", + header: "表头", +}; + +export function formatPackingParseLineageMessage( + lineage: Record, +): string { + const missing = lineage.missing_columns; + if (Array.isArray(missing) && missing.length) { + if (missing.includes("sheet")) { + return "Excel 中没有可读取的工作表,请确认文件未损坏"; + } + if (missing.includes("header")) { + return "表格行数过少或未找到表头,请使用数据模版或卡派清单标准格式"; + } + const labels = missing.map((m) => COLUMN_LABEL[String(m)] || String(m)); + return `表格缺少必要列:${labels.join("、")}。请对照数据模版补全表头后重试`; + } + return "未能从表格解析出货件行,请确认是含「渠道/件数/仓库」表头的卡派清单或数据模版"; +} + function parseNumber(raw: unknown): number | null { if (raw == null || raw === "") return null; @@ -147,7 +204,12 @@ export interface PackingListParseResult { } /** Template footer instruction row — not a shipment */ -export function isInstructionNoiseRow(raw: Record): boolean { +export function isInstructionNoiseRow( + raw: Record, + cells?: string[], +): boolean { + if (cells && isPackingTitleNoiseRow(cells)) return true; + if (cells && isPackingFooterNoiseRow(raw, cells)) return true; const parts = Object.values(raw).map((v) => String(v ?? "").trim()); const blob = parts.join(" "); if (!blob) return false; @@ -165,6 +227,18 @@ export function isInstructionNoiseRow(raw: Record): boolean { return false; } +function assignRawField( + raw: Record, + field: string, + value: string, +): void { + const next = value.trim(); + if (!next) return; + const cur = raw[field]; + if (cur != null && String(cur).trim() !== "") return; + raw[field] = next; +} + export async function parsePackingListBuffer( buffer: Buffer, opts: { @@ -181,9 +255,16 @@ export async function parsePackingListBuffer( // eslint-disable-next-line @typescript-eslint/no-explicit-any await wb.xlsx.load(buffer as any); - let sheet = wb.worksheets.find((s) => /卡派/.test(s.name)); - const sheetFallback = !sheet; - if (!sheet) sheet = wb.worksheets[0]; + let sheet = wb.worksheets.find((s) => /集装箱导入/.test(s.name)); + let sheetFallback = !sheet; + if (!sheet) { + sheet = wb.worksheets.find((s) => /卡派/.test(s.name)); + sheetFallback = !sheet; + } + if (!sheet) { + sheet = wb.worksheets[0]; + sheetFallback = true; + } if (!sheet) { return { container_header: { @@ -196,9 +277,8 @@ export async function parsePackingListBuffer( }; } - const rows: ExcelJS.Row[] = []; - sheet.eachRow((row) => rows.push(row)); - if (rows.length < 2) { + const grid = readWorksheetGrid(sheet); + if (grid.length < 2) { return { container_header: { F_ContainerNo: opts.hintContainerNo || "", @@ -210,29 +290,12 @@ export async function parsePackingListBuffer( }; } - let headerIdx = 0; - for (let i = 0; i < Math.min(rows.length, 15); i++) { - const texts: string[] = []; - rows[i].eachCell((cell) => - texts.push(flattenExcelCellValue(cell.value) || cell.text || ""), - ); - const joined = texts.join("|"); - if ( - /渠道/.test(joined) && - (/件数/.test(joined) || /CTNS/i.test(joined)) && - (/仓库/.test(joined) || /FBACode/i.test(joined)) - ) { - headerIdx = i; - break; - } - } + const headerIdx = findPackingHeaderRowIndex(grid); - const headerRow = rows[headerIdx]; const colMap: Record = {}; - headerRow.eachCell((cell, col) => { - const field = matchColumn( - flattenExcelCellValue(cell.value) || cell.text || "", - ); + const headerCells = grid[headerIdx] || []; + headerCells.forEach((cellText, col) => { + const field = matchColumn(cellText); if (field) colMap[col] = field; }); @@ -257,7 +320,7 @@ export async function parsePackingListBuffer( }; } - const dataRows = rows.slice(headerIdx + 1); + const dataRows = grid.slice(headerIdx + 1); if (dataRows.length > hardLimit) { return { container_header: { @@ -274,23 +337,24 @@ export async function parsePackingListBuffer( let extractedContainer = opts.hintContainerNo || ""; for (let i = 0; i < dataRows.length; i++) { - const row = dataRows[i]; + const rowCells = dataRows[i]; const raw: Record = {}; const harvest: LabeledShipmentFields = {}; - row.eachCell((cell, col) => { - const text = flattenExcelCellValue(cell.value) || cell.text || ""; + for (let col = 0; col < rowCells.length; col++) { + const text = rowCells[col] ?? ""; const field = colMap[col]; const block = parseLabeledCell(text); if (block) mergeLabeledHarvest(harvest, block, field); - if (field) raw[field] = columnValueFromCell(field, text); - }); + if (field) assignRawField(raw, field, columnValueFromCell(field, text)); + } - // 跳过空行 - const hasAny = Object.values(raw).some( - (v) => v != null && String(v).trim() !== "", - ); + const hasAny = + rowCells.some((v) => v.trim() !== "") || + Object.values(raw).some((v) => v != null && String(v).trim() !== ""); if (!hasAny) continue; - if (isInstructionNoiseRow(raw)) continue; + if (isPackingTitleNoiseRow(rowCells)) continue; + if (isInstructionNoiseRow(raw, rowCells)) continue; + if (isPackingSummaryRow(raw)) continue; if (raw.F_ContainerNo && !extractedContainer) { extractedContainer = normalizeContainerNo(String(raw.F_ContainerNo)); @@ -418,22 +482,50 @@ export async function parsePackingListBuffer( } const containerNo = normalizeContainerNo(extractedContainer); - const valid = isValidContainerNo(containerNo, opts.enableIsoCheck !== false); + const headerFromSheet = containerHeaderFromSheet( + grid, + headerIdx, + containerNo, + { enableIsoCheck: opts.enableIsoCheck }, + ); + if (headerFromSheet.F_TransMode == null) { + headerFromSheet.F_TransMode = 0; + } + if (headerFromSheet.F_OperationType == null) { + headerFromSheet.F_OperationType = 0; + } + if (headerFromSheet.F_Classis == null) { + headerFromSheet.F_Classis = 0; + } + + const headerAttachmentKeys = [ + "F_CabinetType", + "F_TransMode", + "F_OperationType", + "F_ETA", + "F_ETD", + "F_BLCopyCode", + "F_LoadPort", + "F_Dock", + "F_Instruction", + "F_MemoRemark", + "shipping_line_hint", + ] as const; + const headerFromAttachment = headerAttachmentKeys.some((k) => { + const v = headerFromSheet[k as keyof ContainerHeader]; + return v != null && v !== ""; + }); return { - container_header: { - F_ContainerNo: containerNo, - container_no_valid: valid && !!containerNo, - F_TransMode: 0, - F_OperationType: 0, - F_Classis: 0, - }, + container_header: headerFromSheet, shipments, lineage: { sheet: sheet.name, sheetFallback, + header_row: headerIdx + 1, row_count: shipments.length, soft_limit_exceeded: shipments.length > softLimit, + header_from_attachment: headerFromAttachment, }, }; } diff --git a/src/services/parse/pipeline.ts b/src/services/parse/pipeline.ts index 8796fd6..ea985e1 100644 --- a/src/services/parse/pipeline.ts +++ b/src/services/parse/pipeline.ts @@ -783,17 +783,42 @@ export class ParsePipeline { }); } } + + let mailType = evidence.mail_type; + let typeEvidence = evidence; + if ( + mailType === "UNKNOWN" && + validCount >= 1 && + (parsed.container_header.F_ContainerNo || "").trim() + ) { + mailType = "NEW_CONTAINER"; + typeEvidence = { + ...evidence, + mail_type: "NEW_CONTAINER", + signals: [ + ...evidence.signals, + { + signal: "装箱表推断预报", + score: 45, + matched: String(parsed.lineage.usedFilename || usedFilename), + source: "attachment" as const, + }, + ], + scores: { ...evidence.scores, NEW_CONTAINER: 45, UNKNOWN: 0 }, + total: Math.max(evidence.total, 45), + }; + } + const status = resolveAfterParse({ - mailType: evidence.mail_type, + mailType, validCount, autoExecEligible: - evidence.mail_type !== "WORK_ORDER" && - isAutoExecEligible(evidence.mail_type), + mailType !== "WORK_ORDER" && isAutoExecEligible(mailType), }); await this.finish(mailId, { - mailType: evidence.mail_type, - evidence, + mailType, + evidence: typeEvidence, status, lastError: validCount === 0 ? "NO_SHIPMENT_ROWS" : null, parse: this.withMailRecord( @@ -803,7 +828,7 @@ export class ParsePipeline { lineage: { ...parsed.lineage, usedFilename }, }, { - mailType: evidence.mail_type, + mailType, subject, body, filenames, diff --git a/src/services/parse/resolve-confirm-instruction.ts b/src/services/parse/resolve-confirm-instruction.ts index dd9f528..6410347 100644 --- a/src/services/parse/resolve-confirm-instruction.ts +++ b/src/services/parse/resolve-confirm-instruction.ts @@ -1,104 +1,301 @@ import { + isInstructionImported, + } from "@/services/parse/instruction-execution-state"; + import { + STANDARD_INSTRUCTION_KINDS, + extractMailInstructions, + type MailInstructionUnit, + } from "@/services/parse/split-instructions"; + + export type ConfirmInstructionReason = + | "ok" + | "missing_id" + | "not_found" + | "not_confirmable" + | "already_imported"; + + export type ConfirmInstructionResolution = { + unit: MailInstructionUnit | null; + candidates: MailInstructionUnit[]; + reason: ConfirmInstructionReason; + }; + + type MailLike = { + subject: string; + body_text?: string | null; + + mail_type?: string; + attachments?: Array<{ filename: string }>; + parse_result?: { + lineage?: unknown; - mail_record?: { work_order_actions?: string[] } | null; + + container_header?: { F_ContainerNo?: string | null } | null; + + mail_record?: { + + kind?: string; + + work_order_actions?: string[]; + + } | null; + } | null; + }; + + export function listConfirmableInstructionUnits( + units: MailInstructionUnit[], + ): MailInstructionUnit[] { + return units.filter((u) => + (STANDARD_INSTRUCTION_KINDS as string[]).includes(u.uiKind), + + ); + +} + + + +/** + + * 是否强制补一张「预报」指令卡。 + + * - 提单预报 / 新增预报 / DO+柜头同封:需要 + + * - 纯工单(预约码、贴标等)主题里碰巧有柜号:不需要 + + */ + +export function shouldForceForecastUnit(mail: MailLike): boolean { + + const lineage = mail.parse_result?.lineage as + + | { mail_record?: { kind?: string } } + + | undefined; + + const kind = + + mail.parse_result?.mail_record?.kind || + + lineage?.mail_record?.kind || + + mail.mail_type || + + ""; + + if (kind === "BL_FORECAST" || kind === "NEW_CONTAINER") return true; + + + + const cn = (mail.parse_result?.container_header?.F_ContainerNo || "").trim(); + + // DO 与预报同封时,主类型常是 DO_UPLOAD,需靠柜头补预报卡 + + if (kind === "DO_UPLOAD" && cn) return true; + + + + const sub = mail.subject || ""; + + // 柜号+提单 / 整柜直送 类主题(邮件1),即使主类型是 DO_UPLOAD + + if ( + + /整柜直送|不带车架|带车架/.test(sub) && + + /柜号|提单/.test(sub) + + ) { + + return true; + + } + + if (/柜号\s*[::]/.test(sub) && /提单/.test(sub)) return true; + + if (/请查收新增预报|新增预报\d*/.test(sub)) return true; + + const hasSpreadsheet = (mail.attachments || []).some((a) => + /\.(xlsx|xls|csv)$/i.test(a.filename), ); + if (/^预报$/.test(sub.trim()) && hasSpreadsheet) return true; + if ( + (mail.attachments || []).some((a) => + /测试AI建单|ClientContainer|AI建单|集装箱导入/i.test(a.filename), + ) + ) { + return true; + } + + return false; + } + + export function extractInstructionsFromMail( + mail: MailLike, + ): MailInstructionUnit[] { + const lineage = mail.parse_result?.lineage as + | { mail_record?: { work_order_actions?: string[] } } + | undefined; + return extractMailInstructions({ + subject: mail.subject, + body: mail.body_text, + filenames: (mail.attachments || []).map((a) => a.filename), + + forceForecast: shouldForceForecastUnit(mail), + workOrderActions: + mail.parse_result?.mail_record?.work_order_actions || + lineage?.mail_record?.work_order_actions || + [], + }); + } + + + + /** + * 确认页只针对「这一条指令」,不看整封邮件主类型。 + * 无 instruction_id 且仅一条可确认指令时直接选中,多条则交给页面列出选择。 + */ + export function resolveConfirmInstruction(input: { + units: MailInstructionUnit[]; + instructionId?: string | null; + lineage?: unknown; + }): ConfirmInstructionResolution { + const candidates = listConfirmableInstructionUnits(input.units); + const instructionId = input.instructionId?.trim() || ""; + + if (instructionId) { + const unit = input.units.find((u) => u.id === instructionId) || null; + if (!unit) { + return { unit: null, candidates, reason: "not_found" }; + } + if (!(STANDARD_INSTRUCTION_KINDS as string[]).includes(unit.uiKind)) { + return { unit, candidates, reason: "not_confirmable" }; + } + if (isInstructionImported(input.lineage, unit.id)) { + return { unit, candidates, reason: "already_imported" }; + } + return { unit, candidates, reason: "ok" }; + } + + const pending = candidates.filter( + (u) => !isInstructionImported(input.lineage, u.id), + ); + if (pending.length === 1) { + return { unit: pending[0], candidates, reason: "ok" }; + } + if (pending.length === 0 && candidates.length === 1) { + return { + unit: candidates[0], + candidates, + reason: isInstructionImported(input.lineage, candidates[0].id) + ? "already_imported" + : "ok", + }; + } + return { unit: null, candidates, reason: "missing_id" }; + } + + export function confirmHref(mailId: string, instructionId: string): string { + return `/mails/${mailId}/confirm?instruction_id=${encodeURIComponent(instructionId)}`; + } + + diff --git a/src/services/parse/revalidate-shipment-row.ts b/src/services/parse/revalidate-shipment-row.ts new file mode 100644 index 0000000..657b6cb --- /dev/null +++ b/src/services/parse/revalidate-shipment-row.ts @@ -0,0 +1,158 @@ +import { mapChannel } from "./channel-map"; +import { validatePackingFill } from "./packing-fill-rules"; +import { normalizeFbaCodeForCc } from "./normalize-fba-code"; +import { applyLabeledBlocksToShipmentRow } from "./address-block"; +import type { ShipmentRow } from "@/types/mail"; + +function toNum(v: unknown): number | null { + if (v == null || v === "") return null; + if (typeof v === "number") return Number.isFinite(v) ? v : null; + const n = Number(String(v).replace(/,/g, "").trim()); + return Number.isFinite(n) ? n : null; +} + +function strOrNull(v: unknown, max = 200): string | null { + const t = String(v ?? "").replace(/\s+/g, " ").trim(); + if (!t || t === "/" || t === "/" || t === "-" || t === "—") return null; + return t.slice(0, max); +} + +/** + * 确认页手工增改货件后,按与装箱表解析相同的规则重算 VALID/INVALID。 + */ +export function revalidateShipmentRow( + input: Partial & { row_index: number }, +): ShipmentRow { + const normalized = applyLabeledBlocksToShipmentRow(input); + const channelRaw = + (normalized.channel_raw || normalized.F_Transporter || "").trim() || ""; + const mapped = mapChannel(channelRaw); + const ctnsNum = toNum(normalized.F_CTNS); + const ctns = ctnsNum == null ? 0 : Math.trunc(ctnsNum); + const cbm = toNum(normalized.F_CBM); + const weight = toNum(normalized.F_Weight); + const addr = strOrNull(normalized.F_Address, 500) || ""; + const fbaIdRaw = strOrNull(normalized.F_FBAID, 80) || ""; + const refRaw = strOrNull(normalized.F_ReferenceId, 80) || ""; + const shipmentIdRaw = strOrNull(normalized.F_ShipmentID, 120) || ""; + + const fill = validatePackingFill({ + channelRaw, + transporter: mapped.transporter, + warehouseId: strOrNull(normalized.F_FBACode, 40) || "", + ctns, + cbm, + weight, + address: addr, + shipmentId: shipmentIdRaw, + fbaId: fbaIdRaw, + referenceId: refRaw, + }); + + let fba = fill.warehouseId; + const warnings = [...fill.warnings]; + const invalid_reasons = [...fill.invalid_reasons]; + if (mapped.unmapped && mapped.transporter) { + warnings.push("CHANNEL_UNMAPPED"); + } + const filledFba = normalizeFbaCodeForCc(fba, mapped.transporter); + if (!fba && filledFba === "HOLD") { + warnings.push("存仓/留仓等无仓库ID,已用 HOLD 作为目的代码"); + } + fba = filledFba; + + const pcs = toNum(normalized.F_Pcs); + + return { + row_index: normalized.row_index, + row_status: invalid_reasons.length ? "INVALID" : "VALID", + invalid_reasons: invalid_reasons.length ? invalid_reasons : undefined, + warnings: warnings.length ? warnings : undefined, + channel_raw: channelRaw || undefined, + F_Transporter: mapped.transporter || undefined, + F_FBACode: fba || undefined, + F_CTNS: ctns, + F_CBM: cbm, + F_Weight: weight, + F_Pcs: pcs, + F_FBAID: fbaIdRaw || null, + F_PO: strOrNull(normalized.F_PO, 80), + F_ReferenceId: refRaw || null, + F_ShipmentID: fill.shipmentId || null, + F_Address: addr || null, + F_City: strOrNull(normalized.F_City, 80), + F_State: strOrNull(normalized.F_State, 80), + F_ZipCode: strOrNull(normalized.F_ZipCode, 40), + F_Contact: strOrNull(normalized.F_Contact, 80), + F_Mobile: strOrNull(normalized.F_Mobile, 40), + F_Email: strOrNull(normalized.F_Email, 120), + F_Expected_DeliveryDateB: strOrNull(normalized.F_Expected_DeliveryDateB, 40), + F_Expected_DeliveryDateE: strOrNull(normalized.F_Expected_DeliveryDateE, 40), + F_Remark: strOrNull(normalized.F_Remark, 500), + F_SKU: strOrNull(normalized.F_SKU, 80), + F_Description: strOrNull(normalized.F_Description, 200), + F_EnglishName: strOrNull(normalized.F_EnglishName, 200), + }; +} + +export function reindexShipments(rows: ShipmentRow[]): ShipmentRow[] { + return rows.map((row, i) => + row.row_index === i ? row : { ...row, row_index: i }, + ); +} + +/** 从 parse_result 载入时重拆 Address 等键值块并校验行状态 */ +export function normalizeParseShipments( + rows: ShipmentRow[] | undefined | null, +): ShipmentRow[] { + if (!rows?.length) return []; + return reindexShipments( + rows.map((s, i) => revalidateShipmentRow({ ...s, row_index: i })), + ); +} + +/** 从确认页提交的货件数组做白名单清洗并重校验 */ +export function sanitizeClientShipments( + raw: unknown, + opts?: { maxRows?: number }, +): ShipmentRow[] { + if (!Array.isArray(raw)) { + throw new Error("shipments 须为数组"); + } + const max = opts?.maxRows ?? 2000; + if (raw.length > max) { + throw new Error(`货件最多 ${max} 行`); + } + return reindexShipments( + raw.map((item, i) => { + const r = (item && typeof item === "object" ? item : {}) as Partial; + return revalidateShipmentRow({ + row_index: typeof r.row_index === "number" ? r.row_index : i, + channel_raw: r.channel_raw, + F_Transporter: r.F_Transporter, + F_FBACode: r.F_FBACode, + F_CTNS: r.F_CTNS, + F_CBM: r.F_CBM, + F_Weight: r.F_Weight, + F_Pcs: r.F_Pcs, + F_FBAID: r.F_FBAID, + F_PO: r.F_PO, + F_ReferenceId: r.F_ReferenceId, + F_ShipmentID: r.F_ShipmentID, + F_Address: r.F_Address, + F_City: r.F_City, + F_State: r.F_State, + F_ZipCode: r.F_ZipCode, + F_Contact: r.F_Contact, + F_Mobile: r.F_Mobile, + F_Email: r.F_Email, + F_Expected_DeliveryDateB: r.F_Expected_DeliveryDateB, + F_Expected_DeliveryDateE: r.F_Expected_DeliveryDateE, + F_Remark: r.F_Remark, + F_SKU: r.F_SKU, + F_Description: r.F_Description, + F_EnglishName: r.F_EnglishName, + }); + }), + ); +} diff --git a/src/services/parse/split-instructions.ts b/src/services/parse/split-instructions.ts index 72b7f6c..9415e82 100644 --- a/src/services/parse/split-instructions.ts +++ b/src/services/parse/split-instructions.ts @@ -754,6 +754,27 @@ export function extractMailInstructions(input: { ); } + // 主题写明「新增预报」且尚无预报卡:即使正文已是贴标工单,也保留预报卡(当前轮为工单) + if ( + !units.some((u) => u.uiKind === "forecast") && + (/新增预报/.test(subject) || /^预报$/.test(subject.trim())) + ) { + const currentIsOps = + bodyHasHardAction || kindsInCurrentBody.has("work_order"); + push( + makeUnit({ + uiKind: "forecast", + source: "subject", + segmentIndex: subjectAnchor.segmentIndex, + isCurrent: !currentIsOps && subjectAnchor.isCurrent, + text: subject, + segmentSubject: subject, + roundAt: subjectAnchor.roundAt, + extraKeywords: ["主题预报"], + }), + ); + } + // 3) 附件:仅补充正文/主题未覆盖的 kind for (const f of filenames) { for (const kind of detectUiKindsFromText("", "", [f])) { @@ -923,25 +944,38 @@ function dedupeSamePayloadUnits( return out; } -/** 预报 Instruction:只用预报段,排除贴标段与移动端转发壳 */ +/** + * 预报 Instruction:只取预报自有内容,禁止混入工单/贴标/DO/拆柜清单等其他指令段。 + * 优先级:modules(船名航次/装箱单 Instruction)→ 纯预报正文段 → 主题船名航次短句。 + */ +const FORECAST_INSTR_POSITIVE = /新增预报|请查收新增预报/; +const FORECAST_INSTR_FOREIGN = + /换标|覆盖贴|贴标|贴好拍照|拆柜清单|派送要求|DO请查收|卡转海|拦截|改自提|开箱拍|操作指令/; + export function extractForecastInstructionText(input: { subject: string; body?: string | null; modulesInstruction?: string | null; }): string { + const mod = stripMobileMailChrome(input.modulesInstruction || "").trim(); + if (mod) return mod.slice(0, 500); + const body = stripMobileMailChrome(input.body || ""); const segments = splitBodySegments(body); const forecastSegs = segments.filter( - (s) => - /新增预报|提单|柜型|ETA|ETD|船名/.test(s) && - !/换标|覆盖贴|贴标|贴好拍照/.test(s), + (s) => FORECAST_INSTR_POSITIVE.test(s) && !FORECAST_INSTR_FOREIGN.test(s), ); - let raw = ""; - if (forecastSegs.length) raw = forecastSegs[0]; - else if (input.modulesInstruction) raw = input.modulesInstruction; - else { - const m = /船名航次\s*([^\s+]+(?:\s+[^\s+]+)?)/i.exec(input.subject); - if (m) raw = `\u8239\u540d\u822a\u6b21 ${m[1]}`; + if (forecastSegs.length) { + return stripMobileMailChrome(forecastSegs[0]).slice(0, 500); + } + + const vessel = + /船名航次\s*[::]?\s*([^\n+]+?)(?=\s*\+|ETA|ETD|$)/i.exec(input.subject) || + /船名航次\s*([^\s+]+(?:\s+[^\s+/]+)?(?:\s*\/\s*[^\s+]+)?)/i.exec( + input.subject, + ); + if (vessel?.[1]) { + return `船名航次 ${vessel[1].trim()}`.slice(0, 500); } - return stripMobileMailChrome(raw).slice(0, 500); + return ""; } diff --git a/src/services/parse/us-ports.ts b/src/services/parse/us-ports.ts index 6104b40..5a05443 100644 --- a/src/services/parse/us-ports.ts +++ b/src/services/parse/us-ports.ts @@ -152,3 +152,28 @@ export function isKnownUsDestinationPort( ): boolean { return matchUsDestinationPort(raw) != null; } + +/** + * 长正文扫美国目的港(对齐助手 header_extract US_PORTS 全文包含)。 + * 短码(LA/NY)不扫,避免正文误伤。 + */ +export function scanUsDestinationPortInText( + raw: string | null | undefined, +): string | null { + const text = (raw || "").trim(); + if (!text) return null; + if (text.length <= 60) return matchUsDestinationPort(text); + const upper = normalizePortKey(text); + for (const row of PORT_LOOKUP) { + if (row.len <= 3) continue; + const safe = row.key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp( + `(?:^|[^A-Z0-9\u4e00-\u9fff])${safe}(?:[^A-Z0-9\u4e00-\u9fff]|$)`, + "i", + ); + if (re.test(upper) || text.includes(row.canonical)) { + return row.canonical; + } + } + return null; +} diff --git a/src/services/parse/work-order-fields.ts b/src/services/parse/work-order-fields.ts new file mode 100644 index 0000000..6f8a065 --- /dev/null +++ b/src/services/parse/work-order-fields.ts @@ -0,0 +1,510 @@ +/** + * 新建工单字段提取(对齐 carriercentral-ai-assistant work_order_ops)。 + * + * 留言内容 / 备注分界: + * - 货件清单、操作/派送步骤进留言内容;备注选填可空 + * - 「注/注意/备注/特别说明:」冒号后进备注 + * - 「注意:」后接 1./2. 编号或货件清单 → 仍为正文;空「注意:」后的说明散文进备注 + * - 货件清单之后的独立催促/时限/拍照叮嘱行进备注(非「安排贴标/卡转海/派送要求」操作句) + * - 编号分节内的开箱拍照等操作说明留在留言内容 + * - 剥离邮箱、QQ 附件栏、转发壳、落款等噪声 + */ +import { stripMobileMailChrome } from "@/services/parse/instruction-lexicon"; +import { + isValidContainerNoFormat, + normalizeContainerNo, +} from "@/utils/iso6346"; + +const REMARK_LINE = + /^\s*[【\[]?\s*(?:注|注意|备注|特别说明|补充说明|温馨提示)\s*[】\]]?\s*[::]\s*(.*)\s*$/; + +const MANIFEST_ID = + /(?:\bFBA[A-Z0-9]+\b|\b[A-Z]{4}\d{7}\b|\bWL\d+\b|\bYT\d+\b|\bMJYT\d+\b|\bLX\d+\b|\bTS\d+\b)/i; +const MANIFEST_ID_G = new RegExp(MANIFEST_ID.source, "gi"); + +const OPS_STEP = + /(?:覆盖贴|换标后|贴\s*FBA|贴\s*SKU|一箱贴|UPS\s*默认|面单|^\s*\d+\s*[::.、.))]\s*\S|(?:安排|请安排|帮忙安排|麻烦安排|麻烦).{0,16}(?:贴标|换标|卡派|卡转海|拆柜|转仓|操作|打板|打托|交付)|操作指令|卡转海|贴标交付|开箱|回传照片|回传|派送要求|送货要求|操作要求|需要拦截|改自提|以此为准|拆柜清单|打板|打托|分货|分拣|按票|混打|修补|预约时间|一面朝外|箱子有字)/i; + +/** 货件/操作之后的催促·时限·拍照叮嘱 → 备注(对齐助手 _REQUEST_TONE) */ +const REQUEST_TONE = + /(?:记得|切记|千万|务必|必须|一定要|一定|特别|重点|严禁|不得|尽量|尽早|早点|早些|提早|提前|赶在|赶早|越早越好|能早则早|优先|加急|催|拜托|烦请|恳请|希望|请务必|请尽快|尽快|马上|立刻|辛苦|麻烦(?!安排(?:贴标|换标|卡派|拆柜|转仓|操作))|拜托了|客户(?:需要|要求|希望|说|强调|叮嘱|交代)|叮嘱|交代|关照|喊|叫(?:仓库|仓|操作|同事|司机)|拍(?:一下|张|个)?(?:照|照片|图片)|照片过来|发我|发过来|今晚|今天|明天|后天|辛苦操作|操作一下|(?:需要|要求|希望|叮嘱).{0,24}(?:前|之前|以内|之内|截止)|(?:\d{1,2}\s*[月/.\-]\s*\d{1,2}|\d{1,2}\s*点|\d{1,2}:\d{2}).{0,16}(?:前|之前|以内|截止)|美时|当地时间|北京时间|(?:送达|交付|送到|提货|入仓|上架).{0,8}(?:前|之前|以内|截止)|这(?:两|几|三|四|五|批|些)?(?:票|柜|单|货).{0,20}(?:需要|要求|务必|客户|尽量|记得|总共|一共)|别晚|不能晚|不要晚|赶不及|来不及|就行|即可|为要|为准)/i; + +const NUMBERED_LINE = /^\s*\d+\s*[.、.::))]/ +const NUMBERED_ONLY = /^\s*\d+\s*[.、.::))]?\s*$/; +const SENT_SPLIT = /(?<=[!?;])\s*|(??\s*[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\s*?$/.test(t)) { + return true; + } + if (ATTACH_CHROME_LINE.test(t)) return true; + if (ATTACH_FILENAME_LINE.test(t)) return true; + if ( + /^[A-Z]{4}\d{7}\s+.+\.(?:zip|rar|pdf|xlsx?)\b/i.test(t) && + !OPS_STEP.test(t) && + !/拦截|改自提|贴标|换标|注意/.test(t) + ) { + return true; + } + if ( + /^(?:发件人|收件人|抄送|寄件人|From|To|Cc)\s*[::]/i.test(t) || + (/^(?:发件人|收件人)/.test(t) && /@/.test(t)) + ) { + return true; + } + if (/^发自我的|^Sent from my|^Get Outlook/i.test(t)) return true; + // 网易灵犀 / Outlook 等邮件客户端签名栏 + if (/签名由/.test(t) || /网易灵犀/.test(t) || /灵犀办公/.test(t)) { + return true; + } + // 落款短名(km16 / luo),勿误伤柜号/唛头/FBA + if ( + /^[A-Za-z][A-Za-z0-9._\-]{1,20}$/.test(t) && + !MANIFEST_ID.test(t) && + !/^(?:Dear|Thanks|Thankyou|Regards|UPS|FedEx|DHL|COSCO|OOCL|ETA|ETD|FBA|DO)$/i.test( + t, + ) + ) { + return true; + } + if ( + /^(?:[\u4e00-\u9fffA-Za-z0-9]{2,20}(?:供应链|物流|货运|国际))\s*$/.test(t) || + /^[\u4e00-\u9fff]{2,8}\s*(?:港后)?客服\s*$/.test(t) || + /^(?:Best\s*regards|Thanks|Thank you)[,!.]?\s*$/i.test(t) + ) { + return true; + } + return false; +} + +function stripTrailingEmailToken(line: string): string { + return line + .replace( + /(?:^|\s)[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\s*$/g, + "", + ) + .trim(); +} + +/** + * 剥离转发壳、邮箱签名、QQ 附件栏等,避免污染留言内容/备注。 + */ +export function stripWorkOrderBodyNoise(text: string): string { + const decoded = (text || "") + .replace(/\r\n/g, "\n") + .replace(/ /gi, " ") + .replace(/>/gi, ">") + .replace(/</gi, "<") + .replace(/&/gi, "&") + .replace(/"/gi, '"'); + const chrome = stripMobileMailChrome(decoded); + const lines = chrome.split("\n"); + const kept: string[] = []; + let inAttachBlock = false; + + for (const line of lines) { + const t = line.trim(); + if (!t) { + if (kept.length && kept[kept.length - 1] !== "") kept.push(""); + continue; + } + + if ( + /在\s*\d{4}[-/.年][^\n]{0,160}写道[::]?/.test(t) || + /^Forwarded message\s*:/i.test(t) || + /^Original Message\s*:/i.test(t) + ) { + continue; + } + + if (ATTACH_CHROME_LINE.test(t) || /^\d+\s*个附件/.test(t)) { + inAttachBlock = true; + continue; + } + if (inAttachBlock) { + if ( + isWorkOrderNoiseLine(t) || + ATTACH_FILENAME_LINE.test(t) || + EMAIL_ONLY_LINE.test(t) || + /\.(zip|rar|pdf|xlsx?)\b/i.test(t) + ) { + continue; + } + inAttachBlock = false; + } + + if (isWorkOrderNoiseLine(t)) continue; + + const cleaned = stripTrailingEmailToken(t); + if (!cleaned || isWorkOrderNoiseLine(cleaned)) continue; + kept.push(cleaned); + } + + while (kept.length) { + const last = kept[kept.length - 1].trim(); + if (!last || isWorkOrderNoiseLine(last)) { + kept.pop(); + continue; + } + break; + } + + return kept + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function isNumberedOpsLine(text: string): boolean { + const t = (text || "").trim(); + if (!t) return false; + return NUMBERED_LINE.test(t) || NUMBERED_ONLY.test(t); +} + +/** 硬操作句:贴标/卡转海/派送要求/开箱回传等 → 留言内容 */ +function isHardOpsLine(text: string): boolean { + const t = (text || "").trim(); + if (!t) return false; + if (REMARK_LINE.test(t)) return false; + if (isNumberedOpsLine(t)) return true; + return OPS_STEP.test(t); +} + +function isManifestLine(text: string): boolean { + const t = (text || "").trim(); + if (!t) return false; + if (!MANIFEST_ID.test(t)) return false; + const soft = REQUEST_TONE.exec(t); + // 整行几乎都是叮嘱(「这两票客户需要美时…」)→ 不算纯清单 + if (soft && soft.index <= 4 && cjkRatio(t) >= 0.45) return false; + return true; +} + +/** + * 纯叮嘱/时限/拍照要求行 → 备注。 + * 硬操作句(安排贴标、卡转海、派送要求、编号内开箱回传)不进备注。 + */ +function isRequestToneRemarkLine(text: string): boolean { + const t = (text || "").trim(); + if (!t) return false; + if (isHardOpsLine(t)) return false; + if (isNumberedOpsLine(t)) return false; + if (/^(?:派送要求|操作要求|送货要求|操作指令)\s*[::]/.test(t)) return false; + if (!REQUEST_TONE.test(t)) return false; + // 须有足够中文,避免误伤纯代码行 + return cjkRatio(t) >= 0.35; +} + +/** + * 备注段内遇「新操作段」才跳出;勿因说明里夹带「分货/打托」误把备注打回正文。 + */ +function isRemarkSectionExit(text: string): boolean { + const t = (text || "").trim(); + if (!t) return false; + if (isNumberedOpsLine(t)) return true; + if (isManifestLine(t) && !isRequestToneRemarkLine(t)) return true; + if ( + /^(?:派送要求|操作要求|送货要求|操作指令|安排|请安排|麻烦安排|麻烦|Dear)\b/i.test( + t, + ) + ) { + return true; + } + if (/^(?:以上|这\d*票).{0,16}(?:需要拦截|改自提|卡转海)/.test(t)) { + return true; + } + return false; +} + +function peelInlineEmphasis(line: string): { head: string; tail: string } { + const t = (line || "").trim(); + if (!t) return { head: "", tail: "" }; + const m = REMARK_LINE.exec(t); + if (m) { + const piece = (m[1] || "").trim(); + if (piece && isNumberedOpsLine(piece)) return { head: t, tail: "" }; + return { head: "", tail: piece }; + } + // 同行:前半货件清单 + 后半叮嘱 → 拆开 + const soft = REQUEST_TONE.exec(t); + if ( + soft && + soft.index > max(8, Math.floor(t.length / 5)) && + MANIFEST_ID.test(t.slice(0, soft.index)) && + !isHardOpsLine(t.slice(soft.index)) && + cjkRatio(t.slice(soft.index)) >= 0.4 + ) { + return { + head: t.slice(0, soft.index).trim(), + tail: t.slice(soft.index).trim(), + }; + } + return { head: t, tail: "" }; +} + +function max(a: number, b: number): number { + return a > b ? a : b; +} + +function iterUnits(raw: string): string[] { + const units: string[] = []; + for (const line of (raw || "").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + // 仅当行内可能含「注意:」或叮嘱时才按句切开,避免把操作说明拆碎 + if ( + !/(?:注|注意|备注|特别说明|补充说明|温馨提示)\s*[::]/.test(trimmed) && + !REQUEST_TONE.test(trimmed) + ) { + units.push(trimmed); + continue; + } + const parts = trimmed + .split(SENT_SPLIT) + .map((p) => p.trim()) + .filter(Boolean); + if (parts.length <= 1) units.push(trimmed); + else units.push(...parts); + } + return units; +} + +export type WorkOrderContentSplit = { + content: string; + remark: string; + /** 备注里仍含货件标识时,建议人工再核对一次 */ + needRemarkReview: boolean; +}; + +/** + * 货件/操作进留言内容;「注意:」与货件后的纯叮嘱进备注。 + */ +export function splitWorkOrderContentAndRemark( + text: string, +): WorkOrderContentSplit { + let raw = stripWorkOrderBodyNoise(text || ""); + if (!raw) return { content: "", remark: "", needRemarkReview: false }; + + const bodyLabeled = /(?:留言内容|工单内容)[::]\s*([\s\S]*)\s*$/.exec(raw); + if (bodyLabeled?.[1]?.trim()) { + raw = bodyLabeled[1].trim(); + } + + raw = raw + .split("\n") + .map((line) => { + const m = /^\s*参考号\s*[::]\s*(.*)$/.exec(line); + if (m) { + const val = (m[1] || "").trim(); + return val || null; + } + return line; + }) + .filter((l): l is string => l != null) + .join("\n"); + + const bodyParts: string[] = []; + const remarks: string[] = []; + let inRemarkProse = false; + let usedExplicitNote = false; + let seenBody = false; + + for (const unit of iterUnits(raw)) { + const stripped = unit.trim(); + if (!stripped || isWorkOrderNoiseLine(stripped)) continue; + + if (isNumberedOpsLine(stripped)) { + inRemarkProse = false; + bodyParts.push(stripped); + seenBody = true; + continue; + } + + const note = REMARK_LINE.exec(stripped); + if (note) { + const piece = stripTrailingEmailToken((note[1] || "").trim()); + usedExplicitNote = true; + if (!piece || isWorkOrderNoiseLine(piece)) { + inRemarkProse = true; + continue; + } + if (isNumberedOpsLine(piece)) { + bodyParts.push(piece); + inRemarkProse = false; + seenBody = true; + continue; + } + remarks.push(piece); + inRemarkProse = true; + continue; + } + + if (inRemarkProse && !isRemarkSectionExit(stripped)) { + if (!isWorkOrderNoiseLine(stripped)) { + const cleaned = stripTrailingEmailToken(stripped); + if (cleaned && !isWorkOrderNoiseLine(cleaned)) remarks.push(cleaned); + } + continue; + } + if (inRemarkProse && isRemarkSectionExit(stripped)) { + inRemarkProse = false; + } + + const peeled = peelInlineEmphasis(stripped); + if (peeled.tail && !peeled.head) { + const tail = stripTrailingEmailToken(peeled.tail); + if (tail && !isWorkOrderNoiseLine(tail)) { + remarks.push(tail); + usedExplicitNote = true; + inRemarkProse = true; + } + continue; + } + if (peeled.head && peeled.tail) { + bodyParts.push(stripTrailingEmailToken(peeled.head) || peeled.head); + seenBody = true; + const tail = stripTrailingEmailToken(peeled.tail); + if (tail && !isWorkOrderNoiseLine(tail)) { + remarks.push(tail); + inRemarkProse = true; + } + continue; + } + + // 货件/操作之后的独立叮嘱行 → 备注 + if ( + seenBody && + isRequestToneRemarkLine(stripped) && + !isManifestLine(stripped) + ) { + remarks.push(stripTrailingEmailToken(stripped) || stripped); + inRemarkProse = true; + continue; + } + + bodyParts.push(stripTrailingEmailToken(stripped) || stripped); + seenBody = true; + } + + const content = clip( + stripWorkOrderBodyNoise(bodyParts.join("\n").trim()), + 800, + ); + const remark = clip( + stripWorkOrderBodyNoise(remarks.filter(Boolean).join("\n")), + 800, + ); + const needRemarkReview = Boolean( + (usedExplicitNote || Boolean(remark)) && remark && MANIFEST_ID.test(remark), + ); + return { content, remark, needRemarkReview }; +} + +/** 参考号:显式「参考号/柜号/原箱号」或正文中的 ISO 柜号 */ +export function parseWorkOrderReferCode(text: string): string { + const raw = text || ""; + const labeled = /(?:参考号|柜号)\s*[::]\s*([A-Za-z0-9\-]+)/i.exec(raw); + if (labeled?.[1]) { + const cand = labeled[1].trim().replace(/[。..]+$/, ""); + const up = normalizeContainerNo(cand); + if (isValidContainerNoFormat(up)) return clip(up, 100); + return clip(cand, 100); + } + const box = /原箱号\s*[::]?\s*([A-Za-z]{2,4}\d{6,14})/i.exec(raw); + if (box?.[1]) return clip(box[1].toUpperCase(), 100); + for (const m of raw.matchAll(/\b([A-Za-z]{4}\d{7})\b/g)) { + const up = normalizeContainerNo(m[1]); + if (isValidContainerNoFormat(up)) return up; + } + return ""; +} + +/** Ref No:显式 Ref No/唛头,或首个非柜号货件标识(FBA/WL 等) */ +export function parseWorkOrderRefNo(text: string, referCode = ""): string { + const raw = text || ""; + const labeled = /(?:Ref\s*No|RefNo|唛头)\s*[::]\s*([^\n]+)/i.exec(raw); + if (labeled?.[1]) { + return clip(labeled[1].trim().replace(/[。..]+$/, ""), 100); + } + const referU = (referCode || "").replace(/[\s\-]/g, "").toUpperCase(); + for (const m of raw.matchAll(MANIFEST_ID_G)) { + const tok = (m[0] || "").trim(); + if (!tok) continue; + const tokU = tok.replace(/[\s\-]/g, "").toUpperCase(); + if (referU && tokU === referU) continue; + if (isValidContainerNoFormat(tokU)) continue; + return clip(tok, 100); + } + return ""; +} + +/** 从邮件正文抽出工单弹窗字段(留言内容/备注已分界) */ +export function extractWorkOrderFormFields(input: { + body: string; + subject?: string; + /** 已剥壳的操作正文;缺省时用 body */ + messageBody?: string; + containerHint?: string | null; +}): { + F_ReferCode: string; + F_RefNo: string; + F_MessageContent: string; + F_Remark: string; + needRemarkReview: boolean; +} { + const bodyClean = stripWorkOrderBodyNoise(input.body || ""); + const msgClean = stripWorkOrderBodyNoise(input.messageBody || ""); + // 段正文若被 messageBody 截掉货件行,优先用更完整的 body + const source = + bodyClean.length >= (msgClean?.length || 0) + ? bodyClean || msgClean + : msgClean || bodyClean; + const split = splitWorkOrderContentAndRemark(source); + const referPool = [input.body, input.messageBody, input.subject] + .filter(Boolean) + .join("\n"); + const refer = + parseWorkOrderReferCode(referPool) || + parseWorkOrderReferCode(source) || + (input.containerHint + ? normalizeContainerNo(input.containerHint) + : ""); + const refNo = + parseWorkOrderRefNo(referPool, refer) || + parseWorkOrderRefNo(source, refer); + return { + F_ReferCode: refer, + F_RefNo: refNo, + F_MessageContent: split.content, + F_Remark: split.remark, + needRemarkReview: split.needRemarkReview, + }; +} diff --git a/src/services/parse/work-order-record.ts b/src/services/parse/work-order-record.ts index fe118de..42dc9d3 100644 --- a/src/services/parse/work-order-record.ts +++ b/src/services/parse/work-order-record.ts @@ -1,4 +1,6 @@ import { extractTransferPairs } from "./mail-intent"; +import { stripMobileMailChrome } from "@/services/parse/instruction-lexicon"; +import { stripWorkOrderBodyNoise } from "@/services/parse/work-order-fields"; import { PACKING_TEMPLATE_COLUMNS, WORK_ORDER_TABLE_COLUMNS, @@ -20,8 +22,30 @@ export const WORK_ORDER_SOFT_KEYWORDS = [ "拆柜清单", "卡转海", "派送要求", + "改地址派送", + "改地址", + "更新派送单", + "改自提", ] as const; +/** 正文里的「变更指令」优先于主题模板的拆柜/派送提示 */ +export function collectBodyOverrideActions(body: string): string[] { + const text = (body || "").replace(/ /gi, " "); + const out: string[] = []; + if (/改地址派送/.test(text) || (/改地址/.test(text) && /派送/.test(text))) { + out.push("改地址派送"); + } else if (/改地址/.test(text)) { + out.push("改地址"); + } + if (/更新派送单/.test(text) && !out.includes("更新派送单")) { + out.push("更新派送单"); + } + if (/改自提|仓库自提/.test(text) && !out.includes("改自提")) { + out.push("改自提"); + } + return out; +} + /** 贴标类同义词 → 工单 */ export const LABEL_WORK_ORDER_KEYWORDS = [ "换标", @@ -70,7 +94,9 @@ export function extractWorkOrderMessageBody( body: string, subject = "", ): string { - const raw = (body || "").replace(/\r\n/g, "\n").trim(); + const raw = stripWorkOrderBodyNoise( + stripMobileMailChrome((body || "").replace(/\r\n/g, "\n")), + ).trim(); if (!raw) return (subject || "").trim(); if ( @@ -82,21 +108,49 @@ export function extractWorkOrderMessageBody( } const dear = /(?:^|\n)(Dear[,,][\s\S]*)/i.exec(raw); - if (dear?.[1]) return dear[1].trim(); + if (dear?.[1]) return stripWorkOrderBodyNoise(dear[1]); const box = /(?:^|\n)((?:原箱号|换标后单号|覆盖贴|操作指令)[\s\S]*)/.exec( raw, ); - if (box?.[1]) return box[1].trim(); + if (box?.[1]) return stripWorkOrderBodyNoise(box[1]); const notice = /(?:^|\n)(拆柜清单更新\s*以此为准[\s\S]*)/.exec(raw); - if (notice?.[1]) return notice[1].trim(); + if (notice?.[1]) return stripWorkOrderBodyNoise(notice[1]); const lines = raw.split("\n"); const startIdx = lines.findIndex((l) => - /Dear[,,]|原箱号|换标|覆盖贴|操作指令|^\d+\s*[::.]/.test(l.trim()), + /Dear[,,]|原箱号|换标|覆盖贴|操作指令|^\d+\s*[::.]|需要拦截|改自提|卡转海|贴标/.test( + l.trim(), + ), ); - if (startIdx >= 0) return lines.slice(startIdx).join("\n").trim(); + if (startIdx >= 0) { + // 操作句之前的货件清单行必须保留(验收:FBA/WL 行 + 安排卡转海) + let from = startIdx; + while (from > 0) { + const prev = lines[from - 1].trim(); + if (!prev) { + from -= 1; + continue; + } + if ( + /写道|原始邮件|Forwarded|发件人|收件人|Subject\s*[::]/i.test(prev) + ) { + break; + } + if ( + /(?:\bFBA[A-Z0-9]+\b|\b[A-Z]{4}\d{7}\b|\bWL\d+\b|\bYT\d+\b)/i.test( + prev, + ) || + /^\d+\s*[.、.]/.test(prev) + ) { + from -= 1; + continue; + } + break; + } + return stripWorkOrderBodyNoise(lines.slice(from).join("\n")); + } const stripped = lines .filter( @@ -109,7 +163,7 @@ export function extractWorkOrderMessageBody( ) .join("\n") .trim(); - return stripped || raw; + return stripWorkOrderBodyNoise(stripped || raw); } export function collectSoftOpsHints( @@ -237,6 +291,9 @@ export function buildWorkOrderRecord(input: { subjectOnly ? "" : input.body, subjectOnly ? [] : filenames, ); + const bodyOverrides = subjectOnly + ? [] + : collectBodyOverrideActions(input.body); const softHints = [ ...(input.modules?.ops_hints || []), ...collectSoftOpsHints( @@ -245,8 +302,12 @@ export function buildWorkOrderRecord(input: { subjectOnly ? [] : filenames, ), ].filter((h, i, arr) => arr.indexOf(h) === i); - // 展示用:硬动作优先;否则用拆柜清单等软词(渠道「拦截-N件」绝不当成整票动作) - const displayActions = hardActions.length ? hardActions : softHints; + // 正文变更指令(改地址等)优先;否则硬动作;再否则主题软词 + const displayActions = bodyOverrides.length + ? bodyOverrides + : hardActions.length + ? hardActions + : softHints; // 主题优先:modules / subject 柜号 const heuristicCn = subjectOnly ? extractContainerFromFirstLines(input.subject, 3) @@ -321,7 +382,7 @@ export function buildWorkOrderRecord(input: { input.modules?.eta ? `ETA ${input.modules.eta}` : null, ].filter(Boolean); - const summary = `工单邮件(主题):${displayActions.join("、") || "待识别动作"}${ + const summary = `工单邮件${subjectOnly ? "(主题)" : ""}:${displayActions.join("、") || "待识别动作"}${ bits.length ? `;${bits.join(" · ")}` : "" }${ input.channelSummary?.length @@ -332,8 +393,8 @@ export function buildWorkOrderRecord(input: { return { kind: "WORK_ORDER", summary, - // 仅硬动作进入 work_order_actions(避免「拆柜清单」被当成工单留言类型/注入工单卡) - work_order_actions: hardActions, + // 正文变更指令写入 actions;否则仅硬动作 + work_order_actions: bodyOverrides.length ? bodyOverrides : hardActions, modules: { ...(input.modules || {}), container_no: containerNo || input.modules?.container_no, @@ -427,7 +488,11 @@ export function buildMailRecord(input: { }): MailRecord { const shipments = input.shipments || []; if (input.mailType === "WORK_ORDER") { - const subjectOnly = Boolean(input.opsSubject); + // 主题可解析柜头时仍带 opsSubject modules;正文有改地址等变更指令时不得 subjectOnly 丢正文 + // 注意:勿用泛化「拍照」打断 subjectOnly(邮件3 正文「开箱拍照」应忽略) + const bodyOverrides = collectBodyOverrideActions(input.body || ""); + const subjectOnly = + Boolean(input.opsSubject) && bodyOverrides.length === 0; return buildWorkOrderRecord({ subject: input.subject, body: subjectOnly ? "" : input.body, diff --git a/src/services/parse/work-order-snapshot.ts b/src/services/parse/work-order-snapshot.ts new file mode 100644 index 0000000..f31a0a5 --- /dev/null +++ b/src/services/parse/work-order-snapshot.ts @@ -0,0 +1,139 @@ +/** + * 工单提交快照:相对邮件抽取基线的「补充 / 已改」字段标记。 + */ +export const WORK_ORDER_SNAPSHOT_FIELDS = [ + "F_ReferCode", + "F_MessageType", + "F_RefNo", + "F_MessageContent", + "F_Remark", + "F_AttachFile", + "F_CustomerName", + "F_UrgencyLevel", +] as const; + +export type WorkOrderSnapshotField = (typeof WORK_ORDER_SNAPSHOT_FIELDS)[number]; + +export type WorkOrderFieldMark = "supplemented" | "modified"; + +export type WorkOrderFieldMarks = Partial< + Record +>; + +export type WorkOrderSnapshotValue = Partial< + Record +>; + +function norm(v: unknown): string { + return String(v ?? "").trim(); +} + +/** 将表单/entity 压成可持久化的字符串快照 */ +export function toWorkOrderSubmittedSnapshot( + value: WorkOrderSnapshotValue | Record | null | undefined, +): Record { + const out: Record = {}; + if (!value) return out; + for (const key of WORK_ORDER_SNAPSHOT_FIELDS) { + const s = norm((value as Record)[key]); + if (s) out[key] = s; + } + return out; +} + +/** + * 对比邮件基线与最终提交: + * - 基线空、提交有值 → supplemented(补充) + * - 基线有值且不同 → modified(已改) + */ +export function diffWorkOrderFieldMarks( + baseline: WorkOrderSnapshotValue | null | undefined, + submitted: WorkOrderSnapshotValue | null | undefined, +): WorkOrderFieldMarks { + const marks: WorkOrderFieldMarks = {}; + for (const key of WORK_ORDER_SNAPSHOT_FIELDS) { + const a = norm(baseline?.[key]); + const b = norm(submitted?.[key]); + if (a === b) continue; + if (!a && b) marks[key] = "supplemented"; + else if (a && b !== a) marks[key] = "modified"; + // 基线有值、提交清空:也算已改(人工删掉) + else if (a && !b) marks[key] = "modified"; + } + return marks; +} + +export function diffWorkOrderSupplementedFields( + baseline: WorkOrderSnapshotValue | null | undefined, + submitted: WorkOrderSnapshotValue | null | undefined, +): { + supplementedFields: WorkOrderSnapshotField[]; + fieldMarks: WorkOrderFieldMarks; + userSupplemented: boolean; +} { + const fieldMarks = diffWorkOrderFieldMarks(baseline, submitted); + const supplementedFields = Object.keys(fieldMarks) as WorkOrderSnapshotField[]; + return { + supplementedFields, + fieldMarks, + userSupplemented: supplementedFields.length > 0, + }; +} + +export function workOrderFormFromSubmitted( + submitted: Record | null | undefined, +): { + F_ReferCode: string; + F_MessageType: string; + F_RefNo: string; + F_MessageContent: string; + F_Remark: string; + F_AttachFile: string; + F_CustomerName?: string; + F_UrgencyLevel: string; +} { + const s = submitted || {}; + return { + F_ReferCode: s.F_ReferCode || "", + F_MessageType: s.F_MessageType || "", + F_RefNo: s.F_RefNo || "", + F_MessageContent: s.F_MessageContent || "", + F_Remark: s.F_Remark || "", + F_AttachFile: s.F_AttachFile || "", + F_CustomerName: s.F_CustomerName || undefined, + F_UrgencyLevel: s.F_UrgencyLevel || "0", + }; +} + +export const WORK_ORDER_FIELD_LABELS: Record = { + F_ReferCode: "参考号", + F_MessageType: "留言类型", + F_RefNo: "Ref No.", + F_MessageContent: "留言内容", + F_Remark: "备注", + F_AttachFile: "附件", + F_CustomerName: "客户", + F_UrgencyLevel: "紧急程度", +}; + +/** 只读回显:列出人工补充/修改了哪些字段及提交值 */ +export function describeWorkOrderFieldMarks( + fieldMarks: WorkOrderFieldMarks | null | undefined, + submitted: Record | null | undefined, +): string[] { + if (!fieldMarks) return []; + const lines: string[] = []; + for (const key of WORK_ORDER_SNAPSHOT_FIELDS) { + const mark = fieldMarks[key]; + if (!mark) continue; + const label = WORK_ORDER_FIELD_LABELS[key]; + const val = norm(submitted?.[key]); + const shown = val || "(空)"; + lines.push( + mark === "supplemented" + ? `${label}:${shown}` + : `${label}:已改为 ${shown}`, + ); + } + return lines; +} diff --git a/src/services/state-machine.ts b/src/services/state-machine.ts index 69b7326..4aa650b 100644 --- a/src/services/state-machine.ts +++ b/src/services/state-machine.ts @@ -26,7 +26,7 @@ const ALLOWED: Record = { PENDING_CONFIRM: ["IMPORTING", "PARSING", "IGNORED", "PENDING_CONFIRM"], IMPORTING: ["SUCCESS", "PARTIAL_SUCCESS", "FAILED"], AUTO_EXECUTING: ["SUCCESS", "FAILED", "PARSED"], - SUCCESS: [], + SUCCESS: ["IMPORTING"], PARTIAL_SUCCESS: ["IMPORTING"], FAILED: [ "IMPORTING", diff --git a/src/types/cc.ts b/src/types/cc.ts index cdc3aef..92cecff 100644 --- a/src/types/cc.ts +++ b/src/types/cc.ts @@ -10,7 +10,7 @@ export interface CcSaveContainerEntity { F_TransMode: number; F_OperationType: number; F_ContainerNo: string; - F_CabinetType?: string; + F_CabinetType?: string | null; F_BLCopyCode?: string; F_ETD?: string | null; F_ETA?: string | null; diff --git a/src/types/index.ts b/src/types/index.ts index d4860df..5a7bb34 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -43,6 +43,8 @@ export interface MailMessage { message_id: string; subject: string; from_addr: string; + /** 本系统接入邮箱(IMAP 收件账号) */ + mailbox_addr?: string | null; ingest_source?: "IMAP"; ingest_source_label?: string; received_at: string; @@ -120,6 +122,15 @@ export interface ContainerImport { updated_at: string; shipments_count?: number; compensations?: ImportCompensationBrief[]; + /** 管理端:归属租户;null=公共邮箱 */ + tenant_id?: string | null; + tenant_username?: string | null; + tenant_display_name?: string | null; + /** forecast | work_order | do_upload | transfer */ + import_kind?: string | null; + import_kind_label?: string | null; + /** 写入时选中的 CC 客户档案名称 */ + cc_customer_name?: string | null; } export interface SessionUser { @@ -138,6 +149,9 @@ export interface ImportPayload { selected_row_indexes: number[]; ack_unmapped_channels?: boolean; force_skip_conflict?: boolean; + cc_profile_id?: string; + /** 确认页手工增改 / 导入装箱表后的货件 */ + shipments?: import("./mail").ShipmentRow[]; } export interface ShippingLine { @@ -200,6 +214,22 @@ export interface CcSettings { updated_at: string | null; } +export interface CcCustomerProfile { + id: string; + name: string; + mock: boolean; + api_base: string; + saas_header: string; + login_mark: string; + username: string; + username_masked: string; + has_password: boolean; + enabled: boolean; + sort_order: number; + owner_user_id: string | null; + updated_at: string; +} + export interface OAuthSettings { public_base_url: string; google_client_id: string; @@ -229,6 +259,14 @@ export interface ImapFilterRuleItem { created_at: string; } +export interface CcMessageTypeDictItem { + id: string; + name: string; + enabled: boolean; + sort_order: number; + updated_at: string; +} + export interface ImapPullSettings { poll_interval_ms: number; poll_interval_ms_min?: number; diff --git a/src/utils/mail-body-text.ts b/src/utils/mail-body-text.ts index d464a25..b89ca3b 100644 --- a/src/utils/mail-body-text.ts +++ b/src/utils/mail-body-text.ts @@ -20,6 +20,8 @@ function decodeBasicEntities(s: string): string { }); } +export { decodeBasicEntities }; + /** 粗抽 HTML 为纯文本(不解脚本;保留换行) */ export function htmlToPlainText(html: string): string { if (!html) return ""; @@ -41,12 +43,12 @@ export function htmlToPlainText(html: string): string { .trim(); } -/** 若像 HTML 则转纯文本,否则原样 */ +/** 若像 HTML 则转纯文本;否则仍解码   等实体(转发链常见) */ export function plainTextFromMaybeHtml(raw: string | null | undefined): string { const t = (raw || "").trim(); if (!t) return ""; if (/<[a-z][\s\S]*>/i.test(t)) return htmlToPlainText(t); - return t; + return decodeBasicEntities(t); } /** diff --git a/tests/unit/address-block.test.ts b/tests/unit/address-block.test.ts index 81d7847..75b557e 100644 --- a/tests/unit/address-block.test.ts +++ b/tests/unit/address-block.test.ts @@ -44,4 +44,20 @@ describe("parseLabeledCell", () => { it("ignores a plain street with no labels", () => { expect(parseLabeledAddressBlock("12924 Pierce St")).toBeNull(); }); + + it("maps non-standard labels Tel / ADDR / Consignee", () => { + const p = parseLabeledCell( + "Consignee: Alice\nTel: 12025550123\nZIP: 10001\nStreet: 100 Main Street", + ); + expect(p?.F_Contact).toBe("Alice"); + expect(p?.F_Mobile).toBe("12025550123"); + expect(p?.F_ZipCode).toBe("10001"); + expect(p?.F_Address).toBe("100 Main Street"); + }); + + it("supports subset and random order", () => { + const p = parseLabeledCell("ADDR: 9 Ocean Ave\n电话: 555-0100"); + expect(p?.F_Address).toBe("9 Ocean Ave"); + expect(p?.F_Mobile).toBe("5550100"); + }); }); diff --git a/tests/unit/cc-api-v173-errors.test.ts b/tests/unit/cc-api-v173-errors.test.ts new file mode 100644 index 0000000..6d8f1a9 --- /dev/null +++ b/tests/unit/cc-api-v173-errors.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + buildCcApiFailureMessage, + ccContainerStatusLabel, + ccShipmentStatusLabel, + isContainerArrivedOrLater, +} from "@/constants/cc-api-codes"; +import { + classifyCcFailure, + formatCcApiFailure, + formatCcCause, + formatCcContainerStatusReason, +} from "@/constants/error-copy"; + +describe("V1.73 CC status labels", () => { + it("maps container F_Status per V1.73", () => { + expect(ccContainerStatusLabel(0)).toBe("0-新单"); + expect(ccContainerStatusLabel(20)).toBe("20-清关结束"); + expect(ccContainerStatusLabel(60)).toBe("60-已到仓"); + expect(ccContainerStatusLabel(65)).toBe("65-直送运输中"); + expect(ccContainerStatusLabel(70)).toBe("70-已拆柜"); + expect(ccContainerStatusLabel(80)).toBe("80-已还柜"); + expect(isContainerArrivedOrLater(40)).toBe(false); + expect(isContainerArrivedOrLater(60)).toBe(true); + }); + + it("maps shipment Status including 22-已装车", () => { + expect(ccShipmentStatusLabel(20)).toBe("20-已出仓"); + expect(ccShipmentStatusLabel(22)).toBe("22-已装车"); + expect(ccShipmentStatusLabel(26)).toBe("26-运输中"); + }); +}); + +describe("formatCcApiFailure per interface", () => { + it("explains GetContainerList 400/410", () => { + expect( + formatCcApiFailure({ + api: "GetContainerList", + code: 410, + info: "没有登录信息", + }), + ).toMatch(/集装箱列表|没有登录信息|410/); + expect( + formatCcApiFailure({ + api: "GetContainerList", + code: 400, + info: "查询条件无效", + }), + ).toMatch(/集装箱列表|业务失败|查询条件/); + }); + + it("explains SaveContainer 400 with field reason", () => { + const msg = formatCcApiFailure({ + api: "SaveContainer", + code: 400, + info: "以下字段未填写,不能保存:柜型", + }); + expect(msg).toMatch(/集装箱数据导入|柜型/); + expect(classifyCcFailure(msg)).toBe("validation"); + }); + + it("buildCcApiFailureMessage covers 500", () => { + expect( + buildCcApiFailureMessage({ api: "GetShipmentInfo", code: 500 }), + ).toMatch(/集装箱货件信息|异常|500/); + }); + + it("formats gate reason with container status", () => { + expect(formatCcContainerStatusReason(60, "批量转仓")).toMatch( + /60-已到仓.*批量转仓/, + ); + }); + + it("still rewrites legacy GetContainerList failed strings", () => { + expect(formatCcCause("GetContainerList failed: 410")).toMatch( + /没有登录|集装箱列表|410/, + ); + }); +}); diff --git a/tests/unit/cc-message-types.test.ts b/tests/unit/cc-message-types.test.ts index 66c9cee..3598e1c 100644 --- a/tests/unit/cc-message-types.test.ts +++ b/tests/unit/cc-message-types.test.ts @@ -9,4 +9,11 @@ describe("suggestCcMessageType", () => { "操作指令", ); }); + + it("filters by enabled dictionary names", () => { + expect(suggestCcMessageType(["拦截"], ["操作指令"])).toBe(""); + expect(suggestCcMessageType(["拦截"], ["货件拦截", "操作指令"])).toBe( + "货件拦截", + ); + }); }); diff --git a/tests/unit/cc-response-speed.test.ts b/tests/unit/cc-response-speed.test.ts index b7c1212..7fc960c 100644 --- a/tests/unit/cc-response-speed.test.ts +++ b/tests/unit/cc-response-speed.test.ts @@ -4,13 +4,15 @@ import { summarizeBody, deriveMailContentKind } from "@/utils/mailContent"; describe("CC token memory reuse", () => { const loginMark = "mark-1"; + const username = "fj"; it("reuses memory token when plenty of TTL remains", () => { const expireAt = new Date(Date.now() + 2 * 60 * 60 * 1000); expect( shouldReuseCcMemoryToken( - { loginMark, expireAt }, + { loginMark, username, expireAt }, loginMark, + username, Date.now(), ), ).toBe(true); @@ -19,8 +21,28 @@ describe("CC token memory reuse", () => { it("does not reuse when loginMark differs", () => { expect( shouldReuseCcMemoryToken( - { loginMark: "other", expireAt: new Date(Date.now() + 3_600_000) }, + { + loginMark: "other", + username, + expireAt: new Date(Date.now() + 3_600_000), + }, loginMark, + username, + Date.now(), + ), + ).toBe(false); + }); + + it("does not reuse when username differs (multi profile same loginMark)", () => { + expect( + shouldReuseCcMemoryToken( + { + loginMark, + username: "cs", + expireAt: new Date(Date.now() + 3_600_000), + }, + loginMark, + username, Date.now(), ), ).toBe(false); @@ -29,15 +51,18 @@ describe("CC token memory reuse", () => { it("falls back to DB when remaining TTL is under 30s", () => { expect( shouldReuseCcMemoryToken( - { loginMark, expireAt: new Date(Date.now() + 10_000) }, + { loginMark, username, expireAt: new Date(Date.now() + 10_000) }, loginMark, + username, Date.now(), ), ).toBe(false); }); it("does not reuse null memory", () => { - expect(shouldReuseCcMemoryToken(null, loginMark, Date.now())).toBe(false); + expect( + shouldReuseCcMemoryToken(null, loginMark, username, Date.now()), + ).toBe(false); }); }); diff --git a/tests/unit/classify-forecast-subject.test.ts b/tests/unit/classify-forecast-subject.test.ts new file mode 100644 index 0000000..e1a6294 --- /dev/null +++ b/tests/unit/classify-forecast-subject.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { classifyMail } from "@/services/parse/classify"; +import { shouldForceForecastUnit } from "@/services/parse/resolve-confirm-instruction"; + +describe("classify — 主题预报+整表附件", () => { + it("classifies subject 预报 + 测试AI建单.xlsx as NEW_CONTAINER", () => { + const ev = classifyMail({ + subject: "预报", + body: "", + filenames: ["测试AI建单.xlsx"], + }); + expect(ev.mail_type).toBe("NEW_CONTAINER"); + expect(ev.scores.NEW_CONTAINER).toBeGreaterThanOrEqual(40); + }); + + it("forces forecast unit for attachment-only 预报 mail", () => { + expect( + shouldForceForecastUnit({ + subject: "预报", + attachments: [{ filename: "测试AI建单.xlsx" }], + }), + ).toBe(true); + }); +}); diff --git a/tests/unit/container-header-from-sheet.test.ts b/tests/unit/container-header-from-sheet.test.ts new file mode 100644 index 0000000..cf0195d --- /dev/null +++ b/tests/unit/container-header-from-sheet.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + containerHeaderFromSheet, + matchContainerHeaderColumn, +} from "@/services/parse/container-header-from-sheet"; + +describe("container-header-from-sheet", () => { + it("matches ClientContainerTemplate header columns exactly", () => { + expect(matchContainerHeaderColumn("柜号Id")).toBeNull(); + expect(matchContainerHeaderColumn("柜号")).toBe("F_ContainerNo"); + expect(matchContainerHeaderColumn("柜型")).toBe("F_CabinetType"); + expect(matchContainerHeaderColumn("运输方式")).toBe("F_TransModeText"); + expect(matchContainerHeaderColumn("业务类型")).toBe("F_OperationTypeText"); + }); + + it("extracts header from template-like grid", () => { + const grid = [ + ["集装箱信息基础", "", "", "", "", "", "", "", "", "", "", "", "", "", "货件详情"], + [ + "柜号", + "柜型", + "运输方式", + "业务类型", + "柜号Id", + "提单号", + "ETD", + "ETA", + "起始港", + "目的港", + "船司", + "客服", + "instruction", + "备注", + "Move Type", + "Custmer Ref No", + "Shipment ID(FBA)", + "PO", + "ReferenceId", + "FBACode", + "Address", + "QTY(CTNS)", + "Vol(CBM)", + "GW(KGS)", + ], + [ + "TEST1234567", + "40HQ", + "海运", + "拆柜", + "TEST1234", + "TEST1234", + "2026-06-08", + "2026-06-20", + "深圳", + "纽约", + "", + "TEST", + "", + "", + "TRUCK", + "TEST123456789", + "FBA123456789", + "", + "", + "TEST", + "", + "123", + "2", + "2", + ], + ]; + const header = containerHeaderFromSheet(grid, 1, "", { + enableIsoCheck: false, + }); + expect(header.F_ContainerNo).toBe("TEST1234567"); + expect(header.F_CabinetType).toBe("40HQ"); + expect(header.F_TransMode).toBe(0); + expect(header.F_OperationType).toBe(0); + expect(header.F_ETD).toBe("2026-06-08"); + expect(header.F_ETA).toBe("2026-06-20"); + expect(header.F_LoadPort).toBe("深圳"); + expect(header.F_Dock).toBe("纽约"); + }); +}); diff --git a/tests/unit/do-confirm-gate.test.ts b/tests/unit/do-confirm-gate.test.ts new file mode 100644 index 0000000..68a6247 --- /dev/null +++ b/tests/unit/do-confirm-gate.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { collectDoImportIssues } from "@/services/cc/do-confirm-gate"; + +describe("collectDoImportIssues", () => { + it("blocks empty or invalid container no", () => { + expect(collectDoImportIssues({ containerNo: "", filenames: ["A-DO.pdf"] }).blockers[0]).toMatch( + /柜号/, + ); + expect( + collectDoImportIssues({ + containerNo: "ABCD1234567", + filenames: ["A-DO.pdf"], + }).errors.containerNo, + ).toMatch(/ISO 6346/); + }); + + it("blocks when no DO attachment", () => { + const issues = collectDoImportIssues({ + containerNo: "TRHU8524770", + filenames: [], + }); + expect(issues.errors.filenames).toBeTruthy(); + expect(issues.blockers[0]).toMatch(/DO 附件/); + }); + + it("allows valid container + DO file", () => { + const issues = collectDoImportIssues({ + containerNo: "TRHU8524770", + filenames: ["TRHU8524770-DO.pdf"], + }); + expect(issues.blockers).toEqual([]); + expect(issues.errors).toEqual({}); + }); +}); diff --git a/tests/unit/excel-sheet-grid.test.ts b/tests/unit/excel-sheet-grid.test.ts new file mode 100644 index 0000000..75d84de --- /dev/null +++ b/tests/unit/excel-sheet-grid.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import fs from "fs"; +import path from "path"; +import ExcelJS from "exceljs"; +import { + findPackingHeaderRowIndex, + isMergedSlaveCell, + isPackingFooterNoiseRow, + isPackingTitleNoiseRow, + readWorksheetGrid, +} from "@/services/parse/excel-sheet-grid"; +import { parsePackingListBuffer } from "@/services/parse/packing-list"; + +describe("excel-sheet-grid", () => { + it("finds Chinese header row on merged 数据模版", async () => { + const file = path.join( + process.cwd(), + "docx", + "邮件", + "模板", + "数据模版.xlsx", + ); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.readFile(file); + const grid = readWorksheetGrid(wb.worksheets[0]); + expect(isPackingTitleNoiseRow(grid[0])).toBe(true); + expect(findPackingHeaderRowIndex(grid)).toBe(2); + expect(grid[2].join("|")).toMatch(/件数.*渠道.*仓库/); + }); + + it("treats merged slaves as empty", async () => { + const file = path.join( + process.cwd(), + "docx", + "邮件", + "模板", + "数据模版.xlsx", + ); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.readFile(file); + const ws = wb.worksheets[0]; + const master = ws.getCell("B4"); + const slave = ws.getCell("B5"); + expect(isMergedSlaveCell(master)).toBe(false); + expect(isMergedSlaveCell(slave)).toBe(true); + const grid = readWorksheetGrid(ws); + expect(grid[3][1]).toBe("可不填"); + expect(grid[4][1]).toBe(""); + }); + + it("detects footer 注意 merged note row", () => { + const cells = [ + "注意:1.ups和fedex的件请直接在渠道栏对应写上ups和fedex", + "注意:1.ups和fedex的件请直接在渠道栏对应写上ups和fedex", + ]; + expect( + isPackingFooterNoiseRow({ F_Transporter: cells[0] }, cells), + ).toBe(true); + }); +}); + +describe("数据模版.xlsx merged template", () => { + it("parses structured region and skips footer note", async () => { + const file = path.join( + process.cwd(), + "docx", + "邮件", + "模板", + "数据模版.xlsx", + ); + const buf = fs.readFileSync(file); + const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false }); + expect(parsed.errorCode).toBeUndefined(); + expect(parsed.lineage.header_row).toBe(3); + expect(parsed.shipments.length).toBe(33); + expect( + parsed.shipments.every( + (s) => !String(s.F_Address || "").startsWith("注意"), + ), + ).toBe(true); + expect( + parsed.shipments.every( + (s) => !String(s.F_Transporter || "").startsWith("注意"), + ), + ).toBe(true); + const las1 = parsed.shipments.find((s) => s.F_FBAID === "FBA16SQQ7PTF"); + expect(las1?.F_Contact).toBe("LAS1"); + expect(las1?.F_Address).toBe("12300 Bermuda Road"); + }); +}); diff --git a/tests/unit/extract-header.test.ts b/tests/unit/extract-header.test.ts index 40f094f..355ee95 100644 --- a/tests/unit/extract-header.test.ts +++ b/tests/unit/extract-header.test.ts @@ -15,7 +15,7 @@ ETA:2026-08-15 ETD:2026-07-20 船司:MSC 装货港:上海 -码头:洋山 +目的港:洛杉矶 提单:BLMATU2745683 `; const h = extractContainerHeaderFromText(text); @@ -24,7 +24,7 @@ ETD:2026-07-20 expect(h.F_ETD).toBe("2026-07-20"); expect(h.shipping_line_hint).toBe("MSC"); expect(h.F_LoadPort).toBe("上海"); - expect(h.F_Dock).toBe("洋山"); + expect(h.F_Dock).toBe("洛杉矶"); expect(h.F_BLCopyCode).toBe("BLMATU2745683"); }); @@ -39,6 +39,13 @@ ETD:2026-07-20 expect(h.F_Classis).toBe(0); }); + it("拆柜清单 → OperationType 0(对齐 AI 助手 header_extract)", () => { + const h = extractContainerHeaderFromText( + "WHSU5574991+WHL063G550810+船名航次:OOCL SINGAPORE / 065W+ETA:6/28+FedEx-29件+UPS-102件+亚马逊卡派-289件+私人地址-166件+拦截-209件+拆柜清单", + ); + expect(h.F_OperationType).toBe(0); + }); + it("shippingLineHintFromBl extracts WHLC", () => { expect(shippingLineHintFromBl("WHLC027G597465")).toBe("WHLC"); }); diff --git a/tests/unit/forecast-confirm-gate.test.ts b/tests/unit/forecast-confirm-gate.test.ts index 9a91b0e..32d7857 100644 --- a/tests/unit/forecast-confirm-gate.test.ts +++ b/tests/unit/forecast-confirm-gate.test.ts @@ -48,6 +48,7 @@ describe("collectForecastImportIssues", () => { transMode: 0 as const, operationType: 0 as const, containerNo: "TRHU8524770", + cabinetType: "40HQ", hasContainerConflict: false, containerSource: null as "body" | "attachment" | null, validRowCount: 1, @@ -56,15 +57,15 @@ describe("collectForecastImportIssues", () => { ackUnmapped: false, }; - it("does not block header-only import when no valid rows", () => { + it("blocks when no shipment info (API requires shipment fields)", () => { const issues = collectForecastImportIssues({ ...base, validRowCount: 0, selectedValidCount: 0, shipmentCount: 0, }); - expect(issues.blockers).toEqual([]); - expect(issues.notes.some((n) => n.includes("只提交柜头"))).toBe(true); + expect(issues.blockers.some((b) => /货件/.test(b))).toBe(true); + expect(issues.errors.rows).toBeTruthy(); }); it("blocks when valid rows exist but none selected", () => { @@ -86,7 +87,7 @@ describe("collectForecastImportIssues", () => { ).toContain("ISO 6346"); }); - it("summarizes invalid packing rows without blocking header-only", () => { + it("blocks invalid packing rows with no valid shipment", () => { const issues = collectForecastImportIssues({ ...base, validRowCount: 0, @@ -96,17 +97,42 @@ describe("collectForecastImportIssues", () => { { row_index: 2, invalid_reasons: ["渠道缺失"] }, ], }); - expect(issues.blockers).toEqual([]); + expect(issues.blockers.some((b) => /有效货件/.test(b))).toBe(true); expect(issues.notes[0]).toContain("渠道缺失(2行)"); expect(issues.notes[0]).toContain("件数无效"); }); - it("does not block client portal on the first tab", () => { + it("still blocks missing required fields on client first tab", () => { const issues = collectForecastImportIssues({ ...base, portal: "client", step: 0, + operationType: undefined, + validRowCount: 0, + selectedValidCount: 0, + shipmentCount: 0, + }); + expect(issues.errors.operationType).toMatch(/业务类型/); + expect(issues.blockers.length).toBeGreaterThanOrEqual(2); + }); + + it("blocks missing transport / operation type with field errors", () => { + const issues = collectForecastImportIssues({ + ...base, + transMode: undefined, + operationType: undefined, + }); + expect(issues.errors.transMode).toMatch(/运输方式/); + expect(issues.errors.operationType).toMatch(/业务类型/); + expect(issues.blockers.length).toBeGreaterThanOrEqual(2); + }); + + it("blocks empty cabinet type", () => { + const issues = collectForecastImportIssues({ + ...base, + cabinetType: "", }); - expect(issues.blockers).toEqual([]); + expect(issues.errors.cabinetType).toMatch(/柜型/); + expect(issues.blockers.some((b) => /柜型/.test(b))).toBe(true); }); }); diff --git a/tests/unit/four-business.test.ts b/tests/unit/four-business.test.ts index eabeb16..d5607ec 100644 --- a/tests/unit/four-business.test.ts +++ b/tests/unit/four-business.test.ts @@ -55,7 +55,22 @@ describe("extractForecastInstructionText P4", () => { body: MAIL4_BODY, }); expect(text).not.toMatch(/覆盖贴/); - expect(text).toMatch(/船名航次|HMM|EMERALD|/); + expect(text).toMatch(/船名航次|HMM|EMERALD/); + }); + + it("keeps Instruction independent of 拆柜清单 body even if forecast card exists", () => { + const subject = + "Fw: 转发:派送要求更新: 拆柜清单更新: DO请查收+拆柜清单更新: WHSU5574991+WHL063G550810+船名航次:OOCL SINGAPORE / 065W+ETA:6/28+FedEx-29件+拆柜清单"; + const body = "拆柜清单更新 以此为准\n注意:需要拦截\n麻烦贴标交付"; + const units = extractMailInstructions({ subject, body, filenames: [] }); + expect(units.some((u) => u.uiKind === "forecast")).toBe(true); + const instr = extractForecastInstructionText({ + subject, + body, + modulesInstruction: "船名航次 OOCL SINGAPORE / 065W", + }); + expect(instr).toBe("船名航次 OOCL SINGAPORE / 065W"); + expect(instr).not.toMatch(/拦截|贴标|拆柜清单更新/); }); }); @@ -179,12 +194,13 @@ describe("evaluateTransferGate mock", () => { it("ARRIVED container → work_order fallback", async () => { const g = await evaluateTransferGate("ARRIVED1234567"); expect(g.canTransfer).toBe(false); - if (!g.canTransfer) expect(g.fallback).toBe("work_order"); - }); - - it("normal container → can transfer in mock", async () => { - const g = await evaluateTransferGate("WHSU8127240"); - // depends on CC_MOCK; if live empty list may fallback - expect(typeof g.canTransfer).toBe("boolean"); - }); + if (!g.canTransfer) { + expect(g.fallback).toBe("work_order"); + // mock 模式:柜态 60-已到仓;live 空列表也会降级工单 + if (g.shipStatus != null) { + expect(g.shipStatus).toBe(60); + expect(g.reason).toMatch(/已到仓/); + } + } + }, 15_000); }); diff --git a/tests/unit/header-service-hints.test.ts b/tests/unit/header-service-hints.test.ts new file mode 100644 index 0000000..4c4d07e --- /dev/null +++ b/tests/unit/header-service-hints.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + applyServiceFromModules, + applyServiceFromText, + normalizeCabinetType, + stripOrChoiceNoise, +} from "@/services/parse/header-service-hints"; +import { extractContainerHeaderFromText } from "@/services/parse/extract-container-header"; +import { mapBlModulesToHeader } from "@/services/parse/bl-plus-template"; +import { extractOpsSubjectFromMail } from "@/services/parse/ops-subject-template"; +import { buildCcForecastFormValue } from "@/components/CcForecastFormOrder"; + +describe("header-service-hints (AI assistant parity)", () => { + it("strips 海运或空运 / 提拆派或直送 option noise", () => { + const t = stripOrChoiceNoise("运输方式(海运或空运)操作类型(提拆派或直送)"); + expect(t).not.toMatch(/海运或空运|提拆派或直送/); + }); + + it("normalizeCabinetType 40HC → 40HQ", () => { + expect(normalizeCabinetType("40HC")).toBe("40HQ"); + expect(normalizeCabinetType("40HQ")).toBe("40HQ"); + }); + + it("拆柜清单 / 提拆派 / 直送 / 只清关", () => { + expect(applyServiceFromText("拆柜清单").F_OperationType).toBe(0); + expect(applyServiceFromText("提拆派组合柜-不带托架")).toMatchObject({ + F_OperationType: 0, + F_Classis: 0, + F_Instruction: "提拆派", + }); + expect(applyServiceFromText("本票直送").F_OperationType).toBe(2); + expect(applyServiceFromText("只清关").F_OperationType).toBe(4); + expect( + applyServiceFromText("提拆派,说明另有直送类型").F_OperationType, + ).toBe(0); + }); + + it("ops_hints 拆柜清单 → modules path", () => { + expect( + applyServiceFromModules({ ops_hints: ["拆柜清单", "派送要求"] }) + .F_OperationType, + ).toBe(0); + expect( + applyServiceFromModules({ ops_hints: ["直送"] }).F_OperationType, + ).toBe(2); + }); +}); + +describe("extractContainerHeaderFromText assistant parity", () => { + it("mail3 channel summary → 拆柜 + 海运 + OOCL 船司别名", () => { + const h = extractContainerHeaderFromText( + "WHSU5574991+WHL063G550810+船名航次:OOCL SINGAPORE / 065W+ETA:6/28+FedEx-29件+UPS-102件+亚马逊卡派-289件+私人地址-166件+拦截-209件+拆柜清单", + ); + expect(h.F_OperationType).toBe(0); + expect(h.F_TransMode).toBe(0); + expect(h.F_ETA).toBe("2026-06-28"); + expect(h.F_BLCopyCode).toBe("WHL063G550810"); + expect(h.shipping_line_hint).toMatch(/WHL/i); + expect(h.F_Instruction).toMatch(/船名航次.*OOCL SINGAPORE/); + // 禁止 WAN/OOCL 船名首词误当船司时:有提单前缀则用 WHL + }); + + it("does not take vessel first token as shipping line (WAN HAI)", () => { + const h = extractContainerHeaderFromText( + "船名航次:WAN HAI 511-V.E110 柜号 TCNU4412465", + ); + expect(h.shipping_line_hint?.toUpperCase()).not.toBe("WAN"); + expect(h.shipping_line_hint?.toUpperCase()).toMatch(/WAN\s*HAI|WHL|WAN HAI/i); + }); + + it("ignores 提拆派或直送 as selected operation", () => { + const h = extractContainerHeaderFromText( + "请选择操作类型(提拆派或直送)柜号 WHSU8127240", + ); + expect(h.F_OperationType).toBeUndefined(); + }); +}); + +describe("ops subject → form full chain", () => { + it("mail3 subject fills OperationType via map + form", () => { + const subject = + "Fw: 转发:派送要求更新: 拆柜清单更新: DO请查收+拆柜清单更新: WHSU5574991+WHL063G550810+船名航次:OOCL SINGAPORE / 065W+ETA:6/28+FedEx-29件+UPS-102件+亚马逊卡派-289件+私人地址-166件+拦截-209件+拆柜清单"; + const ops = extractOpsSubjectFromMail(subject, { defaultYear: 2026 })!; + const header = mapBlModulesToHeader( + { + F_ContainerNo: ops.modules.container_no || "", + container_no_valid: true, + }, + ops.modules, + ); + expect(header.F_OperationType).toBe(0); + expect(header.F_TransMode).toBe(0); + expect(header.F_ETA).toBe("2026-06-28"); + const form = buildCcForecastFormValue({ + header: {}, + modules: ops.modules, + }); + expect(form.F_OperationType).toBe(0); + expect(form.F_TransMode).toBe(0); + expect(form.F_ETA).toBe("2026-06-28"); + expect(form.F_Instruction).toMatch(/船名航次/); + }); +}); diff --git a/tests/unit/import-log-meta.test.ts b/tests/unit/import-log-meta.test.ts new file mode 100644 index 0000000..09cd3bc --- /dev/null +++ b/tests/unit/import-log-meta.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { + importKindLabel, + inferImportKindFromObjectKey, + normalizeExternalId, +} from "@/services/import/import-log-meta"; + +describe("import-log-meta", () => { + it("infers kind from object key prefix", () => { + expect(inferImportKindFromObjectKey("WO/YT1")).toBe("work_order"); + expect(inferImportKindFromObjectKey("DO/TCNU")).toBe("do_upload"); + expect(inferImportKindFromObjectKey("XF/ABCD")).toBe("transfer"); + expect(inferImportKindFromObjectKey("TCNU4412465")).toBe("forecast"); + }); + + it("labels kinds in Chinese", () => { + expect(importKindLabel("work_order")).toBe("工单"); + expect(importKindLabel("forecast")).toBe("新增预报"); + }); + + it("normalizes external ids and drops [object Object]", () => { + expect(normalizeExternalId("[object Object]")).toBeNull(); + expect(normalizeExternalId({ F_Id: "abc-1" })).toBe("abc-1"); + expect(normalizeExternalId("uuid-ok")).toBe("uuid-ok"); + }); +}); diff --git a/tests/unit/instruction-import-log.test.ts b/tests/unit/instruction-import-log.test.ts new file mode 100644 index 0000000..5a9cc4e --- /dev/null +++ b/tests/unit/instruction-import-log.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + instructionImportObjectKey, + instructionImportShipmentsHash, +} from "@/services/import/instruction-import-log"; + +describe("instruction-import-log", () => { + it("builds unique object keys with kind prefix (no raw ISO collide)", () => { + expect( + instructionImportObjectKey({ + kind: "work_order", + instructionId: "wo-1", + display: "YT2604021091", + }), + ).toBe("WO/YT2604021091"); + expect( + instructionImportObjectKey({ + kind: "do_upload", + instructionId: "do-1", + display: "TCNU4412465", + }), + ).toBe("DO/TCNU4412465"); + expect( + instructionImportObjectKey({ + kind: "transfer", + instructionId: "xf-1", + display: "ABCD1234567", + }), + ).toBe("XF/ABCD1234567"); + }); + + it("same instruction id shares shipments hash; different ids differ", () => { + const a = instructionImportShipmentsHash("unit-a"); + const b = instructionImportShipmentsHash("unit-b"); + expect(a).toHaveLength(64); + expect(a).not.toBe(b); + expect(instructionImportShipmentsHash("unit-a")).toBe(a); + }); + + it("falls back to instruction id when display empty", () => { + expect( + instructionImportObjectKey({ + kind: "work_order", + instructionId: "seg-body-2", + display: "", + }), + ).toBe("WO/seg-body-2"); + }); + + it("stable object key length within 50", () => { + const key = instructionImportObjectKey({ + kind: "work_order", + instructionId: "x".repeat(80), + display: "Y".repeat(80), + }); + expect(key.length).toBeLessThanOrEqual(50); + expect(key.startsWith("WO/")).toBe(true); + }); +}); diff --git a/tests/unit/list-http-gate.test.ts b/tests/unit/list-http-gate.test.ts new file mode 100644 index 0000000..63b1aca --- /dev/null +++ b/tests/unit/list-http-gate.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + resetListHttpGateForTests, + runWithListHttpGate, +} from "@/services/cc/list-http-gate"; + +describe("runWithListHttpGate", () => { + afterEach(() => { + vi.useRealTimers(); + resetListHttpGateForTests(); + }); + + it("waits 60s between HTTP slots for the same profile", async () => { + vi.useFakeTimers(); + const calls: number[] = []; + const first = runWithListHttpGate("profile:1", async () => { + calls.push(Date.now()); + return "a"; + }); + await vi.runOnlyPendingTimersAsync(); + await first; + + const second = runWithListHttpGate("profile:1", async () => { + calls.push(Date.now()); + return "b"; + }); + await vi.advanceTimersByTimeAsync(59_000); + let settled = false; + void second.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1_000); + expect(await second).toBe("b"); + expect(calls).toHaveLength(2); + }); +}); diff --git a/tests/unit/match-segment-attachments.test.ts b/tests/unit/match-segment-attachments.test.ts index f60c67f..9c95eca 100644 --- a/tests/unit/match-segment-attachments.test.ts +++ b/tests/unit/match-segment-attachments.test.ts @@ -164,4 +164,17 @@ describe("buildCcWorkOrderFormValue uses the same rule", () => { expect(v.F_AttachFile).toContain("WL99583-LAX9.pdf"); expect(v.F_AttachFile).toContain("卡转海.zip"); }); + + it("does not show empty attach as code; matches zip via 卡转海 action tags", () => { + const v = buildCcWorkOrderFormValue({ + subject: "拆柜清单更新:拦截-209件+拆柜清单", + body: "以上几票需要拦截 清单中已备注", + filenames: ["WHSU5574991 卡转海.zip", "3邮箱.pdf"], + containerNo: "WHSU5574991", + actions: ["拦截", "拆柜清单", "卡转海"], + isCurrent: true, + }); + expect(v.F_AttachFile).toContain("卡转海.zip"); + expect(v.F_AttachFile).not.toMatch(/F_AttachFile/); + }); }); diff --git a/tests/unit/operator-error-copy.test.ts b/tests/unit/operator-error-copy.test.ts index d928346..c1be1ca 100644 --- a/tests/unit/operator-error-copy.test.ts +++ b/tests/unit/operator-error-copy.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; import { + classifyCcFailure, + conflictCheckOperatorMessage, + describeCcFailureForOperator, formatCcCause, formatImapSkipReason, formatMailLastError, @@ -32,9 +35,17 @@ describe("formatOperatorError", () => { it("translates CC transport failures", () => { expect(formatCcCause("CC request timeout")).toMatch(/超时/); expect(formatCcCause("CC request failed")).toMatch(/无法连接/); - expect(formatCcCause("CC save failed with code 400")).toMatch(/拒绝保存/); - expect(formatCcCause("GetContainerList timeout")).toMatch(/查询柜号/); + expect(formatCcCause("CC save failed with code 400")).toMatch( + /集装箱数据导入|拒绝|400/, + ); + expect(formatCcCause("GetContainerList timeout")).toMatch( + /集装箱列表|超时/, + ); expect(formatMailLastError("CC_WRITE_OFF")).toMatch(/未开启/); + expect(formatMailLastError("WO_MOCK_OK")).toMatch(/模拟|未写入/); + expect(formatMailLastError("WO_MOCK_OK")).not.toMatch(/操作失败/); + expect(formatMailLastError("XFER_MOCK_OK")).toMatch(/模拟|未写入/); + expect(formatMailLastError("DO_MOCK_OK")).toMatch(/模拟|未写入/); expect(formatCcCause("Unauthorized")).not.toMatch(/Unauthorized/i); }); @@ -55,7 +66,22 @@ describe("formatOperatorError", () => { it("rewrites English embedded in Chinese", () => { expect( formatOperatorError("查柜失败:GetContainerList timeout,默认降级工单"), - ).toMatch(/查询柜号超时/); + ).toMatch(/集装箱列表|超时/); + }); + + it("keeps Chinese packing-list errors that mention Excel/xlsx", () => { + expect( + formatOperatorError( + "解析装箱表失败,请确认文件为有效的 Excel(xlsx)", + "PARSE_FAILED", + ), + ).toMatch(/装箱表|Excel|xlsx/); + expect( + formatOperatorError( + "未能从表格解析出货件行,请确认是卡派清单等含「渠道/件数/仓库」表头的 xlsx", + "PARSE_FAILED", + ), + ).toMatch(/货件行|卡派清单/); }); it("maps OAuth and IMAP skip reasons", () => { @@ -63,4 +89,32 @@ describe("formatOperatorError", () => { expect(formatImapSkipReason("no_credentials")).toMatch(/邮箱/); expect(formatImapSkipReason("SKIP_BLACKLIST")).toMatch(/黑名单/); }); + + it("classifies conflict check failures for operators", () => { + expect(classifyCcFailure("CC_LOGIN_FAILED:账户或密码错误")).toBe("login"); + expect(classifyCcFailure("访问次数限制,请稍后再试")).toBe("rate_limit"); + expect(classifyCcFailure("GetContainerList timeout")).toBe("timeout"); + expect( + conflictCheckOperatorMessage("login", "CC_LOGIN_FAILED:401"), + ).toMatch(/账号|密码/); + expect(conflictCheckOperatorMessage("rate_limit")).toMatch(/每分钟|60/); + }); + + it("maps CC login credential errors", () => { + expect(formatCcCause("CC_CREDENTIALS_MISSING")).toMatch(/未配置/); + expect(formatCcCause("CC_LOGIN_FAILED:账户或密码错误")).toMatch(/密码/); + }); + + it("describeCcFailureForOperator exposes kind and message", () => { + const login = describeCcFailureForOperator("CC_LOGIN_FAILED:账户或密码错误"); + expect(login.kind).toBe("login"); + expect(login.kindLabel).toMatch(/登录/); + expect(login.message).toMatch(/账号|密码/); + + const validation = describeCcFailureForOperator( + "以下字段未填写,不能保存:柜型", + ); + expect(validation.kind).toBe("validation"); + expect(validation.message).toMatch(/柜型|40HQ/); + }); }); diff --git a/tests/unit/ops-subject-mail3.test.ts b/tests/unit/ops-subject-mail3.test.ts index 4247ca7..e5ed6f7 100644 --- a/tests/unit/ops-subject-mail3.test.ts +++ b/tests/unit/ops-subject-mail3.test.ts @@ -46,6 +46,24 @@ describe("ops-subject-template mail3", () => { expect(r!.modules.eta).toBe("2026-06-28"); }); + it("拆柜清单 ops_hints → mapBlModulesToHeader OperationType 0", async () => { + const { mapBlModulesToHeader } = await import( + "@/services/parse/bl-plus-template" + ); + const r = extractOpsSubjectFromMail(MAIL3_SUBJECT, { defaultYear: 2026 })!; + const header = mapBlModulesToHeader( + { + F_ContainerNo: r.modules.container_no || "", + container_no_valid: true, + }, + r.modules, + ); + expect(r.modules.ops_hints).toEqual( + expect.arrayContaining(["拆柜清单"]), + ); + expect(header.F_OperationType).toBe(0); + }); + it("classifies as WORK_ORDER and builds subject-only mail_record", () => { const evidence = classifyMail({ subject: MAIL3_SUBJECT, diff --git a/tests/unit/packing-list.test.ts b/tests/unit/packing-list.test.ts index 47166c0..83f48d4 100644 --- a/tests/unit/packing-list.test.ts +++ b/tests/unit/packing-list.test.ts @@ -54,6 +54,23 @@ describe("packing-list", () => { expect(parsed.lineage.missing_columns).toBeTruthy(); }); + it("parses 数据模版.xlsx(含合并单元格占位行)", async () => { + const fs = await import("fs/promises"); + const path = await import("path"); + const file = path.join( + process.cwd(), + "docx", + "邮件", + "模板", + "数据模版.xlsx", + ); + const buf = await fs.readFile(file); + const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false }); + expect(parsed.errorCode).toBeUndefined(); + expect(parsed.shipments.length).toBeGreaterThan(0); + expect(parsed.shipments.some((s) => s.row_status === "VALID")).toBe(true); + }); + it("parses 新辰泽官方模板(多行表头 + UPS空仓库ID)", async () => { const fs = await import("fs/promises"); const path = await import("path"); @@ -77,6 +94,14 @@ describe("packing-list", () => { expect(fedex.every((s) => s.F_FBACode === "FEDEX")).toBe(true); const amazon = parsed.shipments.find((s) => s.F_FBAID === "FBA16SQQ7PTF"); expect(amazon?.F_FBACode).toBe("LAS1"); + expect(amazon?.F_Contact).toBe("LAS1"); + expect(amazon?.F_Mobile).toBe("0123456789"); + expect(amazon?.F_State).toBe("NV"); + expect(amazon?.F_City).toBe("HENDERSON"); + expect(amazon?.F_ZipCode).toBe("89044-8746"); + expect(amazon?.F_Address).toBe("12300 Bermuda Road"); + expect(amazon?.F_EnglishName).toBe("AMAZON COM SERVICES INC"); + expect(String(amazon?.F_Address || "")).not.toMatch(/仓库代码|收件人/); const priv = parsed.shipments.find((s) => (s.F_Address || "").includes("12924 Pierce"), ); @@ -283,6 +308,24 @@ describe("packing-list", () => { expect(s.F_Remark).toBe("三票分开打托"); }); + it("splits non-standard ADDR/Tel labels in Address column", async () => { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet("Sheet1"); + ws.addRow(["仓库ID", "渠道", "件数", "派送地址"]); + ws.addRow([ + "LAS1", + "卡派", + 2, + "ADDR: 9 Ocean Ave\n电话: 555-0100", + ]); + const buf = Buffer.from(await wb.xlsx.writeBuffer()); + const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false }); + const s = parsed.shipments[0]; + expect(s.F_Address).toBe("9 Ocean Ave"); + expect(s.F_Mobile).toBe("5550100"); + expect(String(s.F_Address || "")).not.toMatch(/电话/); + }); + it("keeps dedicated Contact column over Address 块里的收件人", async () => { const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet("Sheet1"); @@ -301,4 +344,39 @@ describe("packing-list", () => { expect(s.F_Contact).toBe("Tom"); expect(s.F_Address).toBe("1 Main St"); }); + + it("parses official ClientContainerTemplate.xlsx", async () => { + const fs = await import("fs/promises"); + const path = await import("path"); + const file = path.join( + "x:", + "work", + "carriercentral-ai-assistant", + "docs", + "附件", + "ClientContainerTemplate.xlsx", + ); + try { + await fs.access(file); + } catch { + return; + } + const buf = await fs.readFile(file); + const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false }); + expect(parsed.errorCode).toBeUndefined(); + expect(parsed.container_header.F_ContainerNo).toBe("TEST1234567"); + expect(parsed.container_header.F_CabinetType).toBe("40HQ"); + expect(parsed.container_header.F_TransMode).toBe(0); + expect(parsed.container_header.F_OperationType).toBe(0); + expect(parsed.container_header.F_ETD).toBe("2026-06-08"); + expect(parsed.container_header.F_ETA).toBe("2026-06-20"); + expect(parsed.shipments.length).toBeGreaterThanOrEqual(1); + const row = parsed.shipments[0]; + expect(row.F_Transporter).toBe("TRUCK"); + expect(row.F_ShipmentID).toBe("TEST123456789"); + expect(row.F_FBAID).toBe("FBA123456789"); + expect(row.F_CTNS).toBe(123); + expect(row.F_Weight).toBe(2); + expect(parsed.lineage.header_from_attachment).toBe(true); + }); }); diff --git a/tests/unit/resolve-confirm-instruction.test.ts b/tests/unit/resolve-confirm-instruction.test.ts index 0c2c31f..283c703 100644 --- a/tests/unit/resolve-confirm-instruction.test.ts +++ b/tests/unit/resolve-confirm-instruction.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + extractInstructionsFromMail, resolveConfirmInstruction, + shouldForceForecastUnit, } from "@/services/parse/resolve-confirm-instruction"; import type { MailInstructionUnit } from "@/services/parse/split-instructions"; @@ -78,3 +80,58 @@ describe("resolveConfirmInstruction", () => { expect(resolved.unit?.id).toBe(forecast.id); }); }); + +describe("extractInstructionsFromMail forceForecast parity", () => { + it("mail100-style DO + 整柜直送 subject still yields forecast unit", () => { + const mail = { + subject: + "Fw: Fw:回复:柜号:TRHU8524770+提单号:EGLV143661466958+封号:EMCEJJ0635+YWZD+不带车架+整柜直送", + body_text: "TRHU8524770 DO请查收", + attachments: [{ filename: "TRHU8524770-DO-1.pdf" }], + parse_result: { + container_header: { F_ContainerNo: "TRHU8524770" }, + mail_record: { kind: "BL_FORECAST" }, + }, + }; + expect(shouldForceForecastUnit(mail)).toBe(true); + const units = extractInstructionsFromMail(mail); + expect(units.some((u) => u.uiKind === "do_upload")).toBe(true); + const forecast = units.find((u) => u.uiKind === "forecast"); + expect(forecast).toBeTruthy(); + expect(forecast!.id).toMatch(/^forecast-record-/); + const resolved = resolveConfirmInstruction({ + units, + instructionId: forecast!.id, + }); + expect(resolved.reason).toBe("ok"); + expect(resolved.unit?.uiKind).toBe("forecast"); + }); + + it("forces forecast from 整柜直送 subject even without mail_record", () => { + const units = extractInstructionsFromMail({ + subject: + "回复:柜号:TRHU8524770+提单号:EGLV143661466958+YWZD+不带车架+整柜直送", + body_text: "DO请查收", + attachments: [{ filename: "TRHU8524770-DO-1.pdf" }], + }); + expect(units.some((u) => u.uiKind === "forecast")).toBe(true); + expect(units.some((u) => u.uiKind === "do_upload")).toBe(true); + }); + + it("does not force forecast for work-order mail that only has container in subject", () => { + const mail = { + subject: "Fw: 转发:TIIU8073522-90022", + body_text: + "主题 TIIU8073522-90022\nBAZUS001787430-90022 4PX 预约码: ==CNF26070717", + mail_type: "WORK_ORDER", + attachments: [] as Array<{ filename: string }>, + parse_result: { + container_header: { F_ContainerNo: "TIIU8073522" }, + mail_record: { kind: "WORK_ORDER" }, + }, + }; + expect(shouldForceForecastUnit(mail)).toBe(false); + const units = extractInstructionsFromMail(mail); + expect(units.some((u) => u.uiKind === "forecast")).toBe(false); + }); +}); diff --git a/tests/unit/revalidate-shipment-row.test.ts b/tests/unit/revalidate-shipment-row.test.ts new file mode 100644 index 0000000..81f40fa --- /dev/null +++ b/tests/unit/revalidate-shipment-row.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + revalidateShipmentRow, + sanitizeClientShipments, +} from "@/services/parse/revalidate-shipment-row"; + +describe("revalidateShipmentRow", () => { + it("marks incomplete row INVALID", () => { + const row = revalidateShipmentRow({ row_index: 0 }); + expect(row.row_status).toBe("INVALID"); + expect(row.invalid_reasons?.length).toBeGreaterThan(0); + }); + + it("accepts truck row with qty/cbm/weight", () => { + const row = revalidateShipmentRow({ + row_index: 0, + channel_raw: "TRUCK", + F_FBACode: "ONT8", + F_CTNS: 10, + F_CBM: 1.2, + F_Weight: 100, + F_ShipmentID: "REF-1", + }); + expect(row.row_status).toBe("VALID"); + expect(row.F_Transporter).toBe("TRUCK"); + expect(row.F_ShipmentID).toBe("REF-1"); + }); + + it("sanitizeClientShipments reindexes", () => { + const rows = sanitizeClientShipments([ + { + row_index: 9, + channel_raw: "UPS", + F_CTNS: 1, + F_CBM: 0.1, + F_Weight: 2, + }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0].row_index).toBe(0); + expect(rows[0].row_status).toBe("VALID"); + }); + + it("splits labeled Address blob into city/state/contact/mobile", () => { + const blob = + "仓库代码: LAS1 收件人: LAS1 公司名称: AMAZON COM SERVICES INC 联系电话: 0123456789 省/市/区: NV / HENDERSON / 邮政编码: 89044-8746 收货地址: 12300 Bermuda Road"; + const row = revalidateShipmentRow({ + row_index: 0, + channel_raw: "TRUCK", + F_CTNS: 44, + F_CBM: 5.14, + F_Weight: 567, + F_Address: blob, + }); + expect(row.F_Address).toBe("12300 Bermuda Road"); + expect(row.F_City).toBe("HENDERSON"); + expect(row.F_State).toBe("NV"); + expect(row.F_ZipCode).toBe("89044-8746"); + expect(row.F_Contact).toBe("LAS1"); + expect(row.F_Mobile).toBe("0123456789"); + expect(row.F_EnglishName).toBe("AMAZON COM SERVICES INC"); + expect(row.F_FBACode).toBe("LAS1"); + }); +}); diff --git a/tests/unit/split-instructions.test.ts b/tests/unit/split-instructions.test.ts index 4a9fee0..ac2e256 100644 --- a/tests/unit/split-instructions.test.ts +++ b/tests/unit/split-instructions.test.ts @@ -327,6 +327,32 @@ describe("extractForecastInstructionText", () => { expect(t).not.toMatch(/\u8986\u76d6\u8d34/); }); + it("prefers modulesInstruction over foreign body segments", () => { + const t = extractForecastInstructionText({ + subject: + "Fw: 转发:派送要求更新: 拆柜清单更新: DO请查收+拆柜清单更新: WHSU5574991+船名航次:OOCL SINGAPORE / 065W+ETA:6/28+拆柜清单", + body: [ + "拆柜清单更新 以此为准", + "注意:", + "1.WL103516 以上几票需要拦截", + "2.WL101167 这2票改自提", + "OOCL SINGAPORE / 065W+ETA : 6/28+FedEx-29 件 + 拦截 -209 件 + 拆柜清单", + ].join("\n"), + modulesInstruction: "船名航次 OOCL SINGAPORE / 065W", + }); + expect(t).toBe("船名航次 OOCL SINGAPORE / 065W"); + expect(t).not.toMatch(/拦截|改自提|拆柜清单更新|WL103516/); + }); + + it("does not treat ETA/船名 work-order body as forecast Instruction", () => { + const t = extractForecastInstructionText({ + subject: "DO请查收+拆柜清单更新: WHSU5574991+船名航次:OOCL SINGAPORE / 065W", + body: "拆柜清单更新 以此为准\n注意:需要拦截\n船名航次:OOCL SINGAPORE / 065W+ETA:6/28", + }); + expect(t).toMatch(/船名航次.*OOCL SINGAPORE/); + expect(t).not.toMatch(/拦截|拆柜清单更新|注意/); + }); + it("extracts 时间/发件人/主题 from QQ forward chrome, ignores 收件人", () => { const body = [ "Dear, 请处理。", diff --git a/tests/unit/transfer-gate-rate-limit.test.ts b/tests/unit/transfer-gate-rate-limit.test.ts index 360c73b..659d2b2 100644 --- a/tests/unit/transfer-gate-rate-limit.test.ts +++ b/tests/unit/transfer-gate-rate-limit.test.ts @@ -8,6 +8,7 @@ describe("transfer gate vs GetContainerList rate limit", () => { "访问时长限制,当前接口访问时间间隔[60]秒!", ), ).toBe(true); + expect(isCcRateLimitedMessage("访问次数限制,请稍后再试")).toBe(true); expect(isCcRateLimitedMessage("GetContainerList timeout")).toBe(false); }); }); diff --git a/tests/unit/work-order-fields.test.ts b/tests/unit/work-order-fields.test.ts new file mode 100644 index 0000000..b3cf492 --- /dev/null +++ b/tests/unit/work-order-fields.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "vitest"; +import { + extractWorkOrderFormFields, + parseWorkOrderReferCode, + parseWorkOrderRefNo, + splitWorkOrderContentAndRemark, + stripWorkOrderBodyNoise, +} from "@/services/parse/work-order-fields"; +import { buildCcWorkOrderFormValue } from "@/components/CcWorkOrderForm"; + +describe("splitWorkOrderContentAndRemark(对齐 AI 助手)", () => { + const cases: Array<[string, string, string]> = [ + [ + "安排卡转海,麻烦安排贴标操作。\n注意:ups默认一件贴2张面单", + "安排卡转海", + "ups默认", + ], + ["特别说明:拍照要清晰", "", "拍照要清晰"], + [ + "WL1 2件\n注意:加急处理,必须今天下午前交付", + "WL1", + "加急处理", + ], + [ + "WL103262-6件\n派送要求:分别按票打板 千万不要混打\n注意:柜子里的亚马逊卡派货物,是我们在国内换过标签", + "派送要求", + "换过标签", + ], + [ + "1.WL104834-2 SCK8 2件\n这票2件每箱都需要开箱拍下里面的产品并回传照片\n2.\n以上货物需要卡转海 麻烦贴标交付\n注意:FBA箱号需要对应快递子单号", + "开箱拍下", + "FBA箱号需要对应", + ], + [ + "拆柜清单更新 以此为准\n注意:\n1.WL103516\n以上几票需要拦截\n2.\n这2票改自提", + "改自提", + "", + ], + [ + "TCNU4412465+拆柜清单\n注意:\nWL103262-6件\nWL104381-24件\n派送要求:分别按票打板 千万不要混打\n注意:\n柜子里的亚马逊卡派货物,是我们在国内换过标签,WL唛头上的仓点和FBA标签可能不一样,需要注意下区分。需要根据wl唛头、拆柜清单还有FBA来分货,不能只根据WL唛头来打托。", + "派送要求", + "换过标签", + ], + [ + "TCNU4412465\nWL103262 6件\n这两票客户需要美时7/10 下午2点前送达 尽量早些哈", + "WL103262", + "尽量早些", + ], + [ + "TCNU4412465\nWL103262 6件\nWL104381 25件\n这两票总共31件记得叫仓库拍一下照片过来哈。今晚辛苦操作一下", + "WL104381", + "拍一下照片", + ], + ["请安排贴标", "请安排贴标", ""], + ]; + + for (const [text, needContent, needRemark] of cases) { + it(`splits: ${text.slice(0, 28).replace(/\n/g, " ")}…`, () => { + const { content, remark } = splitWorkOrderContentAndRemark(text); + if (needContent) expect(content).toContain(needContent); + if (needRemark) expect(remark).toContain(needRemark); + else expect(remark.trim()).toBe(""); + if (needContent.includes("开箱拍下")) { + expect(remark).not.toContain("开箱拍下"); + } + if (needRemark.includes("换过标签") && text.includes("派送要求")) { + expect(content).toContain("派送要求"); + expect(content).not.toContain("柜子里的亚马逊"); + expect(remark).not.toContain("派送要求"); + } + if (needRemark.includes("尽量早些")) { + expect(content).not.toContain("尽量早些"); + expect(content).toContain("WL103262"); + } + if (needRemark.includes("拍一下照片")) { + expect(content).not.toContain("拍一下照片"); + expect(content).toContain("WL103262"); + } + }); + } +}); + +describe("work-order refer / ref no", () => { + it("parses ISO container as refer code", () => { + expect(parseWorkOrderReferCode("参考号:TCNU4412465\n请安排换标")).toBe( + "TCNU4412465", + ); + expect(parseWorkOrderReferCode("请处理 TCNU4412465 贴标")).toBe( + "TCNU4412465", + ); + }); + + it("parses Ref No / FBA as ref no, not container", () => { + expect(parseWorkOrderRefNo("Ref No:WL003\n请安排贴标")).toBe("WL003"); + expect( + parseWorkOrderRefNo( + "FBA19DYHCDLS-XPB2-37件\n安排卡转海", + "", + ), + ).toMatch(/FBA19DYHCDLS/); + }); +}); + +describe("stripWorkOrderBodyNoise 邮箱/附件栏", () => { + it("drops standalone email and QQ attachment chrome from content", () => { + const raw = [ + "2.", + "WL101167", + "WL92334", + "这2票改自提", + "clx@cnwally.com.cn", + ].join("\n"); + const { content, remark } = splitWorkOrderContentAndRemark(raw); + expect(content).toContain("改自提"); + expect(content).toContain("WL101167"); + expect(content).not.toMatch(/clx@cnwally\.com\.cn/i); + expect(remark).not.toMatch(/@/); + }); + + it("drops attachment footer after intercept instruction", () => { + const raw = [ + "以上单号需要拦截 清单中已备注", + "1个附件", + "普通附件 (1MB)", + "WHSU5574991 卡转海.zip (1M)", + "clx@cnwally.com.cn", + ].join("\n"); + const cleaned = stripWorkOrderBodyNoise(raw); + expect(cleaned).toContain("需要拦截"); + expect(cleaned).not.toMatch(/个附件|普通附件|\.zip|clx@/i); + }); + + it("drops 网易灵犀签名与落款短名", () => { + const raw = [ + "FCIU7089474 MR03002964 改地址派送到 7400 E. Slauson Ave commerce. CA 90040", + "km16", + "km16@kmfba.com", + "签名由 网易灵犀办公 定制", + ].join("\n"); + const cleaned = stripWorkOrderBodyNoise(raw); + expect(cleaned).toContain("改地址派送到"); + expect(cleaned).toContain("FCIU7089474"); + expect(cleaned).not.toMatch(/km16|网易灵犀|签名由/i); + }); + + it("keeps 注意 remark but strips trailing signature email", () => { + const { content, remark } = splitWorkOrderContentAndRemark( + [ + "WL99583 LAX9 2", + "以上货物需要卡转海 麻烦贴标交付", + "注意:FBA箱号需要对应快递子单号,例如:FBA0001对应第一张快递面单", + "clx@cnwally.com.cn", + ].join("\n"), + ); + expect(content).toContain("卡转海"); + expect(remark).toContain("FBA箱号需要对应"); + expect(remark).not.toMatch(/clx@cnwally\.com\.cn/i); + expect(content).not.toMatch(/clx@cnwally\.com\.cn/i); + }); + + it("strips company/signature lines from remark after 注意", () => { + const { content, remark } = splitWorkOrderContentAndRemark( + [ + "派送要求:分别按票打板", + "注意:", + "柜子里的亚马逊卡派货物,是我们在国内换过标签", + "沃利供应链", + "朱甜甜 港后客服", + ].join("\n"), + ); + expect(content).toContain("派送要求"); + expect(remark).toContain("换过标签"); + expect(remark).not.toMatch(/沃利供应链|港后客服/); + }); +}); + +describe("buildCcWorkOrderFormValue 备注分界", () => { + it("does not put 邮件动作 into F_Remark; only 注意: block", () => { + const v = buildCcWorkOrderFormValue({ + subject: "贴标操作", + body: [ + "Dear,", + "FBA19DYHCDLS-XPB2-37件", + "安排卡转海,麻烦安排贴标操作。", + "注意:ups默认一件贴2张面单", + ].join("\n"), + filenames: [], + }); + expect(v.F_MessageContent).toContain("安排卡转海"); + expect(v.F_MessageContent).toContain("FBA19DYHCDLS"); + expect(v.F_MessageContent).not.toMatch(/注意:/); + expect(v.F_Remark).toContain("ups默认"); + expect(v.F_Remark).not.toMatch(/邮件动作/); + expect(v.F_RefNo).toMatch(/FBA19DYHCDLS/); + }); + + it("keeps numbered ops after 注意: in content", () => { + const v = buildCcWorkOrderFormValue({ + subject: "拆柜清单更新", + body: [ + "Dear,", + "拆柜清单更新 以此为准", + "注意:", + "1.WL103516", + "以上几票需要拦截", + "2.", + "这2票改自提", + ].join("\n"), + }); + expect(v.F_MessageContent).toContain("改自提"); + expect(v.F_MessageContent).toContain("WL103516"); + expect(v.F_Remark.trim()).toBe(""); + }); + + it("mail98 unit1 keeps FBA line in content and ups in remark", () => { + const v = buildCcWorkOrderFormValue({ + subject: "贴标操作", + body: [ + "TCNU4412465 WL003 FBA19DYHCDLS-XPB2-37件", + "安排卡转海,麻烦安排贴标操作。", + "注意:ups默认一件贴2张面单", + ].join("\n"), + filenames: [], + containerNo: "TCNU4412465", + }); + expect(v.F_MessageContent).toContain("FBA19DYHCDLS"); + expect(v.F_MessageContent).toContain("安排卡转海"); + expect(v.F_Remark).toContain("ups默认"); + expect(v.F_RefNo).toMatch(/WL003|FBA19DYHCDLS/); + }); + + it("extractWorkOrderFormFields preserves remark boundary", () => { + const f = extractWorkOrderFormFields({ + body: "请安排贴标\n注意:照片要清晰", + }); + expect(f.F_MessageContent).toBe("请安排贴标"); + expect(f.F_Remark).toContain("照片要清晰"); + }); +}); diff --git a/tests/unit/work-order-record.test.ts b/tests/unit/work-order-record.test.ts index c91b88e..3090b1b 100644 --- a/tests/unit/work-order-record.test.ts +++ b/tests/unit/work-order-record.test.ts @@ -46,6 +46,31 @@ describe("work-order-record", () => { expect(r.table.rows[0]["目标仓"]).toBe("POC2"); }); + it("prefers body 改地址派送 over subject 派拆柜 template", () => { + const r = buildMailRecord({ + mailType: "WORK_ORDER", + subject: "Fw: ZJYC COSU6504130784 FCIU7089474 7.10 派拆柜派送", + body: + "FCIU7089474 MR03002964 改地址派送到 7400 E. Slauson Ave commerce. CA 90040", + opsSubject: { + modules: { + customer_name: "ZJYC", + bl_no: "COSU6504130784", + container_no: "FCIU7089474", + eta: "2026-07-10", + service: "派拆柜派送", + ops_hints: ["拆柜", "派送"], + }, + channels: [], + }, + }); + expect(r.kind).toBe("WORK_ORDER"); + expect(r.work_order_actions).toContain("改地址派送"); + expect(r.summary).toMatch(/改地址派送/); + expect(r.summary).not.toMatch(/拆柜、派送/); + expect(r.lineage.subject_only).toBe(false); + }); + it("builds BL_FORECAST from modules", () => { const r = buildMailRecord({ mailType: "NEW_CONTAINER", diff --git a/tests/unit/work-order-snapshot.test.ts b/tests/unit/work-order-snapshot.test.ts new file mode 100644 index 0000000..47ee5e8 --- /dev/null +++ b/tests/unit/work-order-snapshot.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + describeWorkOrderFieldMarks, + diffWorkOrderSupplementedFields, + toWorkOrderSubmittedSnapshot, + workOrderFormFromSubmitted, +} from "@/services/parse/work-order-snapshot"; +import { markInstructionImported } from "@/services/parse/instruction-execution-state"; + +describe("diffWorkOrderSupplementedFields", () => { + it("marks empty→filled as supplemented", () => { + const { fieldMarks, userSupplemented, supplementedFields } = + diffWorkOrderSupplementedFields( + { F_Remark: "", F_MessageContent: "安排贴标" }, + { F_Remark: "ups默认两张", F_MessageContent: "安排贴标" }, + ); + expect(fieldMarks.F_Remark).toBe("supplemented"); + expect(fieldMarks.F_MessageContent).toBeUndefined(); + expect(userSupplemented).toBe(true); + expect(supplementedFields).toContain("F_Remark"); + }); + + it("marks changed value as modified", () => { + const { fieldMarks } = diffWorkOrderSupplementedFields( + { F_RefNo: "WL001", F_MessageType: "操作指令" }, + { F_RefNo: "WL002", F_MessageType: "操作指令" }, + ); + expect(fieldMarks.F_RefNo).toBe("modified"); + expect(fieldMarks.F_MessageType).toBeUndefined(); + }); + + it("marks cleared value as modified", () => { + const { fieldMarks } = diffWorkOrderSupplementedFields( + { F_AttachFile: "a.pdf" }, + { F_AttachFile: "" }, + ); + expect(fieldMarks.F_AttachFile).toBe("modified"); + }); + + it("returns no marks when identical", () => { + const snap = { + F_ReferCode: "TCNU4412465", + F_MessageContent: "安排卡转海", + F_Remark: "ups默认", + }; + const { userSupplemented, supplementedFields } = + diffWorkOrderSupplementedFields(snap, snap); + expect(userSupplemented).toBe(false); + expect(supplementedFields).toEqual([]); + }); +}); + +describe("toWorkOrderSubmittedSnapshot / workOrderFormFromSubmitted", () => { + it("round-trips non-empty fields", () => { + const snap = toWorkOrderSubmittedSnapshot({ + F_ReferCode: "TCNU4412465", + F_MessageType: "操作指令", + F_RefNo: "WL003", + F_MessageContent: "安排卡转海", + F_Remark: " ", + F_AttachFile: "", + F_UrgencyLevel: "0", + }); + expect(snap.F_Remark).toBeUndefined(); + expect(snap.F_ReferCode).toBe("TCNU4412465"); + const form = workOrderFormFromSubmitted(snap); + expect(form.F_MessageContent).toBe("安排卡转海"); + expect(form.F_Remark).toBe(""); + expect(form.F_UrgencyLevel).toBe("0"); + }); + + it("describeWorkOrderFieldMarks lists supplemented values", () => { + const lines = describeWorkOrderFieldMarks( + { F_MessageType: "supplemented" }, + { F_MessageType: "操作指令" }, + ); + expect(lines).toEqual(["留言类型:操作指令"]); + }); +}); + +describe("markInstructionImported snapshot", () => { + it("persists submitted and userSupplemented flags", () => { + const lineage = markInstructionImported({}, "wo-1", { + status: "IMPORTED", + importedAt: "2026-08-28T00:00:00.000Z", + mode: "live", + submitted: { F_MessageContent: "补全后的正文", F_Remark: "人工备注" }, + supplementedFields: ["F_Remark"], + fieldMarks: { F_Remark: "supplemented" }, + userSupplemented: true, + }); + const entry = ( + lineage as { + instruction_state: Record< + string, + { + submitted?: Record; + userSupplemented?: boolean; + fieldMarks?: Record; + } + >; + } + ).instruction_state["wo-1"]; + expect(entry.submitted?.F_Remark).toBe("人工备注"); + expect(entry.userSupplemented).toBe(true); + expect(entry.fieldMarks?.F_Remark).toBe("supplemented"); + }); +});