Initial commit: mail auto-forecast (email-forecast).

Ship source, prisma, docs, and gold samples. Ignore local .env, IMAP snapshots, smoke logs, and debug scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
main
你的GitHub用户名 1 month ago
commit 2b29fa7497

@ -0,0 +1,64 @@
# 邮件自动预报 — 实施计划
## 当前步骤
MailBusinessSummary forecastShippingId TDZ — Done
## 进度日志
- forecastShippingId useState 前移到 forecastValue useMemo 之前 — Done
- CC 客户端接口 PDF→MD 全文转换 — Done
- extractForecastInstructionText + stripMobileMailChrome 去发件人/大小/iPhone 元数据 — Done
- 跑 `rewrite-mail-business-summary.ts` 以 Unicode 重写 UTF-8 文案;详情页中文恢复 — Done
- 样例邮件附件平铺 + ingest 寻址统一 — Done(DB 停机时需再跑 pnpm sample:mails)
- 一封邮件多指令拆分:`extractMailInstructions` 收敛为 forecast/work_order/transfer/do_upload — Done
- 留仓/拆分/出库 UI:删除 `CcContainerOpsPanel`;新增 FormMoveType 留仓、Split 拆分、客户端出库指令三面板;意图分流 hold/split/outbound — Done
- 邮件3/4 子目录附件平铺到邮件根;`样例附件清单.md`;ingest attachPolicy — Done
- 拆柜/派送/转仓仿制 UI 样式向 ccnew Learun 靠齐(边框/按钮/表头/密度)— Done
- `CcTransferIndexTruck` 仿制 IndexTruck 查询区+合计+货件表+FormFBACode;TRANSFER/工单转仓详情展示 — Done
- 邮件2(MATU2745683)四段主题解析客户 LINK EVER INC;样例 #76 327 行 — Done
- 转仓 UI 仅展示备注「转XXX」行(邮件2=2 行),全柜清单作范围提示 — Done
- 按填写规范生成 `数据模版-新辰泽-WHSU8127240.xlsx`;多行表头/空仓库ID;#79 待确认 23 行 — Done
- 货件工具条仅保留查询 — Done
- 主题 ellipsis;客户剥 Fw/转发/新增预报N;船司按提单前缀匹配 CC 列表;NO_SHIPMENT 中文说明 — Done
- Learun 蓝条箭头 Steps + 左右标签 + 货件工具条外观 + 绿完成;#79/#74 验收 — Done
- `CcForecastFormOrder` 详情只读+确认页可编辑;预报样例 #79 / 工单 #74 目视验收 — Done
- `pnpm sample:mails` 入库 M1–M4;详情页 Playwright 无 antd Descriptions/Table 告警 — Done
- Table rowKey 改预注入 `__rowKey`,不再用 `(row, index)` — Done
- `pnpm sample:mail3` 入库;主题有柜号时允许无正文/附件 — Done
- 邮件3:`ops-subject-template` 抽柜号/提单/船名/ETA/渠道件数;WORK_ORDER 主题短路 — Done
- bl-plus 提单前缀船司优先;提拆派/车架/海运默认;样例单测 — Done
- confirm 两步审查 UI + CC 货件列名;TransMode/OperationType 文案对齐 ccnew — Done
## 进度日志
- 主题 ellipsis;客户剥 Fw/转发/新增预报N;船司按提单前缀匹配 CC 列表;NO_SHIPMENT 中文说明 — Done
- Learun 蓝条箭头 Steps + 左右标签 + 货件工具条外观 + 绿完成;#79/#74 验收 — Done
- `CcForecastFormOrder` 详情只读+确认页可编辑;预报样例 #79 / 工单 #74 目视验收 — Done
- `pnpm sample:mails` 入库 M1–M4;详情页 Playwright 无 antd Descriptions/Table 告警 — Done
- Table rowKey 改预注入 `__rowKey`,不再用 `(row, index)` — Done
- `pnpm sample:mail3` 入库;主题有柜号时允许无正文/附件 — Done
- 邮件3:`ops-subject-template` 抽柜号/提单/船名/ETA/渠道件数;WORK_ORDER 主题短路 — Done
- bl-plus 提单前缀船司优先;提拆派/车架/海运默认;样例单测 — Done
- confirm 两步审查 UI + CC 货件列名;TransMode/OperationType 文案对齐 ccnew — Done
## 路线(顺序执行)
1–25.(既有)— Done
26. 运营按钮 + 确认门禁 + env 统一 + stale→FETCHED — Done
27. 邮件标准记录模板(无导入扩展)— Done
28. 对照 ccnew 对接面(SaveContainer)— Done
29. CC FormOrder 字段对齐审查页 — Done
30. 邮件3主题工单解析(附件/正文后置)— Done
31. CC FormOrder UI 复刻(详情/确认共用)— Done
32. FormOrder 截图视觉对齐(chevron/工具条)— Done
## 已确认约束
- 邮件来源仅 IMAP;无开发种子进库;无操作审计页/`audit_log`
- IMAP lookback 默认 3 天(已读+未读);间隔默认 30min(设置页优先)
- 改类型开关:服务端 `ENABLE_TYPE_OVERRIDE`,前端经 `/api/health.enable_type_override`
- PARSING 卡死回收 → FETCHED,由 worker `drainFetchedMails` 再入队
- OCR/指令 live 仍后置
- 工单/转仓只记 `mail_record`,不自动写 CC;导入后置
- 确认页按 FormOrder 字段对齐(非像素复刻);提拆派→业务类型0;直送仅为类型说明
- 邮件3类「拆柜清单更新」:仅主题;正文/卡转海附件后置
## 设计文档
- 修订摘要:docx/现行口径-修订说明-v1.md
- PRD / 技术 / UI / 韧性 / SOP:已回写现行口径

@ -0,0 +1,13 @@
node_modules
.next
out
dist
data
.env
.env*.local
!.env.example
*.log
.DS_Store
coverage
playwright-report
test-results

@ -0,0 +1,77 @@
DATABASE_URL=mysql://app:app@localhost:7023/email_forecast
POLL_INTERVAL_MS=1800000
# Worker 优先读设置页 imap_settings(默认 30min);上值仅作回退
DATA_RETENTION_DAYS=30
ENABLE_TYPE_OVERRIDE=true
ENABLE_FORCE_IMPORT=false
# Admin 强制跳过冲突:设 true 后确认页冲突 Modal 出现「强制跳过并导入」
ENABLE_ISO_CHECK=true
# CC
# 推荐:Admin「设置 → CarrierCentral」配置(入库优先于下方 .env)
# 无账号保持 mock;有账号后关 mock 并填用户名密码,或 pnpm cc:smoke -- --mode=live
# V1.72 文档 demovip 联调示例:
# CC_MOCK=false
# CC_API_BASE=https://demovip.saas.carriercentral.vip/api
# CC_USERNAME=fj CC_PASSWORD_PLAIN=(见接口文档测试客户密码)
# 然后:pnpm exec tsx scripts/apply-demovip-cc.ts
# 一期 PRD 默认仍是 test.saas + Saas:TEST(有正式 TEST 账号时改回)
CC_MOCK=true
IMAP_HOST=imap.qq.com
IMAP_PORT=993
# 可选回退;生产请用 Admin「设置 → 邮箱绑定」写入 DB(AES 加密)
IMAP_USER=
IMAP_PASS=
CC_API_BASE=https://test.saas.carriercentral.vip/api
CC_SAAS_HEADER=TEST
CC_LOGIN_MARK=11111111-2222-3333-4444-555555555555
CC_USERNAME=
CC_PASSWORD_PLAIN=
# CC_PASSWORD_MD5=
# 生产必须替换默认值;NODE_ENV=production 时弱口令/默认 SESSION_SECRET 会拒绝启动
APP_ADMIN_USER=admin
APP_ADMIN_PASS=admin123
APP_OPS_USER=ops
APP_OPS_PASS=ops123
# 至少 32 字符;生产勿使用下列占位
SESSION_SECRET=change-me-to-a-long-random-string-at-least-32-chars
CC_HTTP_TIMEOUT_MS=60000
# 查柜 / 船司等读接口;过长会卡住页面
CC_READ_TIMEOUT_MS=8000
IMAP_CONNECT_TIMEOUT_MS=30000
IMAP_READ_TIMEOUT_MS=60000
# 同步近 N 天(已读+未读),默认 3;每轮每箱最多拉 N 封新信
IMAP_LOOKBACK_DAYS=3
IMAP_MAX_FETCH_PER_TICK=100
IMAP_IDLE_ENABLED=true
# OCR:off | local | aliyun
OCR_PROVIDER=local
OCR_MIN_CONFIDENCE=40
OCR_ALIYUN_ENDPOINT=
OCR_ALIYUN_ACCESS_KEY=
OCR_ALIYUN_SECRET_KEY=
# OAuth(Gmail / Microsoft);未配置时设置页按钮禁用,仍可用授权码
OAUTH_PUBLIC_BASE_URL=http://localhost:3100
OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET=
OAUTH_MS_CLIENT_ID=
OAUTH_MS_CLIENT_SECRET=
OAUTH_MS_TENANT=common
# 自动执行(TRANSFER / 留仓拆分 / 贴标);须 cc_write_capability live 才算 TEST 完成
ENABLE_AUTO_EXEC_TRANSFER=true
ENABLE_AUTO_EXEC_HOLD_SPLIT=true
ENABLE_AUTO_EXEC_LABEL=true
AUTO_EXEC_REQUIRE_CONFIRM=false
PARSING_STALE_MS=600000
IMPORTING_TIMEOUT_MS=180000
COMPENSATION_MAX_RETRY=3
SHIPMENT_ROW_SOFT_LIMIT=1000
SHIPMENT_ROW_HARD_LIMIT=5000
ATTACHMENT_MAX_BYTES=20971520

40
.gitignore vendored

@ -0,0 +1,40 @@
# deps / build
node_modules
.next
out
dist
coverage
.turbo
*.tsbuildinfo
# local secrets & runtime
.env
.env*.local
!.env.example
data/*
!data/.gitkeep
# local test records / smoke reports
*.log
data/logs/
data/mails/
data/imap-runtime.json
playwright-report
test-results
.playwright-mcp
node_modules/.vite
# editor / OS / local AI scratch
.DS_Store
.idea
.vscode/*
!.vscode/extensions.json
.mypy_cache
.cursor/plans
agent-transcripts
*.tmp
_tmp*
scripts/_dump-*.ts
scripts/_check-*.ts
scripts/debug-*.ts
docx/_excel_summary.py

@ -0,0 +1,31 @@
FROM node:20-bookworm AS deps
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
COPY package.json pnpm-lock.yaml* ./
COPY prisma ./prisma
RUN pnpm install --frozen-lockfile || pnpm install
FROM node:20-bookworm AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm db:generate && pnpm build
FROM node:20-bookworm AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src ./src
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/tsconfig.json ./tsconfig.json
COPY --from=builder /app/tsconfig.worker.json ./tsconfig.worker.json
RUN mkdir -p /app/data && chmod +x /app/scripts/docker-entrypoint-web.sh
EXPOSE 3000
CMD ["pnpm", "start"]

@ -0,0 +1,104 @@
# 邮件自动预报系统
Next.js 15 + Prisma + MySQL 8 + Ant Design 5 + Worker(IMAP 轮询)。
## 快速开始
```bash
# Windows 一键(推荐 local,不拉 node 镜像)
# .\docs\start-system.ps1
# 或 docs\start-system.cmd
# 全量 compose(需 Docker Hub):.\docs\start-system.ps1 -Mode compose
# 1. 依赖
pnpm install
# 2. 环境
cp .env.example .env
# 本地默认 MySQL 映射 7023(避免与宿主机 3306 冲突)
# DATABASE_URL=mysql://app:app@localhost:7023/email_forecast
# CC_MOCK=true # 无 CC 账号时开 mock
# 3. 数据库
docker compose up -d mysql
pnpm exec prisma db push
pnpm db:seed # 仅账号 admin/ops
# 4. Web + Worker(本地联调推荐;Worker 与系统一起起,才有自动拉取)
pnpm dev:stack
# 等价:pnpm dev 与 pnpm worker 同开
# http://localhost:3100 账号 admin/admin123 或 ops/ops123
# Windows 一键(mysql + web + worker):.\docs\start-system.ps1
# 4b. 仅 Web(不拉 IMAP)
pnpm dev
# 4c. 生产形态三件套(mysql + web + worker)
# 需已有 .env;首次会 db push + seed 账号(SEED_ON_START=true)
pnpm compose:up
pnpm compose:ps # 确认 web/worker/mysql 均为 running
# pnpm compose:logs
# 本机已用 pnpm dev 时,可只补 worker 容器:pnpm compose:up:core
# 5. IMAP 邮箱绑定(推荐:Web 设置页,无需改代码)
# Admin 登录 → 设置 → 新增绑定(填对方邮箱 + QQ 授权码)→ 测试连接
# 兼容回退:仍可在 .env 填 IMAP_USER / IMAP_PASS
pnpm imap:smoke # 仅连通(.env 回退)
pnpm imap:poll # 拉取一轮(优先 DB 绑定)
pnpm worker # 常驻轮询
# Admin 顶栏也可点「立即拉取」
```
## 架构
| 进程 | 职责 |
|---|---|
| `web` | UI + `/api/*` + ConfirmImport / SaveContainer + **设置/邮箱绑定** |
| `worker` | 仅 IMAP 拉取(读 `mailbox_account`)+ ParsePipeline + 超时回收 + 补偿 due + retention;**禁止**调 SaveContainer |
| `mysql` | 业务库 `email_forecast` |
> 本地只起 `pnpm dev` + mysql 时 **不会**自动拉信/回收。联调请用 `pnpm dev:stack`、`.\docs\start-system.ps1`,或 `docker compose up` 三件套。
## 邮件来源
列表数据仅来自 **IMAP 拉取**(设置页绑定邮箱)。开发种子 / 样例导入 / 旧 Demo 应用已移除。
## 路由
- `/login` `/mails` `/mails/[id]` `/mails/[id]/confirm` `/logs`(导入日志 / 拉取记录) `/settings`(Admin)
## 测试
```bash
pnpm test # vitest 单元/集成
pnpm test:e2e # playwright(需先起 dev)
# CC
pnpm cc:smoke # 跟随 .env(默认 mock 假成功,非 TEST 验收)
pnpm cc:smoke -- --mode=mock # 强制 mock
pnpm cc:smoke -- --mode=live # TEST 真烟雾:需 CC_MOCK=false + CC_USERNAME + 密码
pnpm retention:cleanup # 按 DATA_RETENTION_DAYS 清理(可加 --dry-run)
pnpm retention:cleanup -- --dry-run
```
### CC mock vs live(一期验收)
| 模式 | 条件 | 含义 |
|---|---|---|
| **mock** | 设置页 Mock 开,或 `.env CC_MOCK=true` | SaveContainer/冲突/船司走内存假数据;**不算** PRD TEST 烟雾 |
| **live** | 设置页关 Mock + 用户名/密码(或 `.env`) | 真打 `test.saas...`:`customerLogin` → 冲突/船司/`SaveContainer` |
配置入口:**设置 → CarrierCentral(CC)连接**(Admin)。DB 配置优先于 `.env`,保存后立即生效。
PDF《carriercentral 客户端通用接口》只定义路径与字段;**不能替代租户账号**。无 `customerLogin` token 无法 SaveContainer。
顶栏 / `GET /api/health` 会暴露 `cc_mode` / `cc_live_ready`,避免误把 mock 当验收。
## 文档
- **协作入口(给接手同事)**:`docs/邮箱项目.md`
- PRD:`docx/需求规格-邮件自动预报-v0.2.md`
- 技术设计:`docx/技术设计方案-邮件自动预报-v1.0.md`
- UI/UX:`docx/产品UI-UX设计方案-邮件自动预报-v1.0.md`
- 韧性:`docx/异常场景与系统韧性设计.md`
- 运营 SOP:`docx/运营SOP-邮件自动预报.md`

@ -0,0 +1,67 @@
services:
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: email_forecast
MYSQL_USER: app
MYSQL_PASSWORD: app
MYSQL_ROOT_PASSWORD: root
ports:
- "7023:3306"
volumes:
- mysql_data:/var/lib/mysql
command:
- --default-authentication-plugin=mysql_native_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uapp", "-papp"]
interval: 5s
timeout: 5s
retries: 20
web:
build: .
command: ["sh", "/app/scripts/docker-entrypoint-web.sh"]
ports:
- "3100:3100"
env_file:
- .env
environment:
DATABASE_URL: mysql://app:app@mysql:3306/email_forecast
SEED_ON_START: ${SEED_ON_START:-true}
volumes:
- ./data:/app/data
depends_on:
mysql:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
worker:
build: .
# tsx 跑源码,避免 dist 中 @/ 别名未重写导致 MODULE_NOT_FOUND
command: ["pnpm", "exec", "tsx", "src/worker/index.ts"]
env_file:
- .env
environment:
DATABASE_URL: mysql://app:app@mysql:3306/email_forecast
volumes:
- ./data:/app/data
depends_on:
mysql:
condition: service_healthy
restart: unless-stopped
volumes:
mysql_data:

@ -0,0 +1,7 @@
@echo off
REM One-click start (default: local = mysql docker + host pnpm)
REM Avoids Docker Hub pull of node images (often blocked).
REM Full compose: start-system.cmd -Mode compose
cd /d "%~dp0.."
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-system.ps1" %*
if errorlevel 1 pause

@ -0,0 +1,378 @@
# email-forecast one-click start
# Stop old services / free ports, then start stack.
#
# Default: local (mysql container + host pnpm dev/worker)
# - avoids rebuilding web/worker images (Docker Hub often blocked in CN)
# Compose full stack:
# .\docs\start-system.ps1 -Mode compose
#
# NOTE: Keep this file ASCII-only (Windows PowerShell 5.1 + UTF-8 without BOM).
[CmdletBinding()]
param(
[ValidateSet("compose", "local")]
[string]$Mode = "local",
[switch]$NoBuild,
[switch]$NoFallback,
[int]$WebPort = 3100,
[int]$MysqlPort = 7023,
[int]$HealthTimeoutSec = 120
)
$ErrorActionPreference = "Stop"
function Get-RepoRoot {
$here = $PSScriptRoot
if (-not $here) {
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
}
$root = Resolve-Path (Join-Path $here "..")
if (-not (Test-Path (Join-Path $root "package.json"))) {
throw "Repo root not found (missing package.json). Keep this script under docs/."
}
return $root.Path
}
function Write-Step([string]$msg) {
Write-Host ""
Write-Host "==> $msg" -ForegroundColor Cyan
}
function Stop-PortListeners([int]$Port) {
try {
$conns = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
} catch {
$conns = $null
}
if (-not $conns) {
Write-Host " port $Port is free"
return
}
$pids = $conns | Select-Object -ExpandProperty OwningProcess -Unique
foreach ($procId in $pids) {
if (-not $procId -or $procId -eq 0) { continue }
try {
$p = Get-Process -Id $procId -ErrorAction SilentlyContinue
$name = if ($p) { $p.ProcessName } else { "?" }
Write-Host " kill PID=$procId ($name) on port $Port"
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
} catch {
Write-Host " cannot kill PID=$procId : $($_.Exception.Message)" -ForegroundColor Yellow
}
}
Start-Sleep -Seconds 1
}
function Stop-NodeDevProcesses {
$markers = @(
"next dev",
"next start",
"src/worker/index.ts",
"src\worker\index.ts",
"email-forecast"
)
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
$cmd = $_.CommandLine
if (-not $cmd) { return $false }
if ($cmd -notmatch "node|tsx|pnpm") { return $false }
foreach ($m in $markers) {
if ($cmd -like "*$m*") { return $true }
}
return $false
} |
ForEach-Object {
Write-Host " kill node-related PID=$($_.ProcessId)"
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
function Ensure-DotEnv([string]$RepoRoot) {
$envFile = Join-Path $RepoRoot ".env"
$example = Join-Path $RepoRoot ".env.example"
if (-not (Test-Path $envFile)) {
if (Test-Path $example) {
Copy-Item $example $envFile
Write-Host " created .env from .env.example"
} else {
Write-Host " WARN: missing .env" -ForegroundColor Yellow
}
}
}
function Wait-HttpOk([string]$Url, [int]$TimeoutSec) {
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
try {
$resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5
if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) {
return $true
}
} catch {
Start-Sleep -Seconds 2
}
}
return $false
}
function Wait-MysqlReady([int]$MysqlPort, [int]$TimeoutSec = 120) {
Write-Step "wait MySQL healthy on port $MysqlPort"
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$portReady = $false
$healthReady = $false
while ((Get-Date) -lt $deadline) {
if (-not $portReady) {
$listening = Get-NetTCPConnection -LocalPort $MysqlPort -State Listen -ErrorAction SilentlyContinue
if ($listening) {
$portReady = $true
Write-Host " port $MysqlPort listening"
}
}
# Prefer compose healthcheck (accepts connections + auth), not just TCP
$status = ""
try {
$cid = (& docker compose ps -q mysql 2>$null | Select-Object -First 1)
if ($cid) {
$status = (& docker inspect -f "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" $cid 2>$null)
if ($status) { $status = $status.Trim() }
}
} catch {
$status = ""
}
if ($status -eq "healthy" -or $status -eq "running") {
# "running" without health still needs ping; healthy is enough
if ($status -eq "healthy") {
$healthReady = $true
Write-Host " mysql health=healthy"
break
}
}
# Fallback: mysqladmin inside container (stderr warning must not stop script)
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$null = & docker compose exec -T mysql mysqladmin ping -h 127.0.0.1 -uapp -papp --silent 2>$null
$pingOk = ($LASTEXITCODE -eq 0)
$ErrorActionPreference = $prevEap
if ($pingOk) {
$healthReady = $true
Write-Host " mysqladmin ping ok"
break
}
Start-Sleep -Seconds 2
}
if (-not $portReady) {
throw "MySQL port $MysqlPort not listening"
}
if (-not $healthReady) {
throw "MySQL not healthy within ${TimeoutSec}s (port up but server not ready — prisma P1017)"
}
# Brief settle: fresh container may still drop first connections
Start-Sleep -Seconds 2
}
function Invoke-PrismaDbPushRetry([int]$MaxAttempts = 8) {
Write-Step "prisma db push + seed"
$ok = $false
for ($i = 1; $i -le $MaxAttempts; $i++) {
pnpm exec prisma db push --skip-generate
if ($LASTEXITCODE -eq 0) {
$ok = $true
break
}
Write-Host " prisma db push attempt $i/$MaxAttempts failed (exit=$LASTEXITCODE), retry in 3s..." -ForegroundColor Yellow
Start-Sleep -Seconds 3
}
if (-not $ok) { throw "prisma db push failed after $MaxAttempts attempts" }
pnpm db:seed
if ($LASTEXITCODE -ne 0) { throw "db:seed failed" }
}
function Start-LocalStack {
param(
[string]$RepoRoot,
[int]$WebPort,
[int]$MysqlPort,
[int]$HealthTimeoutSec
)
Write-Step "start mysql container only (no web/worker image build)"
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& docker compose up -d mysql 2>&1 | ForEach-Object { Write-Host $_ }
$upExit = $LASTEXITCODE
$ErrorActionPreference = $prevEap
if ($upExit -ne 0) {
throw @"
mysql start failed (exit=$upExit).
If pull mysql:8.0 also fails, Docker Hub is unreachable.
Fix: configure registry mirror in Docker Desktop, or use a VPN, then:
docker pull mysql:8.0
"@
}
Wait-MysqlReady -MysqlPort $MysqlPort -TimeoutSec 120
$env:DATABASE_URL = "mysql://app:app@localhost:$MysqlPort/email_forecast"
Invoke-PrismaDbPushRetry -MaxAttempts 8
$logDir = Join-Path $RepoRoot "data\logs"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
# Start-Process forbids the same path for stdout and stderr
$webOut = Join-Path $logDir "web-dev.out.log"
$webErr = Join-Path $logDir "web-dev.err.log"
$workerOut = Join-Path $logDir "worker.out.log"
$workerErr = Join-Path $logDir "worker.err.log"
Write-Step "start pnpm dev:web / pnpm worker (host Node, no Docker Hub)"
foreach ($f in @($webOut, $webErr, $workerOut, $workerErr)) {
if (Test-Path $f) { Remove-Item $f -Force -ErrorAction SilentlyContinue }
}
# Turbopack cache mixed with webpack causes /_next/_next/static/chunks 404 + ChunkLoadError
$nextDir = Join-Path $RepoRoot ".next"
$turboMarker = Join-Path $nextDir "static\chunks"
if ((Test-Path $nextDir) -and (Get-ChildItem $turboMarker -Filter "turbopack-*" -ErrorAction SilentlyContinue)) {
Remove-Item $nextDir -Recurse -Force -ErrorAction SilentlyContinue
Write-Host " cleared stale Turbopack .next cache"
}
# Prefer cmd.exe wrapper: pnpm is often a .CMD shim; Start-Process + redirect is flaky on shims
$devArgs = "/c pnpm run dev:web > `"$webOut`" 2> `"$webErr`""
$workerArgs = "/c pnpm worker > `"$workerOut`" 2> `"$workerErr`""
Start-Process -FilePath "cmd.exe" -ArgumentList $devArgs -WorkingDirectory $RepoRoot -WindowStyle Hidden
Start-Process -FilePath "cmd.exe" -ArgumentList $workerArgs -WorkingDirectory $RepoRoot -WindowStyle Hidden
Write-Step "wait health http://127.0.0.1:$WebPort/api/health"
$ok = Wait-HttpOk -Url "http://127.0.0.1:$WebPort/api/health" -TimeoutSec $HealthTimeoutSec
if (-not $ok) {
Write-Host "health check timeout. logs:" -ForegroundColor Yellow
Write-Host " $webOut / $webErr"
Write-Host " $workerOut / $workerErr"
exit 2
}
# Dev 按需编译:登录后再预热运营页,否则 /mails 会落到登录页、切模块仍要编译 10s
Write-Step "warmup Next.js routes (first compile)"
$webSession = $null
$adminUser = if ($env:APP_ADMIN_USER) { $env:APP_ADMIN_USER } else { "admin" }
$adminPass = if ($env:APP_ADMIN_PASS) { $env:APP_ADMIN_PASS } else { "admin123" }
try {
$loginJson = (@{ username = $adminUser; password = $adminPass } | ConvertTo-Json -Compress)
$null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort/api/auth/login" -Method POST -Body $loginJson -ContentType "application/json" -UseBasicParsing -TimeoutSec 60 -SessionVariable webSession
Write-Host " warmed login session"
} catch {
Write-Host " login warmup skipped (pages still compile on first click)"
}
$warmupPaths = @(
"/login",
"/mails",
"/logs",
"/settings",
"/api/mails",
"/api/imports",
"/api/settings/mailboxes",
"/api/settings/oauth",
"/api/settings/cc",
"/api/audits"
)
foreach ($p in $warmupPaths) {
try {
if ($webSession) {
$null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort$p" -UseBasicParsing -TimeoutSec 180 -WebSession $webSession
} else {
$null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort$p" -UseBasicParsing -TimeoutSec 180
}
Write-Host " warmed $p"
} catch {
Write-Host " warmup $p done (status may be non-200)"
}
}
Write-Host " web log: $webOut / $webErr"
Write-Host " worker log: $workerOut / $workerErr"
}
function Start-ComposeStack {
param(
[switch]$NoBuild,
[int]$WebPort,
[int]$HealthTimeoutSec
)
Write-Step "start compose (mysql + web + worker images)"
if ($NoBuild) {
docker compose up -d
} else {
docker compose up --build -d
}
if ($LASTEXITCODE -ne 0) {
throw "docker compose up failed (exit=$LASTEXITCODE)"
}
Write-Step "wait health http://127.0.0.1:$WebPort/api/health"
$ok = Wait-HttpOk -Url "http://127.0.0.1:$WebPort/api/health" -TimeoutSec $HealthTimeoutSec
docker compose ps
if (-not $ok) {
Write-Host "health check timeout. run: pnpm compose:logs" -ForegroundColor Yellow
exit 2
}
}
# ---------- main ----------
$Root = Get-RepoRoot
Set-Location $Root
Write-Host "repo: $Root"
Write-Host "mode: $Mode (default local = no node image pull)"
Write-Step "stop old services / free ports"
try {
# stop web/worker containers but keep volume; full down then local mysql up is ok
docker compose -f (Join-Path $Root "docker-compose.yml") down --remove-orphans 2>$null | Out-Null
Write-Host " docker compose down done"
} catch {
Write-Host " docker compose down skipped: $($_.Exception.Message)" -ForegroundColor Yellow
}
Stop-NodeDevProcesses
Stop-PortListeners -Port $WebPort
# do not kill 7023 if we will reuse mysql quickly; still free stale non-docker holders
Stop-PortListeners -Port $MysqlPort
Ensure-DotEnv -RepoRoot $Root
if ($Mode -eq "compose") {
try {
Start-ComposeStack -NoBuild:$NoBuild -WebPort $WebPort -HealthTimeoutSec $HealthTimeoutSec
} catch {
Write-Host ""
Write-Host "compose failed: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host "Likely cause: cannot reach registry-1.docker.io (node image pull)." -ForegroundColor Yellow
if ($NoFallback) { throw }
Write-Host "Auto-fallback to local mode (mysql + host pnpm)..." -ForegroundColor Cyan
Start-LocalStack -RepoRoot $Root -WebPort $WebPort -MysqlPort $MysqlPort -HealthTimeoutSec $HealthTimeoutSec
}
} else {
Start-LocalStack -RepoRoot $Root -WebPort $WebPort -MysqlPort $MysqlPort -HealthTimeoutSec $HealthTimeoutSec
}
Write-Host ""
Write-Host "OK - system started (web + worker)" -ForegroundColor Green
Write-Host " open: http://localhost:$WebPort"
Write-Host " login: admin/admin123 or ops/ops123"
Write-Host " worker: IMAP poll / parse / retention (see data\logs\worker.*.log in local mode)"
Write-Host " stop local: kill node on port $WebPort (and worker); docker compose stop mysql"
Write-Host " full compose (needs Docker Hub): .\docs\start-system.ps1 -Mode compose"
Write-Host " clear imap lock: pnpm exec tsx scripts/clear-imap-lock.ts"

@ -0,0 +1,435 @@
# 邮箱项目(邮件自动预报)
> 给接手同事看的入口文档。细则以 `docx/` 下 PRD / 技术设计 / 韧性为准;冲突时以 PRD 为准。
> **更新日期**:2026-08-04(补充开发注意点 + 上市/生产安全清单)
---
## 1. 项目信息
| 项 | 说明 |
|---|---|
| 名称 | 邮件自动预报系统(repo: `email-forecast`) |
| 目标 | 从绑定邮箱 IMAP(多箱/IDLE/OAuth|授权码)拉取 → 解析/分类 → **NEW 人工确认 SaveContainer**;DO/转仓/工单等走对应确认或指令写能力 |
| **不做**(一期) | AI 客服、SMTP 自动回信客户、与 CC 双向全量同步、真实大模型识别(指令拆分为词表/规则) |
| 栈 | Next.js 15 App Router · Prisma 5 · MySQL 8 · Ant Design 5 · TypeScript · pnpm |
| 进程 | `web`(UI+API+ConfirmImport)· `worker`(IMAP+解析+reaper+补偿+retention)· `mysql` |
| 本地入口 | http://localhost:3100 |
| 默认账号(**仅本地开发**) | `admin` / `admin123`(Admin);`ops` / `ops123`(运营) |
| MySQL 映射 | 宿主机 **7023** → 容器 3306(避让本机 3306) |
| 包管理 | **仅 pnpm**(不要 npm/yarn) |
> ⚠️ 生产环境 **禁止** 使用默认口令与默认 `SESSION_SECRET`;`NODE_ENV=production` 时弱口令会 **拒绝启动**。详见 [§10 上市注意点](#10-上市生产注意点)。
### 1.1 路由
| 路径 | 说明 |
|---|---|
| `/login` | 登录 |
| `/mails` | 邮件列表 |
| `/mails/[id]` | 详情(重新解析 / Admin 改类型) |
| `/mails/[id]/confirm` | 确认导入(预报类:有效货件 + 可确认状态;含部分 DO 路径) |
| `/logs` | 导入日志;拉取记录 |
| `/settings` | Admin:自动拉取间隔/过滤、邮箱绑定、CC、OAuth |
### 1.2 设计文档索引
| 文档 | 路径 |
|---|---|
| 需求规格(PRD) | `docx/需求规格-邮件自动预报-v0.2.md` |
| 技术设计 | `docx/技术设计方案-邮件自动预报-v1.0.md` |
| UI/UX | `docx/产品UI-UX设计方案-邮件自动预报-v1.0.md` |
| 异常与韧性 | `docx/异常场景与系统韧性设计.md` |
| 运营 SOP | `docx/运营SOP-邮件自动预报.md` |
| 产品规则(指令拆分) | `docx/产品规则-邮件指令识别与拆分.md` |
| CC 接口 V1.72 | `docx/接口/carriercentral客户端通用接口V1.72.md` |
| 实施计划(Cursor) | `.cursor/rules/implementation-plan.mdc` |
| 快速 README | `README.md` |
---
## 2. 常用命令
### 2.1 日常开发
```bash
pnpm install
cp .env.example .env # 首次;DATABASE_URL 指向 localhost:7023;无 CC 账号时 CC_MOCK=true
docker compose up -d mysql
pnpm exec prisma db push
pnpm db:seed # 仅账号
pnpm dev # Web :3100 + Worker(IMAP 自动拉取)一起启
pnpm dev:web # 仅 Web(不拉信)
pnpm worker # 仅 Worker(IMAP / 解析 / 回收 / 补偿)
```
### 2.2 一键三件套
```bash
pnpm compose:up # mysql + web + worker
pnpm compose:ps
pnpm compose:logs
```
**Windows 一键启停(先杀旧进程/端口再启动,防地址占用):**
```powershell
# 默认 local:只起 mysql 容器 + 本机 pnpm dev/worker(不拉 node 镜像,避开 Docker Hub)
.\docs\start-system.ps1
.\docs\start-system.cmd
# 全量 compose(需能访问 registry-1.docker.io)
.\docs\start-system.ps1 -Mode compose
.\docs\start-system.ps1 -Mode compose -NoBuild
# compose 失败默认会自动 fallback 到 local;禁止回退加 -NoFallback
```
Docker Hub 超时(`registry-1.docker.io` / `node:*-slim`)时用默认 local 即可;Dockerfile 基底已改为本地常见的 `node:20-bookworm`。
### 2.3 数据库
```bash
pnpm db:generate
pnpm db:push
pnpm db:migrate:dev # 开发迁移
pnpm db:migrate # 部署 migrate deploy
pnpm db:seed
```
### 2.4 IMAP / CC / 运维
```bash
pnpm imap:smoke # 连通性(.env 回退凭证)
pnpm imap:poll # 拉一轮(优先设置页 mailbox_account)
pnpm exec tsx scripts/clear-imap-lock.ts # 清残留 imap_poll 锁(lock_busy)
pnpm cc:smoke # 跟随当前配置(默认 mock ≠ TEST 验收)
pnpm cc:smoke -- --mode=mock
pnpm cc:smoke -- --mode=live # 真烟雾:关 Mock + CC 账号
pnpm retention:cleanup
pnpm retention:cleanup -- --dry-run
```
### 2.5 测试与构建
```bash
pnpm test # vitest(含安全路径 tests/unit/security-hardening.test.ts)
pnpm test:watch
pnpm test:e2e # playwright(需先起服务)
pnpm lint
pnpm build
```
### 2.6 默认环境要点
```env
DATABASE_URL=mysql://app:app@localhost:7023/email_forecast
CC_MOCK=true
POLL_INTERVAL_MS=1800000
IMAP_LOOKBACK_DAYS=3
ENABLE_TYPE_OVERRIDE=true
ENABLE_FORCE_IMPORT=false
SESSION_SECRET=change-me-to-a-long-random-string-at-least-32-chars # 生产必须换强随机 ≥32
```
IMAP / CC 凭证:**优先设置页 DB 配置**,无则回退 `.env`。
拉取间隔:**设置 → 自动拉取**(`imap_settings`,默认 30min,3min~7d)优先于 `POLL_INTERVAL_MS`。
---
## 3. 技术方案(摘要)
### 3.1 职责拆分
```
imap.qq.com ──► worker(ImapPoller → Snapshot → ParsePipeline) ──► MySQL
▲
运营浏览器 ──► web(/api + ConfirmImport → CC SaveContainer) ────────┘
```
| 进程 | 允许 | **禁止** |
|---|---|---|
| `web` | UI、API、确认导入、调 CC、设置 | 长期 IMAP 轮询主循环(可 Admin 手动拉一轮) |
| `worker` | IMAP 拉取、解析、stale reaper、补偿 due、retention、受限自动写(见能力表) | **业务 SaveContainer(新增预报)必须在 web 人工确认后调用** |
### 3.2 邮件主路径
1. Worker 按 **可配间隔**(默认 30min,设置页 3min~7d)轮询;Admin「立即拉取」可手动
2. Lookback:`SINCE IMAP_LOOKBACK_DAYS`(默认 3 天,已读+未读)
3. **过滤**:黑名单拒绝 → 发件人白名单必拉 → 关键词命中才拉;三列表皆空则回退业务相关兜底
4. 幂等:`message_id` / `folder+uid` / `raw_hash`;入库后标 `\Seen`;每封写 `imap_pull_log`(最多 1000)
5. `ParsePipeline`:分类 → 解 xlsx/csv/zip → 柜头/货件
6. 预报类有效货件 ≥1 → `PENDING_CONFIRM` → 确认页 → CC `SaveContainer`
### 3.3 状态机(邮件)
常用:`FETCHED` → `PARSING` → `PARSED` | `PENDING_CONFIRM` | `PARSE_FAILED` | `REJECTED_VALIDATION`
导入:`PENDING_CONFIRM` → `IMPORTING` → `SUCCESS` | `PARTIAL_SUCCESS` | `FAILED`
重新解析禁止:`IMPORTING` | `SUCCESS` | `PARTIAL_SUCCESS`
非法迁移抛 `IllegalTransition`;改状态必须走 `assertTransition`。
### 3.4 命名锁
- `imap_poll` / `cc_login`:MySQL `GET_LOCK`
- **必须**经 `src/services/db-lock.ts` 独立连接 acquire/release(Prisma 连接池会导致锁泄漏 → 永久 `lock_busy`)
- Worker 启动会清残留 `imap_poll` 锁
### 3.5 关键目录
```
src/app/ # Next App Router(页面 + API)
src/components/ # UI
src/services/imap/ # 拉取 / lookback / snapshot
src/services/parse/ # 分类 / 卡派 / pipeline
src/services/import/ # 确认导入 / 补偿
src/services/cc/ # CC HTTP / auth / mock / 写能力
src/lib/ # env / session / safe-path / rate-limit / cc-api-base
src/worker/ # 常驻 worker 入口
prisma/ # schema + seed
docx/ # 需求与设计(权威)
docs/ # 协作入口文档(本文)
```
### 3.6 CC 写能力(易踩坑)
表 `cc_write_capability`(启动 `ensureCcWriteCapabilities`):
| id | 默认 mode | 说明 |
|---|---|---|
| `save_container` | live | V1.72 有正式接口;确认导入路径 |
| `do_upload` | live | `SaveFieldValue` + `annexes/upload`;list 验真接口文档未单列,为客户端同源 |
| `transfer` / `batch_transfer` / `hold_split` / `label` / `customer_message` | **mock** | 多数 **不在 V1.72 正式表**;切 live 前必须对方确认 endpoint + 联调 |
`CC_MOCK=true` 或设置页 Mock:**所有写视为 mock,不能当 TEST/上线验收**。
---
## 4. 编码规范
1. **语言**:TypeScript strict;API 入参用 `zod`;日志用 `pino`。
2. **改动范围**:只改任务相关文件;禁止顺手大重构、无关格式化。
3. **兼容**:保持现有状态机、API 契约、Prisma 字段语义;破坏性变更先改 `docx` 再改代码。
4. **UI**:运营后台沿用 Antd 5;确认页大表用 TanStack Virtual;中文文案集中 `src/constants/ui-copy.ts`。
5. **Diff 优先**:补丁级修改;删除代码要确认无引用。
6. **测试**:状态机 / 分类 / 解析 / 锁 / 安全路径变更需补或更新 `tests/unit`;关键路径跑 `pnpm test`。
7. **密钥**:不提交真实 IMAP 授权码、CC 密码;用设置页或本地 `.env`(已 gitignore)。
8. **包管理**:只用 `pnpm`;compose worker 用 `tsx` 跑源码(避免 `dist` 里 `@/` 别名未重写)。
9. **提交**:不擅自 `git commit` / `push`;用户明确要求再建提交。
10. **文档**:改行为后同步本文件或对应 `docx`;Cursor 进度记在 `implementation-plan.mdc`(短摘要 + Done)。
11. **读附件/快照路径**:统一 `src/lib/safe-path.ts`(`resolveDataFile` / `assertUnderRoot`),禁止 `path.startsWith(dataRoot)` 自行拼装。
12. **导入柜头覆盖**:客户端字段须进 `pickContainerHeaderPatch` 白名单(`confirm.ts`),禁止 `z.record` 原样 merge 进 CC。
---
## 5. 红线(绝对不能违反)
| # | 红线 |
|---|---|
| 1 | **Worker 禁止承担新增预报 SaveContainer**(导入只在 web ConfirmImport) |
| 2 | **禁止绕过状态机**非法迁移(含直接改库「修好」状态) |
| 3 | **禁止用 Prisma 连接池直接 GET_LOCK/RELEASE_LOCK**(必须用 `db-lock` 同源连接) |
| 4 | **mock ≠ TEST 验收**:`CC_MOCK=true` / 设置页 Mock 开着时的「成功」不能当上线门禁 |
| 5 | **预报导入走确认页门禁**;指令类/转仓等不可冒充「新增柜」乱走 SaveContainer |
| 6 | **幂等不可丢**:同一邮件不得因重拉产生重复业务柜(依赖 message_id / uid / raw_hash) |
| 7 | **zip 只解一层**;路径穿越条目丢弃;解压后体积受 `ATTACHMENT_MAX_BYTES` 约束 |
| 8 | **冲突柜默认阻断**;强制跳过仅 Admin + `ENABLE_FORCE_IMPORT`,且必须写审计 |
| 9 | **先落库再 `\Seen`**;解析失败仍可「重新解析」,但不得在 `IMPORTING/SUCCESS/PARTIAL_SUCCESS` 重解析 |
| 10 | **不擅自改端口约定**:本地 MySQL 对外 **7023**;Web **3100** |
| 11 | **不把密钥写进仓库**;不在日志打印完整密码/授权码/Session/CC token |
| 12 | SMTP/AI 客服仍不做;OCR/IDLE/OAuth/多邮箱等变更须书面确认后改 PRD |
| 13 | **生产禁止默认口令与默认 SESSION_SECRET**(启动校验) |
| 14 | **附件落盘路径必须在 `data/` 下**;API 下载禁止任意绝对路径读盘 |
违反以上任一条视为事故级改动,须回滚或补审计与文档后再合入。
---
## 6. 排障速查
| 现象 | 处理 |
|---|---|
| 跳过拉取 `lock_busy` | `pnpm exec tsx scripts/clear-imap-lock.ts`;重启 worker;确认无多实例互抢 |
| 拉取成功列表无信 | 看 Toast「新入库 / 候选」;候选 0 = lookback 内无新信或已入库;点刷新;确认 `pnpm worker` 在跑 |
| IMAP 未配置 | Admin → `/settings` 绑定邮箱 + 测试连接 |
| 解析失败 | 详情「重新解析」;看 `last_error`;对照韧性文档错误码 |
| 不能确认导入 | 状态/类型门禁不满足(见门禁逻辑 `forecast-confirm-gate`);有效货件 ≥1 |
| CC 导入失败 | `/logs`;补偿 ≤3;审计 `/logs?tab=audit` |
| compose worker 起不来 | 看日志是否 `@/` MODULE_NOT_FOUND;应用 `tsx src/worker/index.ts` |
| production 起不来 Invalid environment (production security) | 换强 `SESSION_SECRET` + 非弱 `APP_*_PASS`,admin/ops 口令不可相同 |
| 登录 429 | 同 IP+用户名 1 分钟过多失败;稍后再试或换源 IP |
| 设置 CC 提示 HOST_NOT_ALLOWED | `api_base` 仅允许 `*.carriercentral.vip` / localhost / 与 env `CC_API_BASE` 同主机(防 SSRF) |
| OAuth 回调 `bad_state` | state 15 分钟有效,须从本站 Admin 点「绑定」发起;勿复用旧链接 |
---
## 7. 角色与验收提示
| 角色 | 能力 |
|---|---|
| ops | 列表/详情/确认导入/重新解析/附件下载 |
| admin | 上述 + 改类型、设置、立即拉取、审计、FORCE 导入(开关开启时)、忽略邮件 |
**一期验收要点(摘要)**
- 列表有 IMAP 真信;详情可确认导入
- 新增预报 + 清单可进确认导入;勾选行与请求体一致
- CC live 烟雾(非 mock)`customerLogin` → SaveContainer
- 冲突阻断;失败可重试/补偿;审计可查
- 生产环境可启动(密钥已换)、登录与导入有限流、附件只能在 data 根下
---
## 8. 开发注意点(日常易踩)
### 8.1 起环境
| 注意 | 说明 |
|---|---|
| **必须起 worker** | `pnpm dev` 已默认连带 worker;若只用 `pnpm dev:web` 则不拉信、不解析 |
| **MySQL 端口 7023** | `DATABASE_URL` 写错成 3306 会连到别的本机实例 |
| **CC 无账号先 Mock** | `CC_MOCK=true` 可联调 UI;有 demovip/test 账号再关 Mock |
| **多开 worker** | 同库多实例会抢 `imap_poll`(一得锁、一 `lock_busy`);一般只保留一个 worker |
| **Windows 路径** | 附件 path 入库用 `/`;读盘一律走 `resolveDataFile`,不要自己 `join` absolute 逃逸 |
### 8.2 业务逻辑
| 注意 | 说明 |
|---|---|
| **确认门禁** | 以 `canEnterForecastConfirm` / 状态机为准,不是「邮件存在即可点导入」 |
| **version / 幂等** | 导入带 `version` + `idempotency_key`;并发第二请求可能 `VERSION_CONFLICT` / 直接返回已成功 |
| **渠道未映射** | 有 `CHANNEL_UNMAPPED` 须 `ack_unmapped_channels`,否则拒绝 |
| **强制跳冲突** | 须 Admin + `ENABLE_FORCE_IMPORT=true`,默认关 |
| **指令识别无 LLM** | `instruction-lexicon` / `split-instructions`;改规则先对齐 `docx/产品规则-*.md` |
| **OCR** | `OCR_PROVIDER=local` 为启发式兜底,**不是**商用识别;`aliyun` 现为 pending SDK,不能写「已接阿里云 OCR」上线文案 |
| **自动执行** | 新增预报 / DO / 转仓主路径 **人工确认** 优先;`ENABLE_AUTO_EXEC_*` 与 `cc_write_capability` 双重约束;默认 mock endpoint 不等于 CC 真有该接口 |
### 8.3 安全相关(开发也要守)
| 注意 | 说明 |
|---|---|
| 登录限流 | 10 次/分钟/IP+用户名(`/api/auth/login`) |
| 导入限流 | 10 次/分钟/登录用户(`/api/mails/:id/import`) |
| 限流实现 | 进程内 Map,**多副本不共享**;要严格限流需前置网关 |
| 健康检查 | `/api/health` **无鉴权**(docker healthcheck 依赖);勿往 response 里塞密钥 |
| 会话 | `iron-session` + Cookie HttpOnly;改 `SESSION_SECRET` 会使所有旧会话失效 |
| 邮箱密码 | DB 中 AES-GCM,密钥派生自 `SESSION_SECRET`——**换 SESSION_SECRET 后旧邮箱密文无法解密,需重新保存密码** |
| OAuth | Admin 发起;state 含 actor + exp;回调无登录 Cookie 也可完成绑定(依赖 state 签名) |
### 8.4 改 CC / 接口
| 注意 | 说明 |
|---|---|
| **以 V1.72 为契约底** | `customerLogin` / `GetShippingLineList`(GET) / `SaveContainer` / `SaveFieldValue` / `annexes/upload` |
| **文档外 endpoint** | 转仓/工单等默认 mock;live 前写清路径与验收入库 |
| **token 可出现在 query** | GET 船司列表等;日志/反向代理勿明文长期存 query log |
| **CC 密码** | 协议要求 MD5;设置页传明文则服务端 md5 后存用 |
| **改 api_base** | 受主机白名单约束;自定义域名须 `.env` `CC_API_BASE` 同主机才能保存 |
### 8.5 测试建议(改完要跑)
```bash
pnpm test # 全量单测
pnpm exec vitest run tests/unit/security-hardening.test.ts
pnpm cc:smoke -- --mode=mock # UI 联调
pnpm cc:smoke -- --mode=live # 有真账号时
```
---
## 9. 上市 / 生产注意点
上线前把下表当 **检查清单** 勾选;未勾完不算可对客。
### 9.1 必改环境变量
| 变量 | 要求 |
|---|---|
| `NODE_ENV` | `production` |
| `SESSION_SECRET` | **≥32 位高强度随机**;禁止 example 占位串 |
| `APP_ADMIN_PASS` / `APP_OPS_PASS` | 强密码;互不相同;禁止 `admin123`/`ops123`/`password`/`123456` |
| `DATABASE_URL` | 生产 MySQL;账号最小权限;勿把开发 7023 写进生产 |
| `CC_MOCK` | 正式业务 **false**;且设置页 Mock 关闭 |
| `CC_API_BASE` / `CC_SAAS_HEADER` / `CC_LOGIN_MARK` | 生产租户正式地址与 Saas 头(非随意 test 残留) |
| `CC_*` 账号 | 生产只读/业务约定账号;轮换策略由运维定 |
| `OAUTH_PUBLIC_BASE_URL` | **公网 HTTPS 根**,与 OAuth 控制台回调一致 |
| `OAUTH_*_CLIENT_*` | 仅在开启 Gmail/MS 绑定时配置;密钥不入库明文日志 |
| `ENABLE_FORCE_IMPORT` | 生产默认 **false**;仅紧急支持场景临时开 |
| `ENABLE_AUTO_EXEC_*` | 生产按业务决定;无 live endpoint 前保持关或 capability mock |
| `ATTACHMENT_MAX_BYTES` / shipment 上下限 | 按磁盘与 CC 限制校准 |
### 9.2 基础设施
| 项 | 建议 |
|---|---|
| 进程 | `web` + `worker` 都要存活;worker 挂了只读历史、不进新信 |
| 实例数 | IMAP 锁全局一把:**worker 建议单副本**(或明确主备切换) |
| 数据盘 | `./data`(或挂载卷)持久化:eml、附件、`imap-runtime.json`;**勿用无状态容器丢盘** |
| 备份 | MySQL + `data/mails` 同步备份策略 |
| HTTPS | 生产强制 HTTPS;Cookie `secure` 在 production 已开启 |
| 反向代理 | 若记 access log,注意 CC token 可能在 query;脱敏或关闭详细 query 日志 |
| 健康检查 | 用 `/api/health` 即可;监控 mysql / imap_sync_alert |
| 对外暴露 | 运营后台勿裸奔公网无 VPN;登录限流不替代 WAF/零信任 |
### 9.3 安全与合规
| 项 | 说明 |
|---|---|
| 启动硬校验 | 生产弱密钥 **直接拒绝启动**(见 `src/lib/env.ts`) |
| 路径安全 | 下载/解析只读 `data/` 下相对路径;防 `../` 与 `data` 前缀绕过 |
| 登录/导入限流 | 单机有效;多副本需 LB 层限流 |
| 角色 | 仅 admin/ops 两账号模型;**不是多租户**;泄露一账号即全站业务数据 |
| 审计 | 导入成功/失败、FORCE、邮箱绑定、CC 设置、OAuth 绑定须可查 |
| 保留期 | `DATA_RETENTION_DAYS` + worker retention;合规要求则加大或关闭清理并外备 |
| 第三方邮件 | 授权码/OAuth 属客户邮箱权限;SOP 告知运营勿绑个人箱当生产总线 |
### 9.4 业务上线闸门(建议顺序)
1. **配置**:生产 env + 强密钥 + `CC_MOCK=false`
2. **库**:`pnpm db:migrate`(或等价 migrate deploy);**不要**用生产库跑 seed 默认弱口令覆盖
3. **IMAP**:设置页绑定业务邮箱并「测试连接」;确认 lookback/filter 符合运营预期
4. **CC live**:`pnpm cc:smoke -- --mode=live` 或设置页「测试连接」
5. **抽样邮件**:真拉一封预报类 → 确认导入一柜(低风险测试数据)→ 查 CC 柜与审计
6. **补偿与冲突**:人为失败/冲突场景各看一次 `/logs`
7. **监控**:worker 连续失败告警、`imap_sync_alert`、磁盘水位、MySQL 连接
8. **回滚预案**:保留上一镜像 + DB 备份;知悉换 `SESSION_SECRET` 会登出全员并可能需重配邮箱密码
### 9.5 明确不要当作上市完成的事项
- 仅 Mock 绿色通过
- 仅 `customerLogin` 成功、未做过 SaveContainer
- OCR local 启发式「识别准确」宣传
- 文档外写接口未 live 联调就打开自动执行
- 开发默认口令仍在生产 `.env`
- 只部署 web 未部署 worker
---
## 10. 安全加固摘要(实现侧,便于 Code Review)
下列已在代码侧落地,改相关模块时请保持不退化:
| 能力 | 位置(参考) |
|---|---|
| 数据区路径约束 | `src/lib/safe-path.ts` |
| CC api_base 主机约束 | `src/lib/cc-api-base.ts` + `api/settings/cc` |
| 生产弱密钥拒绝启动 | `src/lib/env.ts` |
| 登录/滑动窗口限流 | `src/lib/rate-limit.ts` + `api/auth/login` |
| OAuth state HMAC+TTL+actor | `src/services/oauth/providers.ts` |
| DO 上传 410 最多 1 次重试 | `src/services/cc/do-upload.ts` |
| 柜头字段白名单 merge | `src/services/import/confirm.ts` `pickContainerHeaderPatch` |
| Zip 解压体积上限 | `src/services/parse/zip-extract.ts` |
| 单测 | `tests/unit/security-hardening.test.ts` |
---
## 11. 联系与变更
- 需求/行为变更:先改 `docx/需求规格-*.md` 与韧性文档,再改代码。
- 协作入口文档:即本文件 `docs/邮箱项目.md`。
- 开发进度:`.cursor/rules/implementation-plan.mdc`。
- CC 路径/字段:以 `docx/接口/carriercentral客户端通用接口V1.72.md` 为准;缺接口时先 mock + 书面确认。

@ -0,0 +1,452 @@
# 产品UI/UX设计方案 v1.0(邮件自动预报系统)
> 状态:可直接画原型 / 写前端(**2026-07-23 现行口径回写**)
> 依据:`docx/需求规格-邮件自动预报-v0.2.md`、`docx/技术设计方案-邮件自动预报-v1.0.md`
> UI 栈锁定:Ant Design 5 + TanStack Virtual(货件表)+ 本规范 Token
> **回写要点**:`/settings`;`/logs`=导入+拉取(无操作审计);空态无种子;详情业务意图摘要替代得分表;详见 `docx/现行口径-修订说明-v1.md`
---
# 1. 信息架构(Information Architecture)
## 1.1 页面列表
| 页面 | 路由 | 职责 |
|---|---|---|
| 登录页 | `/login` | 账号密码登录;写 session Cookie |
| 邮件列表页 | `/mails` | 时间倒序展示邮件;按类型/状态筛选;进入详情 |
| 邮件详情页 | `/mails/[id]` | 摘要、正文、附件、证据、货件预览;重新解析;Admin 改类型;进入确认导入 |
| 确认导入页 | `/mails/[id]/confirm` | 柜头编辑;TransMode/OperationType;货件勾选(虚拟滚动);二次确认后提交 CC |
| 导入日志/补偿页 | `/logs` | Tab:导入日志、拉取记录;失败重试 |
| 设置 | `/settings` | Admin:邮箱绑定、IMAP 间隔/过滤、CC、OAuth |
| Admin 改类型 | **详情页内 Modal**(非独立路由) | `ENABLE_TYPE_OVERRIDE`(及前端可见开关)+ `role=admin`;一期不写 audit_log |
不设 `/dashboard`、不设 `/admin/override` 独立页。登录后默认落点:`/mails`。
## 1.2 页面关系图
```
/login
→ /mails
→ /mails/[id]
→ /mails/[id]/confirm (mail_type=NEW_CONTAINER 且 status=PENDING_CONFIRM;或 Admin 改类型后满足条件)
→ /logs (提交结束:成功跳转;失败可停留 confirm 或跳 logs)
⇢ Modal「改类型」 (Admin)
⇢ 动作「重新解析」 (PARSE_FAILED / REJECTED_VALIDATION / 允许重解析的状态)
→ /logs (顶栏入口)
```
禁用路径:
- `mail_type ≠ NEW_CONTAINER` 且未改类型成功 → 不可进入 confirm(直链访问重定向详情 + Toast)
- `status ∈ {IMPORTING, SUCCESS}` → confirm 只读或重定向详情
## 1.3 路由结构(Next.js App Router)
```
/login
/mails
/mails/[id]
/mails/[id]/confirm
/logs
```
顶栏全局导航(登录后):`邮件列表` | `日志` | `设置`(Admin) | 健康/IMAP/CC 状态 | `用户名` | `Admin` 徽标 | `退出`
---
# 2. 交互原型(Interaction Design)
## 2.0 全站壳层
```
┌──────────────────────────────────────────────────────────┐
│ TopNav: Logo「邮件预报」 | 导航链接 | 用户/Admin | 退出 │
├──────────────────────────────────────────────────────────┤
│ PageHeader: 标题 + 右侧主操作(按页) │
├──────────────────────────────────────────────────────────┤
│ MainContent(白底 Card 或铺满表) │
└──────────────────────────────────────────────────────────┘
```
- 最小视口宽:1280px
- 内容最大宽:1440px,水平居中
- Toast:右上角;Modal:居中
---
## 2.1 登录页 `/login`
**结构**
- 居中 Card(宽 400px)
- 标题:邮件自动预报
- 字段:用户名、密码
- 主按钮:登录
**交互**
- 提交 → 按钮 loading → 成功跳转 `/mails`;失败 Toast error + 字段下方文案
- 已登录访问 `/login` → 重定向 `/mails`
**空/错**
- 用户名或密码空 → 内联校验,不发请求
---
## 2.2 邮件列表页 `/mails`
**结构**
```
PageHeader: 「邮件列表」
FilterBar: [类型 Select] [状态 Select] [关键词 Input] [查询 Button]
MailTable(非卡片列表,密度优先用表格):
列:类型Tag | 状态Pill | 主题 | 发件人 | 接收时间 | 柜号(若有)
Pagination: 底右,默认 pageSize=20
```
**交互**
- 行点击(整行可点)→ `/mails/[id]`
- 筛选变更 → 立即请求列表(防抖 300ms,关键词)
- 手动「刷新」按钮 → 重新拉列表(无 WebSocket)
**空态**
- 文案:暂无邮件,等待 IMAP 拉取
- 次按钮:刷新
**角标规则**
- 类型:左侧色点 8px + Tag 文字
- 状态:StatusPill 文字(不只靠颜色)
---
## 2.3 邮件详情页 `/mails/[id]`
**结构**
```
PageHeader:
[← 返回列表] 主题(H1) StatusPill TypeTag
右侧操作组:
[重新解析](条件可见)
[改类型](Admin 条件可见)
[确认导入](主按钮,条件启用)
SummaryStrip: 发件人 | 时间 | Message 状态
Tabs 或 纵向区块:
1) 正文摘要(等宽可读,max-height 240px 可滚动)
2) 附件列表:文件名 | 大小 | 下载
3) 业务意图摘要(动作/关键字/操作内容/转仓对;不展示得分分值);type_evidence JSON 可选收起
4) 货件预览表(前 50 行 + 「共 N 行,确认导入页查看全部」链接)
```
**确认导入按钮启用条件(全部满足)**
- `mail_type === NEW_CONTAINER`
- `status === PENDING_CONFIRM`
- `valid_shipment_count >= 1`
**禁用时 Tooltip / 旁注文案(写死)**
- 类型非 NEW:`类型为 {mail_type},一期不可导入`
- 状态非 PENDING_CONFIRM:`当前状态为 {status},不可导入`
- 无有效行:`无有效货件行`
**交互**
- 确认导入 → `/mails/[id]/confirm`
- 重新解析 → POST reparse → 按钮 loading → 成功 Toast → 重新拉详情;失败 Toast
- 改类型 → 打开 `TypeOverrideModal`:
- Select 目标类型
- Input 原因(必填)
- 展示当前 type_evidence 摘要
- 确认 → loading → 审计成功 Toast → 刷新详情(可能出现确认导入可点)
**直链保护**
- 非法 id → 404 页:邮件不存在 + 回列表
---
## 2.4 确认导入页 `/mails/[id]/confirm`(核心)
**结构**
```
PageHeader:
[← 返回详情] 主题截断 | 柜号大号展示
右侧无第二主按钮(主 CTA 在底栏)
Section「柜头」Card 两列表单:
左列: TransMode* | OperationType* | ContainerNo* | CabinetType | Classis
右列: ETD | ETA | ShippingLineId(Select 可搜索,可空)| MemoRemark | Instruction
* 必填;ContainerNo 失焦触发 ISO 校验
Section「货件」Card:
Toolbar: [全选有效] [反选] [SearchInput: FBACode/ShipmentID] [有效 m / 无效 k]
VirtualTable 高度固定 480px:
列: ☑ | # | 仓库ID | 渠道 | 件数 | FBA ID | Ref | ShipmentID | 行状态 | 警告
INVALID 行: 背景 #F3F4F6,勾选 disabled,行状态 Tag=INVALID
StickyFooter(视口底):
左: 已选 n / 有效 m
右: [取消] [确认导入 Primary]
```
**默认值**
- TransMode = 解析推荐或 `0`
- OperationType = 解析推荐或 `0`
- 勾选 = 全部 `VALID` 行
- `CHANNEL_UNMAPPED` 行可勾选,提交前若存在未 ack → 额外确认勾选「已知晓未映射渠道」
**二次确认 Modal**
- 标题:确认导入至 CarrierCentral
- 正文:`将向 CC 导入柜 {container_no},货件 {n} 行`
- 展示 TransMode / OperationType 文案
- 按钮:取消 | 确认提交
**提交交互**
1. Primary 点击 → 打开 Modal(本地校验先过)
2. Modal 确认 → Primary+Modal 双 loading;`disabled`
3. 成功 → Toast success → 跳转 `/logs?mail_id={id}`
4. 失败 → Toast error(展示 `error.message`)→ 留在本页;可再改再提(若 status 已 IMPORTING 则整页只读 + 「导入中,请稍后刷新」)
5. `409 VERSION_CONFLICT` → Toast + 强制刷新页面数据
**校验(内联)**
- TransMode / OperationType 空 → 阻止 Modal
- ContainerNo 空或 ISO 失败 → 字段 Error
- n=0 → Primary disabled,文案:请至少勾选一行有效货件
---
## 2.5 导入日志/补偿页 `/logs`
**结构**
```
PageHeader: 「导入日志」 [返回邮件列表]
Filter: 状态 Select | 柜号/主题 Search
Table 列:
时间 | 邮件主题(链到详情) | 柜号 | 导入状态Pill | external_id | last_error | 操作
操作:
FAILED / TIMEOUT_UNKNOWN / CONFLICT(可重试类) → [重试]
SUCCESS → 显示 external_id,无重试
```
**交互**
- 重试 → 按钮 loading → Toast → 刷新行
- `COMPENSATION_EXHAUSTED` → 重试按钮对普通 ops 隐藏;Admin 显示「重置并重试」
**空态**
- 暂无导入记录
---
## 2.6 统一交互规则(写死)
1. 所有触发写操作的按钮:点击即 `loading` + `disabled`,防重复提交。
2. 所有 API 失败:全局 Toast `error`;表单字段错误额外内联。
3. 所有必填/格式:失焦或提交前校验,不通过不发请求。
4. 导入类写操作:必须二次确认 Modal。
5. 货件表 ≥50 行:必须虚拟滚动(本系统确认页固定启用)。
6. 状态更新:一期无 WS;用户点「刷新」或返回列表重进。
7. IMPORTING 期间:详情/确认页禁止再次提交;展示 Alert「导入进行中」。
8. 色标旁必须有文字标签。
---
# 3. 视觉设计(Visual Design System)
## 3.1 色彩 Token
| Token | Hex | 用途 |
|---|---|---|
| `--color-primary` | `#2563EB` | 主按钮、链接、焦点 |
| `--color-primary-hover` | `#1D4ED8` | hover |
| `--color-success` | `#16A34A` | 成功 |
| `--color-warning` | `#F59E0B` | 警告/部分成功 |
| `--color-error` | `#DC2626` | 失败 |
| `--color-bg` | `#F8FAFC` | 页面底 |
| `--color-surface` | `#FFFFFF` | 卡片/表单 |
| `--color-text` | `#111827` | 主文字 |
| `--color-text-secondary` | `#6B7280` | 辅助 |
| `--color-border` | `#E5E7EB` | 边框 |
| `--color-disabled` | `#9CA3AF` | 禁用 |
**类型色(Tag 背景 / 文字白或深按对比度)**
| mail_type | 色 |
|---|---|
| NEW_CONTAINER | `#2563EB` |
| TRANSFER | `#F59E0B` |
| INSTRUCTION_HOLD_SPLIT | `#8B5CF6` |
| INSTRUCTION_LABEL | `#EC4899` |
| WORK_ORDER | `#0D9488` |
| UNKNOWN | `#6B7280` |
**状态色(StatusPill)**
| status | 色 |
|---|---|
| FETCHED / PARSING / PARSED | `#9CA3AF` |
| PENDING_CONFIRM | `#2563EB` |
| IMPORTING | `#F59E0B` |
| SUCCESS | `#16A34A` |
| PARTIAL_SUCCESS | `#F59E0B` |
| FAILED / PARSE_FAILED / REJECTED_VALIDATION | `#DC2626` |
| IGNORED | `#6B7280` |
## 3.2 字体
- 字体栈:`Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
- H1:24px / 700 / 1.2
- H2:20px / 600 / 1.3
- Body:14px / 400 / 1.5
- Caption:12px / 400 / 1.4
- Small:11px / 400 / 1.3
- 等宽(证据 JSON):`ui-monospace, SFMono-Regular, Menlo, Consolas, monospace` 12px
## 3.3 间距
`4 / 8 / 12 / 16 / 24 / 32 / 48 / 64`(px)
Card 内边距:16
区块间距:24
Filter 与表:16
## 3.4 圆角
| 元素 | 半径 |
|---|---|
| Button / Input | 6px |
| Card | 8px |
| Modal | 12px |
| Tag / Pill | 12px |
## 3.5 阴影
- Card:`0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)`
- Modal:`0 20px 60px rgba(0,0,0,0.15)`
## 3.6 组件视觉规格
| 组件 | 规格 |
|---|---|
| PrimaryButton | bg primary,文字白,h=36,px=16,radius 6;hover primary-hover;loading 内嵌 Spinner |
| SecondaryButton | bg 白,border primary,文字 primary |
| DangerButton | bg error,文字白 |
| Input / Select | h=36,border 1px border-color,focus:ring 2px primary 30% |
| Card | surface + shadow + radius 8 + p16 |
| Tag | h=22,px=8,文字 12px |
| StatusPill | 同 Tag |
| Table header | bg `#F9FAFB`,文字 secondary,字重 600 |
| INVALID 行 | bg `#F3F4F6`,文字 secondary |
| StickyFooter | 白底,上边框 border,h=56,z-index 10 |
| Toast success/error/warning | 对应色左边条 4px |
---
# 4. 设计规范(Design System for AI Coding)
## 4.1 React 组件清单(映射 Antd)
| 设计组件 | 实现映射 |
|---|---|
| `PrimaryButton` | `Button type="primary"` |
| `SecondaryButton` | `Button` |
| `DangerButton` | `Button danger` |
| `InputField` | `Form.Item` + `Input` |
| `SelectField` | `Form.Item` + `Select` |
| `DatePickerField` | `Form.Item` + `DatePicker` |
| `Checkbox` | `Checkbox` |
| `AppCard` | `Card` |
| `MailTable` | `Table` |
| `ShipmentVirtualTable` | 自研:TanStack Virtual + 行渲染 |
| `TypeTag` | `Tag` + 类型色 |
| `StatusPill` | `Tag` + 状态色 |
| `LoadingSpinner` | `Spin` |
| `ErrorBanner` | `Alert type="error"` |
| `AppToast` | `message` / `notification` |
| `ConfirmModal` | `Modal.confirm` 或受控 `Modal` |
| `SearchInput` | `Input.Search` |
| `Pagination` | `Pagination` |
| `TypeOverrideModal` | 受控 `Modal` + Form |
| `TopNav` | `Layout.Header` + `Menu` |
| `EmptyState` | `Empty` |
页面组件命名:
- `LoginPage`
- `MailListPage`
- `MailDetailPage`
- `MailConfirmPage`
- `ImportLogsPage`
## 4.2 组件状态
每个可交互组件支持:`idle | loading | success | error | disabled | empty`(按上下文取用)。
页面级状态机(前端):
- `pageStatus: idle | loading | error`
- 详情另:`actionLoading: reparse | override | none`
- 确认页另:`submitPhase: idle | validating | confirming | submitting`
## 4.3 命名规范
- 组件:PascalCase
- 路由文件:`page.tsx` 于 `app/mails/[id]/confirm/page.tsx`
- 状态布尔:`isLoading` `isSubmitting` `isAdmin`
- 文案常量:`src/constants/ui-copy.ts`
- Token CSS 变量:`--color-*` 写入 `globals.css`
## 4.4 UI 硬约束(编码强制)
1. 写操作按钮必须 `loading` + 防重复。
2. API 失败必须可见错误(禁止静默)。
3. 表单校验失败禁止发请求。
4. 导入提交必须二次确认 Modal。
5. 货件表 ≥50 行必须虚拟滚动(confirm 页强制)。
6. 空列表必须 EmptyState。
7. 桌面优先,min-width 1280。
8. Tag/Pill 必须带文字,不只靠颜色。
9. confirm 直链:服务端/客户端双重校验 NEW + PENDING_CONFIRM。
10. Admin 改类型按钮:仅 `isAdmin && enableTypeOverride` 渲染。
## 4.5 文案常量(摘录)
| key | 中文 |
|---|---|
| `empty.mails` | 暂无邮件,等待 IMAP 拉取 |
| `btn.confirmImport` | 确认导入 |
| `btn.reparse` | 重新解析 |
| `btn.overrideType` | 改类型 |
| `btn.retry` | 重试 |
| `modal.import.title` | 确认导入至 CarrierCentral |
| `modal.import.body` | 将向 CC 导入柜 {no},货件 {n} 行 |
| `disable.notNew` | 类型为 {type},一期不可导入 |
| `disable.badStatus` | 当前状态为 {status},不可导入 |
| `alert.importing` | 导入进行中,请稍后刷新查看结果 |
| `ack.unmapped` | 已知晓存在未映射渠道,仍提交原值 |
## 4.6 线框尺寸速查
| 区域 | 尺寸 |
|---|---|
| TopNav 高 | 56px |
| PageHeader 高 | 64px |
| FilterBar 高 | 48px |
| 确认页 VirtualTable | 高 480px |
| StickyFooter | 高 56px |
| 登录 Card | 宽 400px |
| Modal 宽 | 480px(改类型)/ 520px(导入确认) |
---
# 5. 页面 ↔ API 绑定(供前端直接接线)
| 页面 | 主要 API |
|---|---|
| Login | `POST /api/auth/login` |
| MailList | `GET /api/mails` |
| MailDetail | `GET /api/mails/:id`;`POST .../reparse`;`POST .../type` |
| Confirm | `POST /api/mails/:id/import` |
| Logs | `GET /api/imports`;`POST /api/imports/:id/retry` |
---
# 6. 文档索引
| 文档 | 路径 |
|---|---|
| 本设计 | `docx/产品UI-UX设计方案-邮件自动预报-v1.0.md` |
| PRD | `docx/需求规格-邮件自动预报-v0.2.md` |
| 技术设计 | `docx/技术设计方案-邮件自动预报-v1.0.md` |

@ -0,0 +1,140 @@
# 产品规则:邮件指令识别与拆分(锁定口径)
> **地位**:本页为指令「怎么认、怎么拆、谁可确认」的**产品真源**之一。
> **实现真源**:`src/services/parse/instruction-lexicon.ts`、`split-instructions.ts`、`classify.ts`。
> **约束**:解析链路**禁止**依赖 LLM / Cursor 临场理解;改规则先改本页 + 词表 + 单测。
> **日期**:2026-08-03
---
## 0. 一句话
主题与正文**都重要、要分开用**;一封多轮邮件里**只有最新一轮指令可确认**;标准四类走业务表单,非标准/软词走「客户指令」;同线程后续回复按**新 UID**再拉,不重做旧信。
---
## 1. 信号源(三者并列,来源可区分)
| 信号源 | 用途 | UI 来源标签 |
|--------|------|-------------|
| **主题** | 柜号/提单/ETA/「新增预报」「DO请查收」等;可单独成指令单元 | 来自主题 |
| **正文** | 按时间轮/转发标签拆段;决定「当前可确认」 | 来自当前正文 / 来自历史引用 |
| **附件名** | 补 DO / 贴标清单等;**不能**仅凭正文「DO也同步上传」造空上传 DO | 来自附件名 |
冲突原则:
1. 正文**最新段**已是硬工单(换标/覆盖贴/贴标/拍照/拦截等)→ **压过**主题链路上历史「新增预报」。
2. **同线程主题未变**(仅 Re:/Fw: 前缀变化)时:后续轮次**只认该时段正文**,不得再用首封主题「新增预报」造当前/补缺预报卡;QQ 转发壳剥壳后的主题残片也不算指令。
3. 主题有、正文完全无指令时 → 才可用主题补缺(只读或非当前)。
4. 正文已覆盖同类型 → 主题**不再重复**出一张一样的卡。
5. 正文写「DO 也同步上传」但本封**无真实 DO 附件** → **不出**空的上传 DO 指令。
---
## 2. 标准四类业务 ↔ UI
| UI kind | 典型关键词(见词表) | 确认动作 |
|---------|----------------------|----------|
| `forecast` | 新增预报、请查收新增预报 | 预报表单(人工确认) |
| `transfer` | 新增转仓、转仓(「不转仓」除外) | 批量转仓 / 到仓后降级工单 |
| `work_order` | 贴标/换标/覆盖贴/操作指令、拍照、**指令性**拦截、快递单号;mail1 仅柜号+预约码 | 客户留言 SaveForm(人工确认) |
| `do_upload` | DO请查收;或真实 `*DO*.pdf` 等 | 上传 DO(人工确认) |
主流程四类均需人工确认,**不**因主题残留「新增预报」自动再写 CC。
> **渠道件数 ≠ 整票指令**:主题里的 `拦截-209件` / `FedEx-29件` 只是渠道汇总;整票真实指令以「拆柜清单更新 / 派送要求」为准。禁止把渠道名写成备注「邮件动作:拦截」。
---
## 3. 客户指令(非标准 / 软词)
下列**不**硬套工单表单,归 `customer_instruction`(只读展示轮次/正文):
- 更新派送单
- 改自提 / 改为仓库自提 / 仓库自提
- 拆柜清单 / 卡转海 / 派送要求
- 按照正常预报接受、帮忙安排派单、安排派单
- 继续暂存 / 暂存等后续
- 有实质内容但无法归入标准四类的段落
---
## 4. 多轮线程拆分与「仅最新可确认」
### 4.1 如何切开
正文按下列边界切段(保留分隔标签到后一段,便于取时间/发件人):
- `在 yyyy-mm-dd … 写道` / `On … wrote:`
- `原始邮件` / `转发的邮件` / `Original Message` / `Original:`
- 发件人+发送时间 等邮件头块
**不用**单独的 `Dear` 作为切段点(避免同轮内误切)。
### 4.2 可确认范围
| 段 | `isCurrent` | 操作 |
|----|-------------|------|
| 最新一条有实质指令的段 | `true` | 可确认提交 |
| 更早的引用轮次(标准四类) | `false` | 只读(如历史预报) |
| 更早的软词「客户指令」 | `false` | **独立时间轮要出卡**:按正常预报接受 / 仓库「好的」 / 无法接收;仅丢掉无时间轮的「更新派送单」噪声 |
| 纯主题补缺单元 | 一般为 `false` | 只读展示线索 |
同轮内允许多种 UI 并存(例如当前换标 + 历史预报只读)。
`work_order_actions` 仅含「转仓」、或正文已拆出转仓/预报等标准单元时,**不得**再补一张工单卡。
历史引用若正文指令相同(同线程连转同一份「拦截 + 改自提」等,仅抄送折行不同)→ **只保留最新一条**,禁止出 2/3/4 张一样的工单卡。
工单备注须列出本段全部动作:硬词(拦截/贴标)与软词(改自提)并存时都写入「邮件动作」,不得只留拦截。
### 4.3 跳过 vs 仍展示的轮次
- **跳过(不出卡)**:QQ 转发壳、`发自我的iPhone`、空的 `Original:`、主题残片
- **仍按时间轮出卡(只读、不可确认)**:仓库 ACK「好的 / 收到」;「无法接收 / 产能有限」;「按照正常预报接受 / 安排派单」
- **同轮内 `====` 分组**(如换标 334 件两段 FBA)**不切开**,仍是这一轮一条工单
---
## 5. 同线程后续指令(拉取幂等)
| 情况 | 行为 |
|------|------|
| 客户在同主题下**新回复**(新 IMAP UID) | **会拉**:新邮件入库、重新拆段;仅最新段可确认 |
| 已入库的旧 UID | **不再拉**(`knownUids` / messageId / rawHash 幂等) |
| 超出 `IMAP_LOOKBACK_DAYS`(默认 3 天)的过旧回复 | 可能拉不到 |
| 新回复未过过滤规则 | 记拉取日志为跳过,不进业务列表 |
旧邮件保持原状态;**不会**因新回复自动把旧预报/旧工单再执行一遍。
---
## 6. 柜头 / 预报相关(与指令并存)
- **提拆派**:预报时 `F_OperationType=0` 表示提拆派服务包,**不是**三个独立管理端「提柜/拆柜/派送」界面。
- **mail1 类**(仅柜号+预约码、无标准关键词):`classify` → `WORK_ORDER`;详情用 `work_order_actions` 补当前工单单元(见 `extractMailInstructions({ workOrderActions })`)。
- 模板表尾「注意:…」是填写说明,**不是**货件行。
- 卡派清单列:附件有则填;UI **隐藏整列皆空**的 FormOrder 列,避免满屏「-」。
- 工单留言内容:取当前指令段,并从 `Dear` / `原箱号` 起,去掉转发头;**禁止**用整封线程正文盖住分段留言。
---
## 7. 实现与测试对照表
| 规则 | 代码 | 单测(示例) |
|------|------|----------------|
| 词表 / 软硬分流 | `instruction-lexicon.ts` | `mail4-time-rounds.test.ts` |
| 拆段 / 最新可确认 / 主题补缺 | `split-instructions.ts` | `split-instructions.test.ts`、`mail4-time-rounds` |
| 正文硬工单压过主题预报 | `classify.ts` | `classify.test.ts`、`instruction-product-rules` |
| 线程拆单(Message 级) | `thread.ts` + poller | `thread.test.ts` |
| UID 幂等拉取 | `poller.ts` `knownUids` | 集成/烟雾 |
| 空 DO 不出指令 | `split-instructions` + `do-upload-extract` | `mail4-time-rounds`、`do-upload-extract` |
| 工单留言清洗 | `CcWorkOrderForm` `extractWorkOrderMessageBody` | `four-business.test.ts` |
改口径流程:**改本页 → 改词表/拆分 → 改/补单测 → `pnpm test` 相关文件通过**。
---
## 8. 与旧文档关系
- `docx/需求规格-邮件自动预报-v0.2.md` §4 类型打分仍有效;**本页补充**「拆分 / 客户指令 / 主题正文分工 / 仅最新确认」。
- 若与旧表述冲突,**以本页 + 上述实现文件为准**,并回写需求规格对应章节。
- Cursor 对话纪要**不是**运行时真源。

@ -0,0 +1,572 @@
# 异常场景与系统韧性设计(邮件自动预报系统专用)
> 本章为 `docx/需求规格-邮件自动预报-v0.2.md` 第 14 章正文;与 §5–§8 状态机、CC 接口、数据模型、运营 SOP 一一对应。
> 常量约定:`IMPORT_LOCK_TTL=120s`;`IMPORTING_TIMEOUT=180s`;`PARSING_STALE=10min`;`COMPENSATION_MAX_RETRY=3`;`IMAP_CONNECT_TIMEOUT=30s`;`IMAP_READ_TIMEOUT=60s`;`CC_HTTP_TIMEOUT=60s`;`CC_REPLAY_ON_410=1`;`SHIPMENT_ROW_SOFT_LIMIT=1000`;`SHIPMENT_ROW_HARD_LIMIT=5000`。
;**`IMAP_LOOKBACK_DAYS=3`**。
> **2026-07-23 回写**:拉取=近 N 天已读+未读;**一期无 audit_log**;详见 `docx/现行口径-修订说明-v1.md`。
---
## 1. 输入层异常处理
### 1.1 邮件缺失主题
- **异常描述**:IMAP 入库的 Message `subject` 为空或仅空白。
- **系统行为**:`mail_message.subject` 存 `"(无主题)"`;类型判定仅用正文+附件名;照常进入 `PARSING`。
- **是否允许降级**:是(继续解析)。
- **是否触发告警**:`warn` 日志 `mail.subject_missing`;不计监控告警。
- **用户看到的结果**:列表主题显示「(无主题)」;详情正常。
### 1.2 邮件缺失正文
- **异常描述**:纯附件邮件或正文 HTML/纯文本均为空。
- **系统行为**:正文摘要空串;类型与柜头字段仅从附件/主题提取;无附件则 `REJECTED_VALIDATION`,`last_error=NO_BODY_NO_ATTACHMENT`。
- **是否允许降级**:有附件则继续;无附件则阻断导入路径。
- **是否触发告警**:无附件 → `error` + 指标 `mail.reject_validation`。
- **用户看到的结果**:状态「校验拒绝」;提示「无正文且无附件,无法解析」;可下载原始快照。
### 1.3 邮件无附件
- **异常描述**:类型可能为 NEW/TRANSFER,但无 xlsx/csv/zip。
- **系统行为**:若主题/正文能抽出柜号则写入 `parse_result.container_header`,`shipments=[]`;`mail_type` 仍按 §4 判定;因有效行=0,即使 NEW 也不进 `PENDING_CONFIRM`,停留 `REJECTED_VALIDATION` 或 `PARSED`(非 NEW)+ `last_error=NO_SHIPMENT_ROWS`。
- **是否允许降级**:是(仅展示,不可导入)。
- **是否触发告警**:`warn` `parse.no_attachment`。
- **用户看到的结果**:详情提示「缺少卡派/装箱清单附件」;确认导入入口不可用。
### 1.4 附件格式非 xlsx/csv,且 zip 内无有效文件
- **异常描述**:仅有 pdf/图片/docx,或 zip 解压后无 xlsx/csv/xls。
- **系统行为**:附件仍写入 `mail_attachment`;解析器标记 `template_id=null`;状态 `PARSE_FAILED`,`last_error=NO_SUPPORTED_SPREADSHEET`。
- **是否允许降级**:附件可下载;不调用 SaveContainer。
- **是否触发告警**:`error` `parse.unsupported_attachment`。
- **用户看到的结果**:「不支持的附件格式」+「重新解析」按钮;运营按 SOP 走客户端预报。
### 1.5 附件关键列缺失(仓库ID / 渠道 / 件数)
- **异常描述**:卡派资料表头缺少映射到 `F_FBACode`/`F_Transporter`/`F_CTNS` 的列。
- **系统行为**:整表无法建有效行 → `PARSE_FAILED`,`lineage.missing_columns=[...]`;若仅部分行缺值见 1.7。
- **是否允许降级**:否(不可进 `PENDING_CONFIRM`)。
- **是否触发告警**:`error` `parse.missing_columns`。
- **用户看到的结果**:展示缺失列名列表;可重新解析。
### 1.6 柜号格式非法(非 ISO 6346)
- **异常描述**:抽出柜号未通过校验位(且未关闭 ISO 校验配置)。
- **系统行为**:`container_header.F_ContainerNo` 保留原值并标 `container_no_valid=false`;状态 `REJECTED_VALIDATION`;禁止确认导入。
- **是否允许降级**:Admin 可在详情手工改柜号后触发「重新校验」;不自动猜正确柜号。
- **是否触发告警**:`warn` `parse.invalid_container_no`。
- **用户看到的结果**:「柜号未通过 ISO 校验:{no}」;编辑柜号后可再校验。
### 1.7 货件行半空(件数=0、仓库ID 空等)
- **异常描述**:单行缺少 `F_FBACode` 或 `F_Transporter` 或 `F_CTNS`≤0/空。
- **系统行为**:该行 `row_status=INVALID`,写入 `shipments[]`;不阻断其他 VALID 行;默认不勾选。
- **是否允许降级**:是(柜级部分行导入,§5.7)。
- **是否触发告警**:INVALID 行占比 >30% → `warn` `parse.high_invalid_ratio`。
- **用户看到的结果**:灰显行 + tooltip 列缺失原因;底栏「已选 n / 有效 m」。
### 1.8 超大附件(>20MB)
- **异常描述**:单附件 `size > 20MB`。
- **系统行为**:不落盘完整文件(或落盘后立即拒绝解析);`mail_attachment` 记 size + `rejected=1`;邮件 `REJECTED_VALIDATION`,`last_error=ATTACHMENT_TOO_LARGE`;仍按 §6.2 标 `\Seen`。
- **是否允许降级**:否。
- **是否触发告警**:`error` `mail.attachment_too_large`。
- **用户看到的结果**:「附件超过 20MB,请客户拆分后重发」;快照仅保留邮件头+正文。
### 1.9 渠道映射词不匹配
- **异常描述**:渠道列值不在 §5.6 黄金表。
- **系统行为**:`F_Transporter`=原文截断 30 字符;行标 `CHANNEL_UNMAPPED`;**仍可为 VALID**(若三必填齐全);确认页该行黄标警告。
- **是否允许降级**:是(不阻断提交);CC 若 400 再走补偿。
- **是否触发告警**:`warn` `map.channel_unmapped`(带原词)。
- **用户看到的结果**:警告「渠道未映射:{raw},将原样提交 CC」;可手工改下拉为标准枚举。
### 1.10 体积/重量含非数字
- **异常描述**:`总体积`/`毛重` 含单位文字或非法字符。
- **系统行为**:剥离常见单位(m³/cbm/kg)后 `parseFloat`;失败则该字段 `null`,行仍 VALID(体积重量非必填);`lineage` 记 `FIELD_PARSE_FALLBACK`。
- **是否允许降级**:是。
- **是否触发告警**:仅 `debug`/`info`。
- **用户看到的结果**:对应单元格空;可手工补填。
### 1.11 地址/日期格式错误
- **异常描述**:送仓日期无法解析,或地址超长。
- **系统行为**:日期统一按 `Asia/Shanghai` 解析;失败则 `F_Expected_DeliveryDateB/E=null`;地址截断至 500 字符并 `warn`。
- **是否允许降级**:是。
- **是否触发告警**:日期失败 `warn` `parse.bad_date`。
- **用户看到的结果**:日期空或截断提示;确认页可改。
---
## 2. 用户行为与并发控制
### 2.1 确认页重复点击提交
- **异常描述**:用户连点「确认导入」。
- **系统行为**:前端按钮立即 `disabled` + loading;请求带 `Idempotency-Key=mail_id:container_no:shipments_hash`;后端对 `mail_message` 执行 `UPDATE ... SET status='IMPORTING' WHERE id=? AND status='PENDING_CONFIRM'`,影响行=0 则返回 `409 ALREADY_IMPORTING`。
- **是否允许降级**:第二次请求直接返回当前状态,不二次调 CC。
- **是否触发告警**:`info` `import.duplicate_click`。
- **用户看到的结果**:首次进入「导入中」;再次点击 Toast「正在导入,请勿重复提交」。
### 2.2 快速连续提交(并发)
- **异常描述**:两次请求几乎同时到达。
- **系统行为**:DB 行锁/条件更新串行化;仅赢家进入 CC 调用;输家 409。
- **是否允许降级**:否(严格单飞)。
- **是否触发告警**:`warn` 若 1 分钟内同 mail 409>3 → `import.contention`。
- **用户看到的结果**:同 2.1。
### 2.3 页面刷新导致状态丢失或重复请求
- **异常描述**:提交后刷新,前端本地 state 清空。
- **系统行为**:一切以 DB `status` 为准;刷新后拉取详情,若 `IMPORTING/SUCCESS/FAILED` 展示对应 UI;不自动重放未完成请求(除非用户点重试)。
- **是否允许降级**:是(只读恢复)。
- **是否触发告警**:否。
- **用户看到的结果**:看到真实状态角标;IMPORTING 显示进度/等待。
### 2.4 浏览器回退/前进
- **异常描述**:确认页回退到详情再前进。
- **系统行为**:确认页 `beforeunload` 不拦截只读浏览;若 status 已非 `PENDING_CONFIRM`,确认页挂载时重定向详情并禁用提交。
- **是否允许降级**:是。
- **是否触发告警**:否。
- **用户看到的结果**:「当前状态不可导入:{status}」。
### 2.5 多标签页同时操作同一邮件
- **异常描述**:两标签同时改字段或提交。
- **系统行为**:提交用乐观锁 `mail_message.updated_at` 或 `version` 字段;冲突返回 `409 VERSION_CONFLICT`;改类型接口同锁。
- **是否允许降级**:后写失败,需刷新。
- **是否触发告警**:`warn` `mail.version_conflict`。
- **用户看到的结果**:「数据已被其他页面更新,请刷新后重试」。
### 2.6 Admin 改类型误操作
- **异常描述**:误将 LABEL 改为 NEW 并准备导入。
- **系统行为**:改类型二次确认弹窗展示旧→新 + 证据分;写入 `audit_log`;支持「改回」若尚未 `IMPORTING`;已 SUCCESS 禁止改类型。
- **是否允许降级**:人工改回。
- **是否触发告警**:每次改类型 `info` 审计;1 小时同用户改类型>10 → `warn`。
- **用户看到的结果**:弹窗确认;一期无审计页。
---
## 3. 网络与通信容错
### 3.1 IMAP 连接断开/超时
- **异常描述**:connect>30s 或 read>60s,或 TLS 中断。
- **系统行为**:本次轮询 abort;释放 `GET_LOCK('imap_poll')`;下个 `POLL_INTERVAL` 重试;已 `FETCHED` 的邮件不受影响。
- **是否允许降级**:retry(无限轮询,指数退避上限 5min 仅当连续失败≥5)。
- **是否触发告警**:连续失败≥3 → `error` 监控 `imap.poll_fail`;列表顶栏红条「邮件同步异常」。
- **用户看到的结果**:已有邮件仍可操作;新邮件暂不出现。
### 3.2 前端调 CC 超时/断开
- **异常描述**:Web→自有 API 或自有 API→CC 超过 `CC_HTTP_TIMEOUT`。
- **系统行为**:若请求已发出且本地未收到响应:柜状态先标 `FAILED` 或入 `import_compensation`(`reason=TIMEOUT_UNKNOWN`);**禁止立即自动重放 SaveContainer**(防双写);需 `GetContainerList` 确认柜是否已存在后再决定重试或标 SUCCESS。
- **是否允许降级**:补偿队列 + 人工确认重试。
- **是否触发告警**:`error` `cc.timeout`。
- **用户看到的结果**:「导入结果不确定,已加入待核查队列」;日志页显示「超时待核对」。
### 3.3 请求已发送但响应丢失(部分成功不确定)
- **异常描述**:CC 可能已建柜,客户端读超时。
- **系统行为**:同 3.2;核对流程:用柜号查 `GetContainerList`;命中近 10 分钟新建 → 回写 `external_id` 并 `SUCCESS`;未命中 → 允许补偿重试。
- **是否允许降级**:核查后自动收敛。
- **是否触发告警**:`error` `cc.ambiguous_success`。
- **用户看到的结果**:状态「待核查」直到收敛。
### 3.4 弱网重试导致重复请求
- **异常描述**:浏览器/代理自动重试 POST。
- **系统行为**:依赖 `Idempotency-Key` + 条件更新 `PENDING_CONFIRM→IMPORTING`;CC 侧再经 §5.8 冲突检测。
- **是否允许降级**:幂等吞掉重复。
- **是否触发告警**:`info`。
- **用户看到的结果**:单次成功结果。
### 3.5 CDN/静态资源加载失败
- **异常描述**:JS/CSS chunk 404。
- **系统行为**:Next error boundary 展示「资源加载失败,请强刷」;不影响已运行的 worker/IMAP。
- **是否允许降级**:用户手动刷新。
- **是否触发告警**:前端上报 `warn` `ui.chunk_fail`(若有)。
- **用户看到的结果**:整页错误壳,非静默白屏。
---
## 4. 后端 API(CarrierCentral)容错策略
### 4.1 customerLogin 返回 410 或 5xx
- **异常描述**:登录失败或 token 接口异常。
- **系统行为**:清 `cc_token_cache` + 进程内存 token;5xx 退避重试登录最多 2 次;仍失败则导入整体 `FAILED`,`last_error=CC_LOGIN_FAILED`。
- **是否允许降级**:否(无法调用业务 API)。
- **是否触发告警**:`error` `cc.login_fail`。
- **用户看到的结果**:§5.10「登录失效/CC 异常」文案;运营检查 `CC_USERNAME`。
### 4.2 SaveContainer 业务调用中途 410
- **异常描述**:token 过期发生在 SaveContainer。
- **系统行为**:重登 1 次并**重放同一 Idempotency 载荷 1 次**(`CC_REPLAY_ON_410=1`);仍 410 → FAILED。
- **是否允许降级**:单次重放。
- **是否触发告警**:重放成功 `info`;失败 `error`。
- **用户看到的结果**:成功则无感;失败见 §5.10。
### 4.3 SaveContainer 返回 400(含柜号已存在)
- **异常描述**:CC 业务校验失败。
- **系统行为**:该柜 `container_import.status=FAILED`;`last_error=info`;**不自动重试**(业务错误);邮件多柜则其他柜继续 → 可能 `PARTIAL_SUCCESS`。
- **是否允许降级**:人工改数据后重新确认(若邮件回到可编辑:从 FAILED 允许「重新打开确认」仅当无任何 SUCCESS 柜;已有 SUCCESS 柜则仅补偿失败柜)。
- **是否触发告警**:`warn` `cc.save_400`。
- **用户看到的结果**:「业务校验失败:{info}」。
### 4.4 GetContainerList 超时或数据不全
- **异常描述**:冲突检测调用失败。
- **系统行为**:**默认阻断导入**(`CONFLICT_CHECK_FAILED`),不盲调 SaveContainer;运营可 Admin 强制跳过冲突检测(需 `ENABLE_FORCE_IMPORT=true`)。
- **是否允许降级**:仅 Admin 强制。
- **是否触发告警**:`error` `cc.list_timeout`。
- **用户看到的结果**:「无法校验柜号是否已存在,请稍后重试」。
### 4.5 响应结构变化(字段新增/删除)
- **异常描述**:`data` 非预期类型或缺柜 id。
- **系统行为**:`code=200` 但无法解析 `external_id` → 走 3.2 不确定流程(列表核对);解析层用宽松 schema(忽略未知字段)。
- **是否允许降级**:核对收敛。
- **是否触发告警**:`error` `cc.schema_drift`。
- **用户看到的结果**:「返回异常,已待核查」。
### 4.6 相同 loginMark「重复提交」语义
- **异常描述**:CC 侧会话与多次 SaveContainer。
- **系统行为**:本系统不轮换 loginMark(进程级固定);重复保护完全靠本库幂等 + 冲突检测,不依赖 CC 对 loginMark 去重。
- **是否允许降级**:本库幂等。
- **是否触发告警**:否。
- **用户看到的结果**:无重复柜(冲突则阻断)。
---
## 5. 邮件解析与附件处理容错(重点)
### 5.1 IMAP 拉取成功但无法解析原始内容
- **异常描述**:损坏 MIME / 编码异常。
- **系统行为**:原始字节仍写入 `snapshot_path`;`PARSE_FAILED`,`last_error=MIME_PARSE_ERROR`;已 `\Seen`。
- **是否允许降级**:人工下载快照;「重新解析」可重试。
- **是否触发告警**:`error` `parse.mime_error`。
- **用户看到的结果**:解析失败页 + 下载原始邮件。
### 5.2 zip 解压失败或嵌套多层
- **异常描述**:损坏 zip,或有效 xlsx 在二层目录。
- **系统行为**:仅解压**一层**;失败 → `PARSE_FAILED` `ZIP_EXTRACT_FAILED`;一层内递归目录找 xlsx/csv;超过一层嵌套 zip **不进入**,记 `warn` `zip.nested_ignored`。
- **是否允许降级**:否(需客户重发扁平附件)。
- **是否触发告警**:失败 `error`;嵌套忽略 `warn`。
- **用户看到的结果**:对应错误文案。
### 5.3 多 sheet 找不到「卡派」
- **异常描述**:无名称含「卡派」的 sheet。
- **系统行为**:回退第一 sheet;若第一 sheet 表头映射失败再 `PARSE_FAILED`。
- **是否允许降级**:fallback 第一 sheet。
- **是否触发告警**:`info` `parse.sheet_fallback`。
- **用户看到的结果**:详情显示「使用 Sheet:{name}」。
### 5.4 表头别名匹配失败
- **异常描述**:列名与映射表无一命中必填语义。
- **系统行为**:同 1.5 → `PARSE_FAILED` + `missing_columns`。
- **是否允许降级**:否。
- **是否触发告警**:`error`。
- **用户看到的结果**:缺失列列表。
### 5.5 货件行数过大(>1000 / >5000)
- **异常描述**:解析行数超过软/硬限制。
- **系统行为**:`>SHIPMENT_ROW_HARD_LIMIT(5000)` → `REJECTED_VALIDATION` 不落全量 JSON;`>SOFT(1000)` 且 ≤HARD → 落库,确认页强制虚拟滚动,默认**不**全选,需用户筛选后勾选,底栏警告。
- **是否允许降级**:软限制降级;硬限制拒绝。
- **是否触发告警**:软 `warn`;硬 `error` `parse.row_limit`。
- **用户看到的结果**:硬限「行数过多请拆分」;软限「行数较多,请筛选后勾选导入」。
### 5.6 柜号提取错误
- **异常描述**:正文柜号与附件柜号不一致,或正则误匹配。
- **系统行为**:附件柜号优先(§ 结构化优先);冲突写入 `lineage` `CONTAINER_NO_CONFLICT`;确认页大红警告,**默认阻断提交**直至人工选定柜号(下拉:附件值/正文值/手输)。
- **是否允许降级**:人工选定。
- **是否触发告警**:`warn` `parse.container_conflict`。
- **用户看到的结果**:必须选择柜号后才能确认。
### 5.7 类型判定信号冲突
- **异常描述**:多类型得分接近或同分。
- **系统行为**:严格按 §4.2 同分优先级执行;`type_evidence` 完整落库;UI 展示各类型得分。
- **是否允许降级**:Admin 改类型。
- **是否触发告警**:得分差≤5 → `info` `type.close_call`。
- **用户看到的结果**:证据面板;非 NEW 导入按钮禁用原因含「当前类型=…」。
### 5.8 重新解析
- **异常描述**:用户/系统对 `PARSE_FAILED`/`REJECTED_VALIDATION`/`PARSED` 点重新解析。
- **系统行为**:仅当 status∉{IMPORTING,SUCCESS,PARTIAL_SUCCESS};置 `PARSING`;覆盖 `parse_result`;写审计。
- **是否允许降级**:是。
- **是否触发告警**:`info`。
- **用户看到的结果**:解析中 → 新结果。
---
## 6. 状态机与生命周期保护
### 6.1 落库成功但标 `\Seen` 失败
- **异常描述**:DB 已有 `FETCHED`,IMAP STORE 失败。
- **系统行为**:邮件保持可解析;下次 lookback 再次命中 UID → 幂等键命中则**跳过新建**,仅重试 `\Seen`;不重复解析除非强制。
- **是否允许降级**:retry Seen。
- **是否触发告警**:连续 Seen 失败 `warn` `imap.seen_fail`。
- **用户看到的结果**:列表仍一条;无重复卡片。
### 6.2 进程崩溃卡在 `PARSING`
- **异常描述**:worker 死于解析中。
- **系统行为**:定时任务(每 1min):`status=PARSING AND updated_at < now-PARSING_STALE` → 目标重置为 `FETCHED` 再入队;现行可先置 `PARSE_FAILED` 后「重新解析」(待收敛)。一期不写 audit_log。
- **是否允许降级**:自动恢复。
- **是否触发告警**:`error` `state.stale_parsing`。
- **用户看到的结果**:短暂后恢复为已解析或失败态。
### 6.3 `IMPORTING` 超时未更新
- **异常描述**:超过 `IMPORTING_TIMEOUT=180s`。
- **系统行为**:回收任务将状态改为 `FAILED` 或 `TIMEOUT_UNKNOWN` 入补偿;触发 3.2 核对;禁止无限 IMPORTING。
- **是否允许降级**:补偿+核对。
- **是否触发告警**:`error` `state.importing_timeout`。
- **用户看到的结果**:「导入超时,请至日志核查/重试」。
### 6.4 解析与 Admin 改类型并发
- **异常描述**:PARSING 中同时改类型。
- **系统行为**:改类型 API 拒绝非终态解析中:`409 MAIL_BUSY`;解析结束后以解析器写的 `mail_type` 为准,Admin 需再改。
- **是否允许降级**:否。
- **是否触发告警**:`info`。
- **用户看到的结果**:「邮件正在解析,请稍后再改类型」。
### 6.5 补偿队列丢记录或超最大重试
- **异常描述**:`retry_count >= COMPENSATION_MAX_RETRY(3)` 或行被误删。
- **系统行为**:超限标 `COMPENSATION_EXHAUSTED`,停止自动重试;监控告警;运营手工重试重置 `retry_count`(Admin)。
- **是否允许降级**:人工介入。
- **是否触发告警**:`error` `compensation.exhausted`。
- **用户看到的结果**:日志「重试已耗尽,请联系值班」;SOP §12。
### 6.6 非法状态迁移
- **异常描述**:客户端伪造 status 回写。
- **系统行为**:所有迁移仅服务端按 §6.3 表执行;无通用 status PATCH。
- **是否允许降级**:否。
- **是否触发告警**:`warn` `state.illegal_transition`。
- **用户看到的结果**:400。
---
## 7. 缓存一致性与幂等设计
### 7.1 DB token 过期但内存仍有效
- **异常描述**:`cc_token_cache.expire_at` 已过,进程内存未清。
- **系统行为**:每次用 token 前比较 DB `expire_at`;以 **DB 为权威**;过期强制重登;内存跟随失效。
- **是否允许降级**:重登。
- **是否触发告警**:`info` `cc.token_refresh`。
- **用户看到的结果**:无感或一次短暂延迟。
### 7.2 多实例 token 刷新未同步
- **异常描述**:实例 A 重登写入 DB,实例 B 仍持旧 token。
- **系统行为**:B 收到 410 → 读 DB 最新 token;若仍 410 → 抢锁重登(`GET_LOCK('cc_login')`)写 DB。
- **是否允许降级**:410 重放路径。
- **是否触发告警**:`warn` 频繁 410 `cc.token_churn`。
- **用户看到的结果**:同 §5.10。
### 7.3 raw_hash 碰撞
- **异常描述**:不同邮件 hash 相同(极低概率)。
- **系统行为**:幂等顺序:Message-ID → folder+uid → raw_hash;hash 命中时若 Message-ID 不同则**不合并**,改用 folder+uid 新建并 `error` `idempotency.hash_collision`。
- **是否允许降级**:分叉新建。
- **是否触发告警**:`error`(必告警)。
- **用户看到的结果**:两条独立邮件记录。
### 7.4 同一 Message-ID 被多次处理
- **异常描述**:重复投递/重复拉取。
- **系统行为**:`UNIQUE(message_id)` 插入失败 → 吞掉;若需更新 `\Seen` 则只做 IMAP。
- **是否允许降级**:幂等跳过。
- **是否触发告警**:`info` `idempotency.msgid_dup`。
- **用户看到的结果**:仍一条。
---
## 8. 并发一致性与锁策略
### 8.1 多 worker 锁竞争失败
- **异常描述**:`GET_LOCK('imap_poll', 0)` 返回 0。
- **系统行为**:本轮直接退出,等待下轮;不报错为故障。
- **是否允许降级**:是(正常互斥)。
- **是否触发告警**:连续 30 轮拿不到锁且无其他实例心跳 → `error` `imap.lock_stuck`(锁持有方崩溃未释放极罕见,MySQL 连接断即释放)。
- **用户看到的结果**:无。
### 8.2 同一柜号多封邮件同时确认
- **异常描述**:两封 PENDING 邮件含同柜,两运营同时提交。
- **系统行为**:提交事务内:`SELECT ... FOR UPDATE` 锁 `container_import` 占位行 `UNIQUE(container_no) WHERE status IN ('IMPORTING','SUCCESS')` 或等价唯一活跃索引;第二人冲突检测失败阻断;第一人 CC 成功后第二人必拦。
- **是否允许降级**:后提交失败。
- **是否触发告警**:`warn` `conflict.cross_mail_container`。
- **用户看到的结果**:「柜号已被邮件#{id}占用/已存在」。
### 8.3 多 Admin 同时改类型并提交
- **异常描述**:改类型+导入竞态。
- **系统行为**:`version` 乐观锁;导入前再次校验 `mail_type=NEW_CONTAINER`;类型已变则 409。
- **是否允许降级**:刷新重来。
- **是否触发告警**:`warn`。
- **用户看到的结果**:版本冲突提示。
### 8.4 补偿队列同一柜重复重试
- **异常描述**:调度器重复投递补偿任务。
- **系统行为**:补偿执行前锁 `container_import`;已 SUCCESS 跳过;`IMPORTING` 跳过;用 `retry_token` 幂等。
- **是否允许降级**:跳过。
- **是否触发告警**:`info`。
- **用户看到的结果**:单次结果。
---
## 9. 数据计算安全
### 9.1 CHANNEL_UNMAPPED 未阻断
- **异常描述**:未知渠道仍提交(见 1.9)。
- **系统行为**:确认页强制展示警告计数;提交 API 若存在 UNMAPPED 且未传 `ack_unmapped_channels=true` → 400 要求确认。
- **是否允许降级**:用户显式确认后放行。
- **是否触发告警**:`warn`。
- **用户看到的结果**:二次确认「存在未映射渠道,仍要提交?」。
### 9.2 浮点精度(体积/重量)
- **异常描述**:Excel 浮点噪声。
- **系统行为**:CBM/Weight 存 DECIMAL(18,4);展示与提交前 `round(value,4)`;不在服务端做单位换算乘法链。
- **是否允许降级**:四舍五入。
- **是否触发告警**:否。
- **用户看到的结果**:最多 4 位小数。
### 9.3 日期时区错误
- **异常描述**:解析为 UTC 导致差一天。
- **系统行为**:所有业务日期按 `Asia/Shanghai` 日历日;写入 CC 格式 `YYYY-MM-DD`;`received_at` 存 UTC 瞬时并 UI 转上海。
- **是否允许降级**:固定时区。
- **是否触发告警**:跨日边界单测覆盖;运行时否。
- **用户看到的结果**:与邮件 ETA 日历日一致。
### 9.4 F_ShippingLineId 匹配失败
- **异常描述**:船名无法匹配。
- **系统行为**:留空;确认页可选船司;**不阻断**提交(字段非 SaveContainer 必填)。
- **是否允许降级**:是。
- **是否触发告警**:`info`。
- **用户看到的结果**:「未匹配船司,可手动选择」。
### 9.5 勾选总数与提交数不一致
- **异常描述**:前端展示 n,实际 payload m。
- **系统行为**:服务端以请求体 `shipment_ids[]` 为准再查 `parse_result`;过滤非 VALID/非本 mail;响应回传 `accepted_count`;与前端 n 不符则 UI 以服务端为准刷新。
- **是否允许降级**:服务端权威。
- **是否触发告警**:不一致 `warn` `import.count_mismatch`。
- **用户看到的结果**:Toast「实际提交 {accepted_count} 行」。
---
## 10. 权限与安全控制
### 10.1 未登录访问 API
- **异常描述**:无 session 调 `/api/mails`、`/api/import`。
- **系统行为**:统一 401;不返回业务数据。
- **是否允许降级**:否。
- **是否触发告警**:暴力 401 计数 `security.unauthorized`。
- **用户看到的结果**:跳转登录。
### 10.2 越权查看(一期单租户)
- **异常描述**:伪造 mail_id 扫库。
- **系统行为**:一期单租户:登录即可访问全部邮件;禁止未登录;二期按 `sender_customer_map`/客户隔离时校验 `customer_code`。
- **是否允许降级**:一期 N/A。
- **是否触发告警**:否(一期)。
- **用户看到的结果**:正常列表(仅登录用户)。
### 10.3 Admin 改类型未授权
- **异常描述**:`ENABLE_TYPE_OVERRIDE=false` 但前端暴露按钮;或普通用户调 API。
- **系统行为**:**服务端**校验 `role=admin AND ENABLE_TYPE_OVERRIDE`;失败 403;前端按同条件隐藏(不可作唯一防线)。
- **是否允许降级**:否。
- **是否触发告警**:`warn` `security.type_override_denied`。
- **用户看到的结果**:无按钮或 403。
### 10.4 伪造 loginMark / CC token
- **异常描述**:客户端上传假 token 想直打 CC。
- **系统行为**:浏览器**永不**持有 CC token;仅服务端 Adapter 使用;用户会话与 CC 凭证隔离。
- **是否允许降级**:否。
- **是否触发告警**:出现客户端传 cc_token 字段 → `error` `security.cc_token_probe`。
- **用户看到的结果**:忽略非法字段。
### 10.5 批量刷导入接口
- **异常描述**:脚本对多邮件狂打确认。
- **系统行为**:用户级限流:确认导入 **10 次/分钟**;同 mail 条件更新防重;超限 429。
- **是否允许降级**:限流。
- **是否触发告警**:`warn` `security.rate_limit_import`。
- **用户看到的结果**:「操作过于频繁」。
### 10.6 审计(一期不做)
- **现行口径**:不建 `audit_log`、无 Admin 审计页;关键操作依赖应用日志与 `/logs`。
- **系统行为**:原写审计点可空实现,不阻断主流程。
- **是否允许降级**:是(已降级为不做)。
- **用户看到的结果**:无审计查询入口。
## 11. 监控指标与告警阈值(落地用)
| 指标 | 级别 | 阈值 |
|---|---|---|
| `imap.poll_fail` 连续 | error | ≥3 轮 |
| `cc.login_fail` | error | ≥1/5min |
| `cc.ambiguous_success` | error | ≥1 |
| `state.stale_parsing` | error | ≥1 |
| `state.importing_timeout` | error | ≥1 |
| `compensation.exhausted` | error | ≥1 |
| `idempotency.hash_collision` | error | ≥1 |
| `parse.high_invalid_ratio` | warn | 单邮件 INVALID>30% |
| `import.contention` | warn | 同 mail 409>3/min |
| `security.rate_limit_import` | warn | 触发即记 |
一期告警出口:应用日志 + 管理页顶栏;企微后置。
---
## 12. QA 异常用例索引(按本章)
| 编号 | 对应用例要点 |
|---|---|
| A-1.x | 无主题/无正文/无附件/坏格式/缺列/坏柜号/半空行/20MB+/未映射渠道/坏数字日期 |
| A-2.x | 双击提交、双标签 version、刷新恢复、回退、误改类型 |
| A-3.x | IMAP 断网、CC 超时、模糊成功核对 |
| A-4.x | login 5xx、Save 400、List 超时阻断、410 重放 |
| A-5.x | 坏 MIME、坏 zip、sheet 回退、1001 行软限、柜号冲突、重新解析 |
| A-6.x | Seen 失败去重、卡 PARSING 回收、IMPORTING 超时 |
| A-7.x | token DB 权威、Message-ID 重复 |
| A-8.x | 双 worker 锁、双邮件同柜 |
| A-9.x | unmapped 需 ack、时区、accepted_count |
| A-10.x | 401/403 改类型、429 限流、无 CC token 出前端 |
### 12.1 自动化覆盖(2026-08-12)
跑法:`pnpm test`(单测/集成)+ `pnpm test:e2e tests/e2e/resilience-auth.spec.ts`(需本机可起 web)。
| 编号 | 覆盖 | 自动化入口 | 真人/联调仍需 |
|---|---|---|---|
| A-1.1 空主题 | ✅ | `resilience-scenarios` + `resilience-metrics` | UI 列表展示「(无主题)」 |
| A-1.2/1.3 无正文无附件 / NEW 无清单 | ✅ | `resolveNoSpreadsheetOutcome` | 详情文案 |
| A-1.4 非表格附件 | ✅ | 同上 | 重新解析按钮 |
| A-1.5 缺列 | ✅ | packing-list | — |
| A-1.6 坏柜号 ISO | ✅ | iso6346 + scenarios | Admin 改柜号再校验 |
| A-1.7 半空行 INVALID | ✅ | packing-list scenarios | 确认页灰显 |
| A-1.8 >20MB | ⚠️ 规则在 pipeline | 未造 20MB fixture | 真附件拒收 + `\Seen` |
| A-1.9 未映射渠道 | ✅ | channel-map + packing-list | 确认页黄标 + ack |
| A-1.10 体积重量单位 | ✅ | packing-list | — |
| A-1.11 日期/地址截断 | ✅ | dates + packing-list | — |
| A-2.1/2.2 连点/并发 | ✅ 门禁+409 码 | forecast-gate + ConfirmImportError | 真双击/双请求打 DB CAS |
| A-2.3 刷新恢复 | ⚠️ | 无独立 E2E | 导入中刷新看 DB 状态 |
| A-2.4 回退确认页 | ⚠️ | 无独立 E2E | 浏览器前进后退 |
| A-2.5 version 冲突 | ✅ 错误码 | ConfirmImportError | 双标签真打 |
| A-2.6 误改类型 | ⚠️ API 有 MAIL_BUSY | type/route | Admin 二次确认弹窗 |
| A-3.1 IMAP 超时退避 | ✅ 阈值/退避公式 | runtime-status | 真断网轮询 |
| A-3.2/3.3 模糊成功窗口 | ✅ 10min 窗口 | compensation.isRecentlyCreated | live GetContainerList |
| A-3.4 弱网重试 | ⚠️ 依赖幂等键 | import CAS | 代理重放 POST |
| A-3.5 chunk 404 | ❌ | 无 | 强刷 error boundary |
| A-4.1 login token 解析 | ✅ extractLoginToken | auth.ts | live 5xx/410 |
| A-4.2 Save 410 重放 1 次 | ⚠️ http.ts 有 retried410 | 无单测打真实 410 | live token 过期 |
| A-4.3 Save 400 | ⚠️ mock 冲突 | cc-save + force-import | live 业务 400 文案 |
| A-4.4 List 超时阻断 | ⚠️ 代码默认阻断 | force-import | live timeout |
| A-4.5 schema drift | ✅ token/id 宽松解析 | extractLoginToken | 缺 F_Id → TIMEOUT_UNKNOWN |
| A-5.1 坏 MIME | ⚠️ pipeline MIME_PARSE_ERROR | 无坏 eml fixture | 重新解析+下载快照 |
| A-5.2 zip 一层/嵌套/穿越 | ✅ | zip-extract | — |
| A-5.3 sheet 回退 | ✅ | packing-list sheetFallback | — |
| A-5.4 表头失败 | ✅ 同 1.5 | — | — |
| A-5.5 软/硬行数 | ✅ hardLimit/softLimit | packing-list | 确认页虚拟滚动 UI |
| A-5.6 柜号冲突 | ⚠️ 规则有 | pipeline lineage | 确认页必须选定柜号 |
| A-5.7 类型 close call | ⚠️ classify 有样例 | classify.test | 证据面板 |
| A-5.8 重新解析门禁 | ✅ 状态机 | state-machine | 点按钮 |
| A-6.1 Seen 失败去重 | ❌ 需 IMAP | — | lookback 重试 Seen |
| A-6.2 卡 PARSING 回收 | ✅ 允许 PARSING→FETCHED | state-machine | worker reaper 真跑 |
| A-6.3 IMPORTING 超时 | ✅ 允许 IMPORTING→FAILED | state-machine | reaper + 补偿行 |
| A-6.4 解析中改类型 | ⚠️ API MAIL_BUSY | type/route | 并发点改类型 |
| A-6.5 补偿耗尽 | ⚠️ 代码有 EXHAUSTED | compensation | Admin 手工重置 |
| A-6.6 非法迁移 | ✅ | state-machine | — |
| A-7.1/7.2 token DB 权威 | ⚠️ 实现有 | 无多实例测 | 双 web 410 |
| A-7.3 hash 碰撞分叉 | ✅ | hash + resilience-metrics | — |
| A-7.4 Message-ID 重复/截断 | ✅ truncate 191 | snapshot | UNIQUE 插入失败 |
| A-8.1 双 worker 锁 | ❌ 需双进程+MySQL | db-lock | 双 worker lock_busy |
| A-8.2 双邮件同柜 | ⚠️ conflict mock | cc-save | 两运营同时确认 |
| A-8.3 改类型+导入竞态 | ⚠️ version | ConfirmImportError | — |
| A-8.4 补偿重复投递 | ⚠️ retry_token | compensation | worker 双 tick |
| A-9.1 unmapped ack | ✅ 错误码 + warning | ConfirmImportError | 确认页二次确认 |
| A-9.2 round4 | ✅ | dates | — |
| A-9.3 时区 | ✅ | hash-dates | — |
| A-9.4 船司匹配失败不阻断 | ⚠️ extract-header | extract-header.test | 确认页手选 |
| A-9.5 accepted_count/hash | ✅ shipments_hash | hash | Toast |
| A-10.1 未登录 401 | ✅ E2E | resilience-auth.spec | — |
| A-10.2 单租户 | N/A 一期 | — | — |
| A-10.3 改类型 403 | ⚠️ requireAdmin + ENABLE_TYPE_OVERRIDE | type/route | ops 调 API |
| A-10.4 前端无 CC token | ⚠️ 架构约束 | 无扫 bundle | Network 面板 |
| A-10.5 导入/登录限流 | ✅ 单测 + E2E 登录 429 | rate-limit + e2e | 多副本无效 |
| A-10.6 审计一期不做 | N/A | — | — |
**图例**:✅ 自动化已断言 · ⚠️ 代码有路径、缺完整 fixture/联调 · ❌ 尚未自动化(需 IMAP/双进程/真人 UI)

@ -0,0 +1,650 @@
# 技术设计方案 v1.0(邮件自动预报系统)
> 状态:可直接开发(**2026-07-23 现行口径回写**;**2026-08-03 指令拆分产品规则锁定**)
> 依据:`docx/需求规格-邮件自动预报-v0.2.md`、`docx/异常场景与系统韧性设计.md`、**`docx/产品规则-邮件指令识别与拆分.md`**
> 栈锁定:Next.js App Router + MySQL 8 + docker-compose(`web` / `worker` / `mysql`)
> **回写要点**:`src/worker`;IMAP lookback N 天;无 audit_log;路由 `/confirm` `/logs` `/settings`;本地 MySQL **7023**;seed 仅账号;详见 `docx/现行口径-修订说明-v1.md`
> **指令口径**:主题/正文/附件分工、仅最新段可确认、软词→客户指令 — 见产品规则页;实现见 `instruction-lexicon.ts` / `split-instructions.ts`(**无 LLM**)
---
## 1. 细化技术选型
### 1.1 ORM
| 维度 | Prisma | Drizzle | TypeORM |
|---|---|---|---|
| AI 生成 Schema | 高(schema 声明式) | 高(TS 表定义) | 中 |
| 类型安全 | 生成客户端强类型 | 推断强 | 装饰器弱于前两者 |
| 迁移 | `prisma migrate` 成熟 | `drizzle-kit` 可用 | 易踩坑 |
| JSON 字段 | 原生支持 | 原生支持 | 一般 |
**唯一推荐:Prisma 5.x**
理由:Cursor 生成/改 schema 成功率最高;迁移命令固定;与 Next.js 官方示例一致;`Json` 字段直接对应 `type_evidence` / `shipments`。
### 1.2 UI 组件库
| 维度 | shadcn/ui | Ant Design | MUI |
|---|---|---|---|
| 客服后台 | 中(需自拼表格) | 高(Table/Form 开箱) | 高 |
| 虚拟滚动 327+ | `@tanstack/react-virtual` 自接 | `Table virtual` 文档成熟 | 需 DataGrid Pro 或自接 |
| 定制 | 最高 | 中 | 中 |
| 包体/样式冲突 | 低 | 中 | 中 |
**唯一推荐:Ant Design 5.x + `@tanstack/react-virtual`(仅确认页货件表)**
理由:列表/筛选/Form/Modal 一次齐;确认页用 TanStack Virtual 包 Antd Table 行,避免 Pro 授权;后台信息密度匹配运营场景。
### 1.3 Worker
| 维度 | setInterval 脚本 | BullMQ | PM2 only |
|---|---|---|---|
| 对齐 60s 轮询 | 直接 | 过重 | 仅进程管理 |
| `GET_LOCK` 互斥 | 业务内实现 | 队列锁重复 | 不解决业务锁 |
| compose 复杂度 | 低(第二容器) | 需 Redis | 需宿主机 PM2 |
**唯一推荐:独立 Node 入口 `worker/index.ts` + `setInterval(POLL_INTERVAL_MS)` + MySQL `GET_LOCK('imap_poll', 0)`**
理由:PRD 明确单连接轮询,无任务扇出;不加 Redis;docker 内 `node dist/worker.js` 常驻即可。Compose 用 `restart: unless-stopped`,不用 PM2。
### 1.4 其余锁定
| 项 | 选型 |
|---|---|
| 语言 | TypeScript 5.x strict |
| 运行时 | Node.js 20 LTS |
| Next | 15.x App Router |
| 鉴权(一期) | 自建 session:`iron-session` + 环境变量账号;Cookie HttpOnly |
| HTTP(CC) | `undici`(Node 原生 fetch 封装层 `CcHttpClient`) |
| IMAP | `imapflow` |
| 邮件解析 | `mailparser` |
| xlsx | `exceljs` |
| zip | `adm-zip`(只解一层) |
| 密码 MD5 | `crypto.createHash('md5')` |
| 校验 | `zod`(API 入参 + 解析中间态) |
| 日志 | `pino` |
| 包管理 | `pnpm` |
| 测试 | `vitest`(单元)+ `playwright`(E2E,M4+) |
---
## 2. 系统架构设计
### 2.1 总体架构(文字)
```
imap.qq.com:993
│ SINCE N天(已读+未读) / STORE \Seen
▼
┌────────────────── worker 容器 ──────────────────┐
│ ImapPoller → SnapshotStore → ParsePipeline │
│ │ │ │ │
│ └──────────────┴──────────────┘ │
│ ▼ │
│ Prisma → MySQL │
│ (FETCHED→PARSING→PARSED|PENDING_CONFIRM|…) │
└─────────────────────────────────────────────────┘
▲
│ 读写同一库
┌────────────────── web 容器 ─────────────────────┐
│ Next.js App Router │
│ UI: 列表/详情/确认导入/日志 │
│ Route Handlers: /api/* │
│ ImportService → ConflictCheck → CcAdapter │
│ │ │ │
│ │ ▼ │
│ │ test.saas.carriercentral.vip│
│ │ customerLogin / SaveContainer│
│ │ GetContainerList / ShipLine │
│ └────────── MySQL (status/import/audit) ──┘
└─────────────────────────────────────────────────┘
```
**硬边界:**
- **人工确认路径**(`NEW_CONTAINER`):仅 Web API 在用户点击确认后调用 `SaveContainer`。
- **自动执行路径**(`TRANSFER` / `INSTRUCTION_HOLD_SPLIT` / `INSTRUCTION_LABEL`):允许 worker/executor 调用对应 CC 写接口,受 `ENABLE_AUTO_EXEC_*` + `cc_write_capability`(live|mock|off)+ 审计约束;mock ≠ TEST 验收。
- 禁止对超时/未知态盲重放写接口。
### 2.2 模块划分
| 模块 | 路径 | 输入 | 输出 |
|---|---|---|---|
| IMAP 拉取 | `src/worker` + `src/services/imap` | 设置页 mailbox + IMAP_LOOKBACK_DAYS | `mail_message` FETCHED + 快照/附件 | `mail_message` FETCHED + 快照/附件 |
| 解析引擎 | `src/services/parse` | mail_id | `mail_type` + `parse_result` + 新 status |
| 类型判定 | `src/services/parse/classify.ts` | subject/body/filenames | `MailType` + `type_evidence` |
| 卡派解析 | `src/services/parse/packing-list.ts` | xlsx/csv buffer | header + shipments[] |
| 状态机 | `src/services/state-machine.ts` | from/to/guard | 合法迁移或抛 `IllegalTransition` |
| CC Adapter | `src/services/cc` | 业务 DTO | CC 响应;token 缓存 |
| 确认导入 | `src/services/import` | mail_id + 勾选行 + 柜头 | `container_import` + 终态 |
| 补偿重试 | `src/services/compensation` | compensation id | 重试 SaveContainer |
| 前端 | `src/app/(ops)/*` | — | 运营 UI |
### 2.3 数据流
```
1. ImapPoller.GET_LOCK('imap_poll')
2. SEARCH SINCE N天 → 过滤已入库 UID → FETCH → 写 data/mails/{id}/raw.eml + attachments
3. INSERT mail_message status=FETCHED(幂等:message_id / folder+uid / raw_hash)
4. STORE \Seen(失败仅记日志,下轮补 Seen)
5. status→PARSING → classify + packing-list
6. NEW + valid rows≥1 → PENDING_CONFIRM;否则 PARSED / PARSE_FAILED / REJECTED_VALIDATION
7. 用户 POST /api/mails/:id/import
8. CAS: PENDING_CONFIRM→IMPORTING(version 校验)
9. GetContainerList 冲突检测 → 失败则回滚可编辑态并返回 CONFLICT
10. 按柜 SaveContainer(一柜一请求;货件=勾选行)
11. 全成功 SUCCESS;多柜部分 PARTIAL_SUCCESS;失败 FAILED + import_compensation
```
### 2.4 第三方依赖
| 依赖 | 用途 | 超时 |
|---|---|---|
| `imap.qq.com:993` | 拉取/标已读 | connect 30s / read 60s |
| `https://test.saas.carriercentral.vip/api` | CC | HTTP 60s |
| 本地 `./data` | 快照与附件 | — |
| exceljs / adm-zip | 清单解析 | 单附件 ≤20MB |
---
## 3. 数据库设计
Prisma schema 落地;下列 SQL 语义等同。字符集 `utf8mb4`,引擎 InnoDB。
### 3.1 `mail_message`
```sql
CREATE TABLE mail_message (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
message_id VARCHAR(998) NULL,
imap_uid BIGINT UNSIGNED NULL,
folder VARCHAR(64) NOT NULL DEFAULT 'INBOX',
subject VARCHAR(998) NOT NULL DEFAULT '(无主题)',
from_addr VARCHAR(320) NOT NULL DEFAULT '',
received_at DATETIME(3) NULL,
body_text MEDIUMTEXT NULL,
mail_type VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN',
status VARCHAR(32) NOT NULL DEFAULT 'FETCHED',
type_evidence JSON NULL,
snapshot_path VARCHAR(512) NULL,
raw_hash CHAR(64) NULL,
last_error VARCHAR(1000) NULL,
version INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_message_id (message_id),
UNIQUE KEY uk_folder_uid (folder, imap_uid),
UNIQUE KEY uk_raw_hash (raw_hash),
KEY idx_status_received (status, received_at),
KEY idx_mail_type (mail_type)
);
```
`mail_type` 枚举值:`NEW_CONTAINER|TRANSFER|INSTRUCTION_HOLD_SPLIT|INSTRUCTION_LABEL|WORK_ORDER|UNKNOWN`
工单关键词(贴标/拦截/拍照/转仓/快递单号)覆盖为 `WORK_ORDER`;解析产出 `parse_result.mail_record`(提单 `BL_FORECAST` / 工单表);**本期工单不 auto-exec**。
`status` 枚举值:PRD §6.3 全量。
### 3.2 `mail_attachment`
```sql
CREATE TABLE mail_attachment (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
mail_id BIGINT UNSIGNED NOT NULL,
filename VARCHAR(512) NOT NULL,
content_type VARCHAR(128) NULL,
sha256 CHAR(64) NOT NULL,
path VARCHAR(512) NOT NULL,
size INT UNSIGNED NOT NULL,
rejected TINYINT(1) NOT NULL DEFAULT 0,
template_id VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_mail (mail_id),
CONSTRAINT fk_att_mail FOREIGN KEY (mail_id) REFERENCES mail_message(id)
);
```
### 3.3 `parse_result`
```sql
CREATE TABLE parse_result (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
mail_id BIGINT UNSIGNED NOT NULL,
container_header JSON NOT NULL,
shipments JSON NOT NULL,
lineage JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_mail (mail_id),
CONSTRAINT fk_parse_mail FOREIGN KEY (mail_id) REFERENCES mail_message(id)
);
```
`shipments[]` 元素契约:
```ts
{
row_index: number;
row_status: 'VALID' | 'INVALID';
invalid_reasons?: string[];
warnings?: string[]; // e.g. CHANNEL_UNMAPPED
F_FBACode?: string;
F_Transporter?: string;
F_CTNS?: number;
F_CBM?: number | null;
F_Weight?: number | null;
F_FBAID?: string | null;
F_ReferenceId?: string | null;
F_ShipmentID?: string | null;
F_Address?: string | null;
F_Expected_DeliveryDateB?: string | null; // YYYY-MM-DD
F_Expected_DeliveryDateE?: string | null;
F_Remark?: string | null;
channel_raw?: string;
}
```
`container_header` 契约:含 `F_ContainerNo`, `container_no_valid`, `F_CabinetType`, `F_ETA`, `F_ETD`, `F_BLCopyCode`, `F_Classis`, `F_ShippingLineId`, `F_MemoRemark`, `F_Instruction`, 推荐 `F_TransMode`/`F_OperationType`。
### 3.4 `container_import`
```sql
CREATE TABLE container_import (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
mail_id BIGINT UNSIGNED NOT NULL,
container_no VARCHAR(50) NOT NULL,
external_id VARCHAR(64) NULL,
request_body MEDIUMTEXT NULL,
response_body MEDIUMTEXT NULL,
status VARCHAR(32) NOT NULL, -- PENDING|IMPORTING|SUCCESS|FAILED|CONFLICT|TIMEOUT_UNKNOWN
last_error VARCHAR(1000) NULL,
shipments_hash CHAR(64) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
KEY idx_mail (mail_id),
KEY idx_container (container_no),
UNIQUE KEY uk_mail_container_shiphash (mail_id, container_no, shipments_hash),
CONSTRAINT fk_imp_mail FOREIGN KEY (mail_id) REFERENCES mail_message(id)
);
```
活跃柜占位(应用层):导入前插入/更新 `status IN ('IMPORTING','SUCCESS')` 同行柜号冲突检查;另建:
```sql
CREATE TABLE container_active_lock (
container_no VARCHAR(50) NOT NULL PRIMARY KEY,
mail_id BIGINT UNSIGNED NOT NULL,
import_id BIGINT UNSIGNED NULL,
locked_at DATETIME(3) NOT NULL
);
```
### 3.5 `import_compensation`
```sql
CREATE TABLE import_compensation (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
import_id BIGINT UNSIGNED NOT NULL,
retry_count INT UNSIGNED NOT NULL DEFAULT 0,
max_retry INT UNSIGNED NOT NULL DEFAULT 3,
next_retry_at DATETIME(3) NULL,
reason VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'OPEN', -- OPEN|DONE|EXHAUSTED
retry_token CHAR(36) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_retry_token (retry_token),
KEY idx_open (status, next_retry_at),
CONSTRAINT fk_comp_imp FOREIGN KEY (import_id) REFERENCES container_import(id)
);
```
### 3.6 `cc_token_cache`
```sql
CREATE TABLE cc_token_cache (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
login_mark CHAR(36) NOT NULL,
token VARCHAR(128) NOT NULL,
expire_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_login_mark (login_mark)
);
```
### 3.7 `audit_log`(**一期不做 / 表已移除**)
> 现行口径:不落库、无 Admin 查询页。以下 DDL 仅作历史参考。
### 3.7.1 (历史)`audit_log`
```sql
CREATE TABLE audit_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
actor VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
mail_id BIGINT UNSIGNED NULL,
payload JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
KEY idx_mail_time (mail_id, created_at)
);
```
### 3.8 其余
```sql
CREATE TABLE sender_customer_map (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
match_type VARCHAR(32) NOT NULL, -- EMAIL|DOMAIN|SUBJECT|ATTACHMENT
match_value VARCHAR(320) NOT NULL,
customer_code VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
UNIQUE KEY uk_match (match_type, match_value)
);
CREATE TABLE app_user (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(128) NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'ops', -- ops|admin
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
);
```
---
## 4. API 设计
Base:`/api`。鉴权:未登录 → `401`。成功包络:
```ts
{ ok: true, data: T }
{ ok: false, error: { code: string, message: string, details?: unknown } }
```
### 4.1 认证
| 方法 | 路径 | 入参 | 出参 | 错误 |
|---|---|---|---|---|
| POST | `/api/auth/login` | `{username,password}` | `{role}` + Set-Cookie | `401 INVALID_CREDENTIALS` |
| POST | `/api/auth/logout` | — | `{ok:true}` | — |
| GET | `/api/auth/me` | — | `{username,role}` | `401` |
### 4.2 邮件列表
| 方法 | 路径 | 入参 | 出参 |
|---|---|---|---|
| GET | `/api/mails` | query: `page=1&pageSize=20&mail_type=&status=&q=` | `{items: MailListItem[], total}` |
`MailListItem`:`id,subject,from_addr,received_at,mail_type,status,container_no?`
错误:`400 VALIDATION`
### 4.3 邮件详情
| 方法 | 路径 | 出参 |
|---|---|---|
| GET | `/api/mails/:id` | `MailDetail`:邮件字段 + `type_evidence` + `parse_result` + `attachments[]` + `imports[]` |
| GET | `/api/mails/:id/attachments/:attId/download` | file stream |
错误:`404 NOT_FOUND`
### 4.4 确认导入
| 方法 | 路径 | 入参 | 出参 |
|---|---|---|---|
| POST | `/api/mails/:id/import` | 见下 | `{mail_status, imports[], accepted_count}` |
```ts
// body
{
version: number;
idempotency_key: string; // mail_id:container_no:shipments_hash 或整单 UUID
F_TransMode: 0|1|3;
F_OperationType: 0|2|4;
container_header: { /* 可覆盖字段 */ };
selected_row_indexes: number[];
ack_unmapped_channels?: boolean;
force_skip_conflict?: boolean; // 需 admin + ENABLE_FORCE_IMPORT
}
```
| 错误码 | 含义 |
|---|---|
| `409 ALREADY_IMPORTING` | CAS 失败 |
| `409 VERSION_CONFLICT` | version 不匹配 |
| `400 ACK_UNMAPPED_REQUIRED` | 未确认未映射渠道 |
| `409 CONFLICT_CONTAINER` | GetContainerList / 本地锁冲突 |
| `503 CONFLICT_CHECK_FAILED` | 列表接口失败且未强跳 |
| `429 RATE_LIMITED` | >10/min |
| `403 FORBIDDEN` | 非 NEW / 无权限强跳 |
| `400 INVALID_ROWS` | 勾选含 INVALID |
限流:每用户 10 次/分钟(内存滑动窗或 DB)。
### 4.5 Admin 改类型
| 方法 | 路径 | 入参 | 条件 |
|---|---|---|---|
| POST | `/api/mails/:id/type` | `{version, mail_type, reason}` | `role=admin` 且 `ENABLE_TYPE_OVERRIDE=true` |
成功:更新 `mail_type`;若 NEW 且 valid≥1 → `PENDING_CONFIRM`;写 `audit_log action=TYPE_OVERRIDE`。
错误:`403` / `409 MAIL_BUSY` / `409 VERSION_CONFLICT` / `409 IMMUTABLE_STATUS`
### 4.6 重新解析
| 方法 | 路径 | 条件 |
|---|---|---|
| POST | `/api/mails/:id/reparse` | status ∉ `IMPORTING|SUCCESS|PARTIAL_SUCCESS` |
行为:status→`PARSING`;worker 或 web 内同步调用 `ParsePipeline`(一期 **web 同步执行解析** 亦可;生产可投递 DB 标志位由 worker 捞——**一期锁定:API 进程内直接跑 ParsePipeline**,与 worker 共用 `src/services/parse`)。
错误:`409 ILLEGAL_TRANSITION`
### 4.7 补偿重试
| 方法 | 路径 | 入参 |
|---|---|---|
| GET | `/api/imports` | `page,status,mail_id` |
| POST | `/api/imports/:importId/retry` | `{retry_token?}` |
| POST | `/api/compensations/:id/retry` | — admin 可重置 exhausted |
行为:锁 `container_import`;SUCCESS 跳过;核对超时单;再 SaveContainer。
错误:`409` / `400 EXHAUSTED`
### 4.8 忽略邮件
| 方法 | 路径 |
|---|---|
| POST | `/api/mails/:id/ignore` | admin → `IGNORED` + audit |
### 4.9 健康检查
| GET `/api/health` | `{mysql:ok, imap_lock?:string}` | 无鉴权 |
---
## 5. 项目工程结构
```
email-forecast/
├── docker-compose.yml # web + worker + mysql
├── Dockerfile # 同一镜像,不同 CMD
├── .env.example
├── package.json # pnpm workspaces 可不用,单 package
├── prisma/
│ ├── schema.prisma
│ └── seed.ts # 仅 AppUser(admin/ops)
├── data/ # volume 挂载(gitignore)
├── docx/ # 需求/设计(已有,不进镜像必填)
├── src/
│ ├── app/
│ │ ├── (auth)/login/page.tsx
│ │ ├── (ops)/
│ │ │ ├── layout.tsx
│ │ │ ├── mails/page.tsx # 列表
│ │ │ ├── mails/[id]/page.tsx # 详情
│ │ │ ├── mails/[id]/import/page.tsx # 确认导入
│ │ │ └── imports/page.tsx # 日志/补偿
│ │ ├── api/... # Route Handlers
│ │ └── layout.tsx
│ ├── components/
│ │ ├── MailTable.tsx
│ │ ├── ShipmentVirtualTable.tsx # antd + tanstack virtual
│ │ └── StatusTag.tsx
│ ├── services/
│ │ ├── db.ts # PrismaClient 单例
│ │ ├── state-machine.ts
│ │ ├── imap/
│ │ │ ├── client.ts
│ │ │ └── poller.ts
│ │ ├── parse/
│ │ │ ├── pipeline.ts
│ │ │ ├── classify.ts
│ │ │ ├── packing-list.ts
│ │ │ └── channel-map.ts
│ │ ├── cc/
│ │ │ ├── http.ts
│ │ │ ├── auth.ts # token DB+memory
│ │ │ ├── save-container.ts
│ │ │ └── conflict.ts
│ │ ├── import/
│ │ │ ├── confirm.ts
│ │ │ └── compensation.ts
│ │ └── audit.ts
│ ├── types/
│ │ ├── mail.ts # MailStatus, MailType
│ │ ├── parse.ts
│ │ └── cc.ts
│ ├── utils/
│ │ ├── hash.ts
│ │ ├── iso6346.ts
│ │ └── dates.ts # Asia/Shanghai
│ └── worker/
│ └── index.ts # setInterval 入口
├── tests/
│ ├── unit/
│ └── e2e/
└── README.md
```
### docker-compose 对应
| 服务 | 镜像/构建 | 命令 | 职责 |
|---|---|---|---|
| `mysql` | `mysql:8.0` | 官方入口 | 库 `email_forecast` |
| `web` | 本 Dockerfile | `pnpm start`(`next start`) | UI + `/api` + 导入/CC |
| `worker` | 同镜像 | `node dist/worker/index.js` 或 `tsx src/worker/index.ts` | 仅 IMAP+解析+PARSING 回收 |
共享:`./data` volume、同一 `DATABASE_URL`、同一代码树 `src/services/*`。
`.env` 键名严格对齐 PRD §9.1:`CC_SAAS_HEADER`、`POLL_INTERVAL_MS`、`ENABLE_TYPE_OVERRIDE` 等。
Worker 轮询基线优先读 `imap_settings.poll_interval_ms`(设置页,默认 30min);过滤规则见 `imap_filter_rule`;每封写 `imap_pull_log`(≤1000)。
---
## 6. MVP 实现路径(PRD §10)
| 里程碑 | 任务拆分(可直接建 Jira) | DoD |
|---|---|---|
| **M0** | 初始化 Next15+Prisma+Antd;`docker-compose.yml`;`.env.example`;`/api/health`;登录页壳 | `docker compose up` 打开登录页;migrate 成功 |
| **M1** | Prisma 全表;`seed.ts` 写入 M1–M4 元数据;列表+详情只读;状态/类型 Tag | 列表 4 条;类型与金样例一致 |
| **M2** | `classify.ts` 规则;`packing-list.ts` 吃邮件2 xlsx;详情展示 327 行虚拟表;种子挂附件路径 | shipments 行数=327;核心列断言通过 vitest |
| **M3** | `CcAdapter` login/token;SaveContainer mock 单测;可选 TEST 烟雾脚本 `scripts/cc-smoke.ts` | mock `code=200`;TEST 有账号则 1 柜成功 |
| **M4** | 确认导入页;CAS+幂等;冲突检测;补偿表+重试 API;Admin 改类型 | 勾选 2 行 → payload 2 货件;冲突阻断;双击不双写 |
| **M5** | GetShippingLineList 下拉;`/logs` 导入+拉取;README;回收定时;**无审计页** | 可演示全流程 | 可演示全流程 |
**Cursor 编码顺序:** 严格 M0→M5;每里程碑只改该阶段文件。
---
## 7. 风险与工程约束
| 项 | 约束 |
|---|---|
| 附件 | 单文件 ≤20MB;zip 只解一层;路径禁止 `..`;落盘 `data/mails/{mailId}/` |
| IMAP 锁 | 每轮 `SELECT GET_LOCK('imap_poll',0)`;`finally RELEASE_LOCK`;拿不到锁直接 `return` |
| Token | 读路径:DB `expire_at` 权威 → 内存缓存;写路径:`GET_LOCK('cc_login',5)` 后 login 写 DB |
| 虚拟滚动 | `ShipmentVirtualTable` 固定行高 48px;只渲染可视区±10 行;禁止 Antd Table 默认全量 DOM |
| CC 超时 | 禁止盲重放;`TIMEOUT_UNKNOWN` → 补偿 + GetContainerList 核对 |
| 状态机 | 仅 `state-machine.ts` 允许改 `status`;API 禁止通用 PATCH status |
| 韧性条文 | 实现错误处理对照 `docx/异常场景与系统韧性设计.md` 全文 |
---
## 8. 核心模块 I/O 契约(Cursor 直接实现)
### 8.1 `ParsePipeline.run(mailId: bigint)`
- 读 `mail_message` + attachments
- 写:`type_evidence`, `mail_type`, `parse_result`, `status`, `last_error`
- 副作用:无 CC 调用
### 8.2 `ImapPoller.tick()`
- 锁 → fetch → upsert mail → seen → 对每个新 id 调 `ParsePipeline`
- 另:回收 `PARSING` 超时、`IMPORTING` 超时扫描(只改状态/写补偿,不调 CC)
### 8.3 `ConfirmImport.execute(input)`
- 校验 NEW + version + 勾选
- CAS status
- conflict check
- 逐柜 `SaveContainer`
- 更新 mail 终态 + audit
### 8.4 `CcAuth.getToken()`
- 返回 `{token, loginMark}`;410 路径由 `CcHttpClient.request` 触发 `refresh+replay once`
---
## 9. docker-compose 骨架(落地原文)
```yaml
services:
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: email_forecast
MYSQL_USER: app
MYSQL_PASSWORD: app
MYSQL_ROOT_PASSWORD: root
ports: ["7023:3306"] # 本地避让宿主机 3306
volumes: ["mysql_data:/var/lib/mysql"]
command: ["--default-authentication-plugin=mysql_native_password","--character-set-server=utf8mb4"]
web:
build: .
command: ["pnpm","start"]
ports: ["3100:3100"]
env_file: [.env]
volumes: ["./data:/app/data"]
depends_on: [mysql]
worker:
build: .
command: ["node","dist/worker/index.js"]
env_file: [.env]
volumes: ["./data:/app/data"]
depends_on: [mysql]
volumes:
mysql_data:
```
---
## 10. 文档索引
| 文档 | 路径 |
|---|---|
| PRD | `docx/需求规格-邮件自动预报-v0.2.md` |
| 韧性 | `docx/异常场景与系统韧性设计.md` |
| 本设计 | `docx/技术设计方案-邮件自动预报-v1.0.md` |
| 实施计划 | `.cursor/rules/implementation-plan.mdc` |
---
## 附录 A — CC Write API 矩阵(一期扩范围)
| capability id | 默认 mode | 拟定 endpoint | 触发 mail_type | 完成标准 |
|---|---|---|---|---|
| `save_container` | live | `/Container/SaveContainer` | NEW_CONTAINER(人工确认) | 已有 |
| `transfer` | mock | `/Container/TransferWarehouse` | TRANSFER | live 真接通 + 烟雾 1 次 |
| `hold_split` | mock | `/Container/HoldSplitInstruction` | INSTRUCTION_HOLD_SPLIT | 同上 |
| `label` | mock | `/Container/ApplyLabelInstruction` | INSTRUCTION_LABEL | 同上 |
表 `cc_write_capability` 可改 mode:`live|mock|off`。`CC_MOCK=true` 时全局按 mock。mock ≠ TEST 验收。

File diff suppressed because it is too large Load Diff

@ -0,0 +1,56 @@
# 邮件样例附件清单
路径约定:`docx/邮件/{邮件N}/` **平铺**业务附件;`*邮箱.pdf` 为 QQ 截图。
## 目录 → 实际附件 → 期望位置
| 邮件 | 实际(修正前) | 期望位置 | 差异 | 修正 |
|---|---|---|---|---|
| **邮件1** | `QQ邮箱.pdf` | 根目录截图;无业务附件 | 一致 | ingest:`none` |
| **邮件2** | `2邮箱.pdf` + `柜号:MATU2745683卡派资料.xlsx` | 根目录 | 一致 | ingest:`packing_xlsx` |
| **邮件3** | `3邮箱.pdf`;卡转海 PDF 在子目录 `WHSU5574991 卡转海/` | 业务 PDF **平铺到 `邮件3/` 根** | 嵌套子目录;ingest 原先不挂附件 | 平铺 + ingest:`business` |
| **邮件4** | `4邮箱.pdf`;换标 xlsx/PDF 在子目录 `原箱号YT…换标操作指令/` | 业务文件 **平铺到 `邮件4/` 根** | 嵌套子目录;ingest 原先不挂附件 | 平铺 + ingest:`label_instruction` |
| **模板** | 预报/DO 共享模板 | `docx/邮件/模板/`(不属于某一封邮件) | 正确 | `ingest-forecast-ui.ts` 单独用 |
## 修正后期望树
```
docx/邮件/
邮件1/
QQ邮箱.pdf
邮件2/
2邮箱.pdf
柜号:MATU2745683卡派资料.xlsx
邮件3/
3邮箱.pdf
WL103276-SBD1(1).pdf
WL103944-WL103940.pdf
WL98967-VGT2(1).pdf
WL99583-LAX9.pdf
邮件4/
4邮箱.pdf
FBA19HW52S0L-76CTN-HIA1-覆盖贴1箱贴2张.pdf
X003UHDF7B-304PCS-覆盖贴1箱贴1张.pdf
YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx
模板/
数据模版.xlsx
数据模版-新辰泽-WHSU8127240.xlsx
…
```
## ingest 策略(`pnpm sample:mails`)
| key | attachPolicy | 入库附件 |
|---|---|---|
| mail1 | `none` | 无 |
| mail2 | `packing_xlsx` | MATU 卡派 xlsx |
| mail3 | `business` | 全部非截图业务文件(卡转海 PDF);解析仍主题短路 |
| mail4 | `label_instruction` | 换标 xlsx + 指令 PDF;pipeline 先试卡派再试贴标指令单 |
## 维护命令
```bash
pnpm exec tsx scripts/flatten-mail-attachments.ts # 子目录 → 邮件根(幂等)
pnpm exec tsx scripts/scan-mail-samples.ts # 扫描树
pnpm sample:mails # 按策略入库
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -0,0 +1,7 @@
{
"id": "M1",
"subject_contains": "TIIU8073522-90022",
"mail_type": "UNKNOWN",
"shipments_expected": 0,
"importable": false
}

@ -0,0 +1,15 @@
{
"id": "M2",
"subject_contains": "MATU2745683",
"mail_type": "WORK_ORDER",
"legacy_mail_type": "TRANSFER",
"container_no": "MATU2745683",
"shipments_expected": 327,
"sample_rows": [
{ "F_FBACode": "ABQ2", "F_Transporter": "TRUCK", "F_CTNS": 1, "F_FBAID": "FBA19G5XJLV4" },
{ "F_FBACode": "FTW1", "F_Transporter": "TRUCK", "F_CTNS": 4, "F_FBAID": "FBA19GLHTHQ2" }
],
"importable_default": false,
"importable_after_admin_type_override_to_NEW": true,
"note": "转仓关键词 → WORK_ORDER 标准记录;导入后置"
}

@ -0,0 +1,6 @@
{
"id": "M3",
"subject_contains": "WHSU5574991",
"mail_type": "INSTRUCTION_HOLD_SPLIT",
"importable": false
}

@ -0,0 +1,8 @@
{
"id": "M4",
"subject_contains": "新增预报",
"mail_type": "WORK_ORDER",
"legacy_mail_type": "INSTRUCTION_LABEL",
"importable": false,
"note": "贴标/拍照关键词 → WORK_ORDER;本期仅标准记录"
}

@ -0,0 +1,22 @@
{
"id": "M5",
"subject_contains": "新增预报",
"mail_type": "NEW_CONTAINER",
"container_no": "MATU2745683",
"shipments_expected": 327,
"importable": true,
"gate": "上线门禁:主题含新增预报 + 卡派清单,无需 Admin 改类型",
"header_expected": {
"F_CabinetType": "40HQ",
"F_ETA": "2026-08-15",
"F_ETD": "2026-07-20",
"F_LoadPort": "上海",
"F_Dock": "洋山",
"F_BLCopyCode": "BLMATU2745683",
"shipping_line_hint": "MSC"
},
"sample_rows": [
{ "F_FBACode": "ABQ2", "F_Transporter": "TRUCK", "F_CTNS": 1 },
{ "F_FBACode": "FTW1", "F_Transporter": "TRUCK", "F_CTNS": 4 }
]
}

@ -0,0 +1,22 @@
{
"id": "M_BL_TEMPLATE",
"subject_contains": "新增预报136",
"mail_type": "NEW_CONTAINER",
"record_kind": "BL_FORECAST",
"modules_expected": {
"customer_name": "新辰泽",
"bl_no": "WHLC027G597465",
"container_no": "WHSU8127240",
"port": "洛杉矶",
"cabinet_type": "40HQ",
"etd": "2026-04-25",
"eta": "2026-05-15",
"vessel_voyage_contains": "HMM EMERALD",
"service_contains": "提拆派",
"classis": 0
},
"plus_payload": "新辰泽+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派组合柜-不带托架",
"template_dir": "docx/邮件/模板",
"importable": false,
"note": "本期仅标准记录;货件表来自附件 xlsx,对齐数据模版列"
}

@ -0,0 +1,10 @@
{
"id": "M_WORK_ORDER",
"subject_contains": "转仓",
"mail_type": "WORK_ORDER",
"record_kind": "WORK_ORDER",
"work_order_actions_any": ["转仓", "贴标", "拦截", "拍照", "快递单号"],
"importable": false,
"auto_exec": false,
"note": "工单关键词覆盖;本期只落 mail_record 表格,不写 CC"
}

@ -0,0 +1,716 @@
# 邮件自动预报系统 — 需求规格 v0.2
> 状态:待评审确认(**2026-07-23 现行口径回写**;**2026-08-03 指令拆分规则见独立产品规则页**)
> 相对 v0.1:补齐状态机/已读策略、导入柜·行语义、金样例、冲突检测、UI 主路径、测试 DoD、运营 SOP
> 相对审查补强:新增 **§14 异常场景与系统韧性设计**(全文亦独立于 `docx/异常场景与系统韧性设计.md`)
> **相对实现回写**:邮件来源仅 IMAP(无开发种子进库);**不做**操作审计页/`audit_log`;IMAP 为近 N 天已读+未读(默认 3 天)+ 幂等;轮询默认 30min(设置页优先);路由含 `/settings`、`/logs`(导入+拉取);详见 `docx/现行口径-修订说明-v1.md`
> **指令识别/拆分锁定口径**:`docx/产品规则-邮件指令识别与拆分.md`(与 §4 类型打分并用;冲突以产品规则页 + `instruction-lexicon.ts` 为准)
> 依据:v0.1、四方审查结论、推荐开发方案、`docx/邮件`、`ccnew` TEST、接口 PDF V1.72
---
## 0. 已确认决策
| # | 决策 | 结论 |
|---|---|---|
| 1 | 验收主路径 | 主题规则识别「新增预报」+ 清单映射以「邮件2 卡派资料.xlsx」为第一模板 |
| 2 | TransMode / OperationType | 解析给推荐默认;确认页**必选可改** |
| 3 | CarrierCentral | TEST `https://test.saas.carriercentral.vip/api` + Header `Saas: TEST`;**SaveContainer** |
| 4 | 邮件线程 | **自动拆单**:按 In-Reply-To/References 拆成独立 Message;解析/执行只看当前条,不把历史主题动作合并进当前 |
| 5 | 导入触发 | `NEW_CONTAINER` **必须人工确认**;`TRANSFER` / 留仓拆分 / 贴标解析成功后 **自动调 CC 写接口**(mock/live 门禁) |
| 6 | 可导入类型 | `NEW_CONTAINER` 人工确认;指令类自动执行(须 CC 能力 live,mock 不可作 TEST 验收) |
| 10 | IMAP 接入 | **多邮箱** + **IDLE**(失败回退轮询)+ **OAuth 与授权码并存**;**可配间隔(默认 30min)** + **黑/白名单与关键词过滤** + **拉取日志(≤1000)** |
| 11 | OCR | PDF/图片 OCR 作为权威正文辅助(参与分类/预筛);置信度低于阈值不覆盖人工可改内容 |
| 7 | 一期验收口径(消解矛盾) | **正式口径**:绑定真实 IMAP → 拉近 N 天邮件 → 列表/详情/确认导入闭环;Admin 改类型保留为运维能力。**上线门禁**:至少 1 封「新增预报+卡派清单」真邮件可进 PENDING_CONFIRM(金样例 JSON 仅对照,不种子进业务库)(见 §3.2) |
| 8 | 已读策略 | **先落库再标已读**;解析失败仍标已读但可「重新解析」(见 §6.2) |
| 9 | 导入粒度 | **一柜一请求**;勾选过滤货件行后组包;多柜才柜级部分成功(见 §5.7) |
| 12 | 操作审计 | **一期不做** Admin 审计查询与 audit_log 落库;以应用日志 / 拉取日志 / 导入日志为准 |
| 13 | 指令拆分 | 见 `docx/产品规则-邮件指令识别与拆分.md`:主题+正文+附件分源;仅最新指令可确认;软词→客户指令;解析无 LLM |
---
## 1. 背景与目标
客户不愿走客户端手工预报,改为向客服邮箱发邮件。系统:拉信 → 解析 → 结构化入库 → 人工确认 → `SaveContainer` 完成新增柜预报。
**一期目标:** Next.js + MySQL + docker-compose;邮件列表/详情/确认导入;对接 TEST `SaveContainer`。
**一期量化成功标准:**
| 指标 | 目标 |
|---|---|
| M2 卡派资料字段映射 | 核心列(柜号/仓库ID/渠道/件数)映射正确率 100%(金样例对照) |
| 人工确认路径 | 从打开详情到提交确认 ≤ 3 分钟(327 行场景含筛选勾选) |
| TEST 导入 | 烟雾 1 柜成功 `code=200` 且返回柜 id;勾选子集导入货件数与请求一致 |
| 类型规则 | M1–M4 金样例 `mail_type` 命中率 4/4 |
**与现有客户端关系:** 本系统是预报入口之一;写入同一 CarrierCentral 账套。一期不做双向同步;冲突以 CC 已有柜为准阻断(§5.8)。
---
## 2. 范围
### 2.1 In Scope
- 多邮箱 IMAP:授权码与 OAuth(Gmail/Microsoft XOAUTH2)并存;QQ 等无 OAuth 的主机仅授权码
- IMAP IDLE(账号可关);失败/不支持时回退轮询(`POLL_INTERVAL_MS`)
- 邮件快照、附件落盘、幂等键(§8.2);业务预筛后入库
- 线程历史自动拆单(In-Reply-To / References → `thread_id`)
- PDF/图片 OCR 权威解析(local/aliyun;置信度门限)
- 类型识别(4 类 + UNKNOWN)+ 金样例
- 「卡派资料」xlsx/csv + 贴标指令单解析 → 柜头 + 货件行
- 前端:列表、详情、确认导入、导入日志/补偿、拉取记录;Admin 改类型;**设置页**(多邮箱/OAuth/CC/拉取间隔与过滤)
- CC:`customerLogin` + `GetContainerList` + `GetShippingLineList` + `SaveContainer`
- CC 写:转仓 / 留仓拆分 / 贴标自动执行(无接口时 mock+门禁;live 真接通才算完成)
- docker-compose:`web` + `worker` + `mysql`
- **不做**:开发种子邮件入库、操作审计页(见决策 #7/#12)
### 2.2 Out of Scope
- AI 客服
- SMTP 自动回信客户(失败亦不自动通知客户)
- 与 CC 双向全量同步(冲突仍以 CC 已有柜阻断)
- 变更须书面确认后方可再扩范围
---
## 3. 样例邮件与验收口径
### 3.1 现网样例(原样展示)
路径:`docx/邮件/`(QQ 截图 PDF + 附件,无 `.eml`)。
| ID | 目录 | 期望 `mail_type` | 一期动作 |
|---|---|---|---|
| M1 | `邮件1/` | `UNKNOWN` | 展示 |
| M2 | `邮件2/` + 卡派资料.xlsx | `WORK_ORDER`(转仓关键词覆盖;历史 TRANSFER) | 标准记录表;不导入 |
| M3 | `邮件3/` | `INSTRUCTION_HOLD_SPLIT`(贴标信号为证据,主类型按最高分;同分见 §4) | 展示 |
| M4 | `邮件4/` 当前 Message | `WORK_ORDER`(贴标/拍照覆盖;历史 INSTRUCTION_LABEL) | 标准记录;不拆根主题 NEW |
| M_BL_TEMPLATE | `邮件/模板/` | `NEW_CONTAINER` + `mail_record.kind=BL_FORECAST` | 模块化柜头 + 数据模版表 |
### 3.2 验收口径(推荐方案,已采纳)
| 阶段 | 口径 |
|---|---|
| **开发/一期验收** | ① 设置页绑定 IMAP 并连通测试;② 拉取近 N 天邮件可见列表;③ 命中「新增预报+清单」或 Admin 改类型后走确认导入(CC mock 可演示;TEST 须 live) |
| **上线门禁** | 至少 1 封真邮件「主题含新增预报 + 卡派/装箱清单」→ `NEW_CONTAINER`/`PENDING_CONFIRM`;金样例 `docx/金样例/*.json` 仅作期望对照,**禁止**再依赖种子邮件验收 |
---
## 4. 邮件类型判定
### 4.1 枚举
`NEW_CONTAINER` | `TRANSFER` | `INSTRUCTION_HOLD_SPLIT` | `INSTRUCTION_LABEL` | `WORK_ORDER` | `UNKNOWN`
### 4.2 规则(加权 + 词边界)
信号源:主题 + 正文纯文本 + 附件名。
取最高分;**同分优先级:** `INSTRUCTION_LABEL` = `INSTRUCTION_HOLD_SPLIT` > `TRANSFER` > `NEW_CONTAINER` > `UNKNOWN`(指令内部:同时命中贴标与拆分时,**贴标分 ≥ 拆分则 LABEL,否则 HOLD_SPLIT**)。
| 信号 | 匹配方式 | 分 | 类型 |
|---|---|---|---|
| 新增预报 | 主题优先;词完整匹配 | +50 | NEW_CONTAINER |
| 新增转仓 / 转仓 | **词边界**;排除「不转仓」 | +50 | TRANSFER(可被工单覆盖) |
| 换标 / 覆盖贴 / 贴标 / 贴好拍照 | 正文或附件名 | +40 | INSTRUCTION_LABEL(可被工单覆盖) |
| 拆柜清单 / 卡转海 / 拦截 / 改自提 / 留仓 | 同上 | +40 | INSTRUCTION_HOLD_SPLIT(可被工单覆盖) |
| 附件名含换标、贴标指令 | 文件名 | +30 | INSTRUCTION_LABEL |
| 附件名含卡派资料 | 文件名 | +15 | **只加分到当前领先类型**,不单独定类型 |
| ISO 柜号 | `^[A-Z]{4}\d{7}$` | +5 | 辅证到领先类型 |
| ETA / 船名航次 / 柜型 | 主题或正文 | +5 | 辅证到领先类型 |
最低可判定分:**40**,否则 `UNKNOWN`。
只依据**当前 Message**。证据写入 `type_evidence`(各信号命中列表 + 总分)。
### 4.3 工单覆盖(现行优先)
正文/主题/附件名命中任一:**贴标 / 拦截 / 拍照 / 转仓 / 快递单号**(「不转仓」除外)→ 最终 `mail_type=WORK_ORDER`,覆盖上表得分结果。
**本期**:工单只做标准记录落库与详情展示,**不**自动写 CC、**不**进确认导入。转仓一律先记工单,到仓分流自动转仓后置。
### 4.4 混乱正文柜号启发式
正文较乱时:优先扫正文**前两行**,取首个 `^[A-Z]{4}\d{7}`(ISO 柜号)写入 `mail_record.modules.container_no`。
### 4.5 提单「+」标准模板 → `mail_record`
样例(`docx/邮件/模板`):主题/正文含
`客户+提单号+柜号+港口+柜型+EDT… ETA…船名航次…+服务描述`。
解析为 `kind=BL_FORECAST` 模块化柜头;货件行对齐 `数据模版.xlsx` 列写入 `mail_record.table`。详情按模块 + 表格标准化展示。
### 4.6 标准记录(本期交付重心)
解析产出 `parse_result.mail_record`(`kind` / `summary` / `modules` / `table` / `work_order_actions`)。
**本期不做导入闭环变更**;`NEW_CONTAINER` 仍可进 `PENDING_CONFIRM`,工单类一律 `PARSED`。
---
## 5. CarrierCentral 对接
### 5.1 环境
| 环境 | API Base | Header |
|---|---|---|
| 一期默认 TEST | `https://test.saas.carriercentral.vip/api` | `Saas: TEST` |
| Demo 备选 | `https://api.saas.carriercentral.vip/api-demo` | 按租户 |
| 本机 ccnew | `http://host.docker.internal:31173` | `Saas: TEST` |
以 ccnew `TEST.json` 为准,不用 PDF 的 demovip。
### 5.2 请求信封
```json
{
"token": "<customerLogin>",
"loginMark": "<进程级固定 UUID v4>",
"data": "<多数接口为 JSON 字符串>"
}
```
Header:`Saas: <CC_SAAS_HEADER>`。
### 5.3 登录
- `POST /learun/adms/user/customerLogin`
- password = MD5(明文) 32 位小写
- token 缓存于 **web/worker 进程内存 + DB 表 `cc_token_cache`**(多实例以 DB 为准);**410 → 清缓存重登 → 重放原请求 1 次**
- 谁调用 SaveContainer:**仅 Web API(用户点击确认)**;worker 不做导入
### 5.4 接口选择
| 接口 | 一期 |
|---|---|
| `/Container/Import` | **不用**(ccnew Insert 可能被注释) |
| `/Container/SaveContainer` | **采用** |
| `/Container/GetContainerList` | 导入前冲突检测 |
| `/ConfigModule/GetShippingLineList` | 船司匹配(可 M5) |
### 5.5 字段映射
**柜头 `entity`:**
| 字段 | 规则 |
|---|---|
| `F_TransMode` | 确认页必选:0/1/3;默认推荐 0 |
| `F_OperationType` | 确认页必选:0/2/4;默认推荐 0;主题含「直送」推荐 2;「提拆派」仍推荐 0(开放项可改) |
| `F_ContainerNo` | 必填;ISO 6346(可配置关闭) |
| `F_CabinetType` | 可解析可改 |
| `F_BLCopyCode` / `F_ETD` / `F_ETA` / `F_LoadPort` / `F_Dock` | 有则填 |
| `F_ShippingLineId` | 模糊匹配;失败留空人工选 |
| `F_Classis` | 不带托架→0;带车架→1;默认 0 |
| `F_MemoRemark` / `F_Instruction` | 正文摘要 |
| `keyValue` | 新建恒 `null` |
**货件 ← 卡派资料:**
| 列 | 字段 | 必填 |
|---|---|---|
| 仓库ID | `F_FBACode` | 是 |
| 渠道 | `F_Transporter`(卡派→`TRUCK`;UPS/FEDEX/DHL/USPS/自提/留仓/扣货) | 是 |
| 件数 | `F_CTNS` | 是 |
| 总体积/毛重 | `F_CBM` / `F_Weight` | 否 |
| FBA ID | `F_FBAID` | 否 |
| Amazon reference ID | `F_ReferenceId` | 否 |
| 分货标识/箱唛 | `F_ShipmentID` | 否 |
| 派送地址 | `F_Address` | 否 |
| 最早/最晚送仓 | `F_Expected_DeliveryDateB/E` | 否 |
| 备注 | `F_Remark` | 否 |
有效行:`F_FBACode` + `F_Transporter` + `F_CTNS` 齐全。无效行 `row_status=INVALID`,默认不勾选。
### 5.6 渠道映射表(黄金)
| 原文(含) | `F_Transporter` |
|---|---|
| 卡派 / TRUCK / truck | TRUCK |
| UPS | UPS |
| FEDEX / FedEx | FEDEX |
| DHL | DHL |
| USPS | USPS |
| 自提 | 自提 |
| 留仓 | 留仓 |
| 扣货 / 拦截 | 扣货 |
| 其他 | 原样上限 30 字符;标 `CHANNEL_UNMAPPED` 警告 |
### 5.7 导入语义(柜 / 行)— 推荐方案已采纳
```
一封邮件解析结果通常 = 1 个柜号 + N 条货件行
确认页:勾选货件行(过滤 INVALID)
提交:按柜号分组
→ 每个柜号 1 次 SaveContainer
→ entity = 柜头;sR_Shipments = 该柜勾选行
多柜(少见):柜 A 成功、柜 B 失败 → PARTIAL_SUCCESS;B 入补偿队列
单柜:要么 SUCCESS 要么 FAILED(行已在提交前过滤,不再「半柜成功」)
```
**禁止:** 同一柜拆多次 SaveContainer 做「行级部分成功」(CC 侧是整柜货件列表)。
重试:补偿队列按柜重试;已成功柜 `external_container_id` 非空则跳过。
### 5.8 冲突检测
导入前(确认提交时):
1. `GetContainerList`,`queryJson.F_ContainerNo = 柜号`,近 30 天(`StartTime/EndTime`)
2. 若存在非归档/有效记录 → 本柜 `CONFLICT`,不调用 SaveContainer,状态保持可编辑
3. 本库 `container_import` 同柜 `SUCCESS` 且同 Message-ID → 幂等跳过
4. 本库他邮件已 SUCCESS 同柜 → 阻断并提示原邮件
### 5.9 SaveContainer 黄金请求样例
```json
{
"loginMark": "11111111-2222-3333-4444-555555555555",
"token": "<from customerLogin>",
"data": "{\"keyValue\":null,\"entity\":{\"F_TransMode\":0,\"F_OperationType\":0,\"F_ContainerNo\":\"MATU2745683\",\"F_CabinetType\":\"40HQ\",\"F_BLCopyCode\":\"\",\"F_ETD\":null,\"F_ETA\":\"2026-07-13\",\"F_LoadPort\":\"\",\"F_Dock\":\"\",\"F_ShippingLineId\":\"\",\"F_Classis\":0,\"F_MemoRemark\":\"邮件预报导入\",\"F_Instruction\":\"\"},\"sR_Shipments\":[{\"F_FBACode\":\"ABQ2\",\"F_Transporter\":\"TRUCK\",\"F_ShipmentID\":\"BAZUS001799251\",\"F_Remark\":\"转POC2\",\"F_FBAID\":\"FBA19G5XJLV4\",\"F_ReferenceId\":\"3NHUDF7E\",\"F_Address\":\"\",\"F_CTNS\":1,\"F_CBM\":0.09,\"F_Weight\":10.69},{\"F_FBACode\":\"FTW1\",\"F_Transporter\":\"TRUCK\",\"F_ShipmentID\":\"\",\"F_FBAID\":\"FBA19GLHTHQ2\",\"F_ReferenceId\":\"5ELVT8ED\",\"F_CTNS\":4,\"F_CBM\":0.21,\"F_Weight\":85.08}]}"
}
```
成功:`code=200`,`data` = 集装箱 id 字符串。
失败:`400/500` 记入 `container_import.last_error`;`410` 触发重登重放一次。
### 5.10 错误码 → 文案
| code | 文案 |
|---|---|
| 200 | 导入成功 |
| 400 | 业务校验失败:{info} |
| 410 | 登录失效,已自动重试;仍失败请检查 CC 账号 |
| 500 | CC 异常:{info} |
| 网络/超时 | 连接 CC 超时,已入补偿队列 |
---
## 6. 邮件接入与状态机
### 6.1 IMAP
| 项 | 值 |
|---|---|
| 服务器 | `imap.qq.com:993` SSL(多主机可配) |
| 鉴权 | 授权码;Gmail/MS 可 OAuth |
| 轮询 | **默认 30min**(设置页 `imap_settings` 优先;`.env POLL_INTERVAL_MS` 回退);可 IDLE |
| 拉取 | **近 N 天已读+未读**(`IMAP_LOOKBACK_DAYS` 默认 3);按 UID 去重后再 FETCH;单连接 + `GET_LOCK` |
| 幂等 | 见 §8.2 |
| SMTP | 不做 |
### 6.2 已读策略(推荐已采纳)
```
SEARCH SINCE (今天起往前 N-1 天) ← 含已读+未读
→ 过滤 DB 已有 (mailbox, folder, uid) / message_id / raw_hash
→ FETCH 新 UID → 落库 FETCHED(raw.eml + 附件)
→ 立即 STORE \Seen ← 避免重复消费;以 DB 状态为准
→ 同进程 PARSING
→ 成功 PARSED / PENDING_CONFIRM /(指令类可 AUTO_EXECUTING)
→ 失败 PARSE_FAILED(可点「重新解析」,不依赖邮箱未读)
```
若落库前进程崩溃:邮件可能仍未入库,下轮 lookback 仍会命中;靠幂等键去重。
(历史口径「仅 UNSEEN」已废止。)
### 6.3 状态机
| 状态 | 含义 | 可执行操作 |
|---|---|---|
| `FETCHED` | 已落库 | 系统自动解析 |
| `PARSING` | 解析中 | 无 |
| `PARSED` | 已解析;类型非 NEW 或未达确认条件 | 查看;Admin 改类型 |
| `PENDING_CONFIRM` | 类型=NEW 且 ≥1 有效货件 | 编辑字段;确认导入 |
| `IMPORTING` | 调用 CC 中 | 禁止重复提交 |
| `SUCCESS` | 全部柜成功 | 查看日志 |
| `PARTIAL_SUCCESS` | 多柜部分成功 | 对失败柜重试 |
| `FAILED` | 单柜失败或全部失败 | 重试;改数据再确认 |
| `PARSE_FAILED` | 解析失败 | 重新解析 |
| `REJECTED_VALIDATION` | 本地校验拒绝(无柜号等) | 编辑/补附件后重解析 |
| `IGNORED` | 人工忽略 | 仅 Admin 从详情触发 |
**迁移规则:**
| 从 | 条件 | 到 |
|---|---|---|
| FETCHED | 开始解析 | PARSING |
| PARSING | 成功且 type=NEW 且有效行≥1 | PENDING_CONFIRM |
| PARSING | 成功且其他类型 | PARSED |
| PARSING | 异常/无模板 | PARSE_FAILED |
| PARSING | 无柜号等核心缺失 | REJECTED_VALIDATION |
| PENDING_CONFIRM | 用户确认 | IMPORTING |
| IMPORTING | 全成功 | SUCCESS |
| IMPORTING | 多柜部分成功 | PARTIAL_SUCCESS |
| IMPORTING | 失败 | FAILED |
| PARSE_FAILED | 重新解析 | PARSING |
| PARSED | Admin 改类型为 NEW 且有效行≥1 | PENDING_CONFIRM |
| * | Admin 忽略 | IGNORED |
---
## 7. 前端(含交互细则)
### 7.1 信息架构 / 主路径
```
登录 → 邮件列表 → 邮件详情(摘要/附件/证据/解析表)
├─ 类型≠NEW:仅查看(按钮禁用+原因)
└─ 类型=NEW 或 Admin 改类型后 → 确认导入页
→ 提交 → 结果 Toast → 导入日志
```
详情与确认:**两步**(详情只读+轻编辑柜头;确认页负责 TransMode/OperationType/行勾选/提交)。
### 7.2 页面
1. **邮件列表** — 主题、发件人、时间、类型色标、状态角标;默认按时间倒序;筛类型/状态
2. **邮件详情** — 正文摘要、附件下载、`type_evidence`、货件表预览(虚拟列表)
3. **确认导入** — TransMode/OperationType 必选;柜头可编辑;货件表:
- 默认勾选所有 `VALID` 行;`INVALID` 灰显不可选
- 顶栏:全选有效 / 反选;按 FBACode/ShipmentID 搜索过滤
- 327 行:**虚拟滚动**;底部显示「已选 n / 有效 m」
- 二次确认对话框:「将向 CC 导入柜 {no},货件 {n} 行」
- 提交后按钮 loading,状态 IMPORTING 防重复
4. **导入日志/补偿** — 按柜成功失败、错误文案、重试按钮
空态:无邮件「等待 IMAP 拉取」;错态见 §5.10。
### 7.3 Admin「改类型」
- 环境变量 `ENABLE_TYPE_OVERRIDE=true` 或角色=admin 时显示
- 正式运营账号默认隐藏
- Admin 改类型:详情 Modal;`ENABLE_TYPE_OVERRIDE`(前端构建侧须同步暴露开关,见 UI 方案);**一期不写 audit_log**
### 7.4 线框要点(一期低保真即可)
- 列表:左类型色点,右状态 pill
- 确认页:上柜头表单两列;下表格 + 固定底栏「确认导入」
- 不强制品牌规范;清晰密度优先(客服桌面端为主,手机不一期优化)
---
## 8. 数据模型
### 8.1 表(核心字段)
**mail_message**
| 列 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | |
| message_id | VARCHAR(998) UNIQUE NULL | RFC Message-ID |
| imap_uid | BIGINT | 与 folder 组合唯一 |
| folder | VARCHAR(64) | 默认 INBOX |
| subject, from_addr, received_at | | |
| mail_type | VARCHAR(32) | |
| status | VARCHAR(32) | |
| type_evidence | JSON | |
| snapshot_path | VARCHAR(512) | |
| raw_hash | CHAR(64) | 正文+关键指纹 |
| created_at / updated_at | | |
**唯一:** `UNIQUE(message_id)`(NULL 不冲突时用下条);`UNIQUE(folder, imap_uid)`;`UNIQUE(raw_hash)` 兜底。
**mail_attachment** — mail_id, filename, sha256, path, size, template_id
**parse_result** — mail_id UNIQUE, container_header JSON, shipments JSON, lineage JSON
**container_import** — mail_id, container_no, external_id, request_body, response_body, status, last_error
**import_compensation** — import_id, retry_count, next_retry_at, max 3
**cc_token_cache** — login_mark, token, expire_at
**audit_log** — actor, action, payload JSON, created_at
**sender_customer_map** — 可空,一期不阻断
### 8.2 幂等键(无 Message-ID 时)
优先级:`Message-ID` → `folder+imap_uid` → `sha256(Date+From+Subject+附件名列表)`。
### 8.3 附件处理
- xlsx/csv 直接解析;xls 转读;**zip 解压一层**取其中 xlsx/csv(邮件3)
- 多 sheet:优先名含「卡派」否则第一 sheet
- 表头别名失败 → `PARSE_FAILED` + 缺失列列表
- 单附件 ≤20MB;超限拒收记 REJECTED_VALIDATION
### 8.4 存储
`./data` volume;保留 **30 天**(可配 `DATA_RETENTION_DAYS`)。
---
## 9. 技术架构
```
Next.js Web ──确认导入──▶ CC Adapter ──▶ test.saas.../SaveContainer
│ ▲
▼ │ token
MySQL 8 ◀──解析入库── worker (IMAP only)
```
- Worker:**只负责 IMAP + 解析**;不调 SaveContainer
- Web:确认导入、补偿重试、Admin
- compose:`web` / `worker` / `mysql`
- 多 worker:用 MySQL `GET_LOCK('imap_poll')` 互斥,保证单连接语义
### 9.1 环境变量(统一命名)
```bash
DATABASE_URL=mysql://app:***@mysql:3306/email_forecast
POLL_INTERVAL_MS=1800000
IMAP_LOOKBACK_DAYS=3
DATA_RETENTION_DAYS=30
ENABLE_TYPE_OVERRIDE=true
IMAP_HOST=imap.qq.com
IMAP_PORT=993
IMAP_USER=
IMAP_PASS=
CC_API_BASE=https://test.saas.carriercentral.vip/api
CC_SAAS_HEADER=TEST
CC_LOGIN_MARK=
CC_USERNAME=
CC_PASSWORD_PLAIN= # 服务端 MD5;或直接 CC_PASSWORD_MD5=
APP_ADMIN_USER=
APP_ADMIN_PASS=
```
---
## 10. 开发路线与 DoD
| 阶段 | 交付 | DoD(可测) |
|---|---|---|
| M0 | 脚手架 compose | `docker compose up` 打开登录页 |
| M1 | IMAP 绑定 + 列表详情 | 绑定邮箱后可拉取;列表/详情可用 |
| M2 | 卡派解析 + 规则 | M2 shipments 行数=327;核心列金样例通过 |
| M3 | CC 客户端 | mock/TEST 烟雾 1 柜 `code=200` |
| M4 | 确认导入 | 勾选 2 行 → 请求仅 2 货件;冲突柜阻断;失败可重试 |
| M5 | 船司匹配 + /logs + README | 演示全流程 + 运维文档(无审计页) |
原则:独立 Next 栈;只复用 CC HTTP 契约;样例驱动。
---
## 11. 测试策略
| 层 | 内容 |
|---|---|
| 单元 | 类型规则(含「不转仓」负例)、渠道映射、ISO 柜号、状态迁移 |
| 契约 | SaveContainer 请求 snapshot 与黄金样例 diff;410 重登重放 |
| 组件/集成 | xlsx 解析 327 行;zip 解压 |
| E2E | IMAP 拉取 →(可选 Admin 改类型)→ 确认导入(CC mock) |
| 手工 | 真实 IMAP(可选);TEST 烟雾 |
**金样例文件(实现时落地):** `docx/金样例/M1.json` … `M4.json`(期望 type + header 摘要 + shipments 前 2 行)。本期文档附录见 §14。
---
## 12. 运营 SOP(一期)
| 场景 | 动作 | 责任 |
|---|---|---|
| 邮件未进系统 | 查 worker 日志 / IMAP 授权码;看是否已读但 PARSE_FAILED | 研发值班 |
| 解析失败 | 详情点「重新解析」;仍失败则人工走客户端预报 | 运营 |
| 类型不对但要导入 | Admin 改类型(需授权)后确认 | 运营主管 |
| 导入失败 | 日志看文案;改字段重试或补偿重试 ≤3 | 运营 |
| 与客户端重复预报 | 冲突阻断后,以 CC 已有单为准,忽略邮件或改柜号 | 运营 |
| 客户追问结果 | 一期无回信 → 运营自行邮件/IM 回复 | 运营 |
失败不自动通知客户(Out of Scope)。
---
## 13. 非功能
- 日邮件 <100;附件 ≤20MB;IMAP 单连接(锁互斥)
- 导入串行;确认页防重复提交
- 拉取/导入日志保留:随 DATA_RETENTION_DAYS(默认 30)
- 准确率:金样例 4/4;不承诺 99% 直至标注集扩大
---
## 14. 异常场景与系统韧性设计
> **实现与 QA 以全文为准:** [`docx/异常场景与系统韧性设计.md`](./异常场景与系统韧性设计.md)(含每条:异常描述 / 系统行为 / 降级 / 告警 / 用户结果)。
> 下文为强制覆盖维度与关键决策摘要,与 §5–§8、§12 SOP 对齐。
### 14.0 韧性常量
| 常量 | 值 |
|---|---|
| `IMPORT_LOCK_TTL` | 120s |
| `IMPORTING_TIMEOUT` | 180s |
| `PARSING_STALE` | 10min |
| `COMPENSATION_MAX_RETRY` | 3 |
| `IMAP_CONNECT_TIMEOUT` / `READ` | 30s / 60s |
| `CC_HTTP_TIMEOUT` | 60s |
| `CC_REPLAY_ON_410` | 1 |
| `SHIPMENT_ROW_SOFT_LIMIT` / `HARD` | 1000 / 5000 |
### 14.1 输入层异常
| 场景 | 落点状态/字段 | 系统行为 |
|---|---|---|
| 无主题 | `subject="(无主题)"` | 继续 `PARSING` |
| 无正文且无附件 | `REJECTED_VALIDATION` / `NO_BODY_NO_ATTACHMENT` | 阻断导入 |
| 无支持表格(非 xlsx/csv,zip 无有效表) | `PARSE_FAILED` / `NO_SUPPORTED_SPREADSHEET` | 可「重新解析」 |
| 缺仓库ID/渠道/件数列 | `PARSE_FAILED` + `missing_columns` | 阻断 |
| 柜号非 ISO 6346 | `REJECTED_VALIDATION`;`container_no_valid=false` | 可手改再校验;不自动猜号 |
| 行半空(CTNS≤0 / 仓空) | `row_status=INVALID` | 跳过行,不挡其他 VALID |
| 附件 >20MB | `REJECTED_VALIDATION` / `ATTACHMENT_TOO_LARGE` | 不落全量附件;仍 `\Seen` |
| 渠道未映射 | `CHANNEL_UNMAPPED` | 可 VALID;提交需 `ack_unmapped_channels=true` |
| 体积/重量非数字 | 字段 `null` | 行仍可 VALID |
| 日期/地址错误 | 日期 null;地址截断 500 | 确认页可改;时区 `Asia/Shanghai` |
### 14.2 用户行为与并发
- 重复/并发确认:前端 disabled + `Idempotency-Key`;DB `UPDATE status PENDING_CONFIRM→IMPORTING`;失败者 **409**。
- 刷新:以 DB `status` 为准;不自动重放 SaveContainer。
- 回退/前进:非 `PENDING_CONFIRM` 禁止提交并重定向详情。
- 多标签:`version` 乐观锁 → **409 VERSION_CONFLICT**。
- Admin 改类型:二次确认 + `audit_log`;`SUCCESS` 后禁止改;`PARSING` 中改类型 → **409 MAIL_BUSY**。
### 14.3 网络与通信
- IMAP 超时/断开:释放 `GET_LOCK('imap_poll')`,下轮重试;连续失败 ≥3 → 顶栏红条 + `imap.poll_fail`。
- CC 超时或响应丢失:**禁止盲重放 SaveContainer**;`TIMEOUT_UNKNOWN` 入补偿;用 `GetContainerList` 核对后收敛 `SUCCESS` 或允许重试。
- 弱网重复 POST:靠幂等键 + 条件更新吞掉。
- 静态资源失败:Error boundary;不影响 worker。
### 14.4 CarrierCentral API
- `customerLogin` 5xx/失败:清 `cc_token_cache`+内存;重试登录 ≤2;仍失败整单 `FAILED`。
- SaveContainer 中途 **410**:重登并重放 **1** 次(`CC_REPLAY_ON_410`)。
- SaveContainer **400**:该柜 `FAILED`,不自动重试;多柜可 `PARTIAL_SUCCESS`。
- `GetContainerList` 失败:**默认阻断导入**;`ENABLE_FORCE_IMPORT=true` 才可强跳(一期无 audit_log)。
- 响应 schema 漂移:`code=200` 但无柜 id → 走超时不确定核对流程。
- 浏览器**永不**持有 CC token。
### 14.5 解析与附件
- MIME 损坏:快照保留 → `PARSE_FAILED` / `MIME_PARSE_ERROR`。
- zip:只解 **一层**;嵌套 zip 忽略并 `warn`;损坏 → `ZIP_EXTRACT_FAILED`。
- 无「卡派」sheet → 回退第一 sheet。
- 行数 >5000 → `REJECTED_VALIDATION`;>1000 → 落库但默认不全选 + 警告。
- 正文/附件柜号冲突 → 确认页强制人选,默认阻断提交。
- 类型同分:严格执行 §4.2;`type_evidence` 落库。
- 「重新解析」:禁止在 `IMPORTING|SUCCESS|PARTIAL_SUCCESS`。
### 14.6 状态机与生命周期
- 落库成功但 `\Seen` 失败:幂等跳过新建,仅重试 Seen。
- `PARSING` 超过 `PARSING_STALE`:自动回收(现行可置 PARSE_FAILED 后可再解析;目标语义回 FETCHED)。
- `IMPORTING` 超过 `IMPORTING_TIMEOUT`:转失败/待核查 + 补偿,禁止永久 IMPORTING。
- 补偿 `retry_count≥3` → `COMPENSATION_EXHAUSTED`,停自动重试,人工重置。
- 无通用 status PATCH;非法迁移拒绝。
### 14.7 缓存与幂等
- Token:**DB `cc_token_cache` 为权威**;内存跟随;多实例 410 时抢 `GET_LOCK('cc_login')` 重登。
- 幂等序:`Message-ID` → `folder+imap_uid` → `raw_hash`。
- `raw_hash` 碰撞且 Message-ID 不同:**分叉新建** + `error` 告警。
- 同 Message-ID 重复拉取:唯一约束吞掉。
### 14.8 并发与锁
- Worker:`GET_LOCK('imap_poll')`;拿不到锁本轮退出(正常)。
- 跨邮件同柜并发确认:活跃柜号占位唯一 + §5.8;后提交阻断。
- 补偿调度:已 `SUCCESS`/`IMPORTING` 跳过;`retry_token` 幂等。
### 14.9 数据计算
- UNMAPPED 渠道:无 `ack_unmapped_channels` 则提交 API **400**。
- CBM/Weight:`DECIMAL(18,4)`,提交前 round 4 位。
- 船司匹配失败:`F_ShippingLineId` 留空,不阻断。
- 勾选数以服务端 `accepted_count` 为准,不一致 `warn`。
### 14.10 权限与安全
- 未登录 API → **401**。
- 改类型:服务端校验 `role=admin && ENABLE_TYPE_OVERRIDE` → 否则 **403**。
- 导入限流:**10 次/用户/分钟** → **429**。
- 改类型 / 确认导入 / 强制跳过冲突 / 补偿重试 / 忽略 → 全写 `audit_log`。
### 14.11 监控与 QA
全文 §11 指标阈值、§12 用例索引 `A-1.x`…`A-10.x`(见独立文件)。
## 15. 附录:金样例期望(摘要)
### M1
```json
{
"id": "M1",
"subject_contains": "TIIU8073522-90022",
"mail_type": "UNKNOWN",
"shipments_expected": 0,
"importable": false
}
```
### M2
```json
{
"id": "M2",
"subject_contains": "MATU2745683",
"mail_type": "TRANSFER",
"container_no": "MATU2745683",
"shipments_expected": 327,
"sample_rows": [
{ "F_FBACode": "ABQ2", "F_Transporter": "TRUCK", "F_CTNS": 1, "F_FBAID": "FBA19G5XJLV4" },
{ "F_FBACode": "FTW1", "F_Transporter": "TRUCK", "F_CTNS": 4, "F_FBAID": "FBA19GLHTHQ2" }
],
"importable_default": false,
"importable_after_admin_type_override_to_NEW": true
}
```
### M3
```json
{
"id": "M3",
"subject_contains": "WHSU5574991",
"mail_type": "INSTRUCTION_HOLD_SPLIT",
"notes": "卡转海/贴标为证据分;主类型按 §4 同分规则",
"importable": false
}
```
### M4
```json
{
"id": "M4",
"subject_contains": "新增预报",
"mail_type": "INSTRUCTION_LABEL",
"notes": "当前 Message 为换标指令;不拆历史新增预报",
"importable": false
}
```
---
## 16. 开放项
- [ ] TEST 客户账号密码(谁提供)
- [ ] 「提拆派组合柜」默认 OperationType 是否维持 0
- [ ] 生产 API Base / Header
- [ ] **上线门禁**:补「新增预报+清单」真邮件
- [ ] 运营登录:简单账号 vs SSO
---
## 17. 索引
| 资料 | 路径 |
|---|---|
| 本文 v0.2 | `docx/需求规格-邮件自动预报-v0.2.md` |
| 异常与韧性(全文) | `docx/异常场景与系统韧性设计.md` |
| v0.1 | `docx/需求规格-邮件自动预报-v0.1.md` |
| 接口 PDF | `docx/接口/carriercentral客户端通用接口V1.72docx(4).pdf` |
| 样例 | `docx/邮件/` |
| ccnew TEST | `X:\work\ccnew\...\App_Data\Saas\TEST.json` |
| 实施计划 | `.cursor/rules/implementation-plan.mdc` |

6
next-env.d.ts vendored

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

@ -0,0 +1,19 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
// 开发态隐藏左下角 Next.js / Dev Tools 指示标
devIndicators: false,
transpilePackages: [
"antd",
"@ant-design/icons",
"@ant-design/cssinjs",
"@ant-design/v5-patch-for-react-19",
],
// 缩小 antd / icons 的编译与打包面,显著降低 dev 首访 compile 时间
experimental: {
optimizePackageImports: ["antd", "@ant-design/icons"],
},
};
export default nextConfig;

@ -0,0 +1,77 @@
{
"name": "email-forecast",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "node scripts/dev-stack.mjs",
"dev:web": "next dev -p 3100",
"dev:turbo": "next dev --turbopack -p 3100",
"build": "next build && tsc -p tsconfig.worker.json",
"start": "next start -p 3100",
"worker": "tsx src/worker/index.ts",
"dev:stack": "node scripts/dev-stack.mjs",
"fix:ui-zh": "tsx scripts/rewrite-mail-business-summary.ts",
"compose:up": "docker compose up --build -d",
"compose:up:core": "docker compose up -d mysql worker",
"compose:logs": "docker compose logs -f --tail=100",
"compose:ps": "docker compose ps",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:push": "prisma db push",
"cc:smoke": "tsx scripts/cc-smoke.ts",
"imap:smoke": "tsx scripts/imap-smoke.ts",
"imap:poll": "tsx scripts/imap-poll.ts",
"sample:mail3": "tsx scripts/ingest-mail3.ts",
"sample:mails": "tsx scripts/ingest-sample-mails.ts",
"sample:do": "tsx scripts/ingest-do-upload-ui.ts",
"sample:recognize": "tsx scripts/ingest-recognize-gold.ts",
"sample:flatten": "tsx scripts/flatten-mail-attachments.ts",
"sample:scan": "tsx scripts/scan-mail-samples.ts",
"retention:cleanup": "tsx scripts/retention-cleanup.ts",
"postinstall": "prisma generate"
},
"dependencies": {
"@ant-design/cssinjs": "^1.22.0",
"@ant-design/icons": "^5.5.2",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@prisma/client": "^5.22.0",
"@tanstack/react-virtual": "^3.11.2",
"adm-zip": "^0.5.16",
"antd": "^5.22.6",
"exceljs": "^4.4.0",
"imapflow": "^1.0.181",
"iron-session": "^8.0.4",
"mailparser": "^3.7.2",
"mysql2": "^3.23.1",
"next": "^15.1.3",
"pino": "^9.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"undici": "^6.21.0",
"uuid": "^11.0.3",
"zod": "^3.24.1"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@types/adm-zip": "^0.5.7",
"@types/mailparser": "^3.4.5",
"@types/mysql": "^2.15.27",
"@types/node": "^20.17.10",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/uuid": "^10.0.0",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

@ -0,0 +1,17 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 60_000,
use: {
baseURL: "http://127.0.0.1:3100",
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "pnpm dev",
url: "http://127.0.0.1:3100",
reuseExistingServer: true,
timeout: 120_000,
},
});

File diff suppressed because it is too large Load Diff

@ -0,0 +1,271 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model MailboxAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
name String @db.VarChar(128)
host String @default("imap.qq.com") @db.VarChar(255)
port Int @default(993) @db.UnsignedInt
username String @db.VarChar(320)
/// AES-GCM ciphertext (base64); empty when OAuth-only
passwordEnc String @default("") @map("password_enc") @db.VarChar(1024)
/// PASSWORD = 授权码;OAUTH = XOAUTH2
authType String @default("PASSWORD") @map("auth_type") @db.VarChar(16)
oauthProvider String? @map("oauth_provider") @db.VarChar(32)
oauthRefreshEnc String? @map("oauth_refresh_enc") @db.VarChar(2048)
oauthAccessEnc String? @map("oauth_access_enc") @db.VarChar(2048)
oauthExpiresAt DateTime? @map("oauth_expires_at") @db.DateTime(3)
idleEnabled Boolean @default(true) @map("idle_enabled")
folder String @default("INBOX") @db.VarChar(64)
enabled Boolean @default(true)
lastTestAt DateTime? @map("last_test_at") @db.DateTime(3)
lastTestOk Boolean? @map("last_test_ok")
lastTestError String? @map("last_test_error") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
mails MailMessage[]
@@unique([username], map: "uk_mailbox_username")
@@index([enabled], map: "idx_mailbox_enabled")
@@map("mailbox_account")
}
model MailMessage {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
// utf8mb4 unique key limit: keep message_id ≤ 191 chars for UNIQUE
messageId String? @unique @map("message_id") @db.VarChar(191)
mailboxAccountId BigInt? @map("mailbox_account_id") @db.UnsignedBigInt
imapUid BigInt? @map("imap_uid") @db.UnsignedBigInt
folder String @default("INBOX") @db.VarChar(64)
subject String @default("(无主题)") @db.VarChar(512)
fromAddr String @default("") @map("from_addr") @db.VarChar(320)
receivedAt DateTime? @map("received_at") @db.DateTime(3)
bodyText String? @map("body_text") @db.MediumText
/// OCR 抽取文本(权威解析辅助)
ocrText String? @map("ocr_text") @db.MediumText
/// 线程根 Message-ID(规范化)
threadId String? @map("thread_id") @db.VarChar(191)
inReplyTo String? @map("in_reply_to") @db.VarChar(191)
referencesHeader String? @map("references_header") @db.Text
isThreadRoot Boolean @default(true) @map("is_thread_root")
mailType String @default("UNKNOWN") @map("mail_type") @db.VarChar(32)
status String @default("FETCHED") @db.VarChar(32)
typeEvidence Json? @map("type_evidence")
snapshotPath String? @map("snapshot_path") @db.VarChar(512)
rawHash String? @unique @map("raw_hash") @db.Char(64)
lastError String? @map("last_error") @db.VarChar(1000)
version Int @default(1) @db.UnsignedInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
mailboxAccount MailboxAccount? @relation(fields: [mailboxAccountId], references: [id], onDelete: SetNull)
attachments MailAttachment[]
parseResult ParseResult?
imports ContainerImport[]
@@unique([mailboxAccountId, folder, imapUid], map: "uk_mailbox_folder_uid")
@@index([status, receivedAt], map: "idx_status_received")
@@index([mailType], map: "idx_mail_type")
@@index([mailboxAccountId], map: "idx_mailbox")
@@index([threadId], map: "idx_thread")
@@map("mail_message")
}
model MailAttachment {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
mailId BigInt @map("mail_id") @db.UnsignedBigInt
filename String @db.VarChar(512)
contentType String? @map("content_type") @db.VarChar(128)
sha256 String @db.Char(64)
path String @db.VarChar(512)
size Int @db.UnsignedInt
rejected Boolean @default(false)
templateId String? @map("template_id") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
mail MailMessage @relation(fields: [mailId], references: [id])
@@index([mailId], map: "idx_mail")
@@map("mail_attachment")
}
model ParseResult {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
mailId BigInt @unique @map("mail_id") @db.UnsignedBigInt
containerHeader Json @map("container_header")
shipments Json
lineage Json?
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
mail MailMessage @relation(fields: [mailId], references: [id])
@@map("parse_result")
}
model ContainerImport {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
mailId BigInt @map("mail_id") @db.UnsignedBigInt
containerNo String @map("container_no") @db.VarChar(50)
externalId String? @map("external_id") @db.VarChar(64)
requestBody String? @map("request_body") @db.MediumText
responseBody String? @map("response_body") @db.MediumText
status String @db.VarChar(32)
lastError String? @map("last_error") @db.VarChar(1000)
shipmentsHash String @map("shipments_hash") @db.Char(64)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
mail MailMessage @relation(fields: [mailId], references: [id])
compensations ImportCompensation[]
@@unique([mailId, containerNo, shipmentsHash], map: "uk_mail_container_shiphash")
@@index([mailId], map: "idx_mail")
@@index([containerNo], map: "idx_container")
@@map("container_import")
}
model ContainerActiveLock {
containerNo String @id @map("container_no") @db.VarChar(50)
mailId BigInt @map("mail_id") @db.UnsignedBigInt
importId BigInt? @map("import_id") @db.UnsignedBigInt
lockedAt DateTime @map("locked_at") @db.DateTime(3)
@@map("container_active_lock")
}
model ImportCompensation {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
importId BigInt @map("import_id") @db.UnsignedBigInt
retryCount Int @default(0) @map("retry_count") @db.UnsignedInt
maxRetry Int @default(3) @map("max_retry") @db.UnsignedInt
nextRetryAt DateTime? @map("next_retry_at") @db.DateTime(3)
reason String @db.VarChar(64)
status String @default("OPEN") @db.VarChar(32)
retryToken String @unique @map("retry_token") @db.Char(36)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
import ContainerImport @relation(fields: [importId], references: [id])
@@index([status, nextRetryAt], map: "idx_open")
@@map("import_compensation")
}
model CcTokenCache {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
loginMark String @unique @map("login_mark") @db.Char(36)
token String @db.VarChar(128)
expireAt DateTime @map("expire_at") @db.DateTime(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
@@map("cc_token_cache")
}
/// Singleton CC 连接配置(id 固定为 1);优先于 .env,密码 AES 加密
model CcSettings {
id Int @id @default(1)
mock Boolean @default(true)
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)
/// AES-GCM;空表示沿用 .env 密码或未配置
passwordEnc String? @map("password_enc") @db.VarChar(1024)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
@@map("cc_settings")
}
/// CC 写能力门禁:live | mock | off
model CcWriteCapability {
id String @id @db.VarChar(32)
mode String @default("mock") @db.VarChar(16)
endpoint String? @db.VarChar(512)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
@@map("cc_write_capability")
}
/// Singleton OAuth 应用凭证(id=1);优先于 .env,secret AES 加密
model OauthSettings {
id Int @id @default(1)
publicBaseUrl String @default("http://localhost:3000") @map("public_base_url") @db.VarChar(512)
googleClientId String @default("") @map("google_client_id") @db.VarChar(256)
googleClientSecretEnc String? @map("google_client_secret_enc") @db.VarChar(1024)
msClientId String @default("") @map("ms_client_id") @db.VarChar(256)
msClientSecretEnc String? @map("ms_client_secret_enc") @db.VarChar(1024)
msTenant String @default("common") @map("ms_tenant") @db.VarChar(64)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
@@map("oauth_settings")
}
model SenderCustomerMap {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
matchType String @map("match_type") @db.VarChar(32)
matchValue String @map("match_value") @db.VarChar(320)
customerCode String @map("customer_code") @db.VarChar(64)
enabled Boolean @default(true)
@@unique([matchType, matchValue], map: "uk_match")
@@map("sender_customer_map")
}
model AppUser {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
username String @unique @db.VarChar(64)
passwordHash String @map("password_hash") @db.VarChar(128)
role String @default("ops") @db.VarChar(16)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@map("app_user")
}
/// Singleton IMAP 自动拉取配置(id=1);poll_interval_ms 优先于 .env POLL_INTERVAL_MS
model ImapSettings {
id Int @id @default(1)
/// 默认 30min;合法范围 3min~7d
pollIntervalMs Int @default(1800000) @map("poll_interval_ms")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(3)
@@map("imap_settings")
}
/// 拉取过滤规则:关键词 / 发件人白名单 / 发件人黑名单 / 关键词黑名单
model ImapFilterRule {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
kind String @db.VarChar(32)
value String @db.VarChar(256)
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@unique([kind, value], map: "uk_imap_filter_kind_value")
@@index([kind, enabled], map: "idx_imap_filter_kind")
@@map("imap_filter_rule")
}
/// 拉取记录(每封一条;最多保留 1000,超则删最旧)
model ImapPullLog {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
pulledAt DateTime @default(now()) @map("pulled_at") @db.DateTime(3)
mailboxAccountId BigInt? @map("mailbox_account_id") @db.UnsignedBigInt
mailboxName String @default("") @map("mailbox_name") @db.VarChar(128)
mailboxUsername String @default("") @map("mailbox_username") @db.VarChar(320)
fromAddr String @default("") @map("from_addr") @db.VarChar(320)
subject String @default("") @db.VarChar(512)
mailType String? @map("mail_type") @db.VarChar(32)
mailId BigInt? @map("mail_id") @db.UnsignedBigInt
result String @db.VarChar(16)
reason String? @db.VarChar(64)
@@index([pulledAt], map: "idx_imap_pull_at")
@@map("imap_pull_log")
}

@ -0,0 +1,61 @@
/**
* 仅初始化登录账号。不再写入 M1–M5 / 样例邮件。
* Usage: pnpm db:seed
*/
import { createHash } from "crypto";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
function hashPassword(plain: string): string {
return createHash("sha256").update(plain).digest("hex");
}
async function main() {
const adminUser = process.env.APP_ADMIN_USER || "admin";
const adminPass = process.env.APP_ADMIN_PASS || "admin123";
const opsUser = process.env.APP_OPS_USER || "ops";
const opsPass = process.env.APP_OPS_PASS || "ops123";
await prisma.appUser.upsert({
where: { username: adminUser },
create: {
username: adminUser,
passwordHash: hashPassword(adminPass),
role: "admin",
},
update: { passwordHash: hashPassword(adminPass), role: "admin" },
});
await prisma.appUser.upsert({
where: { username: opsUser },
create: {
username: opsUser,
passwordHash: hashPassword(opsPass),
role: "ops",
},
update: { passwordHash: hashPassword(opsPass), role: "ops" },
});
const caps = [
{ id: "save_container", mode: "live", endpoint: "/Container/SaveContainer" },
{ id: "transfer", mode: "mock", endpoint: "/Container/TransferWarehouse" },
{ id: "hold_split", mode: "mock", endpoint: "/Container/HoldSplitInstruction" },
{ id: "label", mode: "mock", endpoint: "/Container/ApplyLabelInstruction" },
];
for (const c of caps) {
await prisma.ccWriteCapability.upsert({
where: { id: c.id },
create: c,
update: {},
});
}
console.log(`Seed users only: ${adminUser} (admin), ${opsUser} (ops); cc_write_capability seeded`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

@ -0,0 +1,38 @@
import fs from "fs";
import {
extractMailInstructions,
splitBodySegments,
} from "../src/services/parse/split-instructions";
const raw = JSON.parse(
fs.readFileSync("data/logs/mail-pdf-extract.json", "utf8"),
) as Record<string, string[]>;
function bodyOf(keyPart: string): string {
const key = Object.keys(raw).find((k) => k.includes(keyPart));
if (!key) return "";
return raw[key].join("\n");
}
for (const name of ["邮件2", "邮件3", "邮件4"]) {
const body = bodyOf(name);
const segs = splitBodySegments(body);
console.log(`\n==== ${name} segs=${segs.length} bodyLen=${body.length}`);
segs.forEach((s, i) => {
console.log(` ${i}: ${s.replace(/\s+/g, " ").slice(0, 100)}`);
});
const units = extractMailInstructions({
subject: "",
body,
filenames: [],
});
console.log(
" units",
units.map((u) => ({
kind: u.uiKind,
cur: u.isCurrent,
seg: u.segmentIndex,
kw: u.keywords.slice(0, 4),
})),
);
}

@ -0,0 +1,73 @@
/**
* <EFBFBD>?V1.72 文档写入 demovip CC 配置<EFBFBD>?env 已对齐时使用)<EFBFBD>? * pnpm exec tsx scripts/apply-demovip-cc.ts
*/
import { getEnv, resetEnvCache } from "../src/lib/env";
import {
invalidateCcSettingsCache,
testCcLogin,
upsertCcSettings,
} from "../src/services/cc/settings-config";
import { getCcModeStatus } from "../src/services/cc/mode";
import { prisma } from "../src/services/db";
async function main() {
resetEnvCache();
const env = getEnv();
const pub = await upsertCcSettings({
mock: false,
apiBase: env.CC_API_BASE,
saasHeader: env.CC_SAAS_HEADER,
loginMark: env.CC_LOGIN_MARK,
username: (env.CC_USERNAME || "fj").trim(),
password: (env.CC_PASSWORD_PLAIN || "fj123qwe!").trim(),
});
// 文档有真链路的写能力<E883BD>?live;文档无接口的保<E79A84>?mock
const liveIds = ["save_container", "do_upload"] as const;
for (const id of liveIds) {
await prisma.ccWriteCapability.upsert({
where: { id },
create: {
id,
mode: "live",
endpoint:
id === "save_container"
? "/Container/SaveContainer"
: "/Container/SaveFieldValue",
},
update: { mode: "live" },
});
}
invalidateCcSettingsCache();
const status = await getCcModeStatus();
const login = await testCcLogin();
console.log(
JSON.stringify(
{
settings: pub,
mode: status,
login,
api_base: env.CC_API_BASE,
username: env.CC_USERNAME,
mock_env: env.CC_MOCK,
},
null,
2,
),
);
if (!login.ok) process.exit(2);
if (!status.live_ready) process.exit(1);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

@ -0,0 +1,103 @@
/**
* Bind instruction unit.segmentText into MailBusinessSummary form builders.
* ASCII-only edits to avoid Chinese corruption.
*/
import fs from "fs";
const p = "src/components/MailBusinessSummary.tsx";
let t = fs.readFileSync(p, "utf8");
if (!t.includes("segmentText")) {
// work_order block: use unit segment for form
const woOld = `} else if (unit.uiKind === "work_order") {
body = (
<CcWorkOrderForm
mode={canConfirmOps ? "edit" : "readonly"}
value={workOrderValue}
onChange={(p) => setWorkOrderValue((v) => ({ ...v, ...p }))}
/>
);`;
const woNew = `} else if (unit.uiKind === "work_order") {
const woForUnit = buildCcWorkOrderFormValue({
subject: unit.segmentSubject || mail.subject,
body: unit.segmentText || bodyText,
filenames,
containerNo,
actions: record?.work_order_actions,
});
body = (
<CcWorkOrderForm
mode={canConfirmOps ? "edit" : "readonly"}
value={{
...woForUnit,
// keep edits on shared state when confirming primary WO
...(mail.mail_type === "WORK_ORDER" && unit.isCurrent
? workOrderValue
: {}),
}}
onChange={(p) => setWorkOrderValue((v) => ({ ...v, ...p }))}
/>
);`;
if (!t.includes(woOld)) {
console.error("work_order block not found");
process.exit(1);
}
t = t.replace(woOld, woNew);
const doOld = `} else if (unit.uiKind === "do_upload") {
body = (
<CcDoUploadPanel
mode={canConfirmOps ? "edit" : "readonly"}
value={doValue}
onChange={(p) => setDoValue((v) => ({ ...v, ...p }))}
/>
);`;
const doNew = `} else if (unit.uiKind === "do_upload") {
const doForUnit = buildCcDoUploadFormValue({
subject: unit.segmentSubject || mail.subject,
body: unit.segmentText || bodyText,
containerNo,
filenames:
unit.source === "attachment"
? [unit.segmentText]
: filenames,
});
body = (
<CcDoUploadPanel
mode={canConfirmOps ? "edit" : "readonly"}
value={
mail.mail_type === "DO_UPLOAD" && unit.isCurrent
? doValue
: doForUnit
}
onChange={(p) => setDoValue((v) => ({ ...v, ...p }))}
/>
);`;
if (!t.includes(doOld)) {
console.error("do_upload block not found");
process.exit(1);
}
t = t.replace(doOld, doNew);
fs.writeFileSync(p, t, "utf8");
}
const check = fs.readFileSync(p, "utf8");
console.log(
JSON.stringify(
{
hasSegmentBind: check.includes("woForUnit") && check.includes("doForUnit"),
titleOk: check.includes("\u8fd9\u5c01\u90ae\u4ef6\u5728\u5e72\u4ec0\u4e48"),
},
null,
2,
),
);
if (!check.includes("\u8fd9\u5c01\u90ae\u4ef6\u5728\u5e72\u4ec0\u4e48")) {
console.error("Chinese title corrupted <20>?restore with fix-mail-business-summary-zh.ts");
process.exit(1);
}

@ -0,0 +1,165 @@
/**
* CC 烟雾验收
*
* pnpm cc:smoke # 跟随 .env:mock <EFBFBD>?live
* pnpm cc:smoke -- --mode=mock # 强制 mock(不连网<EFBFBD>?
* pnpm cc:smoke -- --mode=live # 强制 live:需 CC_MOCK=false + 账号
*
* Exit: 0 ok | 1 配置错误 | 2 CC 业务失败 | 3 模式不匹<EFBFBD>?
*/
import { getEnv, resetEnvCache } from "../src/lib/env";
import { getCcModeStatus } from "../src/services/cc/mode";
import { saveContainer } from "../src/services/cc/save-container";
import { getShippingLineList } from "../src/services/cc/shipping-line";
import { getContainerListFromCc } from "../src/services/cc/conflict";
type ModeArg = "auto" | "mock" | "live";
function parseMode(argv: string[]): ModeArg {
const raw = argv.find((a) => a.startsWith("--mode="))?.slice("--mode=".length);
if (raw === "mock" || raw === "live" || raw === "auto") return raw;
const idx = argv.indexOf("--mode");
if (idx >= 0) {
const v = argv[idx + 1];
if (v === "mock" || v === "live" || v === "auto") return v;
}
return "auto";
}
async function main() {
resetEnvCache();
const env = getEnv();
const requested = parseMode(process.argv.slice(2));
const status = await getCcModeStatus();
console.log(
JSON.stringify(
{
requested,
runtime: status,
api_base: env.CC_API_BASE,
saas: env.CC_SAAS_HEADER,
},
null,
2,
),
);
if (requested === "live") {
if (status.mode === "mock") {
console.error(
"FAIL: --mode=live <EFBFBD>?CC_MOCK=true。设 CC_MOCK=false 并配<EFBFBD>?CC_USERNAME + 密码后再跑<EFBFBD>?,
);
process.exit(3);
}
if (!status.live_ready) {
console.error(
"FAIL: live 未就<EFBFBD>?<EFBFBD>?,
status.reason ?? "缺少 CC_USERNAME / CC_PASSWORD_PLAIN|MD5",
);
process.exit(1);
}
}
if (requested === "mock" && status.mode !== "mock") {
console.error(
"FAIL: --mode=mock 但当<EFBFBD>?CC_MOCK=false。请临时<EFBFBD>?CC_MOCK=true,或去掉 --mode<EFBFBD>?,
);
process.exit(3);
}
if (requested === "auto" && status.mode === "live" && !status.live_ready) {
console.error("FAIL:", status.reason);
process.exit(1);
}
const isLive = status.mode === "live" && status.live_ready;
const containerNo = `SMK${Date.now().toString().slice(-8)}`;
if (isLive) {
const lines = await getShippingLineList();
console.log(`shipping_lines: ${lines.length}`);
if (lines.length === 0) {
console.warn("WARN: GetShippingLineList 空(不阻<EFBFBD>?SaveContainer<EFBFBD>?);
}
const list = await getContainerListFromCc(containerNo);
if (!list.ok) {
console.error("FAIL: GetContainerList", list.error);
process.exit(2);
}
console.log(`conflict_probe items=${list.items.length}`);
} else {
console.log("mode=mock <20>?跳过真网 GetShippingLineList / GetContainerList");
}
const res = await saveContainer({
entity: {
F_TransMode: 0,
F_OperationType: 0,
F_ContainerNo: containerNo,
F_CabinetType: "40HQ",
F_ETA: "2026-07-13",
F_Classis: 0,
F_MemoRemark: `smoke-${status.mode}`,
},
shipments: [
{
row_index: 1,
row_status: "VALID",
F_FBACode: "ABQ2",
F_Transporter: "TRUCK",
F_CTNS: 1,
F_FBAID: "FBA19G5XJLV4",
},
],
});
console.log(JSON.stringify({ containerNo, result: res }, null, 2));
if (!res.ok) {
process.exit(2);
}
// 指令写能力烟雾(默认 mock<63>?
const { executeInstructionWrite } = await import(
"../src/services/cc/instruction-write"
);
const { ensureCcWriteCapabilities, listCcWriteCapabilities } = await import(
"../src/services/cc/write-capability"
);
await ensureCcWriteCapabilities();
const caps = await listCcWriteCapabilities();
console.log(
"cc_write_capabilities",
caps.map((c) => `${c.id}=${c.mode}`).join(", "),
);
for (const kind of ["transfer", "hold_split", "label"] as const) {
const r = await executeInstructionWrite(kind, {
mailId: "0",
containerNo: containerNo,
subject: `smoke ${kind}`,
shipments: [],
remark: "cc-smoke",
});
console.log(`instruction ${kind}:`, r.ok ? "PASS" : "FAIL", r.mode, r.error || "");
if (!r.ok) process.exit(2);
}
if (isLive) {
console.log("PASS: TEST live SaveContainer code=200 + external id");
console.log(
"NOTE: transfer/hold/label live 需 cc_write_capability.mode=live <EFBFBD>?CC 侧接口可用才算该项完<EFBFBD>?,
);
} else {
console.log(
"PASS: mock SaveContainer + instruction writes <20>?非设<E99D9E>?TEST 烟雾验收。账号到位后:CC_MOCK=false + pnpm cc:smoke -- --mode=live",
);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

@ -0,0 +1,43 @@
import { classifyMail } from "../src/services/parse/classify";
import { extractMailInstructions } from "../src/services/parse/split-instructions";
import { isDoAttachmentFilename } from "../src/services/parse/do-upload-extract";
import fs from "fs";
import path from "path";
const root = "docx/邮件/模板/附件下载_邮件识别";
const files: string[] = [];
function walk(d: string) {
for (const n of fs.readdirSync(d)) {
const p = path.join(d, n);
if (fs.statSync(p).isDirectory()) walk(p);
else if (!/\.docx$/i.test(n)) files.push(n);
}
}
walk(root);
const subject =
"智鸿2+WHLC027G597465+WHSU8127240+洛杉<EFBFBD>?40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆<EFBFBD>?;
const body = "请查收新增预报\nDO也同步上传至附件请注意查收!";
const ev = classifyMail({ subject, body, filenames: files });
const units = extractMailInstructions({ subject, body, filenames: files });
console.log("files", files);
console.log("classify", ev.mail_type, "score", ev.total);
console.log(
"signals",
ev.signals.map((s) => `${s.signal}:${s.score}`),
);
console.log(
"units",
units.map((u) => ({
k: u.uiKind,
cur: u.isCurrent,
src: u.source,
kw: u.keywords.slice(0, 4),
})),
);
console.log(
"DO?",
files.filter((f) => isDoAttachmentFilename(f) || /\bDO\b/i.test(f)),
);

@ -0,0 +1,19 @@
import { IMAP_LOCK } from "../src/services/imap/poller";
import {
isMysqlNamedLockHeld,
killMysqlNamedLockHolder,
} from "../src/services/db-lock";
async function main() {
const before = await isMysqlNamedLockHeld(IMAP_LOCK);
console.log("before", before);
if (before !== "free") {
console.log(await killMysqlNamedLockHolder(IMAP_LOCK));
}
console.log("after", await isMysqlNamedLockHeld(IMAP_LOCK));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

@ -0,0 +1,50 @@
/**
* Local stack: Next.js web + IMAP worker in one process group.
* Usage: pnpm dev / pnpm dev:stack
*/
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const children = [];
function start(label, args) {
const child = spawn("pnpm", args, {
cwd: root,
stdio: "inherit",
shell: true,
env: process.env,
});
child.on("exit", (code, signal) => {
console.error(`[dev-stack] ${label} exited code=${code} signal=${signal}`);
shutdown(code ?? 1);
});
children.push(child);
return child;
}
let shuttingDown = false;
function shutdown(code = 0) {
if (shuttingDown) return;
shuttingDown = true;
for (const child of children) {
if (!child.killed) {
try {
child.kill("SIGTERM");
} catch {
/* ignore */
}
}
}
// Give children a moment, then force-exit so orphaned cmd.exe on Windows dies with parent intent
setTimeout(() => process.exit(code), 500).unref();
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
console.log("[dev-stack] starting web (next) + worker …");
// Use dev:web so this is never recursive with package.json "dev" = this script
start("web", ["run", "dev:web"]);
start("worker", ["run", "worker"]);

@ -0,0 +1,10 @@
#!/bin/sh
set -e
echo "[web] prisma db push…"
pnpm exec prisma db push --skip-generate
if [ "${SEED_ON_START:-false}" = "true" ]; then
echo "[web] seed…"
pnpm db:seed || true
fi
echo "[web] next start"
exec pnpm start

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
import zipfile, re, html, os
docx = r"docx/邮件/模板/附件下载_邮件识别/邮件识别.docx"
with zipfile.ZipFile(docx) as z:
xml = z.read("word/document.xml").decode("utf-8")
text = re.sub(r"<w:tab[^/]*/>", "\t", xml)
text = re.sub(r"</w:p>", "\n", text)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
text = re.sub(r"\n{3,}", "\n\n", text)
out = r"data/logs/mail-recognize-docx.txt"
os.makedirs("data/logs", exist_ok=True)
open(out, "w", encoding="utf-8").write(text)
print(out, "chars", len(text))
print(text[:4000])

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""Extract plain text from a PDF (pypdf). Usage: python scripts/extract-pdf-text.py <pdf>"""
import sys
from pypdf import PdfReader
def main():
path = sys.argv[1]
reader = PdfReader(path)
parts = []
for page in reader.pages:
t = page.extract_text() or ""
if t.strip():
parts.append(t)
sys.stdout.reconfigure(encoding="utf-8")
print("\n".join(parts))
if __name__ == "__main__":
main()

@ -0,0 +1,119 @@
/**
* 按新辰泽填写规范生成卡派表(不就<EFBFBD>?splice,避免残留旧行)<EFBFBD>?
* 用法: pnpm exec tsx scripts/fill-xcz-packing-template.ts
*/
import ExcelJS from "exceljs";
import path from "path";
const SRC = path.join(process.cwd(), "docx", "邮件", "模板", "数据模版.xlsx");
const OUT = path.join(
process.cwd(),
"docx",
"邮件",
"模板",
"数据模版-新辰<E696B0>?WHSU8127240.xlsx",
);
const CN = "WHSU8127240";
const LAS1_ADDR = [
"仓库代码<E4BBA3>?LAS1",
"收件人: LAS1",
"公司名称<E5908D>?AMAZON COM SERVICES INC",
"联系电话<E794B5>?0123456789",
"<22>?<3F>?区: NV / HENDERSON /",
"邮政编码<E7BC96>?89044-8746",
"收货地址<E59CB0>?12300 Bermuda Road",
].join("\n");
const PRIVATE_ADDR = [
"收件人: Erica Fabian",
"公司名称<E5908D>?Rapid Fulfillment",
"联系电话<E794B5>?818-492-2760",
"<22>?<3F>?区: CA / Pacoima /",
"邮政编码<E7BC96>?91331",
"收货地址<E59CB0>?12924 Pierce St",
].join("\n");
type Row = [
string,
string,
number,
string,
number,
number,
string,
string,
string,
string,
string,
string,
string,
string,
];
const ROWS: Row[] = [
[CN, "", 44, "卡派", 5.14, 567, LAS1_ADDR, "FBA16SQQ7PTF", "758SDXPH", "LAS1", "2026-03-29", "2026-04-06", "6268357756", ""],
[CN, "", 14, "卡派", 5.11, 562, LAS1_ADDR, "FBA16SQTM0R1", "39MFHV4O", "LAS1", "2026-03-29", "2026-04-06", "6268357756", ""],
[CN, "", 6, "卡派", 0.45, 98, LAS1_ADDR, "FBA16SRQFXY5", "3ROVWIOM", "LAS1", "2026-03-29", "2026-04-06", "9926386665", ""],
[CN, "", 5, "卡派", 2.56, 756.32, LAS1_ADDR, "FBA16SKZQCBB", "4UWQROSH", "LAS1", "2026-03-29", "2026-04-06", "20220729LAS139", ""],
[CN, "", 34, "卡派", 2.56, 756.32, LAS1_ADDR, "FBA16SL031T3", "5DQ3HYSM", "LAS1", "2026-03-29", "2026-04-06", "20220729LAS139", ""],
[CN, "", 7, "卡派", 0.24, 128, LAS1_ADDR, "FBA16SP4NBQR", "79VUM4FL", "LAS1", "2026-03-29", "2026-04-06", "822072867821", ""],
[CN, "", 5, "卡派", 0.48, 110.85, LAS1_ADDR, "FBA16SRW4F6W", "38JNRT2Q", "LAS1", "2026-03-28", "2026-04-08", "DGG803076", ""],
[CN, "", 8, "卡派", 0.88, 166, LAS1_ADDR, "FBA16SP4GFX0", "8OSM83RO", "LAS1", "2026-03-28", "2026-04-08", "JBHSF072803", ""],
[CN, "", 6, "卡派", 1.02, 249, LAS1_ADDR, "FBA16SJZHL56", "3ME4KJQV", "LAS1", "2026-03-28", "2026-04-08", "YZ22080003", ""],
[CN, "", 28, "卡派", 2.25, 429, PRIVATE_ADDR, "220729YT28NB", "", "", "", "", "/", "三票分开打托"],
[CN, "", 11, "卡派", 0.59, 242, PRIVATE_ADDR, "220730FK11", "", "", "", "", "/", ""],
[CN, "", 10, "卡派", 0.44, 136, PRIVATE_ADDR, "822072968180", "", "", "", "", "/", ""],
[CN, "", 16, "卡派", 1.76, 144.31, PRIVATE_ADDR, "FBA16SCKQQWC", "", "", "", "", "/", ""],
[CN, "", 6, "卡派", 0.4, 13, PRIVATE_ADDR, "XT0729JBSBD171", "", "", "", "", "/", ""],
[CN, "", 3, "UPS", 1.23, 296, "", "1Z4X98060308009900", "", "", "", "", "/", ""],
[CN, "", 5, "UPS", 1.23, 296, "", "1Z4X98060321113067", "", "", "", "", "/", ""],
[CN, "", 8, "UPS", 1.23, 296, "", "1Z4X98060322191230", "", "", "", "", "/", ""],
[CN, "", 24, "UPS", 0.72, 425, "", "1Z4X98060306370433", "", "", "", "", "/", ""],
[CN, "", 7, "UPS", 0.5, 103, "", "1Z4X98060307290787", "", "", "", "", "FBA16ST52H3N", ""],
[CN, "", 40, "FEDEX", 3.43, 595.86, "", "889396190583", "", "", "", "", "FBA16SV9CKD5", ""],
[CN, "", 12, "FEDEX", 1.1, 210, "", "889396190584", "", "", "", "", "/", ""],
[CN, "", 4, "自提", 0.8, 120, PRIVATE_ADDR, "PICKUP-XCZ-001", "", "", "", "", "/", ""],
[CN, "", 2, "存仓", 0.3, 45, "", "STORAGE-XCZ-001", "", "", "", "", "/", ""],
];
async function main() {
const srcWb = new ExcelJS.Workbook();
await srcWb.xlsx.readFile(SRC);
const src = srcWb.worksheets[0];
if (!src) throw new Error("no source sheet");
const outWb = new ExcelJS.Workbook();
const ws = outWb.addWorksheet(src.name || "Sheet1");
// 复制<E5A48D>?3 行表头(<E5A4B4>?+ 合并可忽略)
for (let r = 1; r <= 3; r++) {
const srcRow = src.getRow(r);
const vals: ExcelJS.CellValue[] = [];
srcRow.eachCell({ includeEmpty: true }, (cell, col) => {
vals[col - 1] = cell.value;
});
ws.addRow(vals);
}
for (const r of ROWS) {
const row = ws.addRow([...r]);
row.getCell(3).value = r[2];
row.getCell(5).value = r[4];
row.getCell(6).value = r[5];
}
await outWb.xlsx.writeFile(OUT);
try {
await outWb.xlsx.writeFile(SRC);
console.log(`also updated ${SRC}`);
} catch (e) {
console.warn(`skip overwrite locked template: ${(e as Error).message}`);
}
console.log(`filled ${ROWS.length} rows <20>?${OUT}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

@ -0,0 +1,5 @@
/**
* Fix MailBusinessSummary Chinese corruption <EFBFBD>?delegates to full UTF-8 rewrite.
* Prefer: pnpm fix:ui-zh
*/
import "./rewrite-mail-business-summary";

@ -0,0 +1,34 @@
import fs from "fs";
const p = "src/components/MailBusinessSummary.tsx";
let t = fs.readFileSync(p, "utf8");
const btnWo = "\u786e\u8ba4\u63d0\u4ea4\u5de5\u5355";
t = t.replace(
/(mail\.mail_type === "WORK_ORDER"\) \{\s*actions = \(\s*<Button[\s\S]*?>\s*)\?{2,}(\s*<\/Button>)/,
`$1${btnWo}$2`,
);
// Comments with ? are fine; flag remaining UI ?
const bad = t
.split("\n")
.map((l, i) => ({ i: i + 1, l }))
.filter(
(x) =>
/\?{3,}/.test(x.l) &&
!x.l.includes("eslint") &&
!x.l.trim().startsWith("//"),
);
fs.writeFileSync(p, t, "utf8");
console.log(
JSON.stringify(
{
btnOk: t.includes(btnWo),
badLines: bad.map((x) => `${x.i}: ${x.l.trim()}`),
},
null,
2,
),
);

@ -0,0 +1,62 @@
/**
* 将邮<EFBFBD>?/4 子目录内业务附件平铺到邮件根目录<EFBFBD>? * 用法: pnpm exec tsx scripts/flatten-mail-attachments.ts
*/
import fs from "fs/promises";
import path from "path";
const MAIL_ROOT = path.join(process.cwd(), "docx", "邮件");
async function exists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function moveUp(subdir: string, mailDir: string): Promise<string[]> {
const moved: string[] = [];
if (!(await exists(subdir))) return moved;
const entries = await fs.readdir(subdir, { withFileTypes: true });
for (const e of entries) {
if (!e.isFile()) continue;
const src = path.join(subdir, e.name);
const dest = path.join(mailDir, e.name);
if (await exists(dest)) {
// 同名已在根:删子目录副本
await fs.unlink(src);
moved.push(`${e.name} (root exists, removed nested)`);
continue;
}
await fs.rename(src, dest);
moved.push(e.name);
}
// 清空后删子目<E5AD90>? const left = await fs.readdir(subdir);
if (!left.length) await fs.rmdir(subdir);
return moved;
}
async function main() {
const ops: Array<{ mail: string; nested: string }> = [
{ mail: "邮件3", nested: "WHSU5574991 卡转<EFBFBD>? },
{
mail: "邮件4",
nested: "原箱号YT2604021091=FBA199R49LD6-MDW2=76件换标操作指<EFBFBD>?,
},
];
for (const op of ops) {
const mailDir = path.join(MAIL_ROOT, op.mail);
const nested = path.join(mailDir, op.nested);
const moved = await moveUp(nested, mailDir);
console.log(
JSON.stringify({ mail: op.mail, nested: op.nested, moved }, null, 2),
);
}
}
main().catch((e) => {
console.error(e);
process.exitCode = 1;
});

@ -0,0 +1,52 @@
/**
* 一次<EFBFBD>?IMAP 真拉取:优先读设置页绑定的邮箱,否则 .env
* pnpm imap:poll
*/
import { getEnv, resetEnvCache } from "../src/lib/env";
import { hasConfiguredMailbox } from "../src/services/imap/mailbox-config";
import { ImapPoller } from "../src/services/imap/poller";
import { readImapRuntimeStatus } from "../src/services/imap/runtime-status";
import { prisma } from "../src/services/db";
async function main() {
resetEnvCache();
const env = getEnv();
const configured = await hasConfiguredMailbox();
const dbCount = await prisma.mailboxAccount.count({ where: { enabled: true } });
console.log(
JSON.stringify(
{
dbEnabledMailboxes: dbCount,
envUser: env.IMAP_USER || "(empty)",
configured,
},
null,
2,
),
);
if (!configured) {
console.error(
"无可用邮箱:请在「设<EFBFBD>?<EFBFBD>?邮箱绑定」配置,或填<EFBFBD>?.env <EFBFBD>?IMAP_USER / IMAP_PASS<EFBFBD>?,
);
process.exit(1);
}
await ImapPoller.clearStaleLock();
try {
const result = await ImapPoller.create().tick(30);
const runtime = await readImapRuntimeStatus();
console.log(JSON.stringify({ result, runtime }, null, 2));
} catch (err) {
const runtime = await readImapRuntimeStatus();
console.error(err);
console.error(JSON.stringify({ runtime }, null, 2));
process.exit(2);
} finally {
await prisma.$disconnect();
}
}
main();

@ -0,0 +1,53 @@
/**
* 仅测 IMAP 连通:登录 + SEARCH UNSEEN,不落库<EFBFBD>?
* pnpm imap:smoke
*/
import { getEnv, resetEnvCache } from "../src/lib/env";
import {
createImapClient,
hasImapCredentials,
} from "../src/services/imap/client";
async function main() {
resetEnvCache();
const env = getEnv();
if (!hasImapCredentials()) {
console.error("缺少 IMAP_USER / IMAP_PASS");
process.exit(1);
}
const client = createImapClient();
if (!client) {
console.error("createImapClient returned null");
process.exit(1);
}
console.log(`connecting ${env.IMAP_HOST}:${env.IMAP_PORT} as ${env.IMAP_USER}`);
try {
await client.connect();
const unseen = await client.fetchUnseen();
console.log(
JSON.stringify(
{
ok: true,
unseen: unseen.length,
samples: unseen.slice(0, 5).map((m) => ({
uid: m.uid,
subject: m.envelope?.subject,
from: m.envelope?.from?.[0]?.address,
messageId: m.envelope?.messageId,
})),
},
null,
2,
),
);
} catch (err) {
console.error("IMAP smoke failed:", err);
process.exit(2);
} finally {
await client.disconnect().catch(() => undefined);
}
}
main();

@ -0,0 +1,145 @@
/**
* 入库「上<EFBFBD>?DO」样例:docx/邮件/模板/WHSU8127240-DO.pdf
* 用法: pnpm sample:do
*/
import { createHash } from "crypto";
import fs from "fs/promises";
import path from "path";
import { prisma } from "@/services/db";
import { ParsePipeline } from "@/services/parse/pipeline";
import { buildCcDoUploadFormValue } from "@/components/CcDoUploadPanel";
import { extractMailInstructions } from "@/services/parse/split-instructions";
import { sha256 } from "@/utils/hash";
const SUBJECT = "请查<E8AFB7>?DO:柜<EFBC9A>?WHSU8127240";
const BODY = "Dear,\n请查收附<EFBFBD>?DO 文件,谢谢<EFBFBD>?;
const MESSAGE_ID = "<sample-do-upload-whsu8127240@local.test>";
const DO_PDF = path.join(
process.cwd(),
"docx",
"邮件",
"模板",
"WHSU8127240-DO.pdf",
);
async function attachDo(mailId: bigint): Promise<string> {
const buf = await fs.readFile(DO_PDF);
const hash = sha256(buf);
const filename = "WHSU8127240-DO.pdf";
const dir = path.join(process.cwd(), "data", "mails", String(mailId));
await fs.mkdir(dir, { recursive: true });
const dest = path.join(dir, filename);
await fs.writeFile(dest, buf);
const rel = path.relative(process.cwd(), dest).replace(/\\/g, "/");
await prisma.mailAttachment.create({
data: {
mailId,
filename,
contentType: "application/pdf",
sha256: hash,
path: rel,
size: buf.length,
rejected: false,
},
});
return filename;
}
async function main() {
await fs.access(DO_PDF);
const rawHash = createHash("sha256")
.update(`do-upload-ui|${MESSAGE_ID}|${SUBJECT}|WHSU8127240-DO.pdf|v1`)
.digest("hex");
const existing = await prisma.mailMessage.findFirst({
where: { OR: [{ messageId: MESSAGE_ID }, { rawHash }] },
});
let mailId: bigint;
if (existing) {
await prisma.importCompensation.deleteMany({
where: { import: { mailId: existing.id } },
});
await prisma.containerImport.deleteMany({ where: { mailId: existing.id } });
await prisma.parseResult.deleteMany({ where: { mailId: existing.id } });
await prisma.mailAttachment.deleteMany({ where: { mailId: existing.id } });
await prisma.mailMessage.update({
where: { id: existing.id },
data: {
subject: SUBJECT.slice(0, 512),
fromAddr: "do-upload@local.test",
bodyText: BODY,
status: "FETCHED",
mailType: "UNKNOWN",
lastError: null,
rawHash,
receivedAt: new Date("2026-04-20T09:00:00.000Z"),
typeEvidence: { note: "do_upload_ui_sample" },
version: { increment: 1 },
},
});
mailId = existing.id;
} else {
const created = await prisma.mailMessage.create({
data: {
messageId: MESSAGE_ID,
subject: SUBJECT.slice(0, 512),
fromAddr: "do-upload@local.test",
bodyText: BODY,
status: "FETCHED",
mailType: "UNKNOWN",
rawHash,
folder: "INBOX",
receivedAt: new Date("2026-04-20T09:00:00.000Z"),
isThreadRoot: true,
typeEvidence: { note: "do_upload_ui_sample" },
},
});
mailId = created.id;
}
const filename = await attachDo(mailId);
await ParsePipeline.run(mailId);
const updated = await prisma.mailMessage.findUniqueOrThrow({
where: { id: mailId },
include: { parseResult: true, attachments: true },
});
const form = buildCcDoUploadFormValue({
subject: updated.subject,
body: updated.bodyText,
filenames: updated.attachments.map((a) => a.filename),
});
const units = extractMailInstructions({
subject: updated.subject,
body: updated.bodyText || "",
filenames: updated.attachments.map((a) => a.filename),
});
console.log(
JSON.stringify(
{
id: String(updated.id),
mail_type: updated.mailType,
status: updated.status,
attached: filename,
form,
ui_kinds: units.map((u) => u.uiKind),
url: `http://localhost:3100/mails/${updated.id}`,
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

@ -0,0 +1,170 @@
/**
* 入库「邮<EFBFBD>? 完整线程<EFBFBD>? 新辰泽卡派模板(历史预报货件表)<EFBFBD>? * 最新指令是换标/贴标工单;主题链路上的新增预报仅作历史只读<EFBFBD>? * 用法: pnpm exec tsx scripts/ingest-forecast-ui.ts
*/
import { createHash } from "crypto";
import fs from "fs/promises";
import path from "path";
import { prisma } from "@/services/db";
import { ParsePipeline } from "@/services/parse/pipeline";
import { sha256 } from "@/utils/hash";
import { loadMailboxPdfBody } from "./lib/load-mailbox-pdf-body";
const SUBJECT =
"Fw: \u8f6c\u53d1\uff1a\u65b0\u589e\u9884\u62a5136\uff1a\u65b0\u8fb0\u6cfd+WHLC027G597465+WHSU8127240+\u6d1b\u6749\u77f6+40HQ+EDT2026.04-25 ETA2026.05-15\u8239\u540d\u822a\u6b21HMM EMERALD 013E+\u63d0\u62c6\u6d3e\u7ec4\u5408\u67dc-\u4e0d\u5e26\u6258\u67b6";
const FALLBACK_BODY = [
"Dear,",
"\u539f\u7bb1\u53f7YT2604021091=FBA199R49LD6-MDW2=76\u4ef6\u64cd\u4f5c\u6307\u4ee4",
"\u6362\u6807\u540e\u5355\u53f7\uff1aFBA19HW52S0L-2G1MCB6X-HIA1=76\u4ef6",
"1\uff1a\u8986\u76d6\u8d34FBA\u6807\u7b7e\uff0c\u4e00\u7bb1\u8d34\u4e24\u5f20",
"3\uff1a\u8d34\u597d\u62cd\u7167\u56de\u4f20",
].join("\n");
const MESSAGE_ID = "<sample-forecast-formorder-ui@local.test>";
const MAIL4_DIR = path.join(process.cwd(), "docx", "\u90ae\u4ef6", "\u90ae\u4ef64");
const TEMPLATE = path.join(
process.cwd(),
"docx",
"\u90ae\u4ef6",
"\u6a21\u677f",
"\u6570\u636e\u6a21\u7248-\u65b0\u8fb0\u6cfd-WHSU8127240.xlsx",
);
async function attachTemplate(mailId: bigint): Promise<void> {
const buf = await fs.readFile(TEMPLATE);
const hash = sha256(buf);
const filename = "\u65b0\u8fb0\u6cfd-\u5361\u6d3e\u8d44\u6599-WHSU8127240.xlsx";
const dir = path.join(process.cwd(), "data", "mails", String(mailId));
await fs.mkdir(dir, { recursive: true });
const dest = path.join(dir, filename);
await fs.writeFile(dest, buf);
const rel = path.relative(process.cwd(), dest).replace(/\\/g, "/");
await prisma.mailAttachment.create({
data: {
mailId,
filename,
contentType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
sha256: hash,
path: rel,
size: buf.length,
rejected: false,
},
});
}
async function main() {
const pdfBody = await loadMailboxPdfBody(MAIL4_DIR);
const bodyText =
pdfBody && pdfBody.length > FALLBACK_BODY.length ? pdfBody : FALLBACK_BODY;
console.log(`body chars=${bodyText.length} (pdf=${Boolean(pdfBody)})`);
const rawHash = createHash("sha256")
.update(`forecast-ui|${MESSAGE_ID}|${SUBJECT}|${bodyText}|tpl-v5-full`)
.digest("hex");
const existing = await prisma.mailMessage.findFirst({
where: { OR: [{ messageId: MESSAGE_ID }, { rawHash }] },
});
let mailId: bigint;
if (existing) {
await prisma.importCompensation.deleteMany({
where: { import: { mailId: existing.id } },
});
await prisma.containerImport.deleteMany({ where: { mailId: existing.id } });
await prisma.parseResult.deleteMany({ where: { mailId: existing.id } });
await prisma.mailAttachment.deleteMany({ where: { mailId: existing.id } });
await prisma.mailMessage.update({
where: { id: existing.id },
data: {
subject: SUBJECT.slice(0, 512),
fromAddr: "forecast-ui@local.test",
bodyText,
status: "FETCHED",
mailType: "UNKNOWN",
lastError: null,
rawHash,
receivedAt: new Date("2026-04-20T08:00:00.000Z"),
typeEvidence: { note: "forecast_formorder_ui_full_thread" },
version: { increment: 1 },
},
});
mailId = existing.id;
} else {
// also refresh by messageId if hash changed
const byMid = await prisma.mailMessage.findFirst({
where: { messageId: MESSAGE_ID },
});
if (byMid) {
await prisma.importCompensation.deleteMany({
where: { import: { mailId: byMid.id } },
});
await prisma.containerImport.deleteMany({ where: { mailId: byMid.id } });
await prisma.parseResult.deleteMany({ where: { mailId: byMid.id } });
await prisma.mailAttachment.deleteMany({ where: { mailId: byMid.id } });
await prisma.mailMessage.update({
where: { id: byMid.id },
data: {
subject: SUBJECT.slice(0, 512),
bodyText,
status: "FETCHED",
mailType: "UNKNOWN",
lastError: null,
rawHash,
typeEvidence: { note: "forecast_formorder_ui_full_thread" },
version: { increment: 1 },
},
});
mailId = byMid.id;
} else {
const created = await prisma.mailMessage.create({
data: {
messageId: MESSAGE_ID,
subject: SUBJECT.slice(0, 512),
fromAddr: "forecast-ui@local.test",
bodyText,
status: "FETCHED",
mailType: "UNKNOWN",
rawHash,
folder: "INBOX",
receivedAt: new Date("2026-04-20T08:00:00.000Z"),
isThreadRoot: true,
typeEvidence: { note: "forecast_formorder_ui_full_thread" },
},
});
mailId = created.id;
}
}
await attachTemplate(mailId);
await ParsePipeline.run(mailId);
const updated = await prisma.mailMessage.findUniqueOrThrow({
where: { id: mailId },
include: { parseResult: true, attachments: true },
});
console.log(
JSON.stringify(
{
id: String(updated.id),
mail_type: updated.mailType,
status: updated.status,
body_chars: (updated.bodyText || "").length,
has_label: /原箱号|换标/.test(updated.bodyText || ""),
shipments: ((updated.parseResult?.shipments as unknown[]) || []).length,
url: `http://localhost:3100/mails/${updated.id}`,
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

@ -0,0 +1,128 @@
/**
* å°?docx/é‚®ä»/é‚®ä»3 主题注入本地库å¹è·?ParsePipeline(主é¢?only,无正文/附ä»ï¼‰ã€?
* 用法: pnpm sample:mail3
*
* 完整样例(å<EFBFBD>«å<EFBFBD>¡è½¬æµ?PDF 入库)请用:pnpm sample:mails
*/
import { createHash } from "crypto";
import { prisma } from "@/services/db";
import { ParsePipeline } from "@/services/parse/pipeline";
const MAIL3_SUBJECT =
"Fw: 转å<C2AC>‘:派é€<C3A9>è¦<C3A8>求更æ–? 拆柜清å<E280A6>•æ›´æ–°ï¼?DO请查æ”?拆柜清å<E280A6>•æ›´æ–°: WHSU5574991+WHL063G550810+船å<C2B9><C3A5>航次:OOCL SINGAPORE / 065W+ETAï¼?/28+FedEx-29ä»?UPS-102ä»?亚马逊å<C5A0>¡æ´?289ä»?ç§<C3A7>人地å<C2B0>€-166ä»?拦截-209ä»?拆柜清å<E280A6>•";
const MAIL3_BODY = [
"æ´¾é€<EFBFBD>è¦<EFBFBD>求更新,请查æ”?DO 与拆柜清å<EFBFBD>•ã€?,
"柜å<C593>·ï¼šWHSU5574991",
"æ<><C3A6>å<EFBFBD>•:WHL063G550810",
"船å<C2B9><C3A5>航次:OOCL SINGAPORE / 065W",
"ETAï¼?026-06-28",
].join("\n");
const MESSAGE_ID = "<sample-mail3-whsu5574991@local.test>";
const FROM_ADDR = "clx@cnwally.com.cn";
async function main() {
const rawHash = createHash("sha256")
.update(`mail3|${MAIL3_SUBJECT}|${MAIL3_BODY}|v2`)
.digest("hex");
const existing = await prisma.mailMessage.findFirst({
where: {
OR: [{ messageId: MESSAGE_ID }, { rawHash }],
},
});
let mailId: bigint;
if (existing) {
await prisma.containerImport.deleteMany({ where: { mailId: existing.id } });
await prisma.parseResult.deleteMany({ where: { mailId: existing.id } });
await prisma.mailAttachment.deleteMany({ where: { mailId: existing.id } });
await prisma.mailMessage.update({
where: { id: existing.id },
data: {
subject: MAIL3_SUBJECT.slice(0, 512),
fromAddr: FROM_ADDR,
bodyText: MAIL3_BODY,
ocrText: null,
status: "FETCHED",
mailType: "UNKNOWN",
lastError: null,
typeEvidence: {
sample_dir: "docx/邮件/邮件3",
note: "subject_only_ingest",
},
receivedAt: new Date("2026-07-14T08:02:00.000Z"),
version: { increment: 1 },
},
});
mailId = existing.id;
console.log(`updated existing mail id=${mailId}`);
} else {
const created = await prisma.mailMessage.create({
data: {
messageId: MESSAGE_ID,
subject: MAIL3_SUBJECT.slice(0, 512),
fromAddr: FROM_ADDR,
bodyText: MAIL3_BODY,
status: "FETCHED",
mailType: "UNKNOWN",
rawHash,
folder: "INBOX",
receivedAt: new Date("2026-07-14T08:02:00.000Z"),
isThreadRoot: true,
typeEvidence: {
sample_dir: "docx/邮件/邮件3",
note: "subject_body_ingest",
},
},
});
mailId = created.id;
console.log(`created mail id=${mailId}`);
}
await ParsePipeline.run(mailId);
const mail = await prisma.mailMessage.findUnique({
where: { id: mailId },
include: { parseResult: true },
});
const header = mail?.parseResult?.containerHeader as {
F_ContainerNo?: string;
F_BLCopyCode?: string;
F_ETA?: string;
F_Instruction?: string;
} | null;
const lineage = mail?.parseResult?.lineage as {
mail_record?: { kind?: string; summary?: string };
} | null;
console.log(
JSON.stringify(
{
id: String(mailId),
status: mail?.status,
mail_type: mail?.mailType,
container_no: header?.F_ContainerNo,
bl: header?.F_BLCopyCode,
eta: header?.F_ETA,
instruction: header?.F_Instruction,
mail_record_kind: lineage?.mail_record?.kind,
summary: lineage?.mail_record?.summary,
url: `http://localhost:3100/mails/${mailId}`,
},
null,
2,
),
);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

@ -0,0 +1,198 @@
/**
* 金样入库:docx/邮件/模板/附件下载_邮件识别
* 四类识别:新增预<EFBFBD>?+ 上传 DO(同封)
* 用法: pnpm sample:recognize
*/
import { createHash } from "crypto";
import fs from "fs/promises";
import path from "path";
import { prisma } from "@/services/db";
import { ParsePipeline } from "@/services/parse/pipeline";
import { extractMailInstructions } from "@/services/parse/split-instructions";
import { classifyMail } from "@/services/parse/classify";
import { sha256 } from "@/utils/hash";
const GOLD_DIR = path.join(
process.cwd(),
"docx",
"邮件",
"模板",
"附件下载_邮件识别",
);
const SUBJECT =
"智鸿2+WHLC027G597465+WHSU8127240+洛杉<EFBFBD>?40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆<EFBFBD>?;
const BODY = [
"请查收新增预报:智鸿2+WHLC027G597465+WHSU8127240+洛杉<EFBFBD>?40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆<EFBFBD>?,
"DO也同步上传至附件请注意查收!",
].join("\n");
const MESSAGE_ID = "<sample-recognize-gold-whsu8127240@local.test>";
const CONTENT_TYPES: Record<string, string> = {
".xlsx":
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xls": "application/vnd.ms-excel",
".pdf": "application/pdf",
".csv": "text/csv",
};
async function collectGoldFiles(dir: string): Promise<string[]> {
const out: string[] = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
out.push(...(await collectGoldFiles(p)));
} else if (!/\.docx$/i.test(e.name)) {
out.push(p);
}
}
return out;
}
async function attachAll(mailId: bigint, files: string[]): Promise<string[]> {
const dir = path.join(process.cwd(), "data", "mails", String(mailId));
await fs.mkdir(dir, { recursive: true });
const names: string[] = [];
for (const src of files) {
const buf = await fs.readFile(src);
const filename = path.basename(src);
const dest = path.join(dir, filename);
await fs.writeFile(dest, buf);
const rel = path.relative(process.cwd(), dest).replace(/\\/g, "/");
const ext = path.extname(filename).toLowerCase();
await prisma.mailAttachment.create({
data: {
mailId,
filename,
contentType: CONTENT_TYPES[ext] || "application/octet-stream",
sha256: sha256(buf),
path: rel,
size: buf.length,
rejected: false,
},
});
names.push(filename);
}
return names;
}
async function resetMail(mailId: bigint): Promise<void> {
await prisma.importCompensation.deleteMany({
where: { import: { mailId } },
});
await prisma.containerImport.deleteMany({ where: { mailId } });
await prisma.parseResult.deleteMany({ where: { mailId } });
await prisma.mailAttachment.deleteMany({ where: { mailId } });
}
async function main() {
await fs.access(GOLD_DIR);
const goldFiles = await collectGoldFiles(GOLD_DIR);
if (!goldFiles.length) {
throw new Error(`no attachments under ${GOLD_DIR}`);
}
const rawHash = createHash("sha256")
.update(
`recognize-gold|${MESSAGE_ID}|${SUBJECT}|${goldFiles.map((f) => path.basename(f)).sort().join(",")}|v1`,
)
.digest("hex");
const existing = await prisma.mailMessage.findFirst({
where: { OR: [{ messageId: MESSAGE_ID }, { rawHash }] },
});
let mailId: bigint;
if (existing) {
await resetMail(existing.id);
await prisma.mailMessage.update({
where: { id: existing.id },
data: {
subject: SUBJECT.slice(0, 512),
fromAddr: "recognize-gold@local.test",
bodyText: BODY,
status: "FETCHED",
mailType: "UNKNOWN",
lastError: null,
rawHash,
receivedAt: new Date("2026-04-20T10:00:00.000Z"),
typeEvidence: { note: "recognize_gold_附件下载_邮件识别" },
version: { increment: 1 },
},
});
mailId = existing.id;
} else {
const created = await prisma.mailMessage.create({
data: {
messageId: MESSAGE_ID,
subject: SUBJECT.slice(0, 512),
fromAddr: "recognize-gold@local.test",
bodyText: BODY,
status: "FETCHED",
mailType: "UNKNOWN",
rawHash,
folder: "INBOX",
receivedAt: new Date("2026-04-20T10:00:00.000Z"),
isThreadRoot: true,
typeEvidence: { note: "recognize_gold_附件下载_邮件识别" },
},
});
mailId = created.id;
}
const attached = await attachAll(mailId, goldFiles);
await ParsePipeline.run(mailId);
const updated = await prisma.mailMessage.findUniqueOrThrow({
where: { id: mailId },
include: { parseResult: true, attachments: true },
});
const filenames = updated.attachments.map((a) => a.filename);
const evidence = classifyMail({
subject: updated.subject,
body: updated.bodyText || "",
filenames,
});
const units = extractMailInstructions({
subject: updated.subject,
body: updated.bodyText || "",
filenames,
});
console.log(
JSON.stringify(
{
id: String(updated.id),
mail_type: updated.mailType,
status: updated.status,
attached,
classify: {
mail_type: evidence.mail_type,
scores: {
DO_UPLOAD: evidence.scores.DO_UPLOAD,
NEW_CONTAINER: evidence.scores.NEW_CONTAINER,
},
},
ui_kinds: units.map((u) => ({
kind: u.uiKind,
current: u.isCurrent,
source: u.source,
})),
url: `http://localhost:3100/mails/${updated.id}`,
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

@ -0,0 +1,341 @@
/**
* å°?docx/é‚®ä»/é‚®ä»1~4(å<EFBFBD>Šå<EFBFBD>¯é€‰æ¨¡æ<EFBFBD>¿é¢„报)注入本地库å¹è·?ParsePipelineã€? * 用法: pnpm sample:mails
*
* 目录约定(平铺)ï¼? * docx/é‚®ä»/é‚®ä»N/
* *邮箱.pdf / QQ邮箱.pdf â€?QQ 截图(默认ä¸<EFBFBD>入库ï¼? * *.xlsx / 业务 *.pdf â€?业务附ä»ï¼ˆæŒ‰ SampleDef.attachPolicy 入库ï¼? * docx/é‚®ä»/模æ<EFBFBD>¿/ â€?共享预报模æ<EFBFBD>¿ï¼ˆM_BL / forecast-uiï¼? *
* M1 主题-only(UNKNOWNï¼? * M2 主题+å<EFBFBD>¡æ´¾ xlsx(WORK_ORDER/转仓ï¼? * M3 主题 + å<EFBFBD>¡è½¬æµ?PDF 展示(WORK_ORDER 拆柜清å<EFBFBD>•;解æž<EFBFBD>ä»<EFBFBD>主题短路ï¼? * M4 主题+æ<EFBFBD>¢æ ‡æ­£æ–‡ + æ<EFBFBD>¢æ ‡æŒ‡ä»¤ xlsx/PDF(WORK_ORDER / è´´æ ‡ï¼? */
import { createHash } from "crypto";
import fs from "fs/promises";
import path from "path";
import { prisma } from "@/services/db";
import { ParsePipeline } from "@/services/parse/pipeline";
import {
selectAttachments,
type AttachPolicy,
} from "@/services/sample/mail-attachment-select";
import { sha256 } from "@/utils/hash";
import { loadMailboxPdfBody } from "./lib/load-mailbox-pdf-body";
const DOC_MAIL_ROOT = path.join(process.cwd(), "docx", "邮件");
type SampleDef = {
key: string;
messageId: string;
fromAddr: string;
subject: string;
bodyText: string;
receivedAt: string;
/** 相对 docx/邮件/ 的目录å<E280A2><C3A5>,如 邮件2 */
sampleDir: string;
attachPolicy: AttachPolicy;
/** å<>¯é€‰ï¼šæ–‡ä»¶å<C2B6><C3A5>须包å<E280A6>«çš„关键è¯<C3A8>(全部命中) */
nameIncludes?: string[];
};
const SAMPLES: SampleDef[] = [
{
key: "mail1",
messageId: "<sample-mail1-tiiu8073522@local.test>",
fromAddr: "cs3@xinfenginc.com",
subject: "Fw: 转å<C2AC>‘:TIIU8073522-90022",
bodyText: [
"(样例)主题为柜å<EFBFBD>·çº¿ç´¢ï¼Œå®Œæ•´æ­£æ–‡è§?QQ 邮箱截图附ä»ã€?,
"柜å<C593>·çº¿ç´¢ï¼šTIIU8073522",
].join("\n"),
receivedAt: "2026-07-14T08:02:00.000Z",
sampleDir: "邮件1",
attachPolicy: "none",
},
{
key: "mail2",
messageId: "<sample-mail2-matu2745683@local.test>",
fromAddr: "op7@xinfenginc.com",
subject:
"Fw: 转å<C2AC>‘:LINK EVER INC + MATS4583030000+ 柜å<C593>·ï¼šMATU2745683+ ETA : 7/13",
bodyText: "新增转仓,请留æ„<C3A6>\n柜å<C593>·ï¼šMATU2745683 æ›´æ–°æ´¾é€<C3A9>å<EFBFBD>•,请查收",
receivedAt: "2026-07-14T08:02:00.000Z",
sampleDir: "邮件2",
attachPolicy: "packing_xlsx",
nameIncludes: ["MATU2745683"],
},
{
key: "mail3",
messageId: "<sample-mail3-whsu5574991@local.test>",
fromAddr: "clx@cnwally.com.cn",
subject:
"Fw: 转å<C2AC>‘:派é€<C3A9>è¦<C3A8>求更æ–? 拆柜清å<E280A6>•æ›´æ–°ï¼?DO请查æ”?拆柜清å<E280A6>•æ›´æ–°: WHSU5574991+WHL063G550810+船å<C2B9><C3A5>航次:OOCL SINGAPORE / 065W+ETAï¼?/28+FedEx-29ä»?UPS-102ä»?亚马逊å<C5A0>¡æ´?289ä»?ç§<C3A7>人地å<C2B0>€-166ä»?拦截-209ä»?拆柜清å<E280A6>•",
bodyText: [
"æ´¾é€<EFBFBD>è¦<EFBFBD>求更新,请查æ”?DO 与拆柜清å<EFBFBD>•ã€?,
"柜å<C593>·ï¼šWHSU5574991",
"æ<><C3A6>å<EFBFBD>•:WHL063G550810",
"船å<C2B9><C3A5>航次:OOCL SINGAPORE / 065W",
"ETAï¼?026-06-28",
"FedEx-29ä»?UPS-102ä»?亚马逊å<EFBFBD>¡æ´?289ä»?ç§<EFBFBD>人地å<EFBFBD>€-166ä»?拦截-209ä»?,
].join("\n"),
receivedAt: "2026-07-14T08:02:00.000Z",
sampleDir: "邮件3",
// å<>¡è½¬æµ?PDF 入库供详情展示;解æž<C3A6>ä»<C3A4>走主题短路,ä¸<C3A4>å<EFBFBD>ƒè¡¨æ ? attachPolicy: "business",
},
{
key: "mail4",
messageId: "<sample-mail4-label-yt2604021091@local.test>",
fromAddr: "xinchenze002@126.com",
subject:
"Fw: 转å<C2AC>‘:新增预æŠ?36:新辰泽+WHLC027G597465+WHSU8127240+æ´›æ<E280BA>‰çŸ?40HQ+EDT2026.04-25 ETA2026.05-15船å<C2B9><C3A5>航次HMM EMERALD 013E+æ<><C3A6>拆派组å<E2809E>ˆæŸœ-ä¸<C3A4>带托架",
bodyText: [
"Dear,",
"原箱å<EFBFBD>·YT2604021091=FBA199R49LD6-MDW2=76仿“<EFBFBD>作指ä»?,
"æ<EFBFBD>¢æ ‡å<EFBFBD>Žå<EFBFBD>•å<EFBFBD>·ï¼šFBA19HW52S0L-2G1MCB6X-HIA1=76ä»?,
"1:覆盖贴FBA标签,一箱贴两张",
"2:覆盖贴SKU标签,一�张(FNSKU:X003UHDF7B�04PCS))",
"3:贴好æ‹<C3A6>照回传等国内客户确认å<C2A4>Žï¼Œå†<C3A5>约仓å<E2809C>¡æ´¾äº¤ä»˜ï¼<C3AF>",
"注:回传过æ<EFBFBD>¥çš„照片需è¦<EFBFBD>清晰æ‹<EFBFBD>ç…§SKU上é<EFBFBD>¢çš„字迹,客户确认å<EFBFBD>Žå†<EFBFBD>安排å<EFBFBD>¡æ´¾äº¤ä»˜ï¼Œä¸<EFBFBD>确认ä¸<EFBFBD>予安排ï¼<EFBFBD>ï¼<EFBFBD>ï¼?,
].join("\n"),
receivedAt: "2026-07-14T08:00:00.000Z",
sampleDir: "邮件4",
attachPolicy: "label_instruction",
},
];
async function resolveSampleDir(sampleDir: string): Promise<string | null> {
const direct = path.join(DOC_MAIL_ROOT, sampleDir);
try {
const st = await fs.stat(direct);
if (st.isDirectory()) return direct;
} catch {
/* fall through */
}
// 兜底:在 docx/邮件 下按目录å<E280A2><C3A5>包å<E280A6>«åŒ¹é…? try {
const entries = await fs.readdir(DOC_MAIL_ROOT, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && (e.name === sampleDir || e.name.includes(sampleDir))) {
return path.join(DOC_MAIL_ROOT, e.name);
}
}
} catch {
return null;
}
return null;
}
/** 递归收集目录内文件(å<CB86>«ä¸€å±‚å­<C3A5>目录,兼容未平铺旧结构) */
async function listFilesRecursive(
root: string,
): Promise<Array<{ abs: string; filename: string; rel: string }>> {
const out: Array<{ abs: string; filename: string; rel: string }> = [];
const stack = [root];
while (stack.length) {
const cur = stack.pop()!;
let entries: Awaited<ReturnType<typeof fs.readdir>>;
try {
entries = await fs.readdir(cur, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(cur, e.name);
if (e.isDirectory()) {
stack.push(full);
continue;
}
out.push({
abs: full,
filename: e.name,
rel: path.relative(root, full).split(path.sep).join("/"),
});
}
}
return out;
}
async function upsertMail(sample: SampleDef): Promise<bigint> {
const rawHash = createHash("sha256")
.update(`sample|${sample.key}|${sample.subject}|${sample.bodyText}|fullbody-v1`)
.digest("hex");
const existing = await prisma.mailMessage.findFirst({
where: {
OR: [{ messageId: sample.messageId }, { rawHash }],
},
});
if (existing) {
await prisma.importCompensation.deleteMany({
where: { import: { mailId: existing.id } },
});
await prisma.containerImport.deleteMany({ where: { mailId: existing.id } });
await prisma.parseResult.deleteMany({ where: { mailId: existing.id } });
await prisma.mailAttachment.deleteMany({ where: { mailId: existing.id } });
await prisma.mailMessage.update({
where: { id: existing.id },
data: {
subject: sample.subject.slice(0, 512),
fromAddr: sample.fromAddr,
bodyText: sample.bodyText,
ocrText: null,
status: "FETCHED",
mailType: "UNKNOWN",
lastError: null,
rawHash,
receivedAt: new Date(sample.receivedAt),
typeEvidence: {
sample_dir: `docx/邮件/${sample.sampleDir}`,
note: "sample_ingest",
sample_key: sample.key,
attach_policy: sample.attachPolicy,
},
version: { increment: 1 },
},
});
return existing.id;
}
const created = await prisma.mailMessage.create({
data: {
messageId: sample.messageId,
subject: sample.subject.slice(0, 512),
fromAddr: sample.fromAddr,
bodyText: sample.bodyText,
status: "FETCHED",
mailType: "UNKNOWN",
rawHash,
folder: "INBOX",
receivedAt: new Date(sample.receivedAt),
isThreadRoot: true,
typeEvidence: {
sample_dir: `docx/邮件/${sample.sampleDir}`,
note: "sample_ingest",
sample_key: sample.key,
attach_policy: sample.attachPolicy,
},
},
});
return created.id;
}
function contentTypeFor(filename: string): string {
if (/\.xlsx?$/i.test(filename)) {
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
if (/\.csv$/i.test(filename)) return "text/csv";
if (/\.pdf$/i.test(filename)) return "application/pdf";
return "application/octet-stream";
}
async function attachFiles(
mailId: bigint,
files: Array<{ abs: string; filename: string }>,
): Promise<void> {
if (!files.length) return;
const dir = path.join(process.cwd(), "data", "mails", String(mailId));
await fs.mkdir(dir, { recursive: true });
for (const f of files) {
const buf = await fs.readFile(f.abs);
const hash = sha256(buf);
const safe = f.filename.replace(/[<>:"|?*\x00-\x1f]/g, "_");
const dest = path.join(dir, safe);
await fs.writeFile(dest, buf);
const rel = path.relative(process.cwd(), dest).replace(/\\/g, "/");
await prisma.mailAttachment.create({
data: {
mailId,
filename: f.filename.slice(0, 512),
contentType: contentTypeFor(f.filename),
sha256: hash,
path: rel,
size: buf.length,
rejected: false,
},
});
}
}
async function main() {
const results: Array<Record<string, unknown>> = [];
for (const sample of SAMPLES) {
const sampleDir = await resolveSampleDir(sample.sampleDir);
let bodyText = sample.bodyText;
if (sampleDir) {
const pdfBody = await loadMailboxPdfBody(sampleDir);
if (pdfBody && pdfBody.length > bodyText.length) {
bodyText = pdfBody;
console.log(
`${sample.key}: body from mailbox PDF (${pdfBody.length} chars)`,
);
}
}
const mailId = await upsertMail({ ...sample, bodyText });
let attached: string[] = [];
if (!sampleDir) {
console.warn(`${sample.key}: sample dir not found: ${sample.sampleDir}`);
} else if (sample.attachPolicy !== "none") {
const files = await listFilesRecursive(sampleDir);
const selected = selectAttachments(
files,
sample.attachPolicy,
sample.nameIncludes,
);
await attachFiles(mailId, selected);
attached = selected.map((f) => f.filename);
console.log(
`${sample.key}: dir=${sample.sampleDir} policy=${sample.attachPolicy} attached=${attached.length ? attached.join(" | ") : "(none)"}`,
);
} else {
console.log(`${sample.key}: attachPolicy=none`);
}
await ParsePipeline.run(mailId);
const mail = await prisma.mailMessage.findUnique({
where: { id: mailId },
include: { parseResult: true, attachments: true },
});
const header = mail?.parseResult?.containerHeader as {
F_ContainerNo?: string;
F_BLCopyCode?: string;
F_ETA?: string;
} | null;
const lineage = mail?.parseResult?.lineage as {
mail_record?: { kind?: string; summary?: string };
note?: string;
} | null;
const shipments = (mail?.parseResult?.shipments as unknown[]) || [];
const row = {
key: sample.key,
id: String(mailId),
status: mail?.status,
mail_type: mail?.mailType,
container_no: header?.F_ContainerNo,
bl: header?.F_BLCopyCode,
eta: header?.F_ETA,
shipments: shipments.length,
attachments: mail?.attachments.length ?? 0,
attached_names: attached,
record_kind: lineage?.mail_record?.kind,
parse_note: lineage?.note,
summary: lineage?.mail_record?.summary?.slice(0, 160),
url: `http://localhost:3100/mails/${mailId}`,
};
results.push(row);
console.log(JSON.stringify(row, null, 2));
}
console.log("\n=== summary ===");
for (const r of results) {
console.log(
`${r.key}\t#${r.id}\t${r.mail_type}\t${r.status}\tatt=${r.attachments}\t${r.url}`,
);
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

@ -0,0 +1,61 @@
/**
* Load full QQ mailbox PDF text for sample mail body (一字不落).
*/
import { execFile } from "child_process";
import { promisify } from "util";
import path from "path";
import fs from "fs/promises";
const execFileAsync = promisify(execFile);
async function listPdfs(root: string): Promise<string[]> {
const out: string[] = [];
const stack = [root];
while (stack.length) {
const cur = stack.pop()!;
let entries;
try {
entries = await fs.readdir(cur, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(cur, e.name);
if (e.isDirectory()) stack.push(full);
else if (/\.pdf$/i.test(e.name) && /邮箱/i.test(e.name)) out.push(full);
}
}
return out;
}
/** Prefer *邮箱.pdf under sample dir; return extracted text or null */
export async function loadMailboxPdfBody(
sampleDirAbs: string,
): Promise<string | null> {
const pdfs = await listPdfs(sampleDirAbs);
if (!pdfs.length) return null;
// Prefer QQ邮箱 / N邮箱 over other PDFs
pdfs.sort((a, b) => {
const score = (p: string) => {
const n = path.basename(p);
if (/^QQ/i.test(n)) return 0;
if (/^\d邮箱/i.test(n)) return 1;
return 2;
};
return score(a) - score(b);
});
const target = pdfs[0];
try {
const script = path.join(process.cwd(), "scripts", "extract-pdf-text.py");
const { stdout } = await execFileAsync(
"python",
[script, target],
{ maxBuffer: 12_000_000, encoding: "utf8", windowsHide: true },
);
const t = (stdout || "").replace(/\r\n/g, "\n").trim();
return t.length > 20 ? t : null;
} catch (err) {
console.warn("loadMailboxPdfBody failed", target, err);
return null;
}
}

@ -0,0 +1,46 @@
/**
* 将库内已拉入的非预报/装箱相关邮件标为 IGNORED(列表默认隐藏)<EFBFBD>?
* Usage: pnpm exec tsx scripts/mark-non-business-ignored.ts
*/
import { PrismaClient } from "@prisma/client";
import { isBusinessRelevantMail } from "../src/services/parse/classify";
import { NON_BUSINESS_SKIP } from "../src/services/imap/poller";
const prisma = new PrismaClient();
async function main() {
const mails = await prisma.mailMessage.findMany({
where: { status: { not: "IGNORED" } },
include: { attachments: { select: { filename: true } } },
});
let marked = 0;
for (const m of mails) {
const relevant = isBusinessRelevantMail({
subject: m.subject,
body: m.bodyText || "",
filenames: m.attachments.map((a) => a.filename),
});
if (relevant) continue;
await prisma.mailMessage.update({
where: { id: m.id },
data: {
status: "IGNORED",
lastError: NON_BUSINESS_SKIP,
version: { increment: 1 },
},
});
marked += 1;
console.log(
`IGNORED id=${m.id} from=${m.fromAddr} subject=${m.subject.slice(0, 80)}`,
);
}
console.log(`done: scanned=${mails.length} marked=${marked}`);
}
main()
.catch((e) => {
console.error(e);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());

@ -0,0 +1,76 @@
/**
* Align classify with 附件下载_邮件识别 gold:
* - 新增预报 / 请查收新增预<EFBFBD>?on subject+body
* - 数据模版 / packing-list xlsx -> NEW_CONTAINER score
*/
import fs from "fs";
const path = "src/services/parse/classify.ts";
let src = fs.readFileSync(path, "utf8");
const nl = src.includes("\r\n") ? "\r\n" : "\n";
const oldForecast = ` if (/\\u65b0\\u589e\\u9884\\u62a5/.test(subject)) {
signals.push({
signal: "\\u65b0\\u589e\\u9884\\u62a5",
score: 50,
matched: "\\u65b0\\u589e\\u9884\\u62a5",
source: "subject",
});
scores.NEW_CONTAINER += 50;
}`;
// Match actual Chinese in file
const oldForecastRe =
/ if \(\/新增预报\/\.test\(subject\)\) \{\r?\n signals\.push\(\{\r?\n signal: "新增预报",\r?\n score: 50,\r?\n matched: "新增预报",\r?\n source: "subject",\r?\n \}\);\r?\n scores\.NEW_CONTAINER \+= 50;\r?\n \}/;
const newForecast = ` if (/新增预报|请查收新增预<E5A29E>?.test(text)) {
signals.push({
signal: "新增预报",
score: 50,
matched: "新增预报",
source: /新增预报|请查收新增预<EFBFBD>?.test(subject) ? "subject" : "body",
});
scores.NEW_CONTAINER += 50;
}
// 金样:数据模<E68DAE>?/ 卡派清单<E6B885>?xlsx <20>?预报<E9A284>?
const forecastPacking = filenames.some(
(f) =>
/数据模版/i.test(f) ||
(/卡派|装箱|packing|货件清单|预报资料/i.test(f) &&
/\\.(xlsx|xls|csv)$/i.test(f)),
);
if (forecastPacking && scores.NEW_CONTAINER < 40) {
signals.push({
signal: "预报清单附件",
score: 45,
matched: filenames.find((f) => /数据模版|卡派|装箱|packing/i.test(f)) || "xlsx",
source: "attachment",
});
scores.NEW_CONTAINER += 45;
} else if (forecastPacking) {
scores.NEW_CONTAINER += 15;
signals.push({
signal: "预报清单附件",
score: 15,
matched: "xlsx",
source: "attachment",
});
}`;
if (!oldForecastRe.test(src)) {
const i = src.indexOf("新增预报");
console.log("near", JSON.stringify(src.slice(i - 30, i + 200)));
throw new Error("forecast block not found");
}
src = src.replace(oldForecastRe, newForecast);
// Also treat 数据模版 as packing boost lead (hasPacking already 卡派资料 only)
const oldHasPacking = ` const hasPacking = /卡派资料/.test(fileText);`;
const newHasPacking = ` const hasPacking = /卡派资料|数据模版/.test(fileText);`;
if (!src.includes(oldHasPacking)) throw new Error("hasPacking missing");
src = src.replace(oldHasPacking, newHasPacking);
fs.writeFileSync(path, src);
console.log("classify patched");

@ -0,0 +1,37 @@
import fs from "fs";
const path = "src/services/parse/classify.ts";
let src = fs.readFileSync(path, "utf8");
const re =
/ \/\/ 金样:[\s\S]*?matched: "xlsx",\r?\n source: "attachment",\r?\n \}\);\r?\n \}/;
const neu = ` // 金样:数据模<E68DAE>?/ 明确预报清单附件 <20>?NEW_CONTAINER(不把单纯「卡派资料」当预报<E9A284>?
const forecastPacking = filenames.some(
(f) =>
/数据模版/i.test(f) ||
(/预报资料|货件清单|装箱清单/i.test(f) &&
/\\.(xlsx|xls|csv)$/i.test(f)),
);
if (forecastPacking) {
const add = scores.NEW_CONTAINER < 40 ? 45 : 15;
signals.push({
signal: "预报清单附件",
score: add,
matched:
filenames.find((f) =>
/数据模版|预报资料|货件清单|装箱清单/i.test(f),
) || "xlsx",
source: "attachment",
});
scores.NEW_CONTAINER += add;
}`;
if (!re.test(src)) {
const i = src.indexOf("金样");
console.log("fail", i, JSON.stringify(src.slice(i, i + 250)));
process.exit(1);
}
src = src.replace(re, neu);
fs.writeFileSync(path, src);
console.log("narrowed ok");

@ -0,0 +1,21 @@
import fs from "fs";
const path = "src/services/parse/classify.ts";
let src = fs.readFileSync(path, "utf8");
// normalize for matching
const nl = src.includes("\r\n") ? "\r\n" : "\n";
const softNeedle = `for (const kw of ["\u62c6\u67dc\u6e05\u5355", "\u5361\u8f6c\u6d77", "\u6d3e\u9001\u8981\u6c42", "\u6539\u81ea\u63d0", "\u7559\u4ed3"]) {`;
const softRepl = `for (const kw of ["\u62c6\u67dc\u6e05\u5355", "\u5361\u8f6c\u6d77", "\u6d3e\u9001\u8981\u6c42", "\u6539\u81ea\u63d0", "\u66f4\u65b0\u6d3e\u9001\u5355", "\u7559\u4ed3"]) {`;
if (!src.includes(softNeedle)) throw new Error("soft list missing");
src = src.replace(softNeedle, softRepl);
const oldTail = ` if (bestScore < 40) best = "UNKNOWN";${nl}${nl} return { total: bestScore, mail_type: best, signals, scores };${nl}}`;
const newTail = ` if (bestScore < 40) best = "UNKNOWN";${nl}${nl} // mail1-like: container / booking only -> WORK_ORDER${nl} if (${nl} best === "UNKNOWN" &&${nl} (/[A-Z]{4}\\d{7}/.test(text) || /\u9884\u7ea6\u7801/.test(text))${nl} ) {${nl} best = "WORK_ORDER";${nl} bestScore = 40;${nl} signals.push({${nl} signal: "\u65e0\u5173\u952e\u8bcd\u515c\u5e95",${nl} score: 40,${nl} matched: iso?.[0] || "\u9884\u7ea6\u7801",${nl} source: "body",${nl} });${nl} }${nl}${nl} return { total: bestScore, mail_type: best, signals, scores };${nl}}`;
if (!src.includes(oldTail)) throw new Error("tail missing: " + JSON.stringify(src.slice(src.indexOf("bestScore < 40"), src.indexOf("bestScore < 40") + 100)));
src = src.replace(oldTail, newTail);
fs.writeFileSync(path, src);
console.log("classify ok");

@ -0,0 +1,29 @@
import fs from "fs";
const path = "src/services/parse/instruction-lexicon.ts";
let src = fs.readFileSync(path, "utf8");
const re =
/\/\/ date[\s\S]*?!BODY_ACTION_RE\.test\(bodyOnly\)\s*\n\s*\) \{\s*\n\s*return true;\s*\n\s*\}/;
if (!re.test(src)) {
// try alternate
const idx = src.indexOf("date / QQ");
console.log("snippet", JSON.stringify(src.slice(idx, idx + 280)));
throw new Error("block not found");
}
src = src.replace(
re,
`// date middle-forward: no Dear = shell (subject may contain \u62c6\u67dc/\u62e6\u622a noise)
if (
/date@usasinogroup\\.com/i.test(compact) &&
/\u5bc4\u4ef6\u4eba|\u4e3b\u65e8/.test(compact) &&
!/Dear[,,]/.test(compact)
) {
return true;
}`,
);
fs.writeFileSync(path, src);
console.log("date shell rule updated");

@ -0,0 +1,157 @@
/**
* Detail page: show full body_text + separate OCR; no silent truncation.
*/
import fs from "fs";
const path = "src/app/(ops)/mails/[id]/page.tsx";
let src = fs.readFileSync(path, "utf8");
const old = ` <Collapse
items={[
{
key: "body",
label: "原始正文(排查用<EFBFBD>?,
children: (
<pre
className="mono"
style={{
maxHeight: 280,
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
lineHeight: 1.5,
fontSize: 12,
}}
>
{mail.body_text ||
mail.ocr_text ||
"(无正<E697A0>?"}
</pre>
),
},
]}
/>`;
const neu = ` <Collapse
defaultActiveKey={
!(mail.body_text || "").trim() && (mail.ocr_text || "").trim()
? ["body"]
: undefined
}
items={[
{
key: "body",
label: \`原始正文(排查用)<EFBFBD>?\${(mail.body_text || "").length} 字\${
(mail.ocr_text || "").trim()
? \` · OCR \${(mail.ocr_text || "").length} 字\`
: ""
}\`,
children: (
<div>
<Typography.Paragraph
type="secondary"
style={{ marginBottom: 8, fontSize: 12 }}
>
入库原文完整保留,解<EFBFBD>?OCR 不会覆盖此字段。可滚动查看全部内容<EFBFBD>?
</Typography.Paragraph>
<pre
className="mono"
style={{
maxHeight: 480,
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: 1.5,
fontSize: 12,
padding: 12,
background: "rgba(0,0,0,0.02)",
borderRadius: 6,
}}
>
{(mail.body_text || "").trim()
? mail.body_text
: "(无正<E697A0>?"}
</pre>
{(mail.ocr_text || "").trim() ? (
<>
<Typography.Text
strong
style={{ display: "block", marginTop: 16, marginBottom: 8 }}
>
OCR 文本(附件识别,与原文分开保存)·{" "}
{(mail.ocr_text || "").length} <EFBFBD>?
</Typography.Text>
<pre
className="mono"
style={{
maxHeight: 320,
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: 1.5,
fontSize: 12,
padding: 12,
background: "rgba(0,0,0,0.02)",
borderRadius: 6,
}}
>
{mail.ocr_text}
</pre>
</>
) : null}
</div>
),
},
]}
/>`;
if (!src.includes('label: "原始正文(排查用<E69FA5>?')) {
console.error("collapse block missing");
process.exit(1);
}
// Replace by finding key markers
const start = src.indexOf('key: "body"');
const labelIdx = src.lastIndexOf("Collapse", start);
const collapseStart = src.lastIndexOf("<Collapse", start);
const endMarker = "TypeOverrideModal";
const end = src.indexOf(endMarker, start);
if (collapseStart < 0 || end < 0) {
console.error("bounds", collapseStart, end);
process.exit(1);
}
// find closing of Collapse before TypeOverrideModal
const collapseEnd = src.lastIndexOf("/>", end);
// better: match from <Collapse to /> that closes it
const slice = src.slice(collapseStart, end);
const closeRel = slice.indexOf("/>");
if (closeRel < 0) {
console.error("no close");
process.exit(1);
}
// Might be wrong close - find ` />\n\n <TypeOverride`
const m = src.slice(collapseStart).match(/^[\s\S]*?\n \/>\n\n <TypeOverrideModal/);
if (!m) {
// try without blank
const m2 = src.slice(collapseStart).match(/^[\s\S]*?\n \/>\r?\n\r?\n?\s*<TypeOverrideModal/);
if (!m2) {
console.error("pattern fail", JSON.stringify(src.slice(collapseStart, collapseStart + 200)));
process.exit(1);
}
src =
src.slice(0, collapseStart) +
neu +
"\n\n <TypeOverrideModal" +
src.slice(collapseStart + m2[0].length - "<TypeOverrideModal".length);
} else {
src =
src.slice(0, collapseStart) +
neu +
"\n\n <TypeOverrideModal" +
src.slice(collapseStart + m[0].length - "<TypeOverrideModal".length);
}
fs.writeFileSync(path, src);
console.log("detail body UI updated");

@ -0,0 +1,63 @@
/**
* DO_UPLOAD + bl-plus: keep customer_name from 客户+提单+柜号 template
*/
import fs from "fs";
const path = "src/services/parse/work-order-record.ts";
let src = fs.readFileSync(path, "utf8");
const old = ` if (input.mailType === "NEW_CONTAINER" && input.blModules) {
return buildBlForecastRecord({
modules: {
...input.blModules,
container_no:
input.blModules.container_no || input.containerNo || undefined,
},
shipments,
source: shipments.length ? "mixed" : "plus_template",
plusPayload: input.plusPayload || undefined,
});
}`;
const neu = ` // DO \u4e3b\u7c7b\u578b\u65f6\u4ecd\u4fdd\u7559\u63d0\u5355\u6a21\u677f\uff08\u5ba2\u6237\u540d\u7b49\uff09\u2014\u2014\u91d1\u6837\u9884\u62a5+DO \u540c\u5c01
if (
input.blModules &&
(input.mailType === "NEW_CONTAINER" || input.mailType === "DO_UPLOAD")
) {
const record = buildBlForecastRecord({
modules: {
...input.blModules,
container_no:
input.blModules.container_no || input.containerNo || undefined,
},
shipments,
source: shipments.length ? "mixed" : "plus_template",
plusPayload: input.plusPayload || undefined,
});
if (input.mailType === "DO_UPLOAD") {
return {
...record,
summary: record.summary.replace(
"\u65b0\u589e\u9884\u62a5\uff08\u63d0\u5355\u6a21\u677f\uff09",
"\u4e0a\u4f20DO\uff08\u542b\u9884\u62a5\u6a21\u677f\uff09",
),
};
}
return record;
}`;
if (!src.includes(old)) {
// try CRLF
const oldCrlf = old.replace(/\n/g, "\r\n");
if (src.includes(oldCrlf)) {
src = src.replace(oldCrlf, neu.replace(/\n/g, "\r\n"));
} else {
console.error("block not found");
process.exit(1);
}
} else {
src = src.replace(old, neu);
}
fs.writeFileSync(path, src);
console.log("buildMailRecord patched");

@ -0,0 +1,47 @@
import fs from "fs";
const path = "src/components/CcForecastFormOrder.tsx";
let src = fs.readFileSync(path, "utf8");
if (src.includes("PACKING_FILL_TIPS")) {
console.log("already");
process.exit(0);
}
src = src.replace(
/} from "@\/constants\/ui-copy";\r?\n/,
`} from "@/constants/ui-copy";\r\nimport { PACKING_FILL_TIPS } from "@/services/parse/packing-fill-rules";\r\n`,
);
const re =
/(<div className="cc-fo-step2">\r?\n)(\s*<div className="cc-fo-toolbar">)/;
if (!re.test(src)) throw new Error("step2 not found");
src = src.replace(
re,
`$1 <div
className="cc-fo-packing-tips"
style={{
marginBottom: 10,
padding: "8px 10px",
fontSize: 12,
lineHeight: 1.55,
color: "#4b5563",
background: "rgba(37,99,235,0.06)",
borderRadius: 6,
}}
>
<div style={{ fontWeight: 600, marginBottom: 4, color: "#1f2937" }}>
\u8d27\u4ef6\u586b\u5199\u987b\u77e5\uff08\u5df2\u5199\u5165\u7cfb\u7edf\u6821\u9a8c\uff1b\u6a21\u677f\u300c\u6ce8\u610f\u300d\u8bf4\u660e\u884c\u4e0d\u5165\u8868\uff09
</div>
<ol style={{ margin: 0, paddingLeft: 18 }}>
{PACKING_FILL_TIPS.map((t) => (
<li key={t}>{t}</li>
))}
</ol>
</div>
$2`,
);
fs.writeFileSync(path, src);
console.log("ok", src.includes("PACKING_FILL_TIPS.map"));

@ -0,0 +1,71 @@
/**
* Wire sample ingest to use full mailbox PDF text as bodyText.
*/
import fs from "fs";
const path = "scripts/ingest-sample-mails.ts";
let src = fs.readFileSync(path, "utf8");
if (!src.includes("loadMailboxPdfBody")) {
src = src.replace(
`import { sha256 } from "@/utils/hash";`,
`import { sha256 } from "@/utils/hash";
import { loadMailboxPdfBody } from "./lib/load-mailbox-pdf-body";`,
);
}
// bump hash version so re-ingest refreshes body
src = src.replace(
'`.update(`sample|${sample.key}|${sample.subject}|${sample.bodyText}`)',
'`.update(`sample|${sample.key}|${sample.subject}|${sample.bodyText}|fullbody-v1`)',
);
// the above might be wrong quoting - fix:
src = src.replace(
"sample|${sample.key}|${sample.subject}|${sample.bodyText}`",
"sample|${sample.key}|${sample.subject}|${sample.bodyText}|fullbody-v1`",
);
const oldLoop = ` for (const sample of SAMPLES) {
const mailId = await upsertMail(sample);
const sampleDir = await resolveSampleDir(sample.sampleDir);`;
const neuLoop = ` for (const sample of SAMPLES) {
const sampleDir = await resolveSampleDir(sample.sampleDir);
let bodyText = sample.bodyText;
if (sampleDir) {
const pdfBody = await loadMailboxPdfBody(sampleDir);
if (pdfBody && pdfBody.length > bodyText.length) {
bodyText = pdfBody;
console.log(
\`\${sample.key}: body from mailbox PDF (\${pdfBody.length} chars)\`,
);
}
}
const mailId = await upsertMail({ ...sample, bodyText });`;
if (!src.includes(oldLoop)) {
console.error("loop missing");
process.exit(1);
}
src = src.replace(oldLoop, neuLoop);
// remove duplicate sampleDir resolve
src = src.replace(
` const mailId = await upsertMail({ ...sample, bodyText });
let attached: string[] = [];
if (!sampleDir) {
console.warn(\`\${sample.key}: sample dir not found: \${sample.sampleDir}\`);
} else if (sample.attachPolicy !== "none") {
const files = await listFilesRecursive(sampleDir);`,
` const mailId = await upsertMail({ ...sample, bodyText });
let attached: string[] = [];
if (!sampleDir) {
console.warn(\`\${sample.key}: sample dir not found: \${sample.sampleDir}\`);
} else if (sample.attachPolicy !== "none") {
const files = await listFilesRecursive(sampleDir);`,
);
fs.writeFileSync(path, src);
console.log("ingest wired", src.includes("loadMailboxPdfBody"), src.includes("fullbody-v1"));

@ -0,0 +1,138 @@
/**
* Product rules:
* 1) Only latest instruction segment confirmable; history readonly
* 2) Soft keywords stay work_order
* 3) No-keyword mail (mail1) -> work_order fallback
*/
import fs from "fs";
const path = "src/services/parse/split-instructions.ts";
let src = fs.readFileSync(path, "utf8");
const marker = " // 2) ";
const idx = src.indexOf(marker);
if (idx < 0) throw new Error("section 2 marker missing");
const attMarker = " // 3) ";
const attIdx = src.indexOf(attMarker, idx);
if (attIdx < 0) throw new Error("section 3 marker missing");
const sortMarker = " // \u5c55\u793a\u987a\u5e8f\uff1a\u5f53\u524d\u6bb5\u4f18\u5148";
const sortIdx = src.indexOf(sortMarker);
if (sortIdx < 0) throw new Error("sort marker missing");
const section2 = ` // 2) \u6b63\u6587\u6309\u5bf9\u8bdd\u6bb5\u62c6\u5206\uff1b\u4ec5\u300c\u6700\u65b0\u4e00\u6761\u6709\u6307\u4ee4\u7684\u6bb5\u300d\u53ef\u786e\u8ba4\uff0c\u5386\u53f2\u53ea\u8bfb
const segments = splitBodySegments(body);
let currentSegIndex: number | null = null;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (isNonInstructionSegment(seg)) continue;
const kinds = detectKindsForSegment(extractSegmentSubject(seg, ""), seg);
if (kinds.length) {
currentSegIndex = i;
break;
}
}
// \u65e0\u5173\u952e\u8bcd\u6bb5\uff1a\u9996\u4e2a\u975e\u58f3\u6bb5\u4f5c\u4e3a\u515c\u5e95\u5de5\u5355\u8f7d\u4f53
if (currentSegIndex === null) {
for (let i = 0; i < segments.length; i++) {
if (!isNonInstructionSegment(segments[i])) {
currentSegIndex = i;
break;
}
}
}
const kindsInCurrentBody = new Set<InstructionUiKind>();
segments.forEach((seg, i) => {
if (isNonInstructionSegment(seg)) return;
const segSubject = extractSegmentSubject(seg, "");
const kinds = detectKindsForSegment(segSubject, seg);
const isCurrent = currentSegIndex !== null && i === currentSegIndex;
if (!kinds.length) {
// \u90ae\u4ef61 \u7b49\uff1a\u65e0\u56db\u7c7b\u5173\u952e\u8bcd \u2192 \u6700\u65b0\u6bb5\u515c\u5e95\u4e3a\u5de5\u5355
if (isCurrent) {
kindsInCurrentBody.add("work_order");
push(
makeUnit({
uiKind: "work_order",
source: "body_segment",
segmentIndex: i,
isCurrent: true,
text: seg,
segmentSubject: segSubject || undefined,
extraKeywords: ["\u65e0\u5173\u952e\u8bcd\u515c\u5e95"],
}),
);
}
return;
}
for (const kind of kinds) {
if (isCurrent) kindsInCurrentBody.add(kind);
push(
makeUnit({
uiKind: kind,
source: "body_segment",
segmentIndex: i,
isCurrent,
text: seg,
segmentSubject: segSubject || undefined,
}),
);
}
});
`;
// Replace from section 2 through just before section 3
src = src.slice(0, idx) + section2 + src.slice(attIdx);
// Re-find markers after splice
const attIdx2 = src.indexOf(attMarker);
const pushAttRe =
/push\(\s*makeUnit\(\{\s*uiKind: kind,\s*source: "attachment",\s*segmentIndex: -2,\s*isCurrent: true,\s*text: f,\s*extraKeywords: \[f\.slice\(0, 40\)\],\s*\}\),\s*\);/;
if (!pushAttRe.test(src.slice(attIdx2, attIdx2 + 800))) {
throw new Error("attachment push block not found");
}
src = src.replace(
pushAttRe,
`const attachCurrent =
kindsInCurrentBody.size === 0 || !kindsInCurrentBody.has(kind);
push(
makeUnit({
uiKind: kind,
source: "attachment",
segmentIndex: -2,
isCurrent: Boolean(attachCurrent),
text: f,
extraKeywords: [f.slice(0, 40)],
}),
);`,
);
const sortIdx2 = src.indexOf(sortMarker);
if (sortIdx2 < 0) throw new Error("sort marker missing after edit");
const fallback = ` // \u6574\u5c01\u4ecd\u65e0\u5355\u5143 \u2192 \u5de5\u5355\u515c\u5e95
if (!units.length && (subject.trim() || body.trim())) {
push(
makeUnit({
uiKind: "work_order",
source: "record",
segmentIndex: -3,
isCurrent: true,
text: body.trim() || subject,
segmentSubject: subject || undefined,
extraKeywords: ["\u65e0\u5173\u952e\u8bcd\u515c\u5e95"],
}),
);
}
`;
src = src.slice(0, sortIdx2) + fallback + src.slice(sortIdx2);
fs.writeFileSync(path, src);
console.log("patched", path);

@ -0,0 +1,57 @@
import fs from "fs";
// packing-list test
{
const path = "tests/unit/packing-list.test.ts";
let src = fs.readFileSync(path, "utf8");
if (!src.includes("skips \u6ce8\u610f instruction footer")) {
const insert = `
it("skips \u6ce8\u610f instruction footer in \u667a\u9e3f gold xlsx", async () => {
const fs = await import("fs/promises");
const path = await import("path");
const file = path.join(
process.cwd(),
"docx",
"\u90ae\u4ef6",
"\u6a21\u677f",
"\u9644\u4ef6\u4e0b\u8f7d_\u90ae\u4ef6\u8bc6\u522b",
"\u667a\u9e3f2+WHLC027G597465+WHSU8127240+\u6d1b\u6749\u77f6+40HQ+EDT2026.04-25 ETA2026.05-15\u8239\u540d\u822a\u6b21HMM EMERALD 013E+\u63d0\u62c6\u6d3e.xlsx",
);
const buf = await fs.readFile(file);
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
expect(parsed.errorCode).toBeUndefined();
expect(parsed.shipments.length).toBe(1);
expect(parsed.shipments[0].row_status).toBe("VALID");
expect(parsed.shipments[0].F_Transporter).toBe("\u5b58\u4ed3");
expect(parsed.shipments[0].F_CTNS).toBe(1041);
expect(
parsed.shipments.every((s) => !String(s.F_FBACode || "").includes("\u6ce8\u610f")),
).toBe(true);
});
`;
src = src.replace(/\n\}\);\s*$/, `${insert}\n});\n`);
fs.writeFileSync(path, src);
console.log("packing test added");
}
}
// channel-map test
{
const path = "tests/unit/channel-map.test.ts";
let src = fs.readFileSync(path, "utf8");
if (!src.includes("ignores Truck inside instruction note")) {
src = src.replace(
/\n\}\);\s*$/,
`
it("ignores Truck inside instruction note", () => {
const note =
"\u6ce8\u610f\uff1a1.ups\u548cfedex\u7684\u4ef6... \u5982UPS/FEDEX/USPS/Truck \u6216 \u5361\u6d3e";
expect(mapChannel(note).transporter).toBe("");
});
});
`,
);
fs.writeFileSync(path, src);
console.log("channel test added");
}
}

@ -0,0 +1,45 @@
import fs from "fs";
const path = "src/services/parse/packing-list.ts";
let src = fs.readFileSync(path, "utf8");
if (!src.includes("function isInstructionNoiseRow")) {
const insertAt = src.indexOf("export async function parsePackingListBuffer");
if (insertAt < 0) throw new Error("fn missing");
const helper = `/** Template footer instruction row <20>?not a shipment */
export function isInstructionNoiseRow(raw: Record<string, unknown>): boolean {
const parts = Object.values(raw).map((v) => String(v ?? "").trim());
const blob = parts.join(" ");
if (!blob) return false;
if (/^\u6ce8\u610f\\s*[\uFF1A:]/.test(blob)) return true;
if (parts.some((p) => /^\u6ce8\u610f\\s*[\uFF1A:]/.test(p))) 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) &&
/\u6ce8\u610f|\u586b\u5199|\u5355\u5143\u683c|\u5fc5\u987b\u586b\u5199/.test(blob)
) {
return true;
}
return false;
}
`;
src = src.slice(0, insertAt) + helper + src.slice(insertAt);
}
if (!src.includes("isInstructionNoiseRow(raw)")) {
const re = /if \(!hasAny\) continue;/;
if (!re.test(src)) throw new Error("no hasAny continue");
src = src.replace(
re,
"if (!hasAny) continue;\n if (isInstructionNoiseRow(raw)) continue;",
);
}
fs.writeFileSync(path, src);
console.log({
helper: src.includes("function isInstructionNoiseRow"),
call: src.includes("isInstructionNoiseRow(raw)"),
});

@ -0,0 +1,75 @@
/**
* Stop pipeline from overwriting bodyText with HTML-normalized / OCR-merged text.
* Parse still uses a local normalized body; OCR stays in ocrText only.
*/
import fs from "fs";
const path = "src/services/parse/pipeline.ts";
let src = fs.readFileSync(path, "utf8");
const oldNorm = ` const subject = mail.subject?.trim() ? mail.subject : "(无主<E697A0>?";
// 历史库可能存了原<E4BA86>?HTML;解析前先归一成纯文本
let body = plainTextFromMaybeHtml(mail.bodyText || "");
if (body && body !== (mail.bodyText || "").trim()) {
await prisma.mailMessage.update({
where: { id: mailId },
data: { bodyText: body },
});
}`;
const neuNorm = ` const subject = mail.subject?.trim() ? mail.subject : "(无主<E697A0>?";
// 解析用归一化正文;绝不回写 bodyText(排查用原文一字不落)
const bodyStored = mail.bodyText || "";
let body = plainTextFromMaybeHtml(bodyStored) || bodyStored;`;
if (!src.includes(oldNorm)) {
const oldCrlf = oldNorm.replace(/\n/g, "\r\n");
if (!src.includes(oldCrlf)) {
console.error("norm block missing");
process.exit(1);
}
src = src.replace(oldCrlf, neuNorm.replace(/\n/g, "\r\n"));
} else {
src = src.replace(oldNorm, neuNorm);
}
const oldOcr = ` body = body.trim()
? \`\${body}\\n\\n\${ocr.text}\`
: ocr.text;
await prisma.mailMessage.update({
where: { id: mailId },
data: { ocrText: ocr.text, bodyText: body },
});`;
const neuOcr = ` // OCR 只写<E58FAA>?ocrText,不污染 bodyText
if (!body.trim()) body = ocr.text;
else body = \`\${body}\\n\\n\${ocr.text}\`;
await prisma.mailMessage.update({
where: { id: mailId },
data: { ocrText: ocr.text },
});`;
if (!src.includes("ocrText: ocr.text, bodyText: body")) {
// try already patched
if (!src.includes("OCR 只写<E58FAA>?ocrText")) {
console.error("ocr block missing", src.includes("bodyText: body"));
process.exit(1);
}
} else {
src = src.replace(
/body = body\.trim\(\)\s*\?\s*`\$\{body\}\\n\\n\$\{ocr\.text\}`\s*:\s*ocr\.text;\s*await prisma\.mailMessage\.update\(\{\s*where: \{ id: mailId \},\s*data: \{ ocrText: ocr\.text, bodyText: body \},\s*\}\);/s,
`// OCR only in ocrText <20>?never overwrite bodyText
if (!body.trim()) body = ocr.text;
else body = \`\${body}\\n\\n\${ocr.text}\`;
await prisma.mailMessage.update({
where: { id: mailId },
data: { ocrText: ocr.text },
});`,
);
}
fs.writeFileSync(path, src);
console.log("pipeline body preserve ok", {
hasStored: src.includes("bodyStored"),
ocrOnly: src.includes("ocrText: ocr.text }") || src.includes("data: { ocrText: ocr.text }"),
});

@ -0,0 +1,51 @@
import fs from "fs";
const path = "src/utils/mail-body-text.ts";
let src = fs.readFileSync(path, "utf8");
const start = src.indexOf("export function resolveParsedMailBody");
if (start < 0) throw new Error("missing");
const neu = `export function resolveParsedMailBody(parsed: {
text?: string | false | null;
html?: string | false | null;
}): string {
const text =
typeof parsed.text === "string" ? parsed.text.trim() : "";
const htmlRaw =
typeof parsed.html === "string" ? parsed.html : "";
const fromHtml = htmlRaw ? htmlToPlainText(htmlRaw) : "";
if (!text && !fromHtml) return "";
if (!text) return fromHtml;
if (!fromHtml) return text;
// Prefer longer side so troubleshooting body is not truncated
if (fromHtml.length > text.length + 40) return fromHtml;
if (text.length > fromHtml.length + 40) return text;
const textHead = text.slice(0, Math.min(48, text.length));
if (textHead && fromHtml.includes(textHead) && fromHtml.length >= text.length) {
return fromHtml;
}
const htmlHead = fromHtml.slice(0, Math.min(48, fromHtml.length));
if (htmlHead && text.includes(htmlHead) && text.length >= fromHtml.length) {
return text;
}
// Both long and not nested: keep both parts
if (
text.length > 80 &&
fromHtml.length > 80 &&
!fromHtml.includes(textHead) &&
!text.includes(htmlHead)
) {
return \`\${text}\\n\\n---\\n\\n\${fromHtml}\`;
}
return fromHtml.length >= text.length ? fromHtml : text;
}
`;
src = src.slice(0, start) + neu;
fs.writeFileSync(path, src);
console.log("resolve ok, len", src.length);

@ -0,0 +1,152 @@
/**
* Fix: ignore subject/主旨 lines when detecting forward shells & kinds pollution
*/
import fs from "fs";
{
const path = "src/services/parse/instruction-lexicon.ts";
let src = fs.readFileSync(path, "utf8");
if (!src.includes("function stripSubjectLines")) {
const insertAt = src.indexOf("export function isForwardShellSegment");
if (insertAt < 0) throw new Error("isForwardShellSegment missing");
const helper = `/** Drop subject/\\u4e3b\\u65e8 lines so thread titles do not count as body actions */\nfunction stripSubjectLines(text: string): string {\n return text\n .split(/\\n/)\n .filter((line) => !/(?:\\u4e3b\\u9898|\\u4e3b\\u65e8|Subject)\\s*[\\uff1a:]/i.test(line))\n .join("\\n");\n}\n\n`;
// Use real Chinese in helper via unicode escapes already - wait use actual:
const helper2 = `/** Drop subject lines so thread titles do not count as body actions */\nfunction stripSubjectLines(text: string): string {\n return text\n .split(/\\n/)\n .filter((line) => !/(?:\u4e3b\u9898|\u4e3b\u65e8|Subject)\\s*[\uFF1A:]/i.test(line))\n .join("\\n");\n}\n\n`;
src = src.slice(0, insertAt) + helper2 + src.slice(insertAt);
}
// Rewrite isForwardShellSegment body
const start = src.indexOf("export function isForwardShellSegment");
const end = src.indexOf("export function isNonInstructionSegment");
if (start < 0 || end < 0) throw new Error("shell funcs missing");
const replacement = `export function isForwardShellSegment(text: string): boolean {
const compact = text.replace(/\\s+/g, " ").trim();
if (/\u53d1\u81ea\u6211\u7684iPhone/.test(compact)) return true;
const bodyOnly = stripSubjectLines(text);
const bodyCompact = bodyOnly.replace(/\\s+/g, " ").trim();
// date / QQ pure forward wrapper (keywords only in \u4e3b\u65e8)
if (
/date@usasinogroup\\.com/i.test(compact) &&
!/Dear[,,]/.test(bodyOnly) &&
!BODY_ACTION_RE.test(bodyOnly)
) {
return true;
}
if (
/\u53d1\u81ea\u6211\u7684iPhone|\u5927\u5c0f\\s*\\d/.test(compact) &&
!BODY_ACTION_RE.test(bodyOnly) &&
!/\u65b0\u589e\u9884\u62a5|\u8bf7\u67e5\u6536\u65b0\u589e\u9884\u62a5|\u65b0\u589e\u8f6c\u4ed3|DO\u8bf7\u67e5\u6536/.test(
bodyOnly,
)
) {
return true;
}
// short date forward: only headers + signature
if (
/\u5bc4\u4ef6\u4eba/.test(compact) &&
/date@usasinogroup\\.com/i.test(compact) &&
bodyCompact.length < 120 &&
!/Dear[,,]/.test(bodyOnly)
) {
return true;
}
return false;
}
`;
src = src.slice(0, start) + replacement + src.slice(end);
// Also update isNonInstructionSegment QQ shell to use stripSubjectLines
src = src.replace(
` // QQ \u5916\u58f3\uff1a\u4ec5\u8f6c\u53d1\u5143\u6570\u636e\u3001\u65e0\u4e1a\u52a1\u52a8\u4f5c
if (
/\u53d1\u81ea\u6211\u7684iPhone|\u5927\u5c0f\\s*\\d/.test(compact) &&
!BODY_ACTION_RE.test(compact) &&
!/\u65b0\u589e\u9884\u62a5|\u8bf7\u67e5\u6536\u65b0\u589e\u9884\u62a5|\u65b0\u589e\u8f6c\u4ed3|DO\u8bf7\u67e5\u6536/.test(compact)
) {
return true;
}`,
` // QQ shell without body actions (ignore subject title noise)
{
const bodyOnly = stripSubjectLines(text);
if (
/\u53d1\u81ea\u6211\u7684iPhone|\u5927\u5c0f\\s*\\d/.test(compact) &&
!BODY_ACTION_RE.test(bodyOnly) &&
!/\u65b0\u589e\u9884\u62a5|\u8bf7\u67e5\u6536\u65b0\u589e\u9884\u62a5|\u65b0\u589e\u8f6c\u4ed3|DO\u8bf7\u67e5\u6536/.test(
bodyOnly,
)
) {
return true;
}
}`,
);
fs.writeFileSync(path, src);
console.log("lexicon shell ok");
}
{
const path = "src/services/parse/split-instructions.ts";
let src = fs.readFileSync(path, "utf8");
// Strengthen bodyForKindDetect: strip ALL subject lines + Re:新增预报 pollution lines
const old = `function bodyForKindDetect(segBody: string): string {
if (!BODY_ACTION_RE.test(segBody)) return segBody;
return segBody
.split(/\\n/)
.filter(
(line) =>
!/(?:\u4e3b\u9898|\u4e3b\u65e8|Subject)\\s*[\uFF1A:].*(?:\u65b0\u589e\u9884\u62a5|Fw:|\u8f6c\u53d1)/i.test(line),
)
.join("\\n");
}`;
const neu = `function bodyForKindDetect(segBody: string): string {
// Always strip subject/Re title lines; they carry historical \u65b0\u589e\u9884\u62a5
const stripped = segBody
.split(/\\n/)
.filter((line) => {
if (/(?:\u4e3b\u9898|\u4e3b\u65e8|Subject)\\s*[\uFF1A:]/i.test(line)) return false;
if (/^Re:\\s*Re:/i.test(line.trim()) && /\u65b0\u589e\u9884\u62a5/.test(line))
return false;
return true;
})
.join("\\n");
return stripped;
}`;
if (!src.includes("function bodyForKindDetect")) throw new Error("no bodyForKindDetect");
// replace by function bounds
const a = src.indexOf("function bodyForKindDetect");
const b = src.indexOf("export function detectKindsForSegment");
if (a < 0 || b < 0) throw new Error("bounds");
src = src.slice(0, a) + neu + "\n\n" + src.slice(b);
// useSubject: only when body (stripped) has no action words
src = src.replace(
` const useSubject =
!BODY_ACTION_RE.test(segBody) ||
/\\u8bf7\\u67e5\\u6536\\u65b0\\u589e\\u9884\\u62a5/.test(bodyText);`,
` const useSubject =
!BODY_ACTION_RE.test(bodyText) ||
/\u8bf7\u67e5\u6536\u65b0\u589e\u9884\u62a5/.test(bodyText);`,
);
// Also fix the actual Chinese version if unicode escape didn't match
src = src.replace(
/const useSubject =\s*!BODY_ACTION_RE\.test\(segBody\) \|\|\s*\/请查收新增预报\/\.test\(bodyText\);/,
`const useSubject =
!BODY_ACTION_RE.test(bodyText) ||
/\u8bf7\u67e5\u6536\u65b0\u589e\u9884\u62a5/.test(bodyText);`,
);
fs.writeFileSync(path, src);
console.log("split bodyForKindDetect ok");
}

@ -0,0 +1,94 @@
import fs from "fs";
{
const path = "src/services/parse/packing-list.ts";
let src = fs.readFileSync(path, "utf8");
if (!src.includes("function isInstructionNoiseRow")) {
const insertAt = src.indexOf("export async function parsePackingListBuffer");
if (insertAt < 0) throw new Error("fn missing");
const helper = `/** Template footer instruction row <20>?not a shipment */
export function isInstructionNoiseRow(raw: Record<string, unknown>): boolean {
const parts = Object.values(raw).map((v) => String(v ?? "").trim());
const blob = parts.join(" ");
if (!blob) return false;
if (/^\u6ce8\u610f\\s*[\\uFF1A:]/.test(blob)) return true;
if (parts.some((p) => /^\u6ce8\u610f\\s*[\\uFF1A:]/.test(p))) 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) &&
/\u6ce8\u610f|\u586b\u5199|\u5355\u5143\u683c|\u5fc5\u987b\u586b\u5199/.test(blob)
) {
return true;
}
return false;
}
`;
src = src.slice(0, insertAt) + helper + src.slice(insertAt);
}
if (!src.includes("isInstructionNoiseRow(raw)")) {
src = src.replace(
/\/\/ \u8df3\u8fc7\u7a7a\u884c\r?\n\s*const hasAny = Object\.values\(raw\)\.some\(\r?\n\s*\(v\) => v != null && String\(v\)\.trim\(\) !== "",\r?\n\s*\);\r?\n\s*if \(!hasAny\) continue;/,
`// skip empty / instruction footer rows\n const hasAny = Object.values(raw).some(\n (v) => v != null && String(v).trim() !== "",\n );\n if (!hasAny) continue;\n if (isInstructionNoiseRow(raw)) continue;`,
);
}
if (!src.includes("isInstructionNoiseRow(raw)")) {
throw new Error("failed to insert skip call");
}
fs.writeFileSync(path, src);
console.log("packing-list patched");
}
{
const path = "src/services/parse/channel-map.ts";
let src = fs.readFileSync(path, "utf8");
if (src.includes("text.length > 40")) {
console.log("channel-map already ok");
} else {
src = src.replace(
/export function mapChannel\(raw: string\): \{[\s\S]*?return \{ transporter: text\.slice\(0, 30\), unmapped: true \};\n\}/,
`export function mapChannel(raw: string): {
transporter: string;
unmapped: boolean;
} {
const text = (raw || "").trim();
if (!text) return { transporter: "", unmapped: true };
// Long instruction text must not map (e.g. note mentioning Truck)
if (text.length > 40 || /^\\u6ce8\\u610f\\s*[\\uFF1A:]/.test(text)) {
return { transporter: "", unmapped: true };
}
const upper = text.toUpperCase();
for (const rule of CHANNEL_MAP) {
if (
rule.keys.some((k) => {
const key = k.toUpperCase();
if (/^[A-Z0-9]+$/i.test(k)) {
return new RegExp(\`(?:^|[^A-Z0-9])\${key}(?:[^A-Z0-9]|$)\`).test(upper);
}
return upper.includes(key);
})
) {
return { transporter: rule.value, unmapped: false };
}
}
return { transporter: text.slice(0, 30), unmapped: true };
}`,
);
// Fix the broken unicode escape in the written file - use real chars
src = src.replace(
"if (text.length > 40 || /^\\\\u6ce8\\\\u610f\\\\s*[\\\\uFF1A:]/.test(text))",
"if (text.length > 40 || /^\u6ce8\u610f\\s*[\uFF1A:]/.test(text))",
);
// Also if the replace used the regex with single backslash unicode incorrectly
if (!src.includes("text.length > 40")) {
throw new Error("channel map replace failed");
}
fs.writeFileSync(path, src);
console.log("channel-map patched");
}
}

@ -0,0 +1,96 @@
import fs from "fs";
const lexPath = "src/services/parse/instruction-lexicon.ts";
let lex = fs.readFileSync(lexPath, "utf8");
if (!lex.includes("isForwardShellSegment")) {
lex = lex.replace(
`export function isNonInstructionSegment(text: string): boolean {
const compact = text.replace(/\\s+/g, " ").trim();
if (compact.length < 6) return true;`,
`/** QQ/中间人纯转发壳,无客<E697A0>?Dear 正文 */
export function isForwardShellSegment(text: string): boolean {
const compact = text.replace(/\\s+/g, " ").trim();
if (/发自我的iPhone/.test(compact)) return true;
if (
/date@usasinogroup\\.com/i.test(compact) &&
!/Dear[,,]/.test(compact) &&
!BODY_ACTION_RE.test(compact)
) {
return true;
}
return false;
}
export function isNonInstructionSegment(text: string): boolean {
const compact = text.replace(/\\s+/g, " ").trim();
if (compact.length < 6) return true;
if (isForwardShellSegment(text)) return true;`,
);
fs.writeFileSync(lexPath, lex, "utf8");
}
const splitPath = "src/services/parse/split-instructions.ts";
let sp = fs.readFileSync(splitPath, "utf8");
if (!sp.includes("bodyForKindDetect")) {
sp = sp.replace(
`export function detectKindsForSegment(
segSubject: string,
segBody: string,
): InstructionUiKind[] {
if (isNonInstructionSegment(segBody)) return [];
const bodyHits = matchKeywordRules(segBody, "body");
const useSubject =
!BODY_ACTION_RE.test(segBody) ||
/新增预报|请查收新增预<EFBFBD>?.test(segBody);
const subjectHits = useSubject
? matchKeywordRules(segSubject, "subject")
: [];
const kinds = new Set<InstructionUiKind>([
...bodyHits.map((h) => h.uiKind),
...subjectHits.map((h) => h.uiKind),
]);
return UI_KIND_ORDER.filter((k) => kinds.has(k));
}`,
`/** 去掉段内「主<E3808C>? Re:Re:新增预报」历史主题污<E9A298>?*/
function bodyForKindDetect(segBody: string): string {
if (!BODY_ACTION_RE.test(segBody)) return segBody;
return segBody
.split(/\\n/)
.filter(
(line) =>
!/(?:主题|主旨|Subject)\\s*[<5B>?].*(?:新增预报|Fw:|转发)/i.test(line),
)
.join("\\n");
}
export function detectKindsForSegment(
segSubject: string,
segBody: string,
): InstructionUiKind[] {
if (isNonInstructionSegment(segBody)) return [];
const bodyText = bodyForKindDetect(segBody);
const bodyHits = matchKeywordRules(bodyText, "body");
const useSubject =
!BODY_ACTION_RE.test(segBody) ||
/请查收新增预<EFBFBD>?.test(bodyText);
const subjectHits = useSubject
? matchKeywordRules(segSubject, "subject")
: [];
const kinds = new Set<InstructionUiKind>([
...bodyHits.map((h) => h.uiKind),
...subjectHits.map((h) => h.uiKind),
]);
return UI_KIND_ORDER.filter((k) => kinds.has(k));
}`,
);
fs.writeFileSync(splitPath, sp, "utf8");
}
console.log(
JSON.stringify({
lex: fs.readFileSync(lexPath, "utf8").includes("isForwardShellSegment"),
split: fs.readFileSync(splitPath, "utf8").includes("bodyForKindDetect"),
}),
);

@ -0,0 +1,108 @@
import fs from "fs";
const p = "src/services/parse/split-instructions.ts";
let t = fs.readFileSync(p, "utf8");
t = t.replace(
`import {
ATTACHMENT_HINT_RE,
KEYWORD_RULES,
SEGMENT_BOUNDARY_PATTERNS,
SEGMENT_START_LINE_RE,
matchKeywordRules,
type InstructionUiKind,
} from "@/services/parse/instruction-lexicon";`,
`import {
ATTACHMENT_HINT_RE,
BODY_ACTION_RE,
KEYWORD_RULES,
SEGMENT_BOUNDARY_PATTERNS,
SEGMENT_START_LINE_RE,
isNonInstructionSegment,
matchKeywordRules,
type InstructionUiKind,
} from "@/services/parse/instruction-lexicon";`,
);
const oldDetect = `export function detectUiKindsFromText(
subject: string,
body: string,
filenames: string[] = [],
): InstructionUiKind[] {
const kinds = new Set<InstructionUiKind>();
for (const h of matchKeywordRules(subject, "subject")) kinds.add(h.uiKind);
for (const h of matchKeywordRules(body, "body")) kinds.add(h.uiKind);
for (const f of filenames) {
const whereHits = matchKeywordRules(f, "attachment");
for (const h of whereHits) kinds.add(h.uiKind);
if (isDoAttachmentFilename(f)) kinds.add("do_upload");
if (ATTACHMENT_HINT_RE.test(f) && /换标|贴标|覆盖贴|操作指令/.test(f)) {
kinds.add("work_order");
}
}
return UI_KIND_ORDER.filter((k) => kinds.has(k));
}`;
const newDetect = `export function detectUiKindsFromText(
subject: string,
body: string,
filenames: string[] = [],
): InstructionUiKind[] {
const kinds = new Set<InstructionUiKind>();
for (const h of matchKeywordRules(subject, "subject")) kinds.add(h.uiKind);
for (const h of matchKeywordRules(body, "body")) kinds.add(h.uiKind);
for (const f of filenames) {
for (const h of matchKeywordRules(f, "attachment")) kinds.add(h.uiKind);
if (isDoAttachmentFilename(f)) kinds.add("do_upload");
if (ATTACHMENT_HINT_RE.test(f) && /换标|贴标|覆盖贴|操作指令/.test(f)) {
kinds.add("work_order");
}
}
return UI_KIND_ORDER.filter((k) => kinds.has(k));
}
/** 单段:正文有动作词时,忽<EFBC8C>?Re: 链路上的历史「新增预报」主<E3808D>?*/
export function detectKindsForSegment(
segSubject: string,
segBody: string,
): InstructionUiKind[] {
if (isNonInstructionSegment(segBody)) return [];
const bodyHits = matchKeywordRules(segBody, "body");
const useSubject =
!BODY_ACTION_RE.test(segBody) ||
/新增预报|请查收新增预<EFBFBD>?.test(segBody);
const subjectHits = useSubject
? matchKeywordRules(segSubject, "subject")
: [];
const kinds = new Set<InstructionUiKind>([
...bodyHits.map((h) => h.uiKind),
...subjectHits.map((h) => h.uiKind),
]);
return UI_KIND_ORDER.filter((k) => kinds.has(k));
}`;
if (!t.includes(oldDetect)) {
console.error("detectUiKindsFromText block not found");
process.exit(1);
}
t = t.replace(oldDetect, newDetect);
t = t.replace(
`segments.forEach((seg, i) => {
const segSubject = extractSegmentSubject(seg, "");
const kinds = detectUiKindsFromText(segSubject || subject, seg, []);`,
`segments.forEach((seg, i) => {
if (isNonInstructionSegment(seg)) return;
const segSubject = extractSegmentSubject(seg, "");
const kinds = detectKindsForSegment(segSubject, seg);`,
);
fs.writeFileSync(p, t, "utf8");
console.log(
JSON.stringify({
ok:
t.includes("detectKindsForSegment") &&
t.includes("isNonInstructionSegment") &&
t.includes("BODY_ACTION_RE"),
}),
);

@ -0,0 +1,158 @@
/**
* Conservative subject-block strip: stop on body actions / Dear / 新增*
*/
import fs from "fs";
const STRIP_FN = `
function stripSubjectLines(text: string): string {
const lines = text.split(/\\n/);
const out: string[] = [];
let inSubject = false;
let cont = 0;
for (const line of lines) {
// "\\u4e3b\\u9898 Re:..." often has NO colon
if (/(?:\u4e3b\u9898|\u4e3b\u65e8|Subject)\\s*[\uFF1A:]?/i.test(line)) {
inSubject = true;
cont = 0;
continue;
}
if (inSubject) {
const t = line.trim();
if (!t) {
inSubject = false;
continue;
}
// real body starts
if (
BODY_ACTION_RE.test(line) ||
/Dear[,,]/.test(t) ||
/(?:\u65b0\u589e\u8f6c\u4ed3|\u65b0\u589e\u9884\u62a5|\u8bf7\u67e5\u6536|\u66f4\u65b0\u6d3e\u9001\u5355)/.test(
t,
) ||
/^\\d+[\\.\\u3001\\uff1a:]/.test(t)
) {
inSubject = false;
out.push(line);
continue;
}
if (
/^(?:\u5bc4\u4ef6\u4eba|\u53d1\u4ef6\u4eba|\u6536\u4ef6\u4eba|\u6284\u9001|\u65e5\u671f|\u53d1\u9001\u65f6\u95f4|From|To|Cc|Date|Sent|----)/i.test(
t,
) ||
/^date@/i.test(t) ||
t === "date"
) {
inSubject = false;
out.push(line);
continue;
}
// wrapped subject: ETA / container / voyage fragments only (max 4 lines)
if (
cont < 4 &&
/Fw:|\u8f6c\u53d1|ETA|ETD|\u8239\u540d|\u822a\u6b21|\u67dc\u53f7|[A-Z]{4}\\d{7}|40HQ|20GP|\u4ef6\\+|\\+\\s*$/i.test(
line,
)
) {
cont += 1;
continue;
}
inSubject = false;
out.push(line);
continue;
}
out.push(line);
}
return out.join("\\n");
}
`.trimStart();
function replaceStripInLexicon() {
const path = "src/services/parse/instruction-lexicon.ts";
let src = fs.readFileSync(path, "utf8");
const a = src.indexOf("function stripSubjectLines");
const b = src.indexOf("export function isForwardShellSegment");
if (a < 0 || b < 0) throw new Error("lexicon markers");
let start = src.lastIndexOf("/**", a);
if (start < 0 || a - start > 200) start = a;
src =
src.slice(0, start) +
"/** Drop subject block; stop early on real body actions */\n" +
STRIP_FN +
"\n" +
src.slice(b);
fs.writeFileSync(path, src);
console.log("lexicon ok");
}
function replaceBodyDetect() {
const path = "src/services/parse/split-instructions.ts";
let src = fs.readFileSync(path, "utf8");
// Import BODY_ACTION already exists from lexicon
const neu = `function bodyForKindDetect(segBody: string): string {
const lines = segBody.split(/\\n/);
const out: string[] = [];
let inSubject = false;
let cont = 0;
for (const line of lines) {
if (/(?:\u4e3b\u9898|\u4e3b\u65e8|Subject)\\s*[\uFF1A:]?/i.test(line)) {
inSubject = true;
cont = 0;
continue;
}
if (inSubject) {
const t = line.trim();
if (!t) {
inSubject = false;
continue;
}
if (
BODY_ACTION_RE.test(line) ||
/Dear[,,]/.test(t) ||
/(?:\u65b0\u589e\u8f6c\u4ed3|\u65b0\u589e\u9884\u62a5|\u8bf7\u67e5\u6536|\u66f4\u65b0\u6d3e\u9001\u5355)/.test(
t,
) ||
/^\\d+[\\.\\u3001\\uff1a:]/.test(t)
) {
inSubject = false;
out.push(line);
continue;
}
if (
/^(?:\u5bc4\u4ef6\u4eba|\u53d1\u4ef6\u4eba|\u6536\u4ef6\u4eba|\u6284\u9001|\u65e5\u671f|\u53d1\u9001\u65f6\u95f4|From|To|Cc|Date|Sent|----)/i.test(
t,
) ||
/^date@/i.test(t) ||
t === "date"
) {
inSubject = false;
out.push(line);
continue;
}
if (
cont < 4 &&
/Fw:|\u8f6c\u53d1|ETA|ETD|\u8239\u540d|\u822a\u6b21|\u67dc\u53f7|[A-Z]{4}\\d{7}|40HQ|20GP|\u4ef6\\+|\\+\\s*$/i.test(
line,
)
) {
cont += 1;
continue;
}
inSubject = false;
out.push(line);
continue;
}
out.push(line);
}
return out.join("\\n");
}
`;
const a = src.indexOf("function bodyForKindDetect");
const b = src.indexOf("export function detectKindsForSegment");
if (a < 0 || b < 0) throw new Error("split markers");
src = src.slice(0, a) + neu + "\n" + src.slice(b);
fs.writeFileSync(path, src);
console.log("split ok");
}
replaceStripInLexicon();
replaceBodyDetect();

@ -0,0 +1,114 @@
/**
* Fix subject-line stripping: optional colon + multi-line subject block
*/
import fs from "fs";
const SUBJECT_LINE_RE =
"/(?:\\u4e3b\\u9898|\\u4e3b\\u65e8|Subject)\\s*[\\uFF1A:]?/i";
const helper = `/** Drop subject / \\u4e3b\\u65e8 block (optional colon, multi-line wrap) */\nfunction stripSubjectLines(text: string): string {\n const lines = text.split(/\\n/);\n const out: string[] = [];\n let inSubject = false;\n for (const line of lines) {\n if (/(?:\\u4e3b\\u9898|\\u4e3b\\u65e8|Subject)\\s*[\\uFF1A:]?/i.test(line)) {\n inSubject = true;\n continue;\n }\n if (inSubject) {\n const t = line.trim();\n if (!t) {\n inSubject = false;\n continue;\n }\n if (\n /^(?:\\u5bc4\\u4ef6\\u4eba|\\u53d1\\u4ef6\\u4eba|\\u6536\\u4ef6\\u4eba|\\u6284\\u9001|\\u65e5\\u671f|\\u53d1\\u9001\\u65f6\\u95f4|From|To|Cc|Date|Sent|Dear|----)/i.test(\n t,\n ) ||\n /^date@/i.test(t) ||\n t === "date" ||\n /^[\\u5728\\\\s]*\\d{4}/.test(t)\n ) {\n inSubject = false;\n out.push(line);\n continue;\n }\n continue;\n }\n out.push(line);\n }\n return out.join("\\n");\n}\n`;
// Simpler: write file with real unicode via \u in the script string that becomes Chinese
const stripFn = `
function stripSubjectLines(text: string): string {
const lines = text.split(/\\n/);
const out: string[] = [];
let inSubject = false;
for (const line of lines) {
// "\\u4e3b\\u9898 Re:..." often has NO colon after \\u4e3b\\u9898
if (/(?:\u4e3b\u9898|\u4e3b\u65e8|Subject)\\s*[\uFF1A:]?/i.test(line)) {
inSubject = true;
continue;
}
if (inSubject) {
const t = line.trim();
if (!t) {
inSubject = false;
continue;
}
if (
/^(?:\u5bc4\u4ef6\u4eba|\u53d1\u4ef6\u4eba|\u6536\u4ef6\u4eba|\u6284\u9001|\u65e5\u671f|\u53d1\u9001\u65f6\u95f4|From|To|Cc|Date|Sent|Dear|----)/i.test(
t,
) ||
/^date@/i.test(t) ||
t === "date" ||
/^\u5728\\s*\\d{4}/.test(t)
) {
inSubject = false;
out.push(line);
continue;
}
continue;
}
out.push(line);
}
return out.join("\\n");
}
`.trimStart();
{
const path = "src/services/parse/instruction-lexicon.ts";
let src = fs.readFileSync(path, "utf8");
const a = src.indexOf("function stripSubjectLines");
const b = src.indexOf("export function isForwardShellSegment");
if (a < 0 || b < 0) throw new Error("markers");
// keep any comment before function - find from /** Drop or function
let start = src.lastIndexOf("/** Drop subject", a);
if (start < 0) start = a;
src =
src.slice(0, start) +
"/** Drop subject/\u4e3b\u65e8 block (optional colon; multi-line wrap) */\n" +
stripFn +
"\n" +
src.slice(b);
fs.writeFileSync(path, src);
console.log("lexicon stripSubjectLines updated");
}
{
const path = "src/services/parse/split-instructions.ts";
let src = fs.readFileSync(path, "utf8");
const neu = `function bodyForKindDetect(segBody: string): string {
// reuse same multi-line subject strip as lexicon (inline copy to avoid circular import)
const lines = segBody.split(/\\n/);
const out: string[] = [];
let inSubject = false;
for (const line of lines) {
if (/(?:\u4e3b\u9898|\u4e3b\u65e8|Subject)\\s*[\uFF1A:]?/i.test(line)) {
inSubject = true;
continue;
}
if (inSubject) {
const t = line.trim();
if (!t) {
inSubject = false;
continue;
}
if (
/^(?:\u5bc4\u4ef6\u4eba|\u53d1\u4ef6\u4eba|\u6536\u4ef6\u4eba|\u6284\u9001|\u65e5\u671f|\u53d1\u9001\u65f6\u95f4|From|To|Cc|Date|Sent|Dear|----)/i.test(
t,
) ||
/^date@/i.test(t) ||
t === "date" ||
/^\\u5728\\s*\\d{4}/.test(t) ||
/^\u5728\\s*\\d{4}/.test(t)
) {
inSubject = false;
out.push(line);
continue;
}
continue;
}
out.push(line);
}
return out.join("\\n");
}
`;
const a = src.indexOf("function bodyForKindDetect");
const b = src.indexOf("export function detectKindsForSegment");
if (a < 0 || b < 0) throw new Error("bounds");
src = src.slice(0, a) + neu + "\n" + src.slice(b);
fs.writeFileSync(path, src);
console.log("split bodyForKindDetect updated");
}

@ -0,0 +1,83 @@
/**
* UI fallback: if mail_record missing customer, extract from subject bl-plus
*/
import fs from "fs";
const path = "src/components/MailBusinessSummary.tsx";
let src = fs.readFileSync(path, "utf8");
if (!src.includes("extractBlPlusFromMail")) {
const importNeedle = `import {
extractForecastInstructionText,
extractMailInstructions,
} from "@/services/parse/split-instructions";`;
const importRepl = `import { extractBlPlusFromMail } from "@/services/parse/bl-plus-template";
import {
extractForecastInstructionText,
extractMailInstructions,
} from "@/services/parse/split-instructions";`;
if (!src.includes(importNeedle)) throw new Error("import block missing");
src = src.replace(importNeedle, importRepl);
}
const oldForecast = ` const forecastValue = useMemo(() => {
const base = buildCcForecastFormValue({
customerName: record?.modules.customer_name,
header,
modules: record?.modules,
});
return {
...base,
F_Instruction: forecastInstruction || base.F_Instruction,
};
}, [
header,
record?.modules,
record?.modules.customer_name,
forecastInstruction,
]);`;
const neuForecast = ` const blPlusModules = useMemo(
() => extractBlPlusFromMail(mail.subject, bodyText)?.modules ?? null,
[mail.subject, bodyText],
);
const forecastValue = useMemo(() => {
const modules = {
...(blPlusModules || {}),
...(record?.modules || {}),
};
const base = buildCcForecastFormValue({
customerName:
record?.modules.customer_name ||
blPlusModules?.customer_name ||
undefined,
header,
modules,
});
return {
...base,
F_Instruction: forecastInstruction || base.F_Instruction,
};
}, [
header,
record?.modules,
record?.modules.customer_name,
blPlusModules,
forecastInstruction,
]);`;
if (!src.includes(oldForecast)) {
// CRLF
const oldC = oldForecast.replace(/\n/g, "\r\n");
if (!src.includes(oldC)) {
console.error("forecastValue block missing");
process.exit(1);
}
src = src.replace(oldC, neuForecast.replace(/\n/g, "\r\n"));
} else {
src = src.replace(oldForecast, neuForecast);
}
fs.writeFileSync(path, src);
console.log("MailBusinessSummary customer fallback ok");

@ -0,0 +1,148 @@
/**
* UI: only unit.isCurrent confirmable; history readonly + tag
*/
import fs from "fs";
// --- Shell ---
{
const path = "src/components/MailInstructionUnitShell.tsx";
let src = fs.readFileSync(path, "utf8");
const oldExtra = ` extra={
unit.keywords.length ? (
<Space wrap size={[4, 4]}>
{unit.keywords.slice(0, 6).map((k) => (
<Tag key={k}>{k}</Tag>
))}
</Space>
) : null
}`;
const newExtra = ` extra={
<Space wrap size={[4, 4]}>
<Tag color={unit.isCurrent ? "processing" : "default"}>
{unit.isCurrent ? "\u53ef\u786e\u8ba4" : "\u5386\u53f2\u53ea\u8bfb"}
</Tag>
{unit.keywords.slice(0, 5).map((k) => (
<Tag key={k}>{k}</Tag>
))}
</Space>
}`;
if (!src.includes(oldExtra)) throw new Error("shell extra block missing");
src = src.replace(oldExtra, newExtra);
fs.writeFileSync(path, src);
console.log("shell ok");
}
// --- MailBusinessSummary confirm gates ---
{
const path = "src/components/MailBusinessSummary.tsx";
let src = fs.readFileSync(path, "utf8");
// transfer: only current can confirm / edit WO fallback
src = src.replace(
` {blocked ? (
<CcWorkOrderForm
mode={canConfirmOps ? "edit" : "readonly"}`,
` {blocked ? (
<CcWorkOrderForm
mode={canConfirmOps && unit.isCurrent ? "edit" : "readonly"}`,
);
src = src.replace(
` if (canConfirmOps) {
actions = blocked ? (
<Button
type="primary"
loading={submitting}
onClick={() => void submitWorkOrder()}
>
\u8f6c\u4ed3\u4e0d\u53ef\u7528\uff0c\u63d0\u4ea4\u5de5\u5355
</Button>
) : (
<Button
type="primary"
loading={submitting}
onClick={() => void submitTransfer()}
>
\u786e\u8ba4\u6279\u91cf\u8f6c\u4ed3
</Button>
);
}`,
` if (canConfirmOps && unit.isCurrent) {
actions = blocked ? (
<Button
type="primary"
loading={submitting}
onClick={() => void submitWorkOrder()}
>
\u8f6c\u4ed3\u4e0d\u53ef\u7528\uff0c\u63d0\u4ea4\u5de5\u5355
</Button>
) : (
<Button
type="primary"
loading={submitting}
onClick={() => void submitTransfer()}
>
\u786e\u8ba4\u6279\u91cf\u8f6c\u4ed3
</Button>
);
}`,
);
// work_order mode
src = src.replace(
` <CcWorkOrderForm
mode={canConfirmOps ? "edit" : "readonly"}
value={{
...woForUnit,
// keep edits on shared state when confirming primary WO
...(mail.mail_type === "WORK_ORDER" && unit.isCurrent
? workOrderValue
: {}),
}}
onChange={(p) => setWorkOrderValue((v) => ({ ...v, ...p }))}
/>
);
if (canConfirmOps && mail.mail_type === "WORK_ORDER") {`,
` <CcWorkOrderForm
mode={canConfirmOps && unit.isCurrent ? "edit" : "readonly"}
value={{
...woForUnit,
...(unit.isCurrent ? workOrderValue : {}),
}}
onChange={(p) => setWorkOrderValue((v) => ({ ...v, ...p }))}
/>
);
if (canConfirmOps && unit.isCurrent) {`,
);
// do_upload
src = src.replace(
` <CcDoUploadPanel
mode={canConfirmOps ? "edit" : "readonly"}
value={
mail.mail_type === "DO_UPLOAD" && unit.isCurrent
? doValue
: doForUnit
}
onChange={(p) => setDoValue((v) => ({ ...v, ...p }))}
/>
);
if (
canConfirmOps &&
(mail.mail_type === "DO_UPLOAD" || unit.isCurrent)
) {`,
` <CcDoUploadPanel
mode={canConfirmOps && unit.isCurrent ? "edit" : "readonly"}
value={unit.isCurrent ? doValue : doForUnit}
onChange={(p) => setDoValue((v) => ({ ...v, ...p }))}
/>
);
if (canConfirmOps && unit.isCurrent) {`,
);
fs.writeFileSync(path, src);
console.log("summary ok", {
transferCur: src.includes("canConfirmOps && unit.isCurrent"),
woMode: src.includes('mode={canConfirmOps && unit.isCurrent ? "edit"'),
});
}

@ -0,0 +1,105 @@
import fs from "fs";
const path = "src/services/parse/packing-list.ts";
let src = fs.readFileSync(path, "utf8");
if (!src.includes("validatePackingFill")) {
src = src.replace(
`import { mapChannel } from "./channel-map";`,
`import { mapChannel } from "./channel-map";
import { validatePackingFill } from "./packing-fill-rules";`,
);
}
const oldBlock = ` const channelRaw = String(raw.F_Transporter ?? "").trim();
const mapped = mapChannel(channelRaw);
const fba = String(raw.F_FBACode ?? "").trim();
const ctnsNum = parseNumber(raw.F_CTNS);
const ctns = ctnsNum == null ? 0 : Math.trunc(ctnsNum);
const cbm = parseNumber(raw.F_CBM);
const weight = parseNumber(raw.F_Weight);
const addr = raw.F_Address != null ? String(raw.F_Address).trim() : "";
const express = /^(UPS|FEDEX|USPS|DHL)$/i.test(
mapped.transporter || channelRaw,
);
const invalid_reasons: string[] = [];
// 亚马逊仓必填仓库代码;快<EFBC9B>?私人地址可空
if (!fba && !express && !addr) invalid_reasons.push("仓库ID缺失");
if (!mapped.transporter) invalid_reasons.push("渠道缺失");
if (!ctns || ctns <= 0) invalid_reasons.push("件数无效");
if (cbm == null) invalid_reasons.push("总体积缺<EFBFBD>?);
if (weight == null) invalid_reasons.push("毛重缺失");
const warnings: string[] = [];
if (mapped.unmapped && mapped.transporter) warnings.push("CHANNEL_UNMAPPED");
let shipmentId =
raw.F_ShipmentID != null ? String(raw.F_ShipmentID).trim() : "";
if (!shipmentId) shipmentId = "/";
const fbaIdRaw =
raw.F_FBAID != null ? String(raw.F_FBAID).trim() : "";
const refRaw =
raw.F_ReferenceId != null ? String(raw.F_ReferenceId).trim() : "";
// 一格多 ID:警告(不自动拆行)
if (/[\\s,<2C>?/;]+/.test(fbaIdRaw) && /FBA/i.test(fbaIdRaw)) {
warnings.push("MULTI_FBAID_IN_CELL");
}
if (/[\\s,<2C>?/;]{2,}/.test(refRaw)) {
warnings.push("MULTI_REFERENCE_IN_CELL");
}`;
const neuBlock = ` const channelRaw = String(raw.F_Transporter ?? "").trim();
const mapped = mapChannel(channelRaw);
const fbaRaw = String(raw.F_FBACode ?? "").trim();
const ctnsNum = parseNumber(raw.F_CTNS);
const ctns = ctnsNum == null ? 0 : Math.trunc(ctnsNum);
const cbm = parseNumber(raw.F_CBM);
const weight = parseNumber(raw.F_Weight);
const addr = raw.F_Address != null ? String(raw.F_Address).trim() : "";
const fbaIdRaw =
raw.F_FBAID != null ? String(raw.F_FBAID).trim() : "";
const refRaw =
raw.F_ReferenceId != null ? String(raw.F_ReferenceId).trim() : "";
const shipmentIdRaw =
raw.F_ShipmentID != null ? String(raw.F_ShipmentID).trim() : "";
const fill = validatePackingFill({
channelRaw,
transporter: mapped.transporter,
warehouseId: fbaRaw,
ctns,
cbm,
weight,
address: addr,
shipmentId: shipmentIdRaw,
fbaId: fbaIdRaw,
referenceId: refRaw,
rawCtns: raw.F_CTNS != null ? String(raw.F_CTNS) : undefined,
rawCbm: raw.F_CBM != null ? String(raw.F_CBM) : undefined,
rawWeight: raw.F_Weight != null ? String(raw.F_Weight) : undefined,
});
const fba = fill.warehouseId;
const shipmentId = fill.shipmentId;
const invalid_reasons = [...fill.invalid_reasons];
const warnings = [...fill.warnings];
if (mapped.unmapped && mapped.transporter) warnings.push("CHANNEL_UNMAPPED");`;
if (!src.includes(oldBlock)) {
// try without double escapes for regex in character class
const old2 = oldBlock.replace(/\\s/g, "\\s").replace(/\\\\/g, "\\");
// Just find by markers
const a = src.indexOf("const channelRaw = String(raw.F_Transporter");
const b = src.indexOf("const dateB = toShanghaiDateString");
if (a < 0 || b < 0) {
console.error("markers", a, b);
process.exit(1);
}
src = src.slice(0, a) + neuBlock + "\n\n " + src.slice(b);
} else {
src = src.replace(oldBlock, neuBlock);
}
// Fix shipments.push F_FBACode: fba
fs.writeFileSync(path, src);
console.log("wired", src.includes("validatePackingFill"), src.includes("fill.warehouseId"));

@ -0,0 +1,48 @@
/**
* 清空全部邮件及相关解<EFBFBD>?导入/附件记录(保留邮箱账号等设置)<EFBFBD>?
* 用法: pnpm exec tsx scripts/purge-all-mails.ts
*/
import fs from "fs/promises";
import path from "path";
import { prisma } from "@/services/db";
async function rmMailDataDir() {
const root = path.join(process.cwd(), "data", "mails");
try {
await fs.rm(root, { recursive: true, force: true });
await fs.mkdir(root, { recursive: true });
console.log("cleared data/mails/");
} catch (e) {
console.warn("data/mails cleanup skipped", e);
}
}
async function main() {
const before = await prisma.mailMessage.count();
console.log("mails before:", before);
// FK-safe order
await prisma.importCompensation.deleteMany({});
await prisma.containerImport.deleteMany({});
await prisma.containerActiveLock.deleteMany({});
await prisma.parseResult.deleteMany({});
await prisma.mailAttachment.deleteMany({});
await prisma.imapPullLog.deleteMany({});
const deleted = await prisma.mailMessage.deleteMany({});
console.log("deleted mail_message:", deleted.count);
await rmMailDataDir();
const after = await prisma.mailMessage.count();
console.log("mails after:", after);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

@ -0,0 +1,29 @@
/**
* 手动跑数据保留清理:pnpm retention:cleanup
* dry-run: pnpm retention:cleanup -- --dry-run
*/
import { getEnv, resetEnvCache } from "../src/lib/env";
import { runDataRetentionCleanup } from "../src/services/retention/cleanup";
async function main() {
resetEnvCache();
const dryRun = process.argv.includes("--dry-run");
const env = getEnv();
console.log(
JSON.stringify(
{
DATA_RETENTION_DAYS: env.DATA_RETENTION_DAYS,
dryRun,
},
null,
2,
),
);
const stats = await runDataRetentionCleanup({ dryRun });
console.log(JSON.stringify(stats, null, 2));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

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

Loading…
Cancel
Save