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 <cursoragent@cursor.com>
main
你的GitHub用户名 4 weeks ago
parent 627d1b7be8
commit 02ad9d83ff

@ -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

2
.gitignore vendored

@ -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

@ -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"

@ -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

@ -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 + 书面确认。

@ -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`) |

@ -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 次<br>2. 集装箱货件列表接口访问次数限制:1 分钟 1 次<br>3. 集装箱状态变更日志接口访问次数限制:5 分钟 1 次<br>4. 集装箱货件信息接口访问次数限制:30 秒 1 次<br>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-已还柜<br>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。*

@ -9,7 +9,7 @@ datasource db {
model MailboxAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
/// 归属客户;空=管理端公共邮箱
/// 归属租户(AppUser.role=customer);空=管理端公共邮箱
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
name String @db.VarChar(128)
host String @default("imap.qq.com") @db.VarChar(255)
@ -127,6 +127,11 @@ model ContainerImport {
status String @db.VarChar(32)
lastError String? @map("last_error") @db.VarChar(1000)
shipmentsHash String @map("shipments_hash") @db.Char(64)
/// forecast | work_order | do_upload | transfer
importKind String? @map("import_kind") @db.VarChar(32)
/// 写入时选中的 CC 客户档案名称
ccCustomerName String? @map("cc_customer_name") @db.VarChar(128)
ccProfileId BigInt? @map("cc_profile_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
@ -136,6 +141,7 @@ model ContainerImport {
@@unique([mailId, containerNo, shipmentsHash], map: "uk_mail_container_shiphash")
@@index([mailId], map: "idx_mail")
@@index([containerNo], map: "idx_container")
@@index([importKind], map: "idx_import_kind")
@@map("container_import")
}
@ -176,11 +182,11 @@ model CcTokenCache {
@@map("cc_token_cache")
}
/// CC 连接配置:userId 为空为管理端默认(原 id=1);客户各有一行
/// CC 连接配置:userId 为空为管理端默认(原 id=1);租户各有一行(遗留,优先用 CcCustomerProfile)
model CcSettings {
id Int @id @default(autoincrement())
userId BigInt? @unique @map("user_id") @db.UnsignedBigInt
mock Boolean @default(true)
mock Boolean @default(false)
apiBase String @map("api_base") @db.VarChar(512)
saasHeader String @map("saas_header") @db.VarChar(64)
loginMark String @map("login_mark") @db.Char(36)
@ -194,6 +200,29 @@ model CcSettings {
@@map("cc_settings")
}
/// 可切换的 CC 客户档案:ownerUserId 空=管理端共享;非空=归属某租户
model CcCustomerProfile {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
name String @db.VarChar(128)
mock Boolean @default(false)
apiBase String @map("api_base") @db.VarChar(512)
saasHeader String @map("saas_header") @db.VarChar(64)
loginMark String @map("login_mark") @db.Char(36)
username String @default("") @db.VarChar(128)
passwordEnc String? @map("password_enc") @db.VarChar(1024)
enabled Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
owner AppUser? @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
@@index([ownerUserId, enabled], map: "idx_cc_profile_owner")
@@index([sortOrder], map: "idx_cc_profile_sort")
@@map("cc_customer_profile")
}
/// CC 写能力门禁:live | mock | off
model CcWriteCapability {
id String @id @db.VarChar(32)
@ -229,6 +258,7 @@ model SenderCustomerMap {
@@map("sender_customer_map")
}
/// 登录账号。role=admin|ops 为员工;role=customer 为租户登录账号(一租户一账号)
model AppUser {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
username String @unique @db.VarChar(64)
@ -243,6 +273,7 @@ model AppUser {
mailboxes MailboxAccount[]
ccSettings CcSettings?
ccCustomerProfiles CcCustomerProfile[]
@@index([role], map: "idx_app_user_role")
@@map("app_user")
@ -258,6 +289,20 @@ model ImapSettings {
@@map("imap_settings")
}
/// CC 客户留言类型字典(设置页维护;工单表单下拉来源)
model CcMessageTypeDict {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
/// 写入 CC 的 F_MessageType 文本
name String @unique @db.VarChar(64)
enabled Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
@@index([enabled, sortOrder], map: "idx_cc_msg_type_enabled_sort")
@@map("cc_message_type_dict")
}
/// 拉取过滤规则:关键词 / 发件人白名单 / 发件人黑名单 / 关键词黑名单
model ImapFilterRule {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt

@ -49,16 +49,56 @@ async function main() {
{ id: "transfer", mode: "mock", endpoint: "/Container/TransferWarehouse" },
{ id: "hold_split", mode: "mock", endpoint: "/Container/HoldSplitInstruction" },
{ id: "label", mode: "mock", endpoint: "/Container/ApplyLabelInstruction" },
{
id: "customer_message",
mode: "live",
endpoint: "/CustomerMessage/SaveForm",
},
{ id: "do_upload", mode: "live", endpoint: "/learun/adms/annexes/upload" },
{
id: "batch_transfer",
mode: "mock",
endpoint: "/Container/SaveStockBatchTransfer",
},
];
for (const c of caps) {
await prisma.ccWriteCapability.upsert({
where: { id: c.id },
create: c,
update: {},
update:
c.id === "customer_message"
? { mode: "live", endpoint: c.endpoint }
: {},
});
}
const defaultMessageTypes = [
"操作指令",
"货件拦截",
"拆柜时自动转仓",
"客户拆分指令",
"扣货",
"扣货问题",
"收费问题",
"查验柜",
"PO查验报错",
"天灾",
"客户新增集装箱",
];
const msgTypeCount = await prisma.ccMessageTypeDict.count();
if (msgTypeCount === 0) {
await prisma.ccMessageTypeDict.createMany({
data: defaultMessageTypes.map((name, i) => ({
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()

@ -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();
});

@ -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();
});

@ -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);
});

@ -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);
});

@ -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<CustomerAccount[]>([]);
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,23 +68,32 @@ 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);
setModalOpen(true);
};
/** Form mounts only after Modal opens (destroyOnClose); fill then. */
const fillCustomerForm = (visible: boolean) => {
if (!visible) return;
if (editing) {
form.setFieldsValue({
username: row.username,
password: row.password,
display_name: row.display_name,
username: editing.username,
password: editing.password,
display_name: editing.display_name,
});
setModalOpen(true);
return;
}
form.resetFields();
};
const onSave = async () => {
@ -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 (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
客户账号
租户账号
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ margin: "8px 0 0" }}>
配置客户登录账密。客户登录后自行绑定 CarrierCentral 与邮箱,即可只看到自己的邮件与导入。
配置租户登录账密。租户登录后自行配置 CarrierCentral
客户档案与邮箱,即可只看到自己的邮件与导入;租户端无本管理页。
</Typography.Paragraph>
</div>
{error ? <ErrorBanner message={error} /> : null}
<Card
extra={
<Button type="primary" onClick={openCreate}>
新增客户
新增租户
</Button>
}
>
@ -192,18 +215,10 @@ export default function CustomersPage() {
width: 80,
},
{
title: "CC 账号",
dataIndex: "cc_username",
width: 140,
render: (v: string, r: CustomerAccount) =>
v ? (
<Space>
<span>{v}</span>
{r.cc_mock ? <Tag>mock</Tag> : <Tag color="green">live</Tag>}
</Space>
) : (
"未绑定"
),
title: "CC 客户档案",
dataIndex: "cc_profile_count",
width: 120,
render: (v: number | undefined) => v ?? 0,
},
{
title: "启用",
@ -232,12 +247,13 @@ export default function CustomersPage() {
</Card>
<Modal
title={editing ? "修改客户账密" : "新增客户"}
title={editing ? "修改租户账密" : "新增租户"}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void onSave()}
confirmLoading={saving}
destroyOnClose
afterOpenChange={fillCustomerForm}
>
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
@ -261,7 +277,11 @@ export default function CustomersPage() {
</Modal>
<Drawer
title={importsFor ? `${importsFor.username} 的导入记录` : "导入记录"}
title={
importsFor
? `租户 ${importsFor.display_name || importsFor.username} 的导入记录`
: "导入记录"
}
open={importsOpen}
onClose={() => setImportsOpen(false)}
width={920}

@ -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<ContainerImport[]>([]);
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 ? (
<Select
allowClear
placeholder="全部租户"
style={{ width: 200 }}
value={tenantId || undefined}
onChange={(v) => setTenantId(v || "")}
options={[
{ value: "public", label: "公共邮箱(无租户)" },
...tenants.map((t) => ({
value: t.id,
label: t.display_name
? `${t.display_name} (${t.username})`
: t.username,
})),
]}
/>
) : null}
<Input
placeholder="柜号/主题"
placeholder={isStaff ? "客户/类型/主题" : "客户/主题"}
style={{ width: 256 }}
value={q}
onChange={(e) => setQ(e.target.value)}
onPressEnter={() => void reload()}
/>
<Button
type="primary"
@ -122,32 +148,54 @@ function ImportLogsPanel() {
loading={loading}
dataSource={items}
pagination={false}
scroll={{ x: 1180 }}
scroll={{ x: isStaff ? 1100 : 1080 }}
columns={[
...(isStaff
? [
{
title: "时间",
dataIndex: "created_at",
width: 170,
render: (v: string) => new Date(v).toLocaleString("zh-CN"),
title: "租户",
dataIndex: "tenant_username",
width: 140,
render: (_: string | null | undefined, row: ContainerImport) =>
row.tenant_username ? (
<span title={row.tenant_display_name || undefined}>
{row.tenant_display_name || row.tenant_username}
</span>
) : (
<Typography.Text type="secondary">公共</Typography.Text>
),
},
]
: []),
{
title: "邮件主题",
dataIndex: "mail_subject",
title: "导入客户",
dataIndex: "cc_customer_name",
width: 160,
ellipsis: true,
render: (subject: string, row) => (
<Link href={`/mails/${row.mail_id}`}>{subject}</Link>
render: (v: string | null | undefined) =>
v?.trim() ? (
v
) : (
<Typography.Text type="secondary">未记录</Typography.Text>
),
},
{
title: "柜号",
dataIndex: "container_no",
width: 140,
render: (v: string) => <span className="mono">{v}</span>,
title: "导入类型",
dataIndex: "import_kind_label",
width: 120,
render: (v: string | null | undefined, row: ContainerImport) =>
v || row.import_kind || "其他",
},
{
title: "导入时间",
dataIndex: "created_at",
width: 170,
render: (v: string) => new Date(v).toLocaleString("zh-CN"),
},
{
title: "状态",
dataIndex: "status",
width: 120,
width: 110,
render: (v: string) => (
<Tag color={IMPORT_STATUS_COLORS[v] || "default"}>
{IMPORT_STATUS_LABEL[v] || v}
@ -155,30 +203,33 @@ function ImportLogsPanel() {
),
},
{
title: "CC柜ID",
dataIndex: "external_id",
width: 220,
title: "错误原因",
dataIndex: "last_error",
ellipsis: true,
render: (v: string | null) => (
<span className="mono" title={v || undefined}>
{v || "—"}
<span style={{ color: "var(--color-error)" }}>
{v ? formatMailLastError(v) : "-"}
</span>
),
},
...(!isStaff
? [
{
title: "错误",
dataIndex: "last_error",
title: "邮件主题",
dataIndex: "mail_subject",
ellipsis: true,
render: (v: string | null) => (
<span style={{ color: "var(--color-error)" }}>
{v ? formatMailLastError(v) : "-"}
</span>
render: (subject: string, row: ContainerImport) => (
<Link href={`/mails/${row.mail_id}`}>{subject}</Link>
),
},
]
: []),
...(isStaff
? [
{
title: "操作",
width: 140,
render: (_, row) => {
width: 120,
render: (_: unknown, row: ContainerImport) => {
const exhausted = row.compensations?.find(
(c) => c.status === "EXHAUSTED",
);
@ -187,12 +238,17 @@ function ImportLogsPanel() {
"TIMEOUT_UNKNOWN",
"CONFLICT",
].includes(row.status);
const key = exhausted ? `c:${exhausted.id}` : `i:${row.id}`;
const key = exhausted
? `c:${exhausted.id}`
: `i:${row.id}`;
if (exhausted) {
if (!isAdmin) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
>
补偿已耗尽
</Typography.Text>
);
@ -205,7 +261,9 @@ function ImportLogsPanel() {
disabled={retrying === key}
onClick={async () => {
setRetrying(key);
const res = await retryCompensationApi(exhausted.id);
const res = await retryCompensationApi(
exhausted.id,
);
setRetrying(null);
if (res.ok) {
message.success(
@ -245,6 +303,8 @@ function ImportLogsPanel() {
);
},
},
]
: []),
]}
/>
)}

@ -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}
</div>
<div className="mail-detail-meta">
<span className="mono">{mail.from_addr}</span>
{mail.ingest_source_label ? (
<span>{mail.ingest_source_label}</span>
) : null}
<span>{new Date(mail.received_at).toLocaleString("zh-CN")}</span>
<span>#{mail.id}</span>
{mail.trace_id ? (
<Typography.Text
copyable={{ text: mail.trace_id }}
type="secondary"
className="mono"
style={{ fontSize: 12 }}
>
trace {mail.trace_id.slice(0, 8)}
</Typography.Text>
) : null}
<span>发件人:{mail.from_addr || "—"}</span>
<span>收件人:{mail.mailbox_addr || "—"}</span>
<span>
时间:
{new Date(mail.received_at).toLocaleString("zh-CN")}
</span>
<span>邮件编码:{mail.id}</span>
</div>
</div>
<div className="mail-detail-actions">
<Space wrap>
<CcUiModeSwitch mode={ccUiMode} onChange={setCcUiMode} />
<CcCustomerProfileSelect
mailId={id}
items={ccProfile.items}
loading={ccProfile.loading}
value={ccProfile.profileId}
onChange={ccProfile.setProfileId}
/>
{showReparse ? (
<Button
loading={reparseLoading}
@ -256,11 +244,6 @@ export default function MailDetailPage() {
{UI_COPY.btnReparse}
</Button>
) : null}
{isAdmin && enableOverride ? (
<Button onClick={() => setOverrideOpen(true)}>
{UI_COPY.btnOverrideType}
</Button>
) : null}
{showIgnore ? (
<Button
danger
@ -340,12 +323,20 @@ export default function MailDetailPage() {
) : null}
{mail.last_error ? (
/_MOCK_OK$/.test(mail.last_error) ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={formatMailLastError(mail.last_error)}
/>
) : (
<ErrorBanner message={formatMailLastError(mail.last_error)} />
)
) : null}
<MailBusinessSummary
mail={mail}
ccUiMode={ccUiMode}
onConfirmed={() => void reload()}
/>
@ -537,30 +528,6 @@ export default function MailDetailPage() {
/>
</div>
<TypeOverrideModal
open={overrideOpen}
loading={overrideLoading}
currentType={mail.mail_type}
evidenceTotal={mail.type_evidence.total}
onCancel={() => 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);
}
}}
/>
</div>
);
}

@ -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 (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
{UI_COPY.navSettings}
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ margin: "8px 0 0" }}>
{isTenant
? "配置本租户账号的 CarrierCentral 客户档案与邮箱;仅能看到本账号的邮件与导入。"
: "管理端公共邮箱与共享 CC 客户档案。各租户的邮箱与档案由租户登录后自行配置。"}
</Typography.Paragraph>
</div>
{error ? <ErrorBanner message={error} /> : null}
<CcSettingsCard />
{user.role === "customer" ? <ChangePasswordCard /> : null}
<CcCustomerProfilesCard />
{user.role === "admin" ? <CcMessageTypeDictCard /> : null}
{isTenant ? <ChangePasswordCard /> : null}
{user.role === "admin" ? <ImapPullSettingsCard /> : null}
<Card
title="邮箱绑定"
title={isTenant ? "本账号邮箱绑定" : "公共邮箱绑定"}
extra={
<Space wrap>
{user.role === "admin" ? (

@ -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;

@ -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) => ({
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: r.externalId,
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,

@ -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) {

@ -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);
}
}

@ -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,

@ -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,

@ -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) {

@ -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 },

@ -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) {

@ -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 });
}

@ -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;
}
}

@ -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 });
}

@ -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 });
}

@ -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);
}

@ -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 });
}

@ -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());

@ -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;

@ -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<CcCustomerProfile[]>([]);
const [loading, setLoading] = useState(true);
const [profileId, setProfileIdState] = useState<string | null>(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 (
<Select
showSearch
optionFilterProp="label"
placeholder={list.length ? "选择 CC 客户" : "请先在设置中配置客户"}
loading={busy}
style={{ minWidth: 200, ...style }}
value={current || undefined}
options={list.map((i) => ({
value: i.id,
label: `${i.name}${i.username ? ` · ${i.username}` : ""}`,
}))}
onChange={(v) => {
const id = v || null;
if (onChange) onChange(id);
else internal.setProfileId(id);
if (id) writeStoredCcProfileId(id);
}}
notFoundContent={
<Typography.Text type="secondary">暂无可用客户档案</Typography.Text>
}
/>
);
}

@ -0,0 +1,265 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
App,
Button,
Card,
Form,
Input,
Modal,
Popconfirm,
Space,
Switch,
Table,
Typography,
} from "antd";
import {
createCcCustomerProfileApi,
deleteCcCustomerProfileApi,
listCcCustomerProfilesApi,
testCcCustomerProfileApi,
updateCcCustomerProfileApi,
} from "@/lib/client-api";
import type { CcCustomerProfile } from "@/types";
type FormValues = {
name: string;
api_base: string;
username: string;
password?: string;
enabled: boolean;
};
export function CcCustomerProfilesCard() {
const { message } = App.useApp();
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<CcCustomerProfile[]>([]);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<CcCustomerProfile | null>(null);
const [saving, setSaving] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [form] = Form.useForm<FormValues>();
const reload = useCallback(async () => {
setLoading(true);
const res = await listCcCustomerProfilesApi();
setLoading(false);
if (!res.ok) {
message.error(res.error.message);
return;
}
setItems(res.data.items);
}, [message]);
useEffect(() => {
void reload();
}, [reload]);
const openCreate = () => {
setEditing(null);
setOpen(true);
};
const openEdit = (row: CcCustomerProfile) => {
setEditing(row);
setOpen(true);
};
/** Form mounts only after Modal opens (destroyOnClose); fill then. */
const fillForm = (visible: boolean) => {
if (!visible) return;
if (editing) {
form.setFieldsValue({
name: editing.name,
api_base: editing.api_base,
username: editing.username,
password: undefined,
enabled: editing.enabled,
});
return;
}
form.setFieldsValue({
name: "",
api_base: "https://demovip.saas.carriercentral.vip/api",
username: "",
password: undefined,
enabled: true,
});
};
const onSave = async () => {
const values = await form.validateFields();
if (!values.username.trim()) {
message.error("请填写 CC 用户名");
return;
}
if (!editing && !values.password?.trim()) {
message.error("请填写密码");
return;
}
setSaving(true);
try {
if (editing) {
const res = await updateCcCustomerProfileApi(editing.id, {
name: values.name,
mock: false,
api_base: values.api_base,
saas_header: editing.saas_header || "TEST",
login_mark: editing.login_mark || crypto.randomUUID(),
username: values.username,
enabled: values.enabled,
...(values.password?.trim()
? { password: values.password.trim() }
: {}),
});
if (!res.ok) {
message.error(res.error.message);
return;
}
message.success("客户档案已更新");
} else {
const res = await createCcCustomerProfileApi({
name: values.name,
mock: false,
api_base: values.api_base,
saas_header: "TEST",
login_mark: crypto.randomUUID(),
username: values.username,
password: values.password,
enabled: values.enabled,
});
if (!res.ok) {
message.error(res.error.message);
return;
}
message.success("客户档案已创建");
}
setOpen(false);
await reload();
} finally {
setSaving(false);
}
};
const onTest = async (row: CcCustomerProfile) => {
setTestingId(row.id);
try {
const res = await testCcCustomerProfileApi(row.id);
if (!res.ok) {
message.error(res.error.message);
return;
}
message.success(res.data.message);
} finally {
setTestingId(null);
}
};
const onDelete = async (row: CcCustomerProfile) => {
const res = await deleteCcCustomerProfileApi(row.id);
if (!res.ok) {
message.error(res.error.message);
return;
}
message.success("已删除");
await reload();
};
return (
<Card
title="CC 客户账密"
extra={
<Button type="primary" onClick={openCreate}>
新增客户
</Button>
}
>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
配置多个 CarrierCentral 客户账密(本账号可见)。操作邮件时在详情页选择对应客户。
</Typography.Paragraph>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{ title: "客户名称", dataIndex: "name", ellipsis: true },
{
title: "CC 用户名",
dataIndex: "username",
render: (v: string, row) => v || row.username_masked || "—",
},
{
title: "启用",
dataIndex: "enabled",
width: 72,
render: (v: boolean) => (v ? "是" : "否"),
},
{
title: "操作",
width: 260,
render: (_, row) => (
<Space wrap>
<Button
size="small"
loading={testingId === row.id}
onClick={() => void onTest(row)}
>
测登录
</Button>
<Button size="small" onClick={() => openEdit(row)}>
编辑
</Button>
<Popconfirm
title="确认删除该客户档案?"
onConfirm={() => void onDelete(row)}
>
<Button size="small" danger>
删除
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
<Modal
title={editing ? "编辑 CC 客户" : "新增 CC 客户"}
open={open}
onCancel={() => setOpen(false)}
onOk={() => void onSave()}
confirmLoading={saving}
destroyOnClose
afterOpenChange={fillForm}
width={560}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="客户名称"
rules={[{ required: true, message: "请填写客户名称" }]}
>
<Input placeholder="弹窗与下拉展示用,如 新辰泽" />
</Form.Item>
<Form.Item name="api_base" label="API Base" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="username" label="CC 用户名">
<Input />
</Form.Item>
<Form.Item
name="password"
label={editing ? "密码(留空不改)" : "密码"}
>
<Input.Password />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</Card>
);
}

@ -18,18 +18,23 @@ function Field({
label,
children,
required,
error,
}: {
label: ReactNode;
children: ReactNode;
required?: boolean;
error?: string;
}) {
return (
<div className="cc-fo-field">
<div className={`cc-fo-field${error ? " is-invalid" : ""}`}>
<div className="cc-fo-label">
{label}
{required ? <span style={{ color: "#dc2626" }}> *</span> : null}
</div>
<div className="cc-fo-control">{children}</div>
<div className="cc-fo-control">
{children}
{error ? <div className="cc-fo-error">{error}</div> : null}
</div>
</div>
);
}
@ -45,12 +50,14 @@ export type CcDoUploadPanelProps = {
mode: "readonly" | "edit";
value: CcDoUploadFormValue;
onChange?: (patch: Partial<CcDoUploadFormValue>) => void;
errors?: Record<string, string>;
};
export function CcDoUploadPanel({
mode,
value,
onChange,
errors = {},
}: CcDoUploadPanelProps) {
const readonly = mode === "readonly";
const patch = (p: Partial<CcDoUploadFormValue>) => {
@ -63,12 +70,15 @@ export function CcDoUploadPanel({
<div className="cc-tr-titlebar">上传 DO(F_DOFile)</div>
<div style={fieldRow}>
<Field label="柜号" required>
<Field label="柜号" required error={errors.containerNo}>
<Input
disabled={readonly}
className="mono"
status={errors.containerNo ? "error" : undefined}
value={value.F_ContainerNo}
onChange={(e) => patch({ F_ContainerNo: e.target.value })}
onChange={(e) =>
patch({ F_ContainerNo: e.target.value.toUpperCase() })
}
/>
</Field>
<Field label="柜任务 Id">
@ -93,9 +103,13 @@ export function CcDoUploadPanel({
<div />
</div>
<div
className={`cc-fo-do-files${errors.filenames ? " is-invalid" : ""}`}
>
{value.filenames.length === 0 ? (
<Typography.Text type="danger">
未识别到 DO 附件(期望如 WHSU8127240-DO.pdf)
{errors.filenames ||
"未识别到 DO 附件(期望如 WHSU8127240-DO.pdf)"}
</Typography.Text>
) : (
<Table
@ -106,6 +120,10 @@ export function CcDoUploadPanel({
columns={[{ title: "待上传 DO 附件", dataIndex: "name" }]}
/>
)}
{errors.filenames && value.filenames.length > 0 ? (
<div className="cc-fo-error">{errors.filenames}</div>
) : null}
</div>
{!readonly && !value.F_ContainerTaskId.trim() ? (
<Typography.Text type="warning" style={{ display: "block", marginTop: 8 }}>
DO 必须挂到已创建的集装箱。若尚未确认导入预报,确认上传会被拒绝。

File diff suppressed because it is too large Load Diff

@ -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<CcMessageTypeDictItem[]>([]);
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 (
<Card
title="留言类型字典"
style={{ marginBottom: 16 }}
styles={expanded ? undefined : { body: { paddingTop: 12, paddingBottom: 12 } }}
extra={
<Space size="middle" wrap>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{loading
? "加载中…"
: `共 ${items.length} 项 · 启用 ${enabledCount}`}
</Typography.Text>
<Button
type="link"
size="small"
icon={expanded ? <UpOutlined /> : <DownOutlined />}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? "收起" : "展开"}
</Button>
</Space>
}
>
{!expanded ? (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
折叠中。展开后可维护工单 / 客户留言表单的留言类型下拉选项。
</Typography.Text>
) : (
<>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
在此维护写入 CarrierCentral 的「留言类型」名称。启用项会出现在确认页工单表单中;首次空库会自动写入默认种子。
</Typography.Paragraph>
<Spin spinning={loading}>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={items}
scroll={items.length > 8 ? { y: 320 } : undefined}
columns={[
{
title: "名称",
dataIndex: "name",
render: (name: string, row) => (
<Input
defaultValue={name}
size="small"
onBlur={(e) => void onRename(row, e.target.value)}
onPressEnter={(e) => {
(e.target as HTMLInputElement).blur();
}}
/>
),
},
{
title: "排序",
dataIndex: "sort_order",
width: 100,
render: (v: number, row) => (
<InputNumber
size="small"
value={v}
style={{ width: 72 }}
onChange={(n) => void onSort(row, n)}
/>
),
},
{
title: "启用",
dataIndex: "enabled",
width: 80,
render: (enabled: boolean, row) => (
<Switch
checked={enabled}
size="small"
onChange={(checked) => void onToggle(row, checked)}
/>
),
},
{
title: "操作",
width: 80,
render: (_, row) => (
<Popconfirm
title="确认删除该留言类型?"
onConfirm={() => void onDelete(row)}
>
<Button type="link" danger size="small">
删除
</Button>
</Popconfirm>
),
},
]}
/>
<Form
form={form}
layout="inline"
style={{ marginTop: 16 }}
onFinish={() => void onAdd()}
>
<Form.Item
name="name"
rules={[{ required: true, message: "请输入名称" }]}
>
<Input placeholder="新留言类型名称" style={{ width: 220 }} />
</Form.Item>
<Form.Item name="sort_order">
<InputNumber placeholder="排序" style={{ width: 88 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={adding}>
添加
</Button>
</Space>
</Form.Item>
</Form>
</Spin>
</>
)}
</Card>
);
}

@ -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<Pick<
CcSettings,
"source" | "has_password" | "updated_at"
> | 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<FormValues>();
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 (
<Card
title="CarrierCentral(CC)连接"
extra={
<Space>
{meta ? (
<Tag color={meta.source === "db" ? "blue" : "default"}>
来源 {meta.source === "db" ? "数据库" : ".env"}
</Tag>
) : null}
<Tag color={mockMode ? "default" : "success"}>
{mockMode ? "mock" : "live"}
</Tag>
</Space>
}
>
<Card title="CarrierCentral(CC)连接">
<Spin spinning={loading}>
<Form form={form} layout="vertical" style={{ maxWidth: 720 }}>
<Form.Item
@ -144,41 +117,17 @@ export function CcSettingsCard() {
>
<Input placeholder="https://demovip.saas.carriercentral.vip/api" />
</Form.Item>
<Space style={{ width: "100%" }} size={16} align="start">
<Form.Item
name="saas_header"
label="Saas Header"
rules={[{ required: true }]}
style={{ flex: 1 }}
>
<Input placeholder="TEST" />
</Form.Item>
<Form.Item
name="login_mark"
label="Login Mark (UUID)"
rules={[{ required: true, type: "string" }]}
style={{ flex: 2 }}
>
<Input placeholder="11111111-2222-3333-4444-555555555555" />
</Form.Item>
</Space>
<Form.Item
name="username"
label="CC 用户名"
rules={
mockMode ? [] : [{ required: true, message: "必填" }]
}
rules={[{ required: true, message: "必填" }]}
>
<Input autoComplete="off" placeholder="客户账号,如 fj" />
</Form.Item>
<Form.Item
name="password"
label={meta?.has_password ? "密码(留空不改)" : "密码"}
extra={
meta?.has_password
? "已配置密码"
: "可填明文;服务端 MD5 后调 customerLogin"
}
label={hasPassword ? "密码(留空不改)" : "密码"}
extra={hasPassword ? "已配置密码" : undefined}
>
<Input.Password autoComplete="new-password" />
</Form.Item>

@ -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<WorkOrderFieldMark, { color: string; text: string }> = {
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 (
<div
className="cc-fo-field"
className={[
"cc-fo-field",
mark ? `cc-fo-field-mark cc-fo-field-mark-${mark}` : "",
]
.filter(Boolean)
.join(" ")}
style={full ? { gridColumn: "1 / -1" } : undefined}
>
<div className="cc-fo-label">
{label}
{required ? <span style={{ color: "#dc2626" }}> *</span> : null}
{markMeta ? (
<Tag
color={markMeta.color}
style={{ marginLeft: 6, fontSize: 11, lineHeight: "18px" }}
>
{markMeta.text}
</Tag>
) : null}
</div>
<div className="cc-fo-control">{children}</div>
</div>
@ -74,6 +100,8 @@ export type CcWorkOrderFormProps = {
onChange?: (patch: Partial<CcWorkOrderFormValue>) => 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<CcWorkOrderFormValue>) => {
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 (
<div
@ -141,7 +207,7 @@ export function CcWorkOrderForm({
/* —— 管理端 Form.cshtml 顺序 —— */
<div className="cc-wo-body">
<div style={fieldRow3}>
<Field label="客户" required>
<Field label="客户" required mark={markOf("F_CustomerName")}>
<Input
disabled={readonly}
value={value.F_CustomerName || ""}
@ -149,7 +215,7 @@ export function CcWorkOrderForm({
onChange={(e) => patch({ F_CustomerName: e.target.value })}
/>
</Field>
<Field label="参考号">
<Field label="参考号" mark={markOf("F_ReferCode")}>
<Input
disabled={readonly}
className="mono"
@ -160,7 +226,7 @@ export function CcWorkOrderForm({
</Field>
</div>
<div style={fieldRow3}>
<Field label="紧急程度">
<Field label="紧急程度" mark={markOf("F_UrgencyLevel")}>
<Select
disabled={readonly}
style={{ width: "100%" }}
@ -171,12 +237,12 @@ export function CcWorkOrderForm({
allowClear
/>
</Field>
<Field label="留言类型" required>
<Field label="留言类型" required mark={markOf("F_MessageType")}>
<Select
disabled={readonly}
style={{ width: "100%" }}
value={value.F_MessageType || undefined}
options={[...CC_CUSTOMER_MESSAGE_TYPE_OPTIONS]}
options={typeSelectOptions}
placeholder="==请选择留言类型=="
onChange={(v) => patch({ F_MessageType: v })}
showSearch
@ -186,7 +252,7 @@ export function CcWorkOrderForm({
</Field>
</div>
<div style={fieldRow3}>
<Field label="Ref No.">
<Field label="Ref No." mark={markOf("F_RefNo")}>
<Input
disabled={readonly}
value={value.F_RefNo}
@ -196,7 +262,7 @@ export function CcWorkOrderForm({
<div />
</div>
<div className="cc-fo-textarea-row">
<Field label="留言内容" required full>
<Field label="留言内容" required full mark={markOf("F_MessageContent")}>
<Input.TextArea
disabled={readonly}
rows={8}
@ -206,7 +272,7 @@ export function CcWorkOrderForm({
</Field>
</div>
<div style={{ ...fieldRow, marginBottom: 12 }}>
<Field label="附件上传" full>
<Field label="附件上传" full mark={markOf("F_AttachFile")}>
<Space.Compact style={{ width: "100%" }}>
<Input
disabled={readonly}
@ -225,7 +291,7 @@ export function CcWorkOrderForm({
</Field>
</div>
<div className="cc-fo-textarea-row">
<Field label="备注" full>
<Field label="备注" full mark={markOf("F_Remark")}>
<Input.TextArea
disabled={readonly}
rows={4}
@ -239,7 +305,7 @@ export function CcWorkOrderForm({
/* —— 客户端 customerMessage 新增 —— */
<div className="cc-wo-body">
<div style={fieldRow}>
<Field label="参考号">
<Field label="参考号" mark={markOf("F_ReferCode")}>
<Input
disabled={readonly}
className="mono"
@ -248,12 +314,12 @@ export function CcWorkOrderForm({
onChange={(e) => patch({ F_ReferCode: e.target.value })}
/>
</Field>
<Field label="留言类型" required>
<Field label="留言类型" required mark={markOf("F_MessageType")}>
<Select
disabled={readonly}
style={{ width: "100%" }}
value={value.F_MessageType || undefined}
options={[...CC_CUSTOMER_MESSAGE_TYPE_OPTIONS]}
options={typeSelectOptions}
placeholder="请选择"
onChange={(v) => patch({ F_MessageType: v })}
showSearch
@ -263,24 +329,42 @@ export function CcWorkOrderForm({
</Field>
</div>
<div style={fieldRow}>
<Field label="Ref No">
<Field label="Ref No" mark={markOf("F_RefNo")}>
<Input
disabled={readonly}
value={value.F_RefNo}
onChange={(e) => patch({ F_RefNo: e.target.value })}
/>
</Field>
<Field label="附件">
<Field label="附件" mark={markOf("F_AttachFile")}>
{attachFiles.length ? (
<div className="cc-wo-attach-list">
{attachFiles.map((name) => (
<div key={name} className="cc-wo-attach-item" title={name}>
{name}
</div>
))}
{!readonly ? (
<Input
disabled={readonly}
style={{ marginTop: 6 }}
value={value.F_AttachFile}
placeholder="F_AttachFile"
placeholder="可编辑:多个文件名用分号分隔"
onChange={(e) => patch({ F_AttachFile: e.target.value })}
/>
) : null}
</div>
) : (
<Input
disabled={readonly}
value=""
placeholder="未匹配到与本指令对应的附件"
onChange={(e) => patch({ F_AttachFile: e.target.value })}
/>
)}
</Field>
</div>
<div className="cc-fo-textarea-row">
<Field label="留言内容" required full>
<Field label="留言内容" required full mark={markOf("F_MessageContent")}>
<Input.TextArea
disabled={readonly}
rows={5}
@ -290,7 +374,7 @@ export function CcWorkOrderForm({
</Field>
</div>
<div className="cc-fo-textarea-row">
<Field label="备注" full>
<Field label="备注" full mark={markOf("F_Remark")}>
<Input.TextArea
disabled={readonly}
rows={3}
@ -341,24 +425,41 @@ export function buildCcWorkOrderFormValue(input: {
extractContainerFromFirstLines(`${body}\n${subject}`, 2) ||
"";
// 段正文已切开时直接用清洗后正文,避免 extractWorkOrderMessageBody 截掉货件行
const looksThreaded =
/在\s*\d{4}[-/.年][^\n]{0,160}写道|-{2,}\s*原始邮件|Forwarded message/i.test(
body || "",
);
const messageBody = looksThreaded
? extractWorkOrderMessageBody(body, subject)
: body;
const fields = extractWorkOrderFormFields({
body,
subject,
messageBody,
containerHint: cn,
});
const content =
extractWorkOrderMessageBody(body, subject) ||
fields.F_MessageContent ||
`工单邮件:${actions.join("、") || "(请补充内容)"}`;
const remarkParts: string[] = [];
if (actions.length) remarkParts.push(`邮件动作:${actions.join("、")}`);
if (input.remarkExtra?.trim()) remarkParts.push(input.remarkExtra.trim());
// 备注仅来自正文「注意/注/备注:」分界;禁止把邮件动作关键词塞进备注
const remark = fields.F_Remark;
const remarkExtra = (input.remarkExtra || "").trim();
const remarkMerged = [remark, remarkExtra].filter(Boolean).join("\n");
return {
F_ReferCode: cn,
F_ReferCode: fields.F_ReferCode || cn,
F_MessageType:
(input.messageType || "").trim() ||
suggestCcMessageType(hardActions),
F_RefNo: "",
F_MessageContent: content.slice(0, 2000),
F_Remark: remarkParts.join(";"),
F_RefNo: fields.F_RefNo,
F_MessageContent: content.slice(0, 800),
F_Remark: remarkMerged.slice(0, 800),
F_AttachFile: matchWorkOrderAttachments(
body,
// 段正文 + 主题 + 识别动作:避免「拦截」段漏挂「卡转海.zip」等角色附件
[body, subject, actions.join(" ")].filter(Boolean).join("\n"),
filenames,
subject,
input.isCurrent !== false,

@ -0,0 +1,37 @@
"use client";
import { Modal } from "antd";
export function ConfirmCcCustomerModal({
open,
customerName,
operationLabel,
confirmLoading,
onCancel,
onOk,
}: {
open: boolean;
customerName: string;
operationLabel: string;
confirmLoading?: boolean;
onCancel: () => void;
onOk: () => void;
}) {
return (
<Modal
title="确认操作客户"
open={open}
onCancel={onCancel}
onOk={onOk}
confirmLoading={confirmLoading}
okText="确认提交"
cancelText="取消"
destroyOnClose
>
<p style={{ margin: 0, fontSize: 15, lineHeight: 1.7 }}>
是否为【<strong>{customerName || "未命名客户"}</strong>】进行【
<strong>{operationLabel}</strong>】?
</p>
</Modal>
);
}

@ -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<string, string> = {
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 (
<Modal
title={
isCheckFailed ? "冲突检测失败" : UI_COPY.conflictTitle
}
title={FAILURE_MODAL_TITLE[reason] || UI_COPY.conflictTitle}
open={open}
confirmLoading={loading}
okText={canForceSkip ? "强制跳过并导入" : "知道了"}
@ -70,15 +93,48 @@ export function ConflictModal({
else onClose();
}}
>
<p>
{isCheckFailed
? "无法校验柜号是否已在 CC 存在(列表超时/失败)。默认阻断导入。"
: UI_COPY.conflictBody}
</p>
{failureKind ? (
<Alert
type="error"
showIcon
style={{ marginBottom: 12 }}
message={`失败类型:${kindText}`}
/>
) : null}
<Typography.Paragraph style={{ marginBottom: showDetail ? 8 : 0 }}>
{body}
</Typography.Paragraph>
{showDetail ? (
<Typography.Paragraph
style={{
marginBottom: 0,
padding: "8px 12px",
background: "var(--color-fill-secondary, #f5f5f5)",
borderRadius: 6,
fontSize: 13,
wordBreak: "break-word",
}}
>
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 4 }}>
CC 返回详情
</Typography.Text>
{detailError}
</Typography.Paragraph>
) : null}
{isCheckFailed ? (
<Typography.Paragraph
type="secondary"
style={{ marginTop: 12, marginBottom: 0, fontSize: 13 }}
>
说明:查重失败不代表柜号已在 CC 存在;换用可登录的客户档案或等待约 1 分钟后再试。
</Typography.Paragraph>
) : null}
{canForceSkip ? (
<p style={{ marginTop: 12, color: "var(--color-warning, #F59E0B)" }}>
<Typography.Paragraph
style={{ marginTop: 12, marginBottom: 0, color: "var(--color-warning, #F59E0B)" }}
>
Admin 且 ENABLE_FORCE_IMPORT=true:可强制跳过冲突检测。
</p>
</Typography.Paragraph>
) : null}
</Modal>
);

@ -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<ImapFilterRuleItem[]>([]);
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,15 +150,9 @@ export function ImapPullSettingsCard() {
};
return (
<Card
title="自动拉取"
loading={loading}
extra={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
来源:{source === "db" ? "数据库" : ".env 回退"}
</Typography.Text>
}
>
<Card title="自动拉取">
<Spin spinning={loading}>
<div>
<Space wrap align="center" size={8} style={{ marginBottom: 12 }}>
<Typography.Text>拉取间隔</Typography.Text>
<Select
@ -284,6 +276,8 @@ export function ImapPullSettingsCard() {
))}
</div>
)}
</div>
</Spin>
</Card>
);
}

@ -38,11 +38,15 @@ import {
buildCcWorkOrderFormValue,
type CcWorkOrderFormValue,
} from "@/components/CcWorkOrderForm";
import {
describeWorkOrderFieldMarks,
workOrderFormFromSubmitted,
} from "@/services/parse/work-order-snapshot";
import { type CcUiMode } from "@/constants/cc-ui-mode";
import { listShippingLinesApi } from "@/lib/client-api";
import { transferSegmentContextsFromLineage } from "@/services/parse/match-transfer-segment-ocr";
import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate";
import { confirmHref } from "@/services/parse/resolve-confirm-instruction";
import { confirmHref, shouldForceForecastUnit } from "@/services/parse/resolve-confirm-instruction";
import { resolveWorkOrderPanelKind } from "@/services/cc/work-order-panel";
import {
matchShippingLineId,
@ -97,16 +101,29 @@ function buildWorkOrderDraft(input: {
containerNo: string;
filenames: string[];
customerName?: string | null;
/** 已导入时优先用提交快照回填,避免空抽取盖住人工补充值 */
submitted?: Record<string, string> | null;
}): CcWorkOrderFormValue {
return buildCcWorkOrderFormValue({
const extracted = buildCcWorkOrderFormValue({
subject: input.unit.segmentSubject || input.mail.subject,
body: input.unit.segmentText || input.mail.body_text || "",
filenames: input.filenames,
containerNo: input.containerNo,
actions: input.unit.keywords,
customerName: input.customerName,
isCurrent: true,
// 必须跟本段 isCurrent:历史拦截段不得误挂当前卡转海 PDF/zip
isCurrent: input.unit.isCurrent,
});
if (!input.submitted || Object.keys(input.submitted).length === 0) {
return extracted;
}
return {
...workOrderFormFromSubmitted(input.submitted),
mailActions: input.unit.keywords,
F_CustomerName:
workOrderFormFromSubmitted(input.submitted).F_CustomerName ||
extracted.F_CustomerName,
};
}
type EvidenceExt = TypeEvidence & {
@ -115,13 +132,13 @@ type EvidenceExt = TypeEvidence & {
export function MailBusinessSummary({
mail,
ccUiMode = "admin",
}: {
mail: MailMessage;
onConfirmed?: () => void;
/** 默认管理端(ccnew);可切换客户端(cc-client) */
/** @deprecated 已固定客户端 UI,忽略传入值 */
ccUiMode?: CcUiMode;
}) {
const ccUiMode: CcUiMode = "client";
const header = asHeader(mail);
const record = asMailRecord(mail);
const evidence = (mail.type_evidence || {
@ -210,14 +227,14 @@ export function MailBusinessSummary({
subject: mail.subject,
body: bodyText,
filenames,
forceForecast: record?.kind === "BL_FORECAST",
forceForecast: shouldForceForecastUnit(mail),
workOrderActions: record?.work_order_actions,
}),
[
mail.subject,
mail,
bodyText,
filenames,
record?.kind,
record?.work_order_actions,
workOrderActionsKey,
],
);
@ -265,7 +282,8 @@ export function MailBusinessSummary({
const canConfirmOps =
mail.status === "PENDING_CONFIRM" ||
mail.status === "FAILED" ||
mail.status === "PARTIAL_SUCCESS";
mail.status === "PARTIAL_SUCCESS" ||
mail.status === "SUCCESS";
/** DO:预报 SUCCESS 后仍可补传 */
const canDoConfirm =
canConfirmOps || mail.status === "SUCCESS";
@ -377,6 +395,7 @@ export function MailBusinessSummary({
containerNo,
filenames,
customerName: record?.modules.customer_name,
submitted: instructionStateMap[unit.id]?.submitted,
}),
]),
),
@ -439,6 +458,7 @@ export function MailBusinessSummary({
containerNo,
filenames,
customerName: record?.modules.customer_name,
submitted: instructionStateMap[unit.id]?.submitted,
}),
]),
),
@ -489,6 +509,7 @@ export function MailBusinessSummary({
currentWorkOrderUnit?.segmentText,
record?.modules,
shipments,
instructionStateMap,
]);
useEffect(() => {
@ -624,8 +645,11 @@ export function MailBusinessSummary({
);
}
} else if (unit.uiKind === "work_order") {
const unitImported =
instructionStateMap[unit.id]?.status === "IMPORTED";
const unitState = instructionStateMap[unit.id];
const unitImportedLive =
unitState?.status === "IMPORTED" && unitState.mode !== "mock";
const unitImportedMock =
unitState?.status === "IMPORTED" && unitState.mode === "mock";
// 仅用本指令段的关键词 + 段正文分流,禁止整封 body 污染(如主题链里的「拦截-209件」)
const unitActions = Array.from(
new Set([
@ -640,25 +664,57 @@ export function MailBusinessSummary({
.filter(Boolean)
.join("\n"),
);
const woForUnit =
workOrderDrafts[unit.id] ||
buildWorkOrderDraft({
const extracted = buildWorkOrderDraft({
unit,
mail,
containerNo,
filenames,
customerName: record?.modules.customer_name,
submitted: unitState?.submitted,
});
// 已导入:一律用提交快照,避免抽取空值盖住「补充」字段
const woForUnit =
(unitImportedLive || unitImportedMock) && unitState?.submitted
? {
...workOrderFormFromSubmitted(unitState.submitted),
mailActions: unit.keywords,
}
: workOrderDrafts[unit.id] || extracted;
const editMode = "readonly";
const markSummary = describeWorkOrderFieldMarks(
unitState?.fieldMarks,
unitState?.submitted,
);
body = (
<>
{unitImported ? (
{unitImportedLive ? (
<Alert
style={{ marginBottom: 12 }}
type="success"
showIcon
message="该工单指令已导入,当前只读"
message={
unitState?.userSupplemented
? "该工单已导入(含人工补充/修改),当前只读"
: "该工单指令已导入,当前只读"
}
description={
markSummary.length > 0 ? (
<div>
{markSummary.map((line) => (
<div key={line}>{line}</div>
))}
</div>
) : undefined
}
/>
) : null}
{unitImportedMock ? (
<Alert
style={{ marginBottom: 12 }}
type="warning"
showIcon
message="上次为模拟提交,未写入 CarrierCentral,可重新提交"
/>
) : null}
{panelKind === "hold" ? (
@ -685,6 +741,11 @@ export function MailBusinessSummary({
portal={ccUiMode}
mode={editMode}
value={woForUnit}
fieldMarks={
unitImportedLive || unitImportedMock
? unitState?.fieldMarks
: undefined
}
onChange={(p) =>
setWorkOrderDrafts((v) => ({
...v,
@ -695,11 +756,13 @@ export function MailBusinessSummary({
)}
</>
);
if (canConfirmOps && !unitImported) {
if (canConfirmOps && !unitImportedLive) {
actions = (
<Link href={confirmHref(mail.id, unit.id)} prefetch>
<Button type="primary">
{panelKind === "message"
{unitImportedMock
? "重新提交到 CarrierCentral"
: panelKind === "message"
? "确认提交工单"
: "打开工单确认页"}
</Button>
@ -753,6 +816,9 @@ export function MailBusinessSummary({
index={index}
total={instructions.length}
imported={instructionStateMap[unit.id]?.status === "IMPORTED"}
userSupplemented={Boolean(
instructionStateMap[unit.id]?.userSupplemented,
)}
>
{body}
{actions ? (

@ -10,12 +10,15 @@ export function InstructionUnitShell({
index,
total,
imported = false,
userSupplemented = false,
children,
}: {
unit: MailInstructionUnit;
index: number;
total: number;
imported?: boolean;
/** 导入时含人工补充/修改字段 */
userSupplemented?: boolean;
children: ReactNode;
}) {
const color =
@ -37,6 +40,11 @@ export function InstructionUnitShell({
{imported ? (
<span className="mail-unit-badge mail-unit-badge-current">已导入</span>
) : null}
{imported && userSupplemented ? (
<span className="mail-unit-badge mail-unit-badge-supplemented">
经人工补充
</span>
) : null}
{unit.isCurrent ? (
<span className="mail-unit-badge mail-unit-badge-current">当前</span>
) : (

@ -74,8 +74,8 @@ export function OauthSettingsButton({ onSaved }: { onSaved?: () => void }) {
}, [form, message]);
const fillForm = (visible: boolean) => {
// destroyOnClose: Form is gone when closed — do not resetFields on close
if (!visible) {
form.resetFields();
setMeta(null);
return;
}

@ -8,8 +8,11 @@ import { formatShipmentIssue } from "@/constants/error-copy";
import { normalizeFbaCodeForCc } from "@/services/parse/normalize-fba-code";
const ROW_HEIGHT = 48;
const ROW_HEIGHT_EDIT = 40;
const MAX_TABLE_HEIGHT = 480;
const MAX_TABLE_HEIGHT_EDIT = 420;
const EMPTY_TABLE_HEIGHT = 96;
const MIN_TABLE_HEIGHT_EDIT = 280;
export interface ShipmentColumn {
key: string;
@ -77,31 +80,46 @@ export function ShipmentVirtualTable({
selected,
onToggle,
readonly = false,
/** 编辑模式下展示全部列,避免空列被过滤掉无法录入 */
showAllColumns = false,
/** 确认页编辑:抬高可视区,行高略压缩以多显示几行 */
editLayout = false,
}: {
rows: ShipmentRow[];
columns: ShipmentColumn[];
selected: Set<number>;
onToggle: (rowIndex: number, checked: boolean) => void;
readonly?: boolean;
showAllColumns?: boolean;
editLayout?: boolean;
}) {
const parentRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const rowH = editLayout ? ROW_HEIGHT_EDIT : ROW_HEIGHT;
const visibleColumns = useMemo(
() => filterShipmentColumns(columns, rows),
[columns, rows],
() =>
showAllColumns || !rows.length
? columns
: filterShipmentColumns(columns, rows),
[columns, rows, showAllColumns],
);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
estimateSize: () => rowH,
overscan: 12,
});
const totalWidth = visibleColumns.reduce((sum, col) => sum + col.width, 0);
const bodyHeight =
rows.length === 0
? EMPTY_TABLE_HEIGHT
: Math.min(MAX_TABLE_HEIGHT, rows.length * ROW_HEIGHT);
const bodyHeight = (() => {
if (rows.length === 0) {
return editLayout ? MIN_TABLE_HEIGHT_EDIT : EMPTY_TABLE_HEIGHT;
}
const content = rows.length * rowH;
const maxH = editLayout ? MAX_TABLE_HEIGHT_EDIT : MAX_TABLE_HEIGHT;
const minH = editLayout ? MIN_TABLE_HEIGHT_EDIT : 0;
return Math.max(minH, Math.min(maxH, content));
})();
const syncHeaderScroll = useCallback((e: UIEvent<HTMLDivElement>) => {
if (headerRef.current) {
@ -175,7 +193,7 @@ export function ShipmentVirtualTable({
left: 0,
display: "flex",
width: totalWidth,
height: ROW_HEIGHT,
height: rowH,
transform: `translateY(${vRow.start}px)`,
borderBottom: "1px solid var(--color-border)",
fontSize: 14,

@ -4,6 +4,11 @@ import { Form, Input, Modal, Select } from "antd";
import type { MailType } from "@/types/mail";
import { MAIL_TYPE_LABEL } from "@/constants/ui-copy";
/** Admin 改类型可选目标:预报由解析/确认链路处理,不在此手工改成新增预报 */
const OVERRIDE_TYPE_OPTIONS = (
Object.keys(MAIL_TYPE_LABEL) as MailType[]
).filter((k) => k !== "NEW_CONTAINER");
export function TypeOverrideModal({
open,
loading,
@ -28,10 +33,21 @@ export function TypeOverrideModal({
width={480}
confirmLoading={loading}
okButtonProps={{ disabled: loading }}
onCancel={() => {
form.resetFields();
onCancel();
destroyOnClose
afterOpenChange={(visible) => {
if (visible) {
const fallback =
currentType !== "NEW_CONTAINER" &&
OVERRIDE_TYPE_OPTIONS.includes(currentType)
? currentType
: "WORK_ORDER";
form.setFieldsValue({
mail_type: fallback,
reason: undefined,
});
}
}}
onCancel={onCancel}
onOk={() => {
form.validateFields().then((values) => onSubmit(values));
}}
@ -39,18 +55,14 @@ export function TypeOverrideModal({
<div style={{ marginBottom: 12, color: "var(--color-text-secondary)" }}>
当前类型:{MAIL_TYPE_LABEL[currentType]} · 证据总分 {evidenceTotal}
</div>
<Form
form={form}
layout="vertical"
initialValues={{ mail_type: "NEW_CONTAINER" as MailType }}
>
<Form form={form} layout="vertical">
<Form.Item
name="mail_type"
label="目标类型"
rules={[{ required: true, message: "请选择目标类型" }]}
>
<Select
options={(Object.keys(MAIL_TYPE_LABEL) as MailType[]).map((k) => ({
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: "请填写改类型原因" }]}
>
<Input placeholder="例如:一期验收改 NEW 走导入闭环" />
<Input placeholder="例如:正文为贴标操作,应归工单" />
</Form.Item>
</Form>
</Modal>

@ -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<ShipmentRow[]>(() =>
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<Set<number>>(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<string | undefined>();
const [conflictDetail, setConflictDetail] = useState<string | undefined>();
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);
"导入失败,可修改后重试";
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({
</Typography.Text>
</Space>
<Space direction="vertical" align="end" size={4}>
<CcUiModeSwitch mode={ccUiMode} onChange={setCcUiMode} />
<CcCustomerProfileSelect
mailId={mail.id}
items={ccProfile.items}
loading={ccProfile.loading}
value={ccProfile.profileId}
onChange={ccProfile.setProfileId}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{ccUiModeLabel(ccUiMode)} · SaveContainer
客户端 · SaveContainer
</Typography.Text>
</Space>
</Space>
@ -503,7 +666,7 @@ export function ForecastConfirmView({
) : null}
{hasUnmappedSelected ? (
<div>
<div className={`cc-fo-ack${errors.ack ? " is-invalid" : ""}`}>
<Checkbox
disabled={pageReadonly}
checked={ackUnmapped}
@ -512,13 +675,7 @@ export function ForecastConfirmView({
{UI_COPY.ackUnmapped}
</Checkbox>
{errors.ack ? (
<div
style={{
marginTop: 4,
fontSize: 12,
color: "var(--color-error)",
}}
>
<div className="cc-fo-error" style={{ marginTop: 4 }}>
{errors.ack}
</div>
) : null}
@ -527,13 +684,18 @@ export function ForecastConfirmView({
<CcForecastFormOrder
mode={pageReadonly ? "readonly" : "edit"}
portal={ccUiMode}
portal="client"
value={form}
shipments={shipments}
shippingLines={shippingLines}
onFetchShippingLine={pageReadonly ? undefined : onFetchShippingLine}
fetchShippingLineLoading={shippingFetchLoading}
selected={selected}
onSelectedChange={setSelected}
onChange={(patch) => 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({
</Link>
</div>
<ConfirmCcCustomerModal
open={customerConfirmOpen}
customerName={ccProfile.selected?.name || ""}
operationLabel="新增预报"
onCancel={() => setCustomerConfirmOpen(false)}
onOk={onCustomerConfirmOk}
/>
<ConfirmImportModal
open={confirmOpen}
loading={submitting}
@ -571,6 +742,9 @@ export function ForecastConfirmView({
<ConflictModal
open={conflictOpen}
reason={conflictReason}
message={conflictMessage}
failureKind={conflictKind}
detailError={conflictDetail}
canForceSkip={canForceSkip}
loading={submitting}
onClose={() => setConflictOpen(false)}

@ -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<typeof useCcUiMode>["mode"];
setCcUiMode: ReturnType<typeof useCcUiMode>["setMode"];
profileSelect: ReactNode;
children: ReactNode;
}) {
return (
@ -109,9 +120,9 @@ function ConfirmChrome({
<Typography.Text type="secondary">{mail.subject}</Typography.Text>
</Space>
<Space direction="vertical" align="end" size={4}>
<CcUiModeSwitch mode={ccUiMode} onChange={setCcUiMode} />
{profileSelect}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{ccUiModeLabel(ccUiMode)} · {instructionUiKindLabel(unit.uiKind)}
客户端 · {instructionUiKindLabel(unit.uiKind)}
</Typography.Text>
</Space>
</Space>
@ -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({
<ConfirmChrome
mail={mail}
unit={unit}
ccUiMode={ccUiMode}
setCcUiMode={setCcUiMode}
profileSelect={
<CcCustomerProfileSelect
mailId={mail.id}
items={ccProfile.items}
loading={ccProfile.loading}
value={ccProfile.profileId}
onChange={ccProfile.setProfileId}
/>
}
>
{unitImported ? (
<Alert type="success" showIcon message="该转仓指令已导入,当前只读" />
@ -353,13 +375,30 @@ export function TransferConfirmView({
<Button
type="primary"
loading={submitting}
disabled={!canSubmit}
onClick={() => void onSubmit()}
disabled={!canSubmit || !ccProfile.profileId}
onClick={() => {
if (!ccProfile.profileId) {
message.error("请先选择 CC 客户");
return;
}
setCustomerConfirmOpen(true);
}}
>
确认批量转仓
</Button>
</Space>
</div>
<ConfirmCcCustomerModal
open={customerConfirmOpen}
customerName={ccProfile.selected?.name || ""}
operationLabel="批量转仓"
confirmLoading={submitting}
onCancel={() => setCustomerConfirmOpen(false)}
onOk={() => {
setCustomerConfirmOpen(false);
void onSubmit();
}}
/>
</ConfirmChrome>
);
}
@ -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({
<ConfirmChrome
mail={mail}
unit={unit}
ccUiMode={ccUiMode}
setCcUiMode={setCcUiMode}
profileSelect={
<CcCustomerProfileSelect
mailId={mail.id}
items={ccProfile.items}
loading={ccProfile.loading}
value={ccProfile.profileId}
onChange={ccProfile.setProfileId}
/>
}
>
{unitImported ? (
<Alert type="success" showIcon message="该上传 DO 指令已导入,当前只读" />
@ -442,8 +507,19 @@ export function DoUploadConfirmView({
<CcDoUploadPanel
mode={canSubmit ? "edit" : "readonly"}
value={value}
errors={canSubmit ? importIssues.errors : {}}
onChange={(p) => setValue((v) => ({ ...v, ...p }))}
/>
{canSubmit && importIssues.blockers.length ? (
<div className="cc-fo-finish-blockers" role="alert" style={{ marginTop: 12 }}>
<strong>无法确认上传</strong>
<ul>
{importIssues.blockers.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
) : null}
<div className="sticky-footer">
<span />
<Space>
@ -453,13 +529,41 @@ export function DoUploadConfirmView({
<Button
type="primary"
loading={submitting}
disabled={!canSubmit}
onClick={() => void onSubmit()}
disabled={finishDisabled}
title={
importIssues.blockers.length
? importIssues.blockers.join(";")
: !ccProfile.profileId
? "请先选择 CC 客户"
: undefined
}
onClick={() => {
if (importIssues.blockers.length) {
message.error(importIssues.blockers[0]);
return;
}
if (!ccProfile.profileId) {
message.error("请先选择 CC 客户");
return;
}
setCustomerConfirmOpen(true);
}}
>
确认上传 DO
</Button>
</Space>
</div>
<ConfirmCcCustomerModal
open={customerConfirmOpen}
customerName={ccProfile.selected?.name || ""}
operationLabel="上传 DO"
confirmLoading={submitting}
onCancel={() => setCustomerConfirmOpen(false)}
onOk={() => {
setCustomerConfirmOpen(false);
void onSubmit();
}}
/>
</ConfirmChrome>
);
}
@ -473,25 +577,40 @@ 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<CcWorkOrderFormValue>(() =>
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 || "",
@ -499,9 +618,30 @@ export function WorkOrderConfirmView({
containerNo,
actions: unit.keywords,
customerName: record?.modules.customer_name,
isCurrent: true,
isCurrent: unit.isCurrent,
}),
[
unit.segmentSubject,
unit.segmentText,
unit.keywords,
unit.isCurrent,
mail.subject,
mail.body_text,
filenames,
containerNo,
record?.modules.customer_name,
],
);
const [draft, setDraft] = useState<CcWorkOrderFormValue>(() => {
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({
<ConfirmChrome
mail={mail}
unit={unit}
ccUiMode={ccUiMode}
setCcUiMode={setCcUiMode}
profileSelect={
<CcCustomerProfileSelect
mailId={mail.id}
items={ccProfile.items}
loading={ccProfile.loading}
value={ccProfile.profileId}
onChange={ccProfile.setProfileId}
/>
}
>
{unitImported ? (
<Alert type="success" showIcon message="该工单指令已导入,当前只读" />
{unitImportedLive ? (
<Alert
type="success"
showIcon
message={
importState?.userSupplemented
? "该工单已导入(含人工补充/修改),当前只读"
: "该工单指令已导入,当前只读"
}
description={
(() => {
const lines = describeWorkOrderFieldMarks(
importState?.fieldMarks,
importState?.submitted,
);
if (!lines.length) return undefined;
return (
<div>
{lines.map((line) => (
<div key={line}>{line}</div>
))}
</div>
);
})()
}
/>
) : null}
{unitImportedMock ? (
<Alert
type="warning"
showIcon
message="上次为模拟提交,未写入 CarrierCentral,可重新提交"
/>
) : null}
{panelKind === "hold" ? (
<CcHoldMoveTypePanel
@ -603,9 +785,14 @@ export function WorkOrderConfirmView({
/>
) : (
<CcWorkOrderForm
portal={ccUiMode}
portal="client"
mode={editMode}
value={draft}
fieldMarks={
unitImportedLive || unitImportedMock
? importState?.fieldMarks
: undefined
}
onChange={(p) => setDraft((v) => ({ ...v, ...p }))}
/>
)}
@ -619,8 +806,14 @@ export function WorkOrderConfirmView({
<Button
type="primary"
loading={submitting}
disabled={!canSubmit}
onClick={() => void onSubmit()}
disabled={!canSubmit || !ccProfile.profileId}
onClick={() => {
if (!ccProfile.profileId) {
message.error("请先选择 CC 客户");
return;
}
setCustomerConfirmOpen(true);
}}
>
确认提交工单
</Button>
@ -631,6 +824,17 @@ export function WorkOrderConfirmView({
)}
</Space>
</div>
<ConfirmCcCustomerModal
open={customerConfirmOpen}
customerName={ccProfile.selected?.name || ""}
operationLabel="提交工单"
confirmLoading={submitting}
onCancel={() => setCustomerConfirmOpen(false)}
onOk={() => {
setCustomerConfirmOpen(false);
void onSubmit();
}}
/>
</ConfirmChrome>
);
}

@ -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,
}));

@ -0,0 +1,157 @@
/**
* CarrierCentral 客户端接口 V1.73:状态枚举与统一返回码说明。
* 操作员可见文案以本表为准;与旧 PDF 冲突时以 V1.73 为准。
*/
/** 统一业务返回码(各接口 code 字段) */
export const CC_RESPONSE_CODE_LABEL: Record<number, string> = {
200: "成功",
400: "业务失败",
410: "没有登录信息",
500: "服务异常",
};
/** GetContainerList 输出 F_Status(V1.73 纠正) */
export const CC_CONTAINER_STATUS_LABEL: Record<number, string> = {
0: "新单",
20: "清关结束",
30: "提柜直达",
40: "待提柜",
60: "已到仓",
65: "直送运输中",
70: "已拆柜",
80: "已还柜",
};
/** GetShipmentInfo 输出 Status(V1.73 增加 22-已装车) */
export const CC_SHIPMENT_STATUS_LABEL: Record<number, string> = {
0: "预到达",
10: "在库",
15: "在库",
16: "已排货",
20: "已出仓",
22: "已装车",
26: "运输中",
30: "已送达",
90: "已取消",
};
/** GetStateTrack TypeCode */
export const CC_STATE_TRACK_LABEL: Record<number, string> = {
0: "已到港",
10: "已提柜",
20: "已到仓",
30: "已拆柜",
40: "已还空",
};
/** 接口路径/名称 → 操作员可读接口名 */
export const CC_API_DISPLAY_NAME: Record<string, string> = {
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 未返回具体原因`;
}

@ -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 : "";
}

@ -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<string, string> = {
NO_CONTAINER: "CarrierCentral 中还没有该柜,请先确认导入预报",
NO_DO_FILE: "本地没有可上传的 DO 附件",
CC_WRITE_OFF: "当前未开启向 CarrierCentral 写入,请到设置检查 CC 模式",
PARSE_FAILED: "文件解析失败,请确认上传的是有效的 Excel 装箱表(xlsx/xls)",
};
const LAST_ERROR_EXACT: Record<string, string> = {
@ -66,6 +74,12 @@ const LAST_ERROR_EXACT: Record<string, string> = {
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<string, string> = {
/** 英文 / 机器短语 → 操作员说明(按长词优先) */
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)) {
if (!leftoverLatin(rewritten)) return rewritten;
const again = applyPhraseReplacements(rewritten);
if (hasCjk(again) && !leftoverLatin(again)) return again;
}
if (!leftoverLatin(rewritten)) return rewritten;
return again;
}
if (apiCode && API_CODE_MESSAGE[apiCode] && leftoverLatin(rewritten)) {
if (apiCode && API_CODE_MESSAGE[apiCode]) {
return API_CODE_MESSAGE[apiCode];
}
@ -273,21 +299,213 @@ 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<CcFailureKind, string> = {
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)) {
return "CarrierCentral 处理失败,请稍后重试或到 CC 核对该柜状态";
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 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 {
return s
@ -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();
}

@ -28,7 +28,7 @@ export const UI_COPY = {
navMails: "邮件列表",
navLogs: "日志",
navSettings: "设置",
navCustomers: "客户账号",
navCustomers: "租户账号",
emptyMails: "暂无邮件,等待 IMAP 拉取",
emptyImports: "暂无导入记录",
btnConfirmImport: "确认导入",

@ -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<CcOwnerStore>();
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<T>(
ownerUserId: bigint | null,
fn: () => T,
): T {
return als.run({ ownerUserId }, fn);
return als.run({ ownerUserId, profileId: null }, fn);
}
export function runWithCcProfile<T>(
profileId: bigint,
ownerUserId: bigint | null,
fn: () => T,
): T {
return als.run({ ownerUserId, profileId }, fn);
}
export async function runWithMailCcOwner<T>(
mailId: bigint,
fn: () => Promise<T>,
opts?: { profileId?: bigint | null },
): Promise<T> {
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);
}

@ -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<ApiResponse<{ externalId: string | null; mode: string }>> {
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<ApiResponse<{ items: ContainerImport[]; total: number }>> {
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<ApiResponse<{ items: ShippingLine[] }>> {
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<ApiResponse<{ items: CcMessageTypeDictItem[] }>> {
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<ApiResponse<CcMessageTypeDictItem>> {
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<ApiResponse<CcMessageTypeDictItem>> {
return request(`/api/settings/cc-message-types/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
});
}
export async function deleteCcMessageTypeApi(
id: string,
): Promise<ApiResponse<{ id: string; deleted: boolean }>> {
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<ApiResponse<{ items: CcCustomerProfile[] }>> {
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<ApiResponse<CcCustomerProfile>> {
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<ApiResponse<CcCustomerProfile>> {
return request(`/api/settings/cc-profiles/${id}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
export async function deleteCcCustomerProfileApi(
id: string,
): Promise<ApiResponse<{ deleted: boolean }>> {
return request(`/api/settings/cc-profiles/${id}`, { method: "DELETE" });
}
export async function testCcCustomerProfileApi(
id: string,
body?: { password?: string },
): Promise<ApiResponse<{ message: string }>> {
return request(`/api/settings/cc-profiles/${id}/test`, {
method: "POST",
body: JSON.stringify(body ?? {}),
});
}
export async function getOAuthSettingsApi(): Promise<ApiResponse<OAuthSettings>> {
return request<OAuthSettings>("/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;

@ -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;

@ -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<void> {
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 };
});

@ -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 {

@ -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, {
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 },
};
}

@ -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,
};
}

@ -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 {

@ -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<CcCustomerProfilePublic[]> {
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<CcCustomerProfilePublic[]> {
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<CcCustomerProfilePublic | null> {
const row = await prisma.ccCustomerProfile.findUnique({ where: { id } });
return row ? toPublic(row) : null;
}
export async function loadCcCustomerProfileRuntime(
id: bigint,
): Promise<CcRuntimeConfig | null> {
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<CcCustomerProfilePublic> {
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<CcCustomerProfilePublic | null> {
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<boolean> {
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<void> {
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,
},
});
}

@ -0,0 +1,40 @@
import { isValidContainerNo } from "@/utils/iso6346";
export type DoImportIssues = {
errors: Record<string, string>;
blockers: string[];
notes: string[];
};
/**
* DO 上传确认页缺项:柜号必填且 ISO;至少一份 DO 附件。
* 对齐 assistant:柜须可定位,口语「DO请查收」不算已有文件。
*/
export function collectDoImportIssues(input: {
containerNo: string;
filenames: string[];
}): DoImportIssues {
const errors: Record<string, string> = {};
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 };
}

@ -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}`,
}),
};
}

@ -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 };

@ -0,0 +1,37 @@
/** V1.73:GetContainerList 真网调用串行 + 60 秒间隔 */
const LIST_HTTP_MIN_INTERVAL_MS = 60_000;
const listHttpLastAt = new Map<string, number>();
const listHttpChains = new Map<string, Promise<void>>();
export function resetListHttpGateForTests(): void {
listHttpLastAt.clear();
listHttpChains.clear();
}
export async function runWithListHttpGate<T>(
profileKey: string,
fn: () => Promise<T>,
): Promise<T> {
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;
}

@ -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<void> {
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<CcMessageTypeDictPublic[]> {
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<CcMessageTypeDictPublic> {
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<CcMessageTypeDictPublic | null> {
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<boolean> {
const existing = await prisma.ccMessageTypeDict.findUnique({ where: { id } });
if (!existing) return false;
await prisma.ccMessageTypeDict.delete({ where: { id } });
return true;
}

@ -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 {

@ -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<string, CacheEntry>();
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<CcRuntimeConfig> {
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;
}

@ -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<{

@ -34,7 +34,7 @@ const DEFAULTS: Record<CcWriteCapabilityId, { mode: CcWriteMode; endpoint: strin
endpoint: "/Container/ApplyLabelInstruction",
},
customer_message: {
mode: "mock",
mode: "live",
endpoint: "/CustomerMessage/SaveForm",
},
do_upload: {
@ -43,14 +43,20 @@ const DEFAULTS: Record<CcWriteCapabilityId, { mode: CcWriteMode; endpoint: strin
},
};
/** 工单必须真写 CC;历史库若仍是 mock 则每次 ensure 拉回 live */
const FORCE_LIVE_IDS = new Set<CcWriteCapabilityId>(["customer_message"]);
export async function ensureCcWriteCapabilities(): Promise<void> {
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<CcWriteMode> {
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;
}

@ -142,9 +142,16 @@ function rowToConfig(
};
}
export async function listMailboxes(ownerUserId?: bigint | null): Promise<MailboxPublic[]> {
/**
* 列出邮箱。
* - bigint:该租户名下邮箱
* - null:仅管理端公共邮箱(owner_user_id IS NULL)
*/
export async function listMailboxes(
ownerUserId: bigint | null,
): Promise<MailboxPublic[]> {
const rows = await prisma.mailboxAccount.findMany({
where: ownerUserId != null ? { ownerUserId } : undefined,
where: { ownerUserId },
orderBy: [{ enabled: "desc" }, { id: "asc" }],
});
return rows.map(toPublic);

@ -131,6 +131,7 @@ async function runAutoExecInner(mailId: bigint): Promise<{
responseBody: JSON.stringify(result.response),
status: "SUCCESS",
shipmentsHash: "auto-exec",
importKind: "forecast",
},
}).catch(() => undefined);
}

@ -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,8 +34,11 @@ export async function confirmBatchTransfer(input: {
instructionId?: string;
containerNo: string;
payload: BatchTransferPayload;
ccProfileId?: bigint;
}): Promise<{ externalId: string | null; mode: string }> {
return runWithMailCcOwner(input.mailId, () =>
return runWithMailCcOwner(
input.mailId,
() =>
runWithMailTrace(input.mailId, "submit", async () => {
const checked = await validateInstructionImportable({
mailId: input.mailId,
@ -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 },
);
}

@ -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,13 +137,16 @@ 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, () =>
return runWithMailCcOwner(
input.mailId,
() =>
runWithMailTrace(input.mailId, "submit", async () => {
const checked = await validateInstructionImportable({
mailId: input.mailId,
@ -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 },
);
}

@ -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, () =>
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<string, unknown>,
);
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 },
);
}

@ -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<ConfirmImportResult> {
return runWithMailCcOwner(input.mailId, () =>
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 = (
const containerNoForGate =
(typeof input.containerHeader?.F_ContainerNo === "string" &&
input.containerHeader.F_ContainerNo.trim()) ||
(
mail.parseResult?.containerHeader as { F_ContainerNo?: string } | null
)?.F_ContainerNo || "";
)?.F_ContainerNo ||
"";
if (validShipmentCount < 1 && !containerNoForGate.trim()) {
throw new ConfirmImportError(
@ -300,13 +328,34 @@ 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[]),
),
}
: clientShipments
? {
container_header: {
F_ContainerNo: "",
container_no_valid: false,
} as ContainerHeader,
shipments: fillShipmentFbaCodes(clientShipments),
}
: null;
@ -314,7 +363,8 @@ export class ConfirmImport {
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,
},
});

@ -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<string, string> = {
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<string, unknown>;
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,
};
}

@ -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<string, string> = {
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 };
}

@ -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)) {
// 历史:mock 误标 SUCCESS 时仍允许对该 mock 指令重提
const mockRetry =
mail.status === "SUCCESS" &&
!!input.instructionId &&
(
lineage as {
instruction_state?: Record<string, { status?: string; mode?: string }>;
}
)?.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<string, { status?: string }> })
(lineage as { instruction_state?: Record<string, { status?: string; mode?: string }> })
?.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<string, string>;
supplementedFields?: WorkOrderSnapshotField[];
fieldMarks?: WorkOrderFieldMarks;
userSupplemented?: boolean;
}): Promise<MailStatus | null> {
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,

@ -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<string, unknown>,
>(row: T): T {
const out: Record<string, unknown> = { ...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 | undefined>): string | null {
for (const v of vals) {
const t = (v || "").trim();

@ -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),

@ -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) =>

@ -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<string, string[]> = {
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<string, unknown>): 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<string, unknown> = {};
const hdr = grid[headerIdx] || [];
const ccol: Record<number, string> = {};
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<string, unknown>)[key] = val;
}
}
return out;
}

@ -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;

@ -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 "";
}
}

@ -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<string, unknown>): 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<string, unknown>,
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;
}

@ -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<string>([raw, raw.toLowerCase(), upper, upper.toLowerCase()]);
const out = new Set<string>([
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,8 +340,7 @@ export function matchShippingLineId(
if (!q) return null;
const exact = lines.find(
(l) =>
const exact = lines.find((l) =>
hints.some(
(h) =>
(l.F_FullName || "").toLowerCase() === h.toLowerCase() ||
@ -317,19 +349,15 @@ export function matchShippingLineId(
);
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();

@ -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);
}

@ -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<string, string>;
/** 相对邮件抽取基线被补充或修改的字段名 */
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";
}

@ -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: "派送单请查收",

@ -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(

@ -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<string, string[]> = {
F_FBACode: [
"仓库代码",
@ -23,13 +33,24 @@ const HEADER_ALIASES: Record<string, string[]> = {
"目的代码",
"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<string, string[]> = {
"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<string, string[]> = {
"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<string, string> = {
F_FBACode: "仓库代码/FBACode",
F_Transporter: "渠道/派送方式",
F_CTNS: "件数",
sheet: "工作表",
header: "表头",
};
export function formatPackingParseLineageMessage(
lineage: Record<string, unknown>,
): 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<string, unknown>): boolean {
export function isInstructionNoiseRow(
raw: Record<string, unknown>,
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<string, unknown>): boolean {
return false;
}
function assignRawField(
raw: Record<string, unknown>,
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<number, string> = {};
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<string, unknown> = {};
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,
},
};
}

@ -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,

@ -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)}`;
}

@ -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<ShipmentRow> & { 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<ShipmentRow>;
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,
});
}),
);
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save