commit 2b29fa74979b5fbfbe0ebe0b9f98cefe7dbdec44 Author: 你的GitHub用户名 <3457189498@qq.com> Date: Wed Aug 12 10:21:15 2026 +0800 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 diff --git a/.cursor/rules/implementation-plan.mdc b/.cursor/rules/implementation-plan.mdc new file mode 100644 index 0000000..99f02d3 --- /dev/null +++ b/.cursor/rules/implementation-plan.mdc @@ -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:已回写现行口径 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..68da880 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.next +out +dist +data +.env +.env*.local +!.env.example +*.log +.DS_Store +coverage +playwright-report +test-results diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d70e143 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26456a7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aff844f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..124df28 --- /dev/null +++ b/README.md @@ -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` diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..022e0de --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/start-system.cmd b/docs/start-system.cmd new file mode 100644 index 0000000..ea4d933 --- /dev/null +++ b/docs/start-system.cmd @@ -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 diff --git a/docs/start-system.ps1 b/docs/start-system.ps1 new file mode 100644 index 0000000..f65987b --- /dev/null +++ b/docs/start-system.ps1 @@ -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" diff --git a/docs/邮箱项目.md b/docs/邮箱项目.md new file mode 100644 index 0000000..60f21fd --- /dev/null +++ b/docs/邮箱项目.md @@ -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 + 书面确认。 diff --git a/docx/产品UI-UX设计方案-邮件自动预报-v1.0.md b/docx/产品UI-UX设计方案-邮件自动预报-v1.0.md new file mode 100644 index 0000000..ee3241c --- /dev/null +++ b/docx/产品UI-UX设计方案-邮件自动预报-v1.0.md @@ -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` | diff --git a/docx/产品规则-邮件指令识别与拆分.md b/docx/产品规则-邮件指令识别与拆分.md new file mode 100644 index 0000000..b2c5211 --- /dev/null +++ b/docx/产品规则-邮件指令识别与拆分.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 对话纪要**不是**运行时真源。 diff --git a/docx/异常场景与系统韧性设计.md b/docx/异常场景与系统韧性设计.md new file mode 100644 index 0000000..cb9d162 --- /dev/null +++ b/docx/异常场景与系统韧性设计.md @@ -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) diff --git a/docx/技术设计方案-邮件自动预报-v1.0.md b/docx/技术设计方案-邮件自动预报-v1.0.md new file mode 100644 index 0000000..c4db54b --- /dev/null +++ b/docx/技术设计方案-邮件自动预报-v1.0.md @@ -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 验收。 diff --git a/docx/接口/carriercentral客户端通用接口V1.72.md b/docx/接口/carriercentral客户端通用接口V1.72.md new file mode 100644 index 0000000..f74810f --- /dev/null +++ b/docx/接口/carriercentral客户端通用接口V1.72.md @@ -0,0 +1,1628 @@ +# 客户接口 V1.72 + +> 本文档由 `carriercentral客户端通用接口V1.72docx(4).pdf`(共 15 页)全文转换。 +> +> - **结构化正文**:接口 1–13 的协议、地址、参数表、示例、输出、限流均按 PDF 收录。 +> - **附录 A**:`pypdf` 逐页原文抽取,保证文本层零遗漏,可对照查漏。 +> - 字段名处 PDF 抽取产生的异常空格(如 `F_ TransMode`)在结构化表中还原为合法标识符;说明文字按原文语义收录(含原文笔误,如 `F_Email` 说明写作「邮编」)。 + +--- + +## 环境与账号 + +| 项 | 值 | +| --- | --- | +| 客户端 | https://demovip.saas.carriercentral.vip | +| 测试接口环境 | https://demovip.saas.carriercentral.vip | +| 测试客户账号 | fj | +| 测试客户密码 | fj123qwe! | + +--- + +## 更改历史 + +| 版本 | 更改内容 | +| --- | --- | +| V1.3 | 1. 增加接口:获取船司列表
2. 接口 SaveContainer 增加字段:船司,是否带车架,备注
3. 接口 GetContainerList 的数据返回增加字段:船司,是否带车架,备注
4. 接口 GetContainerList 的数据返回增加字段:报关资料,packingList
5. 增加接口:修改集装箱字段值 SaveFieldValue
6. 增加接口:上传文件 upload
7. 接口 SaveContainer 返回集装箱 id 在 data
8. 接口 GetShipmentInfo 返回参数 status 增加状态说明
9. 接口 GetContainerList 返回参数 status 增加状态说明
10. 接口 SaveContainer 输入参数 sR_Shipments 添加字段:城市,州,邮编,联系人,手机号,邮箱
11. 接口 SaveTruckInfo 输入参数 sR_Shipments 添加字段:城市,州,邮编,联系人,手机号,邮箱 | +| V1.4 | 1. 增加接口:下载 POD 和 ClosePage | +| V1.5 | 1. 接口 SaveContainer 输入参数 sR_Shipments 添加字段:品名,英文名称,PCS
2. 接口 GetShipmentList 返回参数 F_ChineseName 变更为 F_Description
3. 增加接口:下载拆柜图片 DownDevanFile | +| V1.6 | 1. GetShipmentInfo 增加 PlanETA 作为预计送达日期,ISATime 更改定义为 ISA 时间。 | +| V1.7 | 1. 集装箱列表接口访问次数限制:1 分钟 1 次
2. 集装箱货件列表接口访问次数限制:1 分钟 1 次
3. 集装箱状态变更日志接口访问次数限制:5 分钟 1 次
4. 集装箱货件信息接口访问次数限制:30 秒 1 次
5. 集装箱拆箱报告:1 分钟 1 次 | +| V1.71 | 1. 集装箱数据导入接口添加字段:柜号id(F_ContainerId) | +| V1.72 | 1. 集装箱列表接口添加字段:提柜供应商(F_SupplierId) | + +--- + +## 目录 + +1. [登录](#1-登录) +2. [获取船司列表](#2-获取船司列表) +3. [集装箱数据导入](#3-集装箱数据导入) +4. [卡派数据导入](#4-卡派数据导入) +5. [集装箱列表](#5-集装箱列表) +6. [集装箱货件列表](#6-集装箱货件列表) +7. [集装箱状态变更日志](#7-集装箱状态变更日志) +8. [集装箱货件信息](#8-集装箱货件信息) +9. [集装箱拆箱报告](#9-集装箱拆箱报告) +10. [修改集装箱字段值](#10-修改集装箱字段值) +11. [上传文件](#11-上传文件) +12. [下载 POD 和 ClosePage](#12-下载-pod-和-closepage) +13. [下载拆柜图片](#13-下载拆柜图片) + +--- + +## 1. 登录 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/learun/adms/user/customerLogin | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变 | +| Data (json 字符串) / username | string | 是 | 登录账号(客户账号) | +| Data (json 字符串) / password | string | 是 | 登录密码,32 位 MD5 加密后的字符串 | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "Data": "{\"username\":\"登录账号\",\"password\":\"登录密码,32 位 MD5 加密后的字符串\"}" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data.token | string | 是 | 通讯票据 | + +--- + +## 2. 获取船司列表 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/ConfigModule/GetShippingLineList | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | List data | — | 见下表 | + +#### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_ID | String(50) | 是 | 船司 ID | +| F_ShippingLineCode | String(20) | 是 | 船司代码 | +| F_ShippingLineName | String(100) | 是 | 船司名 | +| F_Prefix | String(100) | 否 | 前缀 | +| F_DayLow | Int | 是 | 船司 ETA 后提柜时间(示例:1-2 天,1) | +| F_DayHigh | int | 是 | 船司 ETA 后提柜时间(示例:1-2 天,2) | +| F_Remark | String(1000) | 否 | 备注 | +| F_ShippingLine | String(120) | 是 | 船司 | + +--- + +## 3. 集装箱数据导入 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/SaveContainer | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| keyValue | string | 否 | SR_ContainerTaskEntity 对象主键,修改时必填 | +| entity | SR_ContainerTaskEntity 对象 | — | 见 entity 表 | +| sR_Shipments | List SR_ShipmentEntity 对象 | — | 见 sR_Shipments 表 | + +#### entity(SR_ContainerTaskEntity) + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_TransMode | int | 是 | 运输方式:0-海运 1-空运 3-自送 | +| F_OperationType | int | 是 | 业务类型:0-拆柜 2-直送 4-仅清关 | +| F_ContainerNo | string(50) | 是 | 柜号 | +| F_ContainerId | string(50) | 否 | 柜号id | +| F_CabinetType | string(20) | 否 | 柜型:20GP, 40GP,40HQ,40HR,40HC,45HQ,53HQ | +| F_BLCopyCode | string(15) | 否 | 提单号 | +| F_ETD | datetime? | 否 | ETD(格式:2025/04/24 或 2025-04-24) | +| F_ETA | datetime? | 否 | ETA(格式:2025/04/24 或 2025-04-24) | +| F_LoadPort | string(50) | 否 | 起始港 | +| F_Dock | string(50) | 否 | 目的港 | +| F_ShippingLineId | string(50) | 否 | 船司 ID(由船司列表接口返回的值) | +| F_Classis | int | 否 | 是否自带车架 1-是 0-否 | +| F_MemoRemark | string(1000) | 否 | 备注 | +| F_Instruction | string(1000) | 否 | 拆柜指引 | + +#### sR_Shipments(List SR_ShipmentEntity) + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_FBACode | string(30) | 是 | 目的代码 | +| F_Transporter | string(30) | 是 | 派送方式:留仓,自提,扣货,TRUCK, UPS,FEDEX,DHL,USPS | +| F_ShipmentID | string(500) | 否 | 客户参考号 Customer Ref No | +| F_Remark | string(1000) | 否 | 行备注 | +| F_FBAID | string(100) | 否 | FBA id | +| F_PO | string(100) | 否 | PO | +| F_ReferenceId | string(500) | 否 | 参考号 | +| F_Address | string(500) | 否 | 详细地址,商业地址需要填写 | +| F_CTNS | int | 是 | 件数 | +| F_CBM | decimal? | 否 | 体积 | +| F_Weight | decimal? | 否 | 重量 | +| F_SKU | string(50) | 否 | SKU | +| F_Description | string(500) | 否 | 品名 | +| F_EnglishName | string(500) | 否 | 英文名称 | +| F_PCS | int | 否 | PCS | +| F_Expected_DeliveryDateB | datetime? | 否 | 最早送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_Expected_DeliveryDateE | datetime? | 否 | 最晚送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_City | string(50) | 否 | 城市 | +| F_State | string(50) | 否 | 州 | +| F_ZipCode | string(50) | 否 | 邮编 | +| F_Contact | string(50) | 否 | 联系人 | +| F_Mobile | string(50) | 否 | 手机号 | +| F_Email | string(300) | 否 | 邮编(原文参数说明) | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "{\"keyValue\":null,\"entity\":{\"F_TransMode\":0,\"F_OperationType\":0,\"F_ContainerNo\":\"GH25042416\",\"F_CabinetType\":null,\"F_BLCopyCode\":\"\",\"F_ETD\":\"2025-04-01\",\"F_ETA\":\"2025-04-24\",\"F_LoadPort\":\"SHENZHEN\",\"F_Dock\":\"LAX\",\"F_ShippingLineId\":\"c3f80d26-3174-4eba-9460-735ce33771da\",\"F_Classis\":1,\"F_MemoRemark\":\"remarkk\",\"F_Instruction\":\"这是接口创建集装箱\"},\"sR_Shipments\":[{\"F_FBACode\":\"SMF3\",\"F_Transporter\":\"truck\",\"F_ShipmentID\":\"FBA424POST17\",\"F_Remark\":\"备注1\",\"F_FBAID\":\"FBA424PMAN17\",\"F_PO\":null,\"F_ReferenceId\":null,\"F_Address\":null,\"F_CTNS\":50,\"F_CBM\":2.36,\"F_Weight\":135.6,\"F_SKU\":null,\"F_Description\":\"CHina\",\"F_EnglishName\":\"Eng\",\"F_PCS\":10},{\"F_FBACode\":\"ABE2\",\"F_Transporter\":\"扣货\",\"F_ShipmentID\":\"FBA424POST17\",\"F_Remark\":\"备注2\",\"F_FBAID\":\"FBA424PMAN17\",\"F_PO\":\"\",\"F_ReferenceId\":\"\",\"F_Address\":\"\",\"F_CTNS\":30,\"F_CBM\":1.23,\"F_Weight\":108.3,\"F_SKU\":null,\"F_City\":null,\"F_State\":null,\"F_ZipCode\":null,\"F_Contact\":null,\"F_Mobile\":null,\"F_Email\":null,\"F_Expected_DeliveryDateB\":\"2025-04-01\",\"F_Expected_DeliveryDateE\":\"2025-04-01\"}]}" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | 集装箱 id | + +--- + +## 4. 卡派数据导入 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/truck/SaveTruckInfo | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| keyValue | string | 否 | SR_ContainerTaskEntity 对象主键,修改时必填 | +| entity | SR_ContainerTaskEntity 对象 | — | 见 entity 表 | +| sR_Shipments | List SR_ShipmentEntity 对象 | — | 见 sR_Shipments 表 | + +#### entity + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_IsPickUp | int | 是 | 是否取货:0-自送 1-提货 2-提货派送 | +| F_PickUpDate | datetime? | 否 | 提货日期(格式:2025/04/24 或 2025-04-24);自送不需要填写 | +| F_PickUpBegin | int? | 否 | 提货起始时间(0-23 点),自送不需要填写 | +| F_PickUpEnd | int? | 否 | 提货截止时间(0-23 点),自送不需要填写 | +| F_PickUpAddress | string | 否 | 提货地址,自送不需要填写。 | + +#### sR_Shipments + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_CustomterBolCode | string(50) | 否 | 客户单号 | +| F_MoveType | string(10) | 否 | 移动类型:LTL,FTL,FCL,不传默认 LTL | +| F_FBACode | string(30) | 是 | 目的代码 | +| F_HouseNo | string(30) | 否 | 商业地址的 FBACode,商业地址需要填写 | +| F_Address | string(500) | 否 | 详细地址,商业地址需要填写 | +| F_FBAID | string(100) | 否 | FBA id | +| F_PO | string(100) | 否 | PO | +| F_CTNS | int? | 否 | 件数 | +| F_CBM | decimal? | 否 | 体积 | +| F_Weight | decimal? | 否 | 重量 | +| F_BOLActual Pallet | decimal? | 否 | 板数(PDF 字段名含空格:`F_BOLActual Pallet`) | +| F_Expected_DeliveryDateB | datetime? | 否 | 最早送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_Expected_DeliveryDateE | datetime? | 否 | 最晚送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_City | string(50) | 否 | 城市 | +| F_State | string(50) | 否 | 州 | +| F_ZipCode | string(50) | 否 | 邮编 | +| F_Contact | string(50) | 否 | 联系人 | +| F_Mobile | string(50) | 否 | 手机号 | +| F_Email | string(300) | 否 | 邮编(原文参数说明) | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "{\"keyValue\":\"\",\"entity\":{\"F_IsPickUp\":5,\"F_PickUpDate\":\"4/24\",\"F_PickUpBegin\":null,\"F_PickUpEnd\":null,\"F_PickUpAddress\":\"pick up address\"},\"sR_Shipments\":[{\"F_CustomterBolCode\":\"JK2504412\",\"F_MoveType\":\"\",\"F_FBACode\":\"ONT8\",\"F_HouseNo\":\"\",\"F_Address\":\"\",\"F_FBAID\":\"\",\"F_PO\":\"\",\"F_CTNS\":null,\"F_CBM\":null,\"F_Weight\":null,\"F_BOLActual Pallet\":0.02},{\"F_CustomterBolCode\":\"JK25042413\",\"F_MoveType\":\"\",\"F_FBACode\":\"PORK\",\"F_HouseNo\":\"\",\"F_Address\":\"\",\"F_FBAID\":\"\",\"F_PO\":\"\",\"F_CTNS\":null,\"F_CBM\":null,\"F_Weight\":null,\"F_BOLActual Pallet\":0.3,\"F_City\":null,\"F_State\":null,\"F_ZipCode\":null,\"F_Contact\":null,\"F_Mobile\":null,\"F_Email\":null,\"F_Expected_DeliveryDateB\":\"2025-04-01\",\"F_Expected_DeliveryDateE\":\"2025-04-01\"}]}" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | 无 | + +--- + +## 5. 集装箱列表 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/GetContainerList | +| 访问次数限制(V1.7) | 1 分钟 1 次 | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +#### pagination 分页 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| rows | int | 是 | 一页行数 | +| page | int | 是 | 页码 | +| sidx | string | 是 | 按什么排序,例如 F_CreateDate | +| sord | String | 是 | 排序:desc 或 asc | + +#### queryJson 查询参数 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_ContainerNo | string(50) | 否 | 柜号 | +| F_CabinetType | string(10) | 否 | 柜型:20GP,40GP,40HQ,40HR,45HC,45HQ,53HQ 不传默认查所有 | +| F_TransMode | string(30) | 否 | 运输方式:海运,空运,卡派,自送。不传默认查所有 | +| F_Status | string(30) | 否 | 状态:0-新单,20-清关结束,30-提柜直达,40-待提柜,60-已到仓,65-直送运输中,70-已拆柜,80-已还柜 | +| F_FBACode | string(30) | 否 | FBACode | +| F_FBAID | string(100) | 否 | FBA id | +| F_ShipmentId | string(100) | 否 | Ref No. | +| StartTime | datetime? | 否 | 开始日期(格式:2025/04/24 或 2025-04-24) | +| EndTime | datetime? | 否 | 结束日期(格式:2025/04/24 或 2025-04-24) | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "{\"pagination\":{\"rows\":10,\"page\":1,\"sidx\":\"F_CreateDate\",\"sord\":\"desc\"},\"queryJson\":\"{\\\"F_ContainerNo\\\":\\\"\\\",\\\"F_CabinetType\\\":\\\"\\\",\\\"F_TransMode\\\":\\\"\\\",\\\"F_Status\\\":\\\"\\\",\\\"F_FBAID\\\":\\\"\\\",\\\"F_ShipmentId\\\":\\\"\\\",\\\"StartTime\\\":\\\"\\\",\\\"EndTime\\\":\\\"\\\",\\\"F_FBACode\\\":\\\"ABE2\\\"}\"}" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| total | int | 是 | 总数 | +| page | int | 是 | 页码 | +| records | int | 是 | 记录数 | +| rows | List rows 对象 | — | 见 rows 表 | + +#### rows 字段 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_ContainerTaskId | String | 是 | 集装箱唯一主键 id | +| F_ContainerNo | String | 是 | 柜号 | +| F_CustomerId | String | 否 | 柜号 id | +| F_TransMode | Int | 是 | 运输方式 0-海运 1-空运 2-卡派 3-自送柜 | +| F_IsISF | Int | 是 | 是否填写 ISF, 0-不填写 1-填写 | +| F_CabinetType | String | 是 | 柜型 | +| F_ETD | datetime | 否 | ETD | +| F_ETA | datetime | 否 | ETA | +| F_EstimateExtractionTime | datetime | 否 | 预计提柜时间 | +| F_ExtractionTime | datetime | 否 | 实际提柜时间 | +| F_ATA | datetime | 否 | 到港时间 | +| F_UnstowTime | datetime | 否 | 拆柜日期 | +| F_GiveBackTime | datetime | 否 | 还柜日期 | +| F_NotifyGiveBackTime | datetime | 否 | 通知还柜日期 | +| F_Status | Int | 是 | 0-新单 1-已导入 Transload 2-已通知 3-已提柜 4-已到仓 5-已还柜(已结束) 9-异常 | +| F_CreateDate | datetime | 是 | 创建时间 | +| F_LoadPort | string | 否 | 起运港 | +| F_Dock | String | 否 | 目的港 | +| F_ToWhDate | datetime | 否 | 到仓日期 | +| F_InvoiceFile | String | 否 | Commericial Invoice 文件 ID | +| F_BLFile | String | 否 | BL Copy 提单文件 ID | +| F_ANFile | String | 否 | AN 到货通知文件 ID | +| F_DOFile | String | 否 | DO 文件 id | +| F_EntrySummaryFile | String | 否 | Entry Summary 文件 ID | +| F_ISFFile | String | 否 | 客户 ISF 文件 ID | +| F_OperationType | String | 否 | 业务类型:0,1 是拆柜,2 是直送,4 是只清关 | +| F_PackingList | string | 否 | Packing liist 文件 id | +| F_OfficialISF | string | 否 | 正式 ISF 文件 ID | +| F_CustomsFile | String | 否 | 报关资料 | +| F_ATD | datetime | 否 | ATD,实际开船日期 | +| F_BLCopyCode | String | 否 | 提单号 | +| F_AreaCode | String | 否 | 库区 | +| F_MemoRemark | String | 否 | 备注 | +| F_Classis | Int | 否 | 是否自带车架 1-是 0-否 | +| F_ShippingLineId | String | 否 | 船司 ID | +| F_PackingList | String | 否 | Packing list 文件 id(文档表重复列出) | +| F_CustomsFile | String | 否 | 报关资料文件 id(文档表重复列出) | +| F_SupplierId | String | 否 | 提柜供应商id(V1.72) | + +### 输出示例 JSON + +原文输出示例字段完整保留如下(已将 PDF 换行拼接;`info` 原文含空格「响 应 成 功」处规整为「响应成功」,键值全集未删): + +```json +{ + "code": 200, + "info": "响应成功", + "data": { + "rows": [ + { + "F_ContainerTaskId": "e4643726-6930-4a92-b9a8-937d59dbcce0", + "F_ContainerNo": "BC25090910", + "F_CustomerId": "d1d2fc3d-a4de-4281-a728-fc2d6a145d3f", + "F_TransMode": 0, + "F_IsISF": 0, + "F_CabinetType": "20GP", + "F_ETD": null, + "F_ETA": null, + "F_EstimateExtractionTime": "2025-09-19 13:00:00", + "F_ExtractionTime": null, + "F_ATA": null, + "F_UnstowTime": null, + "F_GiveBackTime": null, + "F_NotifyGiveBackTime": null, + "F_Status": 0, + "F_CreateUserId": "System", + "F_CreateDate": "2025-09-09 03:11:11", + "F_CreateUserName": "超级管理员", + "F_ModifyUserId": "System", + "F_ModifyDate": "2025-09-16 20:16:13", + "F_ModifyUserName": "超级管理员", + "F_WhCode": null, + "F_FileName": null, + "F_FullFileName": null, + "F_LoadPort": "", + "F_Dock": "", + "F_ANTime": null, + "F_CustomsBroker": null, + "F_SupplierId": "", + "F_SupplierTrailer": null, + "F_AbNormalTime": null, + "F_AbNormalInfo": "", + "F_ToWhDate": null, + "F_BracketFreeDay": null, + "F_InvoiceFile": null, + "F_BLFile": null, + "F_ANFile": null, + "F_DOFile": null, + "F_EntrySummaryFile": null, + "F_ISFFile": null, + "F_CustomsClearanceTime": null, + "F_OperationType": 0, + "F_SourceType": null, + "F_PackingList": null, + "F_OfficialISF": null, + "F_CustomsFile": null, + "F_OpenPhoto": null, + "F_ATD": null, + "F_FlowId": null, + "F_IsPickUp": null, + "F_PickUpCode": null, + "F_PickUpDate": null, + "F_PickUpBegin": null, + "F_PickUpEnd": null, + "F_PickUpAddress": null, + "F_TotalPalltes": null, + "F_CustomerBolFile": null, + "F_PickAmount": null, + "F_LogAcceptUrl": null, + "F_PrintContent": null, + "F_Instruction": "", + "F_ShipStatus": null, + "F_Report": null, + "F_IsCustoms": null, + "F_CustomsDate": null, + "F_CustomsUserName": null, + "F_AddressName": null, + "F_FullAddress": null, + "F_PODFile": null, + "F_PODTime": null, + "F_IsSecurityFiling": null, + "F_ISFDraft": null, + "F_IsVWH": null, + "F_GW": null, + "F_ToContact": null, + "F_UnstowData": null, + "F_TaskId": null, + "F_BLCopyCode": "", + "F_PickUpCost": null, + "F_Convert": null, + "F_Loader": "", + "F_LoadingCost": null, + "F_IsInner": null, + "F_UnstowImage": null, + "F_ConfirmTime": null, + "F_IsCharge": null, + "F_OverWeight": 0, + "F_LFD": null, + "F_ISA": null, + "F_ISATime": null, + "F_StorageType": null, + "F_TrainTrend": "", + "F_AreaCode": "", + "F_Inspection": 0, + "F_InspectionDesc": "", + "F_ContainerId": "", + "F_MemoRemark": "", + "F_DismantleStatus": null, + "F_New": null, + "F_Classis": 0, + "F_ShippingLineId": "c3f80d26-3174-4eba-9460-735ce33771da", + "F_CustomerCode": null, + "F_CustomerName": null, + "F_ShortName": "Third Party_Usbic", + "F_SupplierName": null, + "CustomsBrokerName": null, + "OpObject": null, + "markCheckList": null, + "shipmentList": null, + "ErrorSortBy": null, + "F_Profit": null, + "F_AreaAddress": null, + "F_UnstowFileList": null, + "F_ShippingLineName": null, + "F_PODFileName": null, + "F_PODFileType": null, + "F_PODFileUrl": "/Common/DownAnnexesFile?file Id=", + "F_PODFileIcon": "/Content/images/filetype/.png", + "F_ChargeFinished": 0, + "F_MinReportTime": null + } + ], + "total": 44, + "page": 1, + "records": 44 + } +} +``` + +--- + +## 6. 集装箱货件列表 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Shipment/GetShipmentList | +| 访问次数限制(V1.7) | 1 分钟 1 次 | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,集装箱唯一主键 id | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "集装箱唯一主键 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | JSON 字符串 | + +#### data 内容 List data + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| F_Transporter | String(30) | 是 | MoveType:TRUCK,留仓,扣货,UPS,FEDEX,DHL,USPS,自提 | +| F_ID | String(50) | 是 | 货件 ID | +| F_FBACode | String(30) | 是 | 仓库代码 FBACode | +| F_ShipmentID | String(1000) | 否 | Customer Ref no. | +| F_CTNS | Int | 是 | 箱数 | +| F_CBM | decimal? | 是 | 体积 | +| F_Weight | decimal? | 是 | 重量 | +| F_Address | String(500) | 否 | 地址 | +| F_FBAID | String(2000) | 否 | FBA Id | +| F_ReferenceId | String(1000) | 否 | Reference Id | +| F_SKU | String(50) | 否 | SKU | +| F_EnglishName | String(500) | 否 | 英文品名 | +| F_Description | String(500) | 否 | 中文品名 | +| F_Expected_DeliveryDateB | datetime? | 否 | 最早送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_Expected_DeliveryDateE | datetime? | 否 | 最晚送仓日期(格式:2025/04/24 或 2025-04-24) | +| F_City | String(50) | 否 | 城市 | +| F_State | String(50) | 否 | 州省 | +| F_ZipCode | String(50) | 否 | 邮编 | +| F_Contact | String(50) | 否 | 收件人 | +| F_Mobile | String(50) | 否 | 联系电话 | +| F_Email | String(100) | 否 | 邮箱 | +| F_Remark | String(2000) | 否 | 行备注 | + +--- + +## 7. 集装箱状态变更日志 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/GetStateTrack | +| 访问次数限制(V1.7) | 5 分钟 1 次 | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,集装箱唯一主键 id | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "集装箱唯一主键 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | JSON 字符串 | + +#### data 内容 List data + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| TypeCode | Int | 是 | 状态:0-已到港,10-已提柜,20-已到仓,30-已拆柜,40-已还空 | +| TypeName | String(50) | 是 | 状态名:0-已到港,10-已提柜,20-已到仓,30-已拆柜,40-已还空 | +| F_CreateDate | datetime? | 否 | 创建时间 | +| F_CreateUserName | String | 否 | 创建人 | +| DataJson | String | 否 | JSON 字符串 | + +#### DataJson 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| date | datetime? | 是 | 时间 | +| image | string | 否 | 图片 | +| remark | String | 否 | 备注 | + +--- + +## 8. 集装箱货件信息 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/GetShipmentInfo | +| 访问次数限制(V1.7) | 30 秒 1 次 | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,集装箱唯一主键 id | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "集装箱唯一主键 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | JSON 字符串 | + +#### data 内容 List data + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| Transporter | String(30) | 是 | MoveType:TRUCK,留仓,扣货,UPS,FEDEX,DHL,USPS,自提 | +| Mark | String(30) | 是 | 仓库代码 FBACode | +| ShipmentID | String(1000) | 否 | Customer Ref no. | +| Ctns | Int | 是 | 箱数 | +| Pallets | decimal? | 是 | 板数 | +| Status | decimal? | 是 | 状态:0-预到达 10-在库 15-在库 16-已排货 20-已出仓 26-运输中 30-已送达 90-已取消 | +| PlanDate | datetime? | 否 | 计划发车时间 | +| PlanETA | datetime? | 否 | 预计送达日期 | +| ISATime | datetime? | 否 | ISA 时间 | +| ETA | datetime? | 否 | ETA | +| ISA | String(50) | 否 | ISA | +| PODTime | datetime? | 否 | 签收时间 | +| Closed Page | String(50) | 否 | ClosePage 文件 id(PDF 字段名写作 Closed Page) | +| PODFile | String(50) | 否 | POD 文件 id | +| ISAChangeReason | String | 否 | ISA 变更原因 | +| ProofChange | String | 否 | ISA 变更凭证 | +| Remark | String | 否 | 备注 | + +--- + +## 9. 集装箱拆箱报告 + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/GetBillReport | +| 访问次数限制(V1.7) | 1 分钟 1 次 | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,集装箱唯一主键 id | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "集装箱唯一主键 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | binary / Blob | 否 | 拆柜报告 PDF 文件 | + +--- + +## 10. 修改集装箱字段值 + +(注意:上传集装箱文件前,若对应字段为空,可使用该接口将此字段设置成文件 Id 或文件夹 Id) + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/SaveFieldValue | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | JSON 字符串 | + +### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| keyValue | string | 是 | 集装箱主键唯一 id | +| field | String | 是 | 字段名(见映射) | +| fieldValue | String | 是 | 字段值,唯一 Id | + +#### field 字段名映射 + +| 业务含义 | field 取值 | +| --- | --- | +| DO | F_DOFile | +| B/L Copy | F_BLFile | +| Commericial Invoice | F_InvoiceFile | +| Packing list | F_PackingList | +| 报关资料 | F_CustomsFile | +| AN | F_ANFile | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "{\"keyValue\":\"729dbc73-e5fc-4207-9e7f-a58f9e2c9773\",\"field\":\"F_DOFile\",\"fieldValue\":\"80e0a295-f8f6-47e2-be2b-e8faece90694\"}" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | 无 | + +--- + +## 11. 上传文件 + +(注意:上传文件使用 GetContainerList 接口先获取集装箱的文件字段对应 id,如果为空的情况使用 SaveFieldValue 接口为对应文件字段赋值) + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | POST | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/learun/adms/annexes/upload | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 集装箱对应文件字段的文件夹 id(举例:集装箱 F_DOFile 对应的 id) | +| file | binary / Blob | 是 | 上传的文件 | + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | string | 是 | 文件 id | + +--- + +## 12. 下载 POD 和 ClosePage + +注:相同文件 id 下载成功次数上限 5 次(不区分账户); + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/learun/adms/annexes/down | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,POD 文件 id 或 ClosePage 文件 id(提示:POD 文件 id 和 ClosePage 文件 id 在 GetShipmentInfo 接口返回值中) | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "POD 文件 id 或 ClosePage 文件 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | binary / Blob | 是 | POD 文件 或 ClosePage 文件 | + +--- + +## 13. 下载拆柜图片 + +注:相同集装箱下载成功次数上限 5 次(不区分账户); + +| 项 | 值 | +| --- | --- | +| 接口协议 | HTTP/WebApi | +| 请求方式 | GET | +| 请求地址 | https://demovip.saas.carriercentral.vip/api/Container/DownDevanFile | + +### 输入参数 + +| 输入参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| loginMark | string | 是 | GUID 登录设备标识, 由请求端生成, 同一设备要求值不变,同登录接口保持一致。 | +| token | string | 是 | 通讯票据(由登录接口返回的值) | +| data | string | 是 | 字符串,集装箱唯一主键 id | + +### 输入示例 JSON + +```json +{ + "loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", + "token": "通讯票据(由登录接口返回的值)", + "data": "集装箱唯一主键 id" +} +``` + +### 输出参数 + +| 输出参数名 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| code | int | 是 | 200:成功 400:失败 500:异常 410:没有登录信息 | +| info | string | 是 | 成功或者错误消息 | +| data | String | 否 | JSON | + +#### data 内容 + +| 字段 | 类型 | 必须 | 参数说明 | +| --- | --- | --- | --- | +| bytes | String | 是 | 拆柜图片文件压缩包(Base64 编码的字符串) | +| fileName | string | 是 | 文件名 | + +--- + +## 附录 B:接口清单与限流速查 + +| # | 名称 | 方法 | 路径 | +| --- | --- | --- | --- | +| 1 | 登录 | POST | /api/learun/adms/user/customerLogin | +| 2 | 获取船司列表 | GET | /api/ConfigModule/GetShippingLineList | +| 3 | 集装箱数据导入 | POST | /api/Container/SaveContainer | +| 4 | 卡派数据导入 | POST | /api/truck/SaveTruckInfo | +| 5 | 集装箱列表 | POST | /api/Container/GetContainerList | +| 6 | 集装箱货件列表 | GET | /api/Shipment/GetShipmentList | +| 7 | 集装箱状态变更日志 | GET | /api/Container/GetStateTrack | +| 8 | 集装箱货件信息 | GET | /api/Container/GetShipmentInfo | +| 9 | 集装箱拆箱报告 | GET | /api/Container/GetBillReport | +| 10 | 修改集装箱字段值 | POST | /api/Container/SaveFieldValue | +| 11 | 上传文件 | POST | /api/learun/adms/annexes/upload | +| 12 | 下载 POD 和 ClosePage | GET | /api/learun/adms/annexes/down | +| 13 | 下载拆柜图片 | GET | /api/Container/DownDevanFile | + +| 接口 | 限制 | +| --- | --- | +| GetContainerList | 1 分钟 1 次 | +| GetShipmentList | 1 分钟 1 次 | +| GetStateTrack | 5 分钟 1 次 | +| GetShipmentInfo | 30 秒 1 次 | +| GetBillReport | 1 分钟 1 次 | +| 下载 POD/ClosePage | 相同文件 id 下载成功上限 5 次(不区分账户) | +| DownDevanFile | 相同集装箱下载成功上限 5 次(不区分账户) | + +--- + +## 附录 A:PDF 原文抽取(逐页,未删减) + +> 以下为 `pypdf` 对源 PDF 全部 15 页的完整文本抽取,用于对照查漏;行内异常空格为 PDF 文本层排版产物,非编辑删改。 + +### 第 1 页 + +``` +客户接口 V1.72 +客户端 :https://demovip.saas.carriercentral.vip +测试接 口环境 :https://demovip.saas.carriercentral.vip +测试客户账号: fj +fj123qwe! +更改历史 +版本 更改内容 +V1.3 1. 增加接口 :获取船司列表 +2. 接口 SaveContainer 增加字段 :船司,是否带车架,备注 +3. 接口 GetContainerList 的数据返回增加字段:船司,是否带车架,备注 +4. 接口 GetContainerList 的数据返回增加字段:报关资料, packingList +5. 增加接口 :修改集装箱字段值 SaveFieldValue +6. 增加接口:上传文件 upload +7. 接口 SaveContainer 返回集装箱 id 在 data +8. 接口 GetShipmentInfo 返回参数 status 增加状态说明 +9. 接口 GetContainerList 返回参数 status 增加状态说明 +10. 接口 SaveContainer 输入参数 sR_Shipments 添加字段:城市,州 ,邮编,联系 +人 ,手机号, 邮箱 +11. 接口 SaveTruckInfo 输入参数 sR_Shipments 添加字段 :城市,州 ,邮编,联系 +人 ,手机号, 邮箱 +V1.4 1. 增加接口:下载 POD 和 ClosePage +V1.5 1.接口 SaveContainer 输入参数 sR_Shipments 添加字段: 品名,英文名称, PCS +2.接口 GetShipmentList 返回参数 F_ChineseName 变更为 F_Description +3.增加接口:下载拆柜图片 DownDevanFile +V1.6 1.GetShipmentInfo 增加 PlanETA 作为预计送达日期,ISATime 更改定义为 ISA 时间。 +V1.7 1.集装箱列表接口访问次数限制: 1 分钟 1 次 +2.集装箱货件列表接口访问次数限制: 1 分钟 1 次 +3.集装箱状态变更日志接口访问次数限制 :5 分钟 1 次 +4.集装箱货件信息接口访问次数限制 :30 秒 1 次 +5.集装箱拆箱报告: 1 分钟 1 次 +V1.71 1.集装箱数据导入接口添加字段:柜号id(F_ContainerId) +V1.72 1.集装箱列表接口添加字段:提柜供应商(F_SupplierId) +1. 登录 +接口协议 HTTP/WebApi +``` + +### 第 2 页 + +``` +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/learun/adms/user/customerLogin +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识 , 由请求端生成, 同一设备要求值 +不变 +Data +(json 字符 +串) +username string 是 登录账号(客户账号) +password string 是 登录密码, 32 位 MD5 加密后的字符串 +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"Data": "{\"username\":\"登录账号\",\"password\":\"登录密码,32 位 MD5 加密后的字符串\"}" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400:失败 500:异常 410:没有登录信息 +info string 是 成功或者错误消息 +data token string 是 通讯票据 +2. 获取船司列表 +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/ConfigModule/GetShippingLineList +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) " +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data 内容 List +F_ID String(50) 是 船司 ID +F_ShippingLineCode String(20) 是 船司代码 +F_ShippingLineName String(100) 是 船司名 +F_Prefix String(100) 否 前缀 +F_DayLow Int 是 船司 ETA 后提柜时间(示例: 1-2 天 , 1) +``` + +### 第 3 页 + +``` +F_DayHigh int 是 船司 ETA 后提柜时间(示例: 1-2 天 ,2) +F_Remark String(1000) 否 备注 +F_ ShippingLine String(120) 是 船司 +3. 集装箱数据导入 +接口协议 HTTP/WebApi +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/Container/SaveContainer +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变 +token string 是 通讯票据(由登录接口返回的值) +data string 是 JSON 字符串 +data 内容 +keyValue string 否 SR_ContainerTaskEntity 对象主键,修改 时必填 +entity SR_ContainerTaskEntity 对象 +F _ TransMode int 是 运输方式 :0-海运 1-空运 3-自送 +F_OperationType int 是 业务类型 :0-拆柜 2-直送 4-仅清关 +F_ContainerNo string(50) 是 柜号 +F_ContainerId string(50) 否 柜号id +F_CabinetType string(20) 否 柜型: +20GP, 40GP,40HQ,40HR,40HC,45HQ,53HQ +F_BLCopyCode string(15) 否 提单号 +F_ETD datetime? 否 ETD(格式:2025/04/24 或 2025-04-24) +F_ETA datetime? 否 ETA(格式:2025/04/24 或 2025-04-24) +F _ LoadPort string(50) 否 起始港 +F_Dock string(50) 否 目的港 +F_ShippingLineId string(50) 否 船司 ID(由船司列表接口返回的值) +F Classis int 否 是否自带车架 1-是 0-否 +F MemoRemark string(1000) 否 备注 +F_Instruction string(1000) 否 拆柜指引 +sR_Shipments List对象 +F_ FBACode string(30) 是 目的代码 +F_Transporter string(30) 是 派 送 方 式 : 留 仓 , 自 提 , 扣 货 , TRUCK, +UPS,FEDEX,DHL,USPS +F_ShipmentID string(500) 否 客户参考号 Customer Ref No +F_ Remark string(1000) 否 行备注 +F_ FBAID string(100) 否 FBA id +F_PO string(100) 否 PO +F_ReferenceId string(500) 否 参考号 +F_ Address string(500) 否 详细地址, 商业地址需要填写 +F_CTNS int 是 件数 +``` + +### 第 4 页 + +``` +F_CBM decimal? 否 体积 +F_ Weight decimal? 否 重量 +F_SKU string(50) 否 SKU +F_ Description string(500) 否 品名 +F_ EnglishName string(500) 否 英文名称 +F_PCS int 否 PCS +F_Expected_DeliveryDateB datetime? 否 最 早 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_Expected_DeliveryDateE datetime? 否 最 晚 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_City string(50) 否 城市 +F_ State string(50) 否 州 +F_ ZipCode string(50) 否 邮编 +F_ Contact string(50) 否 联系人 +F_ Mobile string(50) 否 手机号 +F_ Email string(300) 否 邮编 +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"{\"keyValue\":null,\"entity\":{\"F_TransMode\":0,\"F_OperationType\":0,\"F_ContainerNo\":\"GH25042 +416\",\"F_CabinetType\":null,\"F_BLCopyCode\":\"\",\"F_ETD\":\"2025-04-01\",\"F_ETA\":\"2025-04-24\",\"F_L +oadPort\":\"SHENZHEN\",\"F_Dock\":\"LAX\",\"F_ShippingLineId\":\"c3f80d26-3174-4eba-9460-735ce33771d +a\",\"F_Classis\":1,\"F_MemoRemark\":\"remarkk\",\"F_Instruction\":\"这是接口创建集装箱 +\"},\"sR_Shipments\":[{\"F_FBACode\":\"SMF3\",\"F_Transporter\":\"truck\",\"F_ShipmentID\":\"FBA424POST17\ +",\"F_Remark\":\"备注 +1\",\"F_FBAID\":\"FBA424PMAN17\",\"F_PO\":null,\"F_ReferenceId\":null,\"F_Address\":null,\"F_CTNS\":50,\"F_C +BM\":2.36,\"F_Weight\":135.6,\"F_SKU\":null,\"F_Description\":\"CHina\",\"F_EnglishName\":\"Eng\",\"F_PCS\":1 +0},{\"F_FBACode\":\"ABE2\",\"F_Transporter\":\"扣货\",\"F_ShipmentID\":\"FBA424POST17\",\"F_Remark\":\"备 +注 +2\",\"F_FBAID\":\"FBA424PMAN17\",\"F_PO\":\"\",\"F_ReferenceId\":\"\",\"F_Address\":\"\",\"F_CTNS\":30,\"F_C +BM\":1.23,\"F_Weight\":108.3,\"F_SKU\":null,\"F_City\":null,\"F_State\":null,\"F_ZipCode\":null,\"F_Contact\":null, +\"F_Mobile\":null,\"F_Email\":null, \"F_Expected_DeliveryDateB \":\"2025-04-01\",\"F_Expected_DeliveryDateE +\":\"2025-04-01\"}]}" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 集装箱 id +``` + +### 第 5 页 + +``` +4. 卡派数据导入 +接口协议 HTTP/WebApi +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/truck/SaveTruckInfo +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 JSON 字符串 +data 内容 +keyValue string 否 SR_ContainerTaskEntity 对象主键,修改 时必填 +entity SR_ContainerTaskEntity 对象 +F_IsPickUp int 是 是否取货 :0-自送 1-提货 2-提货派送 +F_PickUpDate datetime? 否 提货日期(格式:2025/04/24 或 2025-04-24) +自送不需要填写 +F_ PickUpBegin int? 否 提货起始时间(0-23 点), 自送不需要填写 +F_ PickUpEnd int? 否 提货截止时间(0-23 点), 自送不需要填写 +F _ PickUpAddress string 否 提货地址, 自送不需要填写。 +sR_Shipments List对象 +F_CustomterBolCode string(50) 否 客户单号 +F_ MoveType string(10) 否 移动类型: LTL, FTL, FCL,不传默认 LTL +F_ FBACode string(30) 是 目的代码 +F_HouseNo string(30) 否 商业地址的 FBACode, 商业地址需要填写 +F_Address string(500) 否 详细地址, 商业地址需要填写 +F_FBAID string(100) 否 FBA id +F_PO string(100) 否 PO +F_CTNS int? 否 件数 +F_CBM decimal? 否 体积 +F_ Weight decimal? 否 重量 +F_ BOLActual Pallet decimal? 否 板数 +F_Expected_DeliveryDateB datetime? 否 最 早 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_Expected_DeliveryDateE datetime? 否 最 晚 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_City string(50) 否 城市 +F_ State string(50) 否 州 +F_ ZipCode string(50) 否 邮编 +F_ Contact string(50) 否 联系人 +F_ Mobile string(50) 否 手机号 +F_ Email string(300) 否 邮编 +输入示例 JSON +{ +``` + +### 第 6 页 + +``` +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"{\"keyValue\":\"\",\"entity\":{\"F_IsPickUp\":5,\"F_Pick UpDate\":\"4/24\",\"F_PickUpBegin\":null,\"F_Pic +k UpEnd\":null,\"F_PickUpAddress\":\"pick up +address\"},\"sR_Shipments\":[{\"F_CustomterBolCode\":\"JK2504412\",\"F_MoveType\":\"\",\"F_FBACode\":\"O +NT8\",\"F_HouseNo\":\"\",\"F_Address\":\"\",\"F_FBAID\":\"\",\"F_PO\":\"\",\"F_CTNS\":null,\"F_CBM\":null,\"F_W +eight\":null,\"F_BOLActual Pallet\":0.02},{\"F_CustomterBolCode\":\"JK25042413\",\"F_MoveType\":\"\",\"F_FBA +Code\":\"PORK\",\"F_HouseNo\":\"\",\"F_Address\":\"\",\"F_FBAID\":\"\",\"F_PO\":\"\",\"F_CTNS\":null,\"F_CBM\ +":null,\"F_Weight\":null,\"F_BOLActual Pallet\":0.3,\"F_City\":null,\"F_State\":null,\"F_ZipCode\":null,\"F_Contact\" +:null,\"F_Mobile\":null,\"F_Email\":null,\"F_Expected_DeliveryDateB\":\"2025-04-01\",\"F_Expected_DeliveryDate +E \":\"2025-04-01\"}]}" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 无 +5. 集装箱列表 +接口协议 HTTP/WebApi +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/Container/GetContainerList +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 JSON 字符串 +data 内容 +pagination 分 页 +rows int 是 一页行数 +page int 是 页码 +sidx string 是 按什么排序,例如 F_CreateDate +sord String 是 排序 :desc 或 asc +queryJson 查询参数 +F_ ContainerNo string(50) 否 柜号 +F_ CabinetType string(10) 否 柜型:20GP,40GP,40HQ,40HR,45HC, +45HQ,53HQ 不传默认查所有 +F _ TransMode string(30) 否 运输方式 :海运, 空运, 卡派, 自送 。不传默 +认查所有 +F_Status string(30) 否 状态:0-新单,20-清关结束,30-提柜直达,40-待 +提柜,60-已到仓,65-直送运输中,70-已拆柜,80- +``` + +### 第 7 页 + +``` +已还柜 +F_FBACode string(30) 否 FBACode +F_FBAID string(100) 否 FBA id +F_ ShipmentId string(100) 否 Ref No. +StartTime datetime? 否 开始日期(格式:2025/04/24 或 2025-04-24) +EndTime datetime? 否 结束日期(格式:2025/04/24 或 2025-04-24) +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":”{\"pagination\":{\"rows\":10,\"page\":1,\"sidx\":\"F_CreateDate\",\"sord\":\"desc\"},\"queryJson\":"{\"F +_ContainerNo\":\"\",\"F_CabinetType\":\"\",\"F_TransMode\":\"\",\"F_Status\":\"\",\"F_FBAID\":\"\",\"F_Shipment +Id\":\"\",\"StartTime\":\"\",\"EndTime\":\"\",\"F_FBACode\":\"ABE2\"}"} ” +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 JSON 字符串 +data 内容 +total int 是 总数 +page int 是 页码 +records int 是 记录数 +rows List对象 +F_ContainerTaskId String 是 集装箱唯一主键 id +F_ ContainerNo String 是 柜号 +F_ CustomerId String 否 柜号 id +F_ TransMode Int 是 运输方式 0-海运 1-空运 2-卡派 3-自送柜 +F_IsISF Int 是 是否填写 ISF, 0-不填写 1-填写 +F_ CabinetType String 是 柜型 +F_ETD datetime 否 ETD +F_ETA datetime 否 ETA +F_EstimateExtractionTime datetime 否 预计提柜时间 +F _ ExtractionTime datetime 否 实际提柜时间 +F_ATA datetime 否 到港时间 +F_ UnstowTime datetime 否 拆柜日期 +F _ GiveBackTime datetime 否 还柜日期 +F_NotifyGiveBackTime datetime 否 通知还柜日期 +F_Status Int 是 0-新单 1-已导入 Transload 2-已通知 3-已提 +柜 4-已到仓 5-已还柜(已结束) 9 - 异常 +F_ CreateDate datetime 是 创建时间 +F_LoadPort string 否 起运港 +F_Dock String 否 目的港 +``` + +### 第 8 页 + +``` +F_ToWh Date datetime 否 到仓日期 +F_ InvoiceFile String 否 Commericial Invoice 文件 ID +F_BLFile String 否 BL Copy 提单文件 ID +F_ANFile String 否 AN 到货通知文件 ID +F_DOFile String 否 DO 文件 id +F_EntrySummaryFile String 否 Entry Summary 文件 ID +F_ISFFile String 否 客户 ISF 文件 ID +F _ OperationType String 否 业务类型:0 , 1 是拆柜, 2 是直送,4 是只清 +关 +F_ PackingList string 否 Packing liist 文件 id +F_ OfficialISF string 否 正式 ISF 文件 ID +F_ CustomsFile String 否 报关资料 +F_ATD datetime 否 ATD, 实际开船日期 +F_ BLCopyCode String 否 提单号 +F_ AreaCode String 否 库区 +F _ MemoRemark String 否 备注 +F_Classis Int 否 是否自带车架 1-是 0-否 +F _ ShippingLineId String 否 船司 ID +F_ PackingList String 否 Packing list 文件 id +F_ CustomsFile String 否 报关资料文件 id +F_ Su p p li er Id String 否 提柜供应商id +输出示例 JSON +{"code":200,"info":" 响 应 成 功 +","data":{"rows":[{"F_ContainerTaskId":"e4643726-6930-4a92-b9a8-937d59dbcce0","F_ContainerNo":"BC2509 +0910","F_CustomerId":"d1d2fc3d-a4de-4281-a728-fc2d6a145d3f","F_TransMode":0,"F_IsISF":0,"F_CabinetTyp +e":"20GP","F_ETD":null,"F_ETA":null,"F_EstimateExtractionTime":"2025-09-19 +13:00:00","F_ExtractionTime":null,"F_ATA":null,"F_UnstowTime":null,"F_GiveBackTime":null,"F_NotifyGiveBackTi +me":null,"F_Status":0,"F_CreateUserId":"System","F_CreateDate":"2025-09-09 03:11:11","F_CreateUserName":" +超级管理员","F_ModifyUserId":"System","F_ModifyDate":"2025-09-16 20:16:13","F_ModifyUserName":"超级管 +理 员 +","F_WhCode":null,"F_FileName":null,"F_FullFileName":null,"F_Load Port":"","F_Dock":"","F_ANTime":null,"F_Cust +omsBroker":null,"F_SupplierId":"","F_SupplierTrailer":null,"F_AbNormalTime":null,"F_AbNormalInfo":"","F_ToWh +Date":null,"F_BracketFreeDay":null,"F_InvoiceFile":null,"F_BLFile":null,"F_ANFile":null,"F_DOFile":null,"F_EntrySu +mmaryFile":null,"F_ ISFFile":null,"F_CustomsClearanceTime":null,"F_OperationType":0,"F_SourceType":null,"F_Pa +c kingList":null,"F_OfficialISF":null,"F_CustomsFile":null,"F_OpenPhoto":null,"F_ATD":null,"F_FlowId":null,"F_IsPick +Up":null,"F_PickUpCode":null,"F_Pick UpDate":null,"F_PickUpBegin":null,"F_PickUpEnd":null,"F_PickUpAddress":n +ull,"F_ TotalPalltes":null,"F_CustomerBolFile":null,"F_PickAmount":null,"F_ LogAcceptUrl":null,"F_ PrintContent":nu +ll,"F_Instruction":"","F_ShipStatus":null,"F_Report":null,"F_IsCustoms":null,"F_CustomsDate":null,"F_CustomsUser +Name":null,"F_AddressName":null,"F_FullAddress":null,"F_PODFile":null,"F_PODTime":null,"F_IsSecurityFiling":n +ull,"F_ISFDraft":null,"F_IsVWH":null,"F_GW":null,"F_ToContact":null,"F_UnstowData":null,"F_TaskId":null,"F_BLCo +pyCode":"","F_PickUpCost":null,"F_Convert":null,"F_Loader":"","F_LoadingCost":null,"F_IsInner":null,"F_UnstowI +mage":null,"F_ConfirmTime":null,"F_IsCharge":null,"F_OverWeight":0,"F_LFD":null,"F_ISA":null,"F_ISATime":null," +F_StorageType":null,"F_TrainTrend":"","F_AreaCode":"","F_Inspection":0,"F_InspectionDesc":"","F_ContainerId":"" +,"F_MemoRemark":"","F_DismantleStatus":null,"F_New":null,"F_Classis":0,"F_ShippingLineId": +"c3f80d26-3174-4eba-9460-735ce33771da","F_CustomerCode":null,"F_CustomerName":null,"F_ShortName":" +``` + +### 第 9 页 + +``` +Third Party_Usbic","F_SupplierName":null,"CustomsBrokerName":null,"OpObject":null,"markCheckList":null,"shi +pmentList": null," ErrorSortBy":null,"F_ Profit":null,"F_ AreaAddress":null,"F_ UnstowFileList": null,"F_ShippingLineNa +me": +null,"F_ PODFileName":null,"F_PODFileType":null,"F_PODFileUrl":"/Common/DownAnnexesFile?file Id=","F_POD +File Icon":"/Content/images/filetype/.png","F_ChargeFinished":0,"F_MinReportTime":null}],"total":44,"page":1,"r +ecords":44}} +6. 集装箱货件列表 +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/Shipment/GetShipmentList +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,集装箱唯一主键 id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"集装箱唯一主键 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 JSON 字符串 +data 内容 List +F_ Transporter String(30) 是 MoveType:TRUCK,留仓,扣货,UPS,FEDEX, +DHL, USPS, 自提 +F_ID String(50) 是 货件 ID +F_FBACode String(30) 是 仓库代码 FBACode +F_ ShipmentID String(1000) 否 Customer Ref no. +F_CTNS Int 是 箱数 +F_CBM decimal? 是 体积 +F_Weight decimal? 是 重量 +F_Address String(500) 否 地址 +F_FBAID String(2000) 否 FBA Id +F_ ReferenceId String(1000) 否 Reference Id +F_SKU String(50) 否 SKU +``` + +### 第 10 页 + +``` +F_ EnglishName String(500) 否 英文品名 +F_ Description String(500) 否 中文品名 +F_Expected_DeliveryDate +B +datetime? 否 最 早 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_Expected_DeliveryDate +E +datetime? 否 最 晚 送 仓 日 期 ( 格 式 :2025/04/24 或 +2025-04-24) +F_City String(50) 否 城市 +F_State String(50) 否 州省 +F_ZipCode String(50) 否 邮编 +F_Contact String(50) 否 收件人 +F_Mobile String(50) 否 联系电话 +F_Email String(100) 否 邮箱 +F_Remark String(2000) 否 行备注 +7. 集装箱状态变更日志 +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/Container/GetStateTrack +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,集装箱唯一主键 id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"集装箱唯一主键 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 JSON 字符串 +data 内容 List +TypeCode Int 是 状态:0-已到港,10-已提柜,20-已到仓,30- +已拆柜,40-已还空 +TypeName String(50) 是 状态名:0-已到港, 10-已提柜, 20-已到仓, +30-已拆柜,40-已还空 +F_ CreateDate datetime? 否 创建时间 +``` + +### 第 11 页 + +``` +F_CreateUserName String 否 创建人 +DataJson String 否 JSON 字符串 +DataJson 内容 +date datetime? 是 时间 +image string 否 图片 +remark String 否 备注 +8. 集装箱货件信息 +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/Container/GetShipmentInfo +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,集装箱唯一主键 id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"集装箱唯一主键 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 JSON 字符串 +data 内容 List +Transporter String(30) 是 MoveType:TRUCK,留仓,扣货,UPS,FEDEX, +DHL, USPS, 自提 +Mark String(30) 是 仓库代码 FBACode +ShipmentID String(1000) 否 Customer Ref no. +Ctns Int 是 箱数 +Pallets decimal? 是 板数 +Status decimal? 是 状态:0-预到达 10-在库 15-在库 16-已排货 +20-已出仓 26-运输中 30-已送达 90-已取消 +PlanDate datetime? 否 计划发车时间 +PlanETA datetime? 否 预计送达日期 +ISATime datetime? 否 ISA 时间 +ETA datetime? 否 ETA +``` + +### 第 12 页 + +``` +ISA String(50) 否 ISA +PODTime datetime? 否 签收时间 +Closed Page String(50) 否 ClosePage 文件 id +PODFile String(50) 否 POD 文件 id +ISAChangeReason String 否 ISA 变更原因 +ProofChange String 否 ISA 变更凭证 +Remark String 否 备注 +9. 集装箱拆箱报告 +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/Container/GetBillReport +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,集装箱唯一主键 id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"集装箱唯一主键 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data binary / Blob 否 拆柜报告 PDF 文件 +10. 修改集装箱字段值 +(注意 :上传集装箱文件前 ,若对应字段为空, 可使用该接口将此字段设置成文件 Id 或文 +件夹 Id) +接口协议 HTTP/WebApi +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/Container/SaveFieldValue +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +``` + +### 第 13 页 + +``` +求值不变 +token string 是 通讯票据(由登录接口返回的值) +data string 是 JSON 字符串 +data 内容 +keyValue string 是 集装箱主键唯一 id +field String 是 字段名 ( +DO: F_DOFile +B/L Copy: F_BLFile +Commericial Invoice: F _ InvoiceFile +Packing list: F_PackingList +报关资料: F_CustomsFile +AN: F_ANFile +) +fieldValue String 是 字段值, 唯一 Id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"{\"keyValue\":\"729dbc73-e5fc-4207-9e7f-a58f9e2c9773\",\"field\":\"F_DOFile\",\"fieldValue\":\"80e +0a295-f8f6-47e2-be2b-e8faece90694\"}" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 无 +11. 上传文件 +(注意:上传文件使用 GetContainerList 接口先获取集装箱的文件字段对应 id,如果为空的情 +况使用 SaveFieldValue 接口为对应文件字段赋值) +接口协议 HTTP/WebApi +请求方式: POST +请求地址: https://demovip.saas.carriercentral.vip/api/learun/adms/annexes/upload +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 集装箱对应文件字段的文件夹 id(举例:集装 +箱 F_DOFile 对应的 id) +file binary / Blob 是 上传的文件 +``` + +### 第 14 页 + +``` +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data string 是 文件 id +12. 下载 POD 和 ClosePage +注 :相同文件 id 下载成功次数上限 5 次(不区分账户); +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/learun/adms/annexes/down +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,POD 文件 id 或 ClosePage 文件 id(提 +示 : POD 文 件 id 和 ClosePage 文 件 id 在 +GetShipmentInfo 接口返回值中) +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"POD 文件 id 或 ClosePage 文件 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data binary / Blob 是 POD 文件 或 ClosePage 文件 +13. 下载拆柜图片 +注 :相同集装箱下载成功次数上限 5 次(不区分账户); +``` + +### 第 15 页 + +``` +接口协议 HTTP/WebApi +请求方式: GET +请求地址: https://demovip.saas.carriercentral.vip/api/Container/DownDevanFile +输入参数名 类型 必须 参数说明 +loginMark string 是 GUID 登录设备标识, 由请求端生成, 同一设备要 +求值不变, 同登录接口保持一致。 +token string 是 通讯票据(由登录接口返回的值) +data string 是 字符串,集装箱唯一主键 id +输入示例 JSON +{ +"loginMark": "GUID 登录设备标识, 由请求端生成, 同一设备要求值不变", +"token": "通讯票据(由登录接口返回的值) ", +"data":"集装箱唯一主键 id" +} +输出参数名 类型 必须 参数说明 +code int 是 200:成功 400: 失败 500: 异常 410:没有登 +录信息 +info string 是 成功或者错误消息 +data String 否 JSON +data 内容 +bytes String 是 拆柜图片文件压缩包(Base64 编码的字符串) +fileName string 是 文件名 +``` + +--- + +*来源 PDF:`carriercentral客户端通用接口V1.72docx(4).pdf`(15 页)* diff --git a/docx/邮件/样例附件清单.md b/docx/邮件/样例附件清单.md new file mode 100644 index 0000000..953f317 --- /dev/null +++ b/docx/邮件/样例附件清单.md @@ -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 # 按策略入库 +``` diff --git a/docx/邮件/模板/WHSU8127240-DO.pdf b/docx/邮件/模板/WHSU8127240-DO.pdf new file mode 100644 index 0000000..316c879 Binary files /dev/null and b/docx/邮件/模板/WHSU8127240-DO.pdf differ diff --git a/docx/邮件/模板/数据模版-新辰泽-WHSU8127240.xlsx b/docx/邮件/模板/数据模版-新辰泽-WHSU8127240.xlsx new file mode 100644 index 0000000..596c5dc Binary files /dev/null and b/docx/邮件/模板/数据模版-新辰泽-WHSU8127240.xlsx differ diff --git a/docx/邮件/模板/数据模版.xlsx b/docx/邮件/模板/数据模版.xlsx new file mode 100644 index 0000000..c8a47af Binary files /dev/null and b/docx/邮件/模板/数据模版.xlsx differ diff --git a/docx/邮件/模板/智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派.xlsx b/docx/邮件/模板/智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派.xlsx new file mode 100644 index 0000000..ffd7bdf Binary files /dev/null and b/docx/邮件/模板/智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派.xlsx differ diff --git a/docx/邮件/模板/附件下载_邮件识别/智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派.xlsx b/docx/邮件/模板/附件下载_邮件识别/智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派.xlsx new file mode 100644 index 0000000..edd5560 Binary files /dev/null and b/docx/邮件/模板/附件下载_邮件识别/智鸿2+WHLC027G597465+WHSU8127240+洛杉矶+40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派.xlsx differ diff --git a/docx/邮件/模板/附件下载_邮件识别/邮件识别.docx b/docx/邮件/模板/附件下载_邮件识别/邮件识别.docx new file mode 100644 index 0000000..0624324 Binary files /dev/null and b/docx/邮件/模板/附件下载_邮件识别/邮件识别.docx differ diff --git a/docx/邮件/模板/附件下载_邮件识别/附件/WHSU8127240-DO.pdf b/docx/邮件/模板/附件下载_邮件识别/附件/WHSU8127240-DO.pdf new file mode 100644 index 0000000..316c879 Binary files /dev/null and b/docx/邮件/模板/附件下载_邮件识别/附件/WHSU8127240-DO.pdf differ diff --git a/docx/邮件/模板/附件下载_邮件识别/附件/数据模版.xlsx b/docx/邮件/模板/附件下载_邮件识别/附件/数据模版.xlsx new file mode 100644 index 0000000..c8a47af Binary files /dev/null and b/docx/邮件/模板/附件下载_邮件识别/附件/数据模版.xlsx differ diff --git a/docx/邮件/邮件1/QQ邮箱.pdf b/docx/邮件/邮件1/QQ邮箱.pdf new file mode 100644 index 0000000..88a05c7 Binary files /dev/null and b/docx/邮件/邮件1/QQ邮箱.pdf differ diff --git a/docx/邮件/邮件2/2邮箱.pdf b/docx/邮件/邮件2/2邮箱.pdf new file mode 100644 index 0000000..b1d33b2 Binary files /dev/null and b/docx/邮件/邮件2/2邮箱.pdf differ diff --git a/docx/邮件/邮件2/柜号:MATU2745683卡派资料.xlsx b/docx/邮件/邮件2/柜号:MATU2745683卡派资料.xlsx new file mode 100644 index 0000000..23a5410 Binary files /dev/null and b/docx/邮件/邮件2/柜号:MATU2745683卡派资料.xlsx differ diff --git a/docx/邮件/邮件3/3邮箱.pdf b/docx/邮件/邮件3/3邮箱.pdf new file mode 100644 index 0000000..345102b Binary files /dev/null and b/docx/邮件/邮件3/3邮箱.pdf differ diff --git a/docx/邮件/邮件3/WL103276-SBD1(1).pdf b/docx/邮件/邮件3/WL103276-SBD1(1).pdf new file mode 100644 index 0000000..1babcc5 Binary files /dev/null and b/docx/邮件/邮件3/WL103276-SBD1(1).pdf differ diff --git a/docx/邮件/邮件3/WL103944-WL103940.pdf b/docx/邮件/邮件3/WL103944-WL103940.pdf new file mode 100644 index 0000000..0907ae3 Binary files /dev/null and b/docx/邮件/邮件3/WL103944-WL103940.pdf differ diff --git a/docx/邮件/邮件3/WL98967-VGT2(1).pdf b/docx/邮件/邮件3/WL98967-VGT2(1).pdf new file mode 100644 index 0000000..0cb93d9 Binary files /dev/null and b/docx/邮件/邮件3/WL98967-VGT2(1).pdf differ diff --git a/docx/邮件/邮件3/WL99583-LAX9.pdf b/docx/邮件/邮件3/WL99583-LAX9.pdf new file mode 100644 index 0000000..3d3f9af Binary files /dev/null and b/docx/邮件/邮件3/WL99583-LAX9.pdf differ diff --git a/docx/邮件/邮件4/4邮箱.pdf b/docx/邮件/邮件4/4邮箱.pdf new file mode 100644 index 0000000..6a1041f Binary files /dev/null and b/docx/邮件/邮件4/4邮箱.pdf differ diff --git a/docx/邮件/邮件4/FBA19HW52S0L-76CTN-HIA1-覆盖贴1箱贴2张.pdf b/docx/邮件/邮件4/FBA19HW52S0L-76CTN-HIA1-覆盖贴1箱贴2张.pdf new file mode 100644 index 0000000..49b6615 Binary files /dev/null and b/docx/邮件/邮件4/FBA19HW52S0L-76CTN-HIA1-覆盖贴1箱贴2张.pdf differ diff --git a/docx/邮件/邮件4/X003UHDF7B-304PCS-覆盖贴1箱贴1张.pdf b/docx/邮件/邮件4/X003UHDF7B-304PCS-覆盖贴1箱贴1张.pdf new file mode 100644 index 0000000..3b9148a Binary files /dev/null and b/docx/邮件/邮件4/X003UHDF7B-304PCS-覆盖贴1箱贴1张.pdf differ diff --git a/docx/邮件/邮件4/YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx b/docx/邮件/邮件4/YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx new file mode 100644 index 0000000..3869955 Binary files /dev/null and b/docx/邮件/邮件4/YT2604021091=76件换标走HIA1卡派出库操作指令.xlsx differ diff --git a/docx/金样例/M1.json b/docx/金样例/M1.json new file mode 100644 index 0000000..1ae526f --- /dev/null +++ b/docx/金样例/M1.json @@ -0,0 +1,7 @@ +{ + "id": "M1", + "subject_contains": "TIIU8073522-90022", + "mail_type": "UNKNOWN", + "shipments_expected": 0, + "importable": false +} diff --git a/docx/金样例/M2.json b/docx/金样例/M2.json new file mode 100644 index 0000000..8615796 --- /dev/null +++ b/docx/金样例/M2.json @@ -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 标准记录;导入后置" +} diff --git a/docx/金样例/M3.json b/docx/金样例/M3.json new file mode 100644 index 0000000..5415cf6 --- /dev/null +++ b/docx/金样例/M3.json @@ -0,0 +1,6 @@ +{ + "id": "M3", + "subject_contains": "WHSU5574991", + "mail_type": "INSTRUCTION_HOLD_SPLIT", + "importable": false +} diff --git a/docx/金样例/M4.json b/docx/金样例/M4.json new file mode 100644 index 0000000..683bbfd --- /dev/null +++ b/docx/金样例/M4.json @@ -0,0 +1,8 @@ +{ + "id": "M4", + "subject_contains": "新增预报", + "mail_type": "WORK_ORDER", + "legacy_mail_type": "INSTRUCTION_LABEL", + "importable": false, + "note": "贴标/拍照关键词 → WORK_ORDER;本期仅标准记录" +} diff --git a/docx/金样例/M5.json b/docx/金样例/M5.json new file mode 100644 index 0000000..89044ce --- /dev/null +++ b/docx/金样例/M5.json @@ -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 } + ] +} diff --git a/docx/金样例/M_BL_TEMPLATE.json b/docx/金样例/M_BL_TEMPLATE.json new file mode 100644 index 0000000..7675271 --- /dev/null +++ b/docx/金样例/M_BL_TEMPLATE.json @@ -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,对齐数据模版列" +} diff --git a/docx/金样例/M_WORK_ORDER.json b/docx/金样例/M_WORK_ORDER.json new file mode 100644 index 0000000..ab963d1 --- /dev/null +++ b/docx/金样例/M_WORK_ORDER.json @@ -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" +} diff --git a/docx/需求规格-邮件自动预报-v0.2.md b/docx/需求规格-邮件自动预报-v0.2.md new file mode 100644 index 0000000..92f2031 --- /dev/null +++ b/docx/需求规格-邮件自动预报-v0.2.md @@ -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": "", + "loginMark": "<进程级固定 UUID v4>", + "data": "<多数接口为 JSON 字符串>" +} +``` + +Header:`Saas: `。 + +### 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": "", + "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` | diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..15f6080 --- /dev/null +++ b/next.config.ts @@ -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; diff --git a/package.json b/package.json new file mode 100644 index 0000000..206ad0a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..065c46d --- /dev/null +++ b/playwright.config.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, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b648848 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3955 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ant-design/cssinjs': + specifier: ^1.22.0 + version: 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/icons': + specifier: ^5.5.2 + version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/v5-patch-for-react-19': + specifier: ^1.0.3 + version: 1.0.3(antd@5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@prisma/client': + specifier: ^5.22.0 + version: 5.22.0(prisma@5.22.0) + '@tanstack/react-virtual': + specifier: ^3.11.2 + version: 3.14.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + adm-zip: + specifier: ^0.5.16 + version: 0.5.18 + antd: + specifier: ^5.22.6 + version: 5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + exceljs: + specifier: ^4.4.0 + version: 4.4.0 + imapflow: + specifier: ^1.0.181 + version: 1.4.8 + iron-session: + specifier: ^8.0.4 + version: 8.0.4 + mailparser: + specifier: ^3.7.2 + version: 3.9.14 + mysql2: + specifier: ^3.23.1 + version: 3.23.1(@types/node@20.19.43) + next: + specifier: ^15.1.3 + version: 15.5.21(@playwright/test@1.61.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + pino: + specifier: ^9.6.0 + version: 9.14.0 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + undici: + specifier: ^6.21.0 + version: 6.27.0 + uuid: + specifier: ^11.0.3 + version: 11.1.1 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + '@playwright/test': + specifier: ^1.49.1 + version: 1.61.1 + '@types/adm-zip': + specifier: ^0.5.7 + version: 0.5.8 + '@types/mailparser': + specifier: ^3.4.5 + version: 3.4.6 + '@types/mysql': + specifier: ^2.15.27 + version: 2.15.27 + '@types/node': + specifier: ^20.17.10 + version: 20.19.43 + '@types/react': + specifier: ^18.3.18 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.5 + version: 18.3.7(@types/react@18.3.31) + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + prisma: + specifier: ^5.22.0 + version: 5.22.0 + tsx: + specifier: ^4.19.2 + version: 4.23.1 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@20.19.43) + +packages: + + '@ant-design/colors@7.2.1': + resolution: {integrity: sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==} + + '@ant-design/cssinjs-utils@1.1.3': + resolution: {integrity: sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@ant-design/cssinjs@1.24.0': + resolution: {integrity: sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/fast-color@2.0.6': + resolution: {integrity: sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==} + engines: {node: '>=8.x'} + + '@ant-design/icons-svg@4.5.0': + resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==} + + '@ant-design/icons@5.6.1': + resolution: {integrity: sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/react-slick@1.1.2': + resolution: {integrity: sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==} + peerDependencies: + react: '>=16.9.0' + + '@ant-design/v5-patch-for-react-19@1.0.3': + resolution: {integrity: sha512-iWfZuSUl5kuhqLUw7jJXUQFMMkM7XpW7apmKzQBQHU0cpifYW4A79xIBt9YVO5IBajKpPG5UKP87Ft7Yrw1p/w==} + engines: {node: '>=12.x'} + peerDependencies: + antd: '>=5.22.6' + react: '>=19.0.0' + react-dom: '>=19.0.0' + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@emotion/unitless@0.7.5': + resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@next/env@15.5.21': + resolution: {integrity: sha512-hjJI/GfrjWHgNguRIBzItjRRu0m3Nrz17GhxsjuHfjIvg9hyg3239REd2dpI+bpMTFuVrVprHzEQ19m++cDtbw==} + + '@next/swc-darwin-arm64@15.5.21': + resolution: {integrity: sha512-ZfjqPEdi6TRC/fWx7UDbwb1fbVgyh2uD5tVTRKIDZDlYM+UNuE/LafDG2fwuAoZilADpABh46OY/F5qf9JjqLQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.21': + resolution: {integrity: sha512-TlCf1NpxgQLzTrexuev75xwmNCJMd1/qkJpTVP1GRRcih93hlIBn1P72hkh8T0gnRFr6BmWksQtbyG3jT6jnww==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.21': + resolution: {integrity: sha512-LXRsq1p+HvHSi7ygwNcSEEcK0zuo5jS75ZlqFHtOH+LF7qntXAJVJxah+1Pi/GyBm7EpkwU7m4EgbvIKrMqm9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.5.21': + resolution: {integrity: sha512-hyGixhFxpDKjqoev6l4KlcRBlt9AXWrGhDZwmwg49sMJM5tnKQPSi+SEj9+e5n+l/bthRGZUdh59GKIs6lQPRw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.5.21': + resolution: {integrity: sha512-qfE+YfOba6S2+13e8qn1/UozDVNZ2clBlrs8UtDoax4s8ediu6sq93z66OEHUYlb69Tffh5JTNkgtsAKiSuugg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.5.21': + resolution: {integrity: sha512-BXLGG+EvIwp/Rrgl6HY8sqvD6BOUOIRz8/naDbeLNX7mlA5H2XRcL6MW/0IGnJISfj5BA9gNhFyJj5yOoiIDJQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.5.21': + resolution: {integrity: sha512-tNGNOlT0Wn7E4IMsSnufjXN/l2L2/AGdLLpa2vzS89SYCBuihgLn3ngLsIrvndAnWo9nAkus+4gZHTI/Ijx9HA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.21': + resolution: {integrity: sha512-DmIdWmC9p4rdNIiQqo8ap0+Cnj6kKtTZnuSCxoYydSc8sgpDgAg9wFhxplunak9imLV0pTvc5WVCOHwm5eHLtQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} + + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} + + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + + '@rc-component/async-validator@5.1.2': + resolution: {integrity: sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==} + engines: {node: '>=14.x'} + + '@rc-component/color-picker@2.0.1': + resolution: {integrity: sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/context@1.4.0': + resolution: {integrity: sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mini-decimal@1.1.4': + resolution: {integrity: sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==} + engines: {node: '>=8.x'} + + '@rc-component/mutate-observer@1.1.0': + resolution: {integrity: sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/portal@1.1.2': + resolution: {integrity: sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/qrcode@1.1.3': + resolution: {integrity: sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tour@1.15.1': + resolution: {integrity: sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/trigger@2.3.1': + resolution: {integrity: sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@selderee/plugin-htmlparser2@0.12.0': + resolution: {integrity: sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==} + peerDependencies: + selderee: ~0.12.0 + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tanstack/react-virtual@3.14.7': + resolution: {integrity: sha512-11uSrj77IDijNBqizD4lY4y1laMyRrqMLSxjnWy5CvWkCjyRDW+gGmxYq0lwQKVas/sq7zyzYWXbL/BvBzR32g==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.5': + resolution: {integrity: sha512-AXfBC3sq6PuYSwyxYORqqgHCNjPGAvKJvZuBBJ1klhztWBB5cgqgwsq8+fNfaQJG7/K4xYBja9S90QFn2zmQAg==} + + '@types/adm-zip@0.5.8': + resolution: {integrity: sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/mailparser@3.4.6': + resolution: {integrity: sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==} + + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + '@zone-eu/mailsplit@5.4.14': + resolution: {integrity: sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==} + + adm-zip@0.5.18: + resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} + engines: {node: '>=12.0'} + + antd@5.29.3: + resolution: {integrity: sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + + encoding-japanese@2.2.0: + resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} + engines: {node: '>=8.10.0'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + html-to-text@10.0.0: + resolution: {integrity: sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==} + engines: {node: '>=20.19.0'} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + imapflow@1.4.8: + resolution: {integrity: sha512-cHa23g7h0X04a2v+H9luLg0GWMQ0vApmQ+UxL5swglOgBypNpqxurLMa1JS9mjpTekCsQhKrCZwaziPRBbHp8Q==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + iron-session@8.0.4: + resolution: {integrity: sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + json2mq@0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + leac@0.7.0: + resolution: {integrity: sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==} + + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.4.1: + resolution: {integrity: sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mailparser@3.9.14: + resolution: {integrity: sha512-3QD6TRXcyXtq2NCuyA2AEjqmallQkyxYmZI9GMCIvQDCaB9Uc034WUI1x8RUBYFnk5+p7h14JEz4O/lrxQmttw==} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mysql2@3.23.1: + resolution: {integrity: sha512-tTuRnC7qCet2IOfSNMYZ5SwXuBnfvBPAcIA28P0gtruXyZlU1LMxA6uha32kYypoFgyYklMqhLWwt4laYwXR/Q==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@15.5.21: + resolution: {integrity: sha512-/TsdBtkWLhkl+NVL3Uqws2UphNd6IPzOtzSk1fHaf+0P7GQKLZDUytyhns/Ykbzdy9+YRjwG7ONvrHaaTDdFqQ==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parseley@0.13.1: + resolution: {integrity: sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + peberminta@0.10.0: + resolution: {integrity: sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.21: + resolution: {integrity: sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==} + engines: {node: ^10 || ^12 || >=14} + + prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} + engines: {node: '>=16.13'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + rc-cascader@3.34.0: + resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-checkbox@3.5.0: + resolution: {integrity: sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-collapse@3.9.0: + resolution: {integrity: sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dialog@9.6.0: + resolution: {integrity: sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-drawer@7.3.0: + resolution: {integrity: sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dropdown@4.2.1: + resolution: {integrity: sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==} + peerDependencies: + react: '>=16.11.0' + react-dom: '>=16.11.0' + + rc-field-form@2.7.1: + resolution: {integrity: sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-image@7.12.0: + resolution: {integrity: sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input-number@9.5.0: + resolution: {integrity: sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input@1.8.0: + resolution: {integrity: sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-mentions@2.20.0: + resolution: {integrity: sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-menu@9.16.1: + resolution: {integrity: sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-motion@2.9.5: + resolution: {integrity: sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-notification@5.6.4: + resolution: {integrity: sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-overflow@1.5.0: + resolution: {integrity: sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-pagination@5.1.0: + resolution: {integrity: sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-picker@4.11.3: + resolution: {integrity: sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==} + engines: {node: '>=8.x'} + peerDependencies: + date-fns: '>= 2.x' + dayjs: '>= 1.x' + luxon: '>= 3.x' + moment: '>= 2.x' + react: '>=16.9.0' + react-dom: '>=16.9.0' + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + + rc-progress@4.0.0: + resolution: {integrity: sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-rate@2.13.1: + resolution: {integrity: sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-resize-observer@1.4.3: + resolution: {integrity: sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-segmented@2.7.1: + resolution: {integrity: sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-select@14.16.8: + resolution: {integrity: sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-slider@11.1.9: + resolution: {integrity: sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-steps@6.0.1: + resolution: {integrity: sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-switch@4.1.0: + resolution: {integrity: sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-table@7.54.0: + resolution: {integrity: sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tabs@15.7.0: + resolution: {integrity: sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-textarea@1.10.2: + resolution: {integrity: sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tooltip@6.4.0: + resolution: {integrity: sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tree-select@5.27.0: + resolution: {integrity: sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==} + peerDependencies: + react: '*' + react-dom: '*' + + rc-tree@5.13.1: + resolution: {integrity: sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==} + engines: {node: '>=10.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-upload@4.11.0: + resolution: {integrity: sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-util@5.44.4: + resolution: {integrity: sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-virtual-list@3.19.2: + resolution: {integrity: sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + selderee@0.12.0: + resolution: {integrity: sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sql-escaper@1.5.1: + resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-convert@0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + thread-stream@3.2.0: + resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + engines: {node: '>=18.17'} + + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@ant-design/colors@7.2.1': + dependencies: + '@ant-design/fast-color': 2.0.6 + + '@ant-design/cssinjs-utils@1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/cssinjs': 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@babel/runtime': 7.29.7 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@ant-design/cssinjs@1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/hash': 0.8.0 + '@emotion/unitless': 0.7.5 + classnames: 2.5.1 + csstype: 3.2.3 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + stylis: 4.4.0 + + '@ant-design/fast-color@2.0.6': + dependencies: + '@babel/runtime': 7.29.7 + + '@ant-design/icons-svg@4.5.0': {} + + '@ant-design/icons@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/icons-svg': 4.5.0 + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@ant-design/react-slick@1.1.2(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + json2mq: 0.2.0 + react: 18.3.1 + resize-observer-polyfill: 1.5.1 + throttle-debounce: 5.0.2 + + '@ant-design/v5-patch-for-react-19@1.0.3(antd@5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + antd: 5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@babel/runtime@7.29.7': {} + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emotion/hash@0.8.0': {} + + '@emotion/unitless@0.7.5': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@next/env@15.5.21': {} + + '@next/swc-darwin-arm64@15.5.21': + optional: true + + '@next/swc-darwin-x64@15.5.21': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.21': + optional: true + + '@next/swc-linux-arm64-musl@15.5.21': + optional: true + + '@next/swc-linux-x64-gnu@15.5.21': + optional: true + + '@next/swc-linux-x64-musl@15.5.21': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.21': + optional: true + + '@next/swc-win32-x64-msvc@15.5.21': + optional: true + + '@pinojs/redact@0.4.0': {} + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + '@prisma/client@5.22.0(prisma@5.22.0)': + optionalDependencies: + prisma: 5.22.0 + + '@prisma/debug@5.22.0': {} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} + + '@prisma/engines@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 + + '@prisma/fetch-engine@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 + + '@prisma/get-platform@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + + '@rc-component/async-validator@5.1.2': + dependencies: + '@babel/runtime': 7.29.7 + + '@rc-component/color-picker@2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ant-design/fast-color': 2.0.6 + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/context@1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/mini-decimal@1.1.4': + dependencies: + '@babel/runtime': 7.29.7 + + '@rc-component/mutate-observer@1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/portal@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/qrcode@1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/tour@1.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rc-component/trigger@2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@selderee/plugin-htmlparser2@0.12.0(selderee@0.12.0)': + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + selderee: 0.12.0 + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tanstack/react-virtual@3.14.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.17.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.17.5': {} + + '@types/adm-zip@0.5.8': + dependencies: + '@types/node': 20.19.43 + + '@types/estree@1.0.9': {} + + '@types/mailparser@3.4.6': + dependencies: + '@types/node': 20.19.43 + iconv-lite: 0.6.3 + + '@types/mysql@2.15.27': + dependencies: + '@types/node': 20.19.43 + + '@types/node@14.18.63': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/uuid@10.0.0': {} + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.19.43))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@20.19.43) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + '@zone-eu/mailsplit@5.4.14': + dependencies: + libbase64: 1.3.0 + libmime: 5.4.1 + libqp: 2.1.1 + + adm-zip@0.5.18: {} + + antd@5.29.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/cssinjs': 1.24.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/cssinjs-utils': 1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/fast-color': 2.0.6 + '@ant-design/icons': 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@ant-design/react-slick': 1.1.2(react@18.3.1) + '@babel/runtime': 7.29.7 + '@rc-component/color-picker': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/mutate-observer': 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/qrcode': 1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/tour': 1.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + copy-to-clipboard: 3.3.3 + dayjs: 1.11.21 + rc-cascader: 3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-checkbox: 3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-collapse: 3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-dialog: 9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-drawer: 7.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-dropdown: 4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-field-form: 2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-image: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-input-number: 9.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-mentions: 2.20.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-notification: 5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-pagination: 5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-picker: 4.11.3(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-progress: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-rate: 2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-segmented: 2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-slider: 11.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-steps: 6.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-switch: 4.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-table: 7.54.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tabs: 15.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-textarea: 1.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tooltip: 6.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree: 5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree-select: 5.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-upload: 4.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + scroll-into-view-if-needed: 3.1.0 + throttle-debounce: 5.0.2 + transitivePeerDependencies: + - date-fns + - luxon + - moment + + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + + assertion-error@2.0.1: {} + + async@3.2.6: {} + + atomic-sleep@1.0.0: {} + + aws-ssl-profiles@1.1.2: {} + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + big-integer@1.6.52: {} + + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird@3.4.7: {} + + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + + buffer-crc32@0.2.13: {} + + buffer-indexof-polyfill@1.0.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffers@0.1.1: {} + + cac@6.7.14: {} + + caniuse-lite@1.0.30001806: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + + check-error@2.1.3: {} + + classnames@2.5.1: {} + + client-only@0.0.1: {} + + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + compute-scroll-into-view@3.1.1: {} + + concat-map@0.0.1: {} + + cookie@0.7.2: {} + + copy-to-clipboard@3.3.3: + dependencies: + toggle-selection: 1.0.6 + + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + + csstype@3.2.3: {} + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deepmerge-ts@7.1.5: {} + + denque@2.1.0: {} + + detect-libc@2.1.2: + optional: true + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + + encoding-japanese@2.2.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + entities@4.5.0: {} + + entities@7.0.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.21 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.7 + unzipper: 0.10.14 + uuid: 8.3.2 + + expect-type@1.4.0: {} + + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + + fs-constants@1.0.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + graceful-fs@4.2.11: {} + + he@1.2.0: {} + + html-to-text@10.0.0: + dependencies: + '@selderee/plugin-htmlparser2': 0.12.0(selderee@0.12.0) + deepmerge-ts: 7.1.5 + dom-serializer: 2.0.0 + htmlparser2: 10.1.0 + selderee: 0.12.0 + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + imapflow@1.4.8: + dependencies: + '@zone-eu/mailsplit': 5.4.14 + encoding-japanese: 2.2.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libmime: 5.4.1 + libqp: 2.1.1 + nodemailer: 9.0.3 + pino: 10.3.1 + socks: 2.8.9 + + immediate@3.0.6: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + iron-session@8.0.4: + dependencies: + cookie: 0.7.2 + iron-webcrypto: 1.2.1 + uncrypto: 0.1.3 + + iron-webcrypto@1.2.1: {} + + is-property@1.0.2: {} + + isarray@1.0.0: {} + + js-tokens@4.0.0: {} + + json2mq@0.2.0: + dependencies: + string-convert: 0.2.1 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + leac@0.7.0: {} + + libbase64@1.3.0: {} + + libmime@5.4.1: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.7.3 + libbase64: 1.3.0 + libqp: 2.1.1 + + libqp@2.1.1: {} + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + + listenercount@1.0.1: {} + + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isundefined@3.0.1: {} + + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + + long@5.3.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru.min@1.1.4: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mailparser@3.9.14: + dependencies: + '@zone-eu/mailsplit': 5.4.14 + encoding-japanese: 2.2.0 + he: 1.2.0 + html-to-text: 10.0.0 + iconv-lite: 0.7.3 + libmime: 5.4.1 + linkify-it: 5.0.2 + nodemailer: 9.0.3 + punycode.js: 2.3.1 + tlds: 1.261.0 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.16 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.2 + + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + mysql2@3.23.1(@types/node@20.19.43): + dependencies: + '@types/node': 20.19.43 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.5.1 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + nanoid@3.3.16: {} + + next@15.5.21(@playwright/test@1.61.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 15.5.21 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001806 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.6(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.21 + '@next/swc-darwin-x64': 15.5.21 + '@next/swc-linux-arm64-gnu': 15.5.21 + '@next/swc-linux-arm64-musl': 15.5.21 + '@next/swc-linux-x64-gnu': 15.5.21 + '@next/swc-linux-x64-musl': 15.5.21 + '@next/swc-win32-arm64-msvc': 15.5.21 + '@next/swc-win32-x64-msvc': 15.5.21 + '@playwright/test': 1.61.1 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + nodemailer@9.0.3: {} + + normalize-path@3.0.0: {} + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + pako@1.0.11: {} + + parseley@0.13.1: + dependencies: + leac: 0.7.0 + peberminta: 0.10.0 + + path-is-absolute@1.0.1: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + peberminta@0.10.0: {} + + picocolors@1.1.1: {} + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.2.0 + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.4.31: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.21: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prisma@5.22.0: + dependencies: + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + + process-nextick-args@2.0.1: {} + + process-warning@5.0.0: {} + + punycode.js@2.3.1: {} + + quick-format-unescaped@4.0.4: {} + + rc-cascader@3.34.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree: 5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-checkbox@3.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-collapse@3.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-dialog@9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-drawer@7.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-dropdown@4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/async-validator': 5.1.2 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-image@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-dialog: 9.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-input-number@9.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/mini-decimal': 1.1.4 + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-input@1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-mentions@2.20.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-textarea: 1.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-menu@9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-motion@2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-notification@5.6.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-overflow@1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-pagination@5.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-picker@4.11.3(dayjs@1.11.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + dayjs: 1.11.21 + + rc-progress@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-rate@2.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-resize-observer@1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + resize-observer-polyfill: 1.5.1 + + rc-segmented@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-select@14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-overflow: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-slider@11.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-steps@6.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-switch@4.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-table@7.54.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/context': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-tabs@15.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-dropdown: 4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-menu: 9.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-textarea@1.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-tooltip@6.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-tree-select@5.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-select: 14.16.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-tree: 5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-tree@5.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-virtual-list: 3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-upload@4.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + rc-util@5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + + rc-virtual-list@3.19.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rc-util: 5.44.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@18.3.1: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + resize-observer-polyfill@1.5.1: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + + selderee@0.12.0: + dependencies: + parseley: 0.13.1 + + semver@7.8.5: + optional: true + + setimmediate@1.0.5: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + siginfo@2.0.0: {} + + smart-buffer@4.2.0: {} + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + sql-escaper@1.5.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-convert@0.2.1: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + styled-jsx@5.1.6(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + + stylis@4.4.0: {} + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + thread-stream@3.2.0: + dependencies: + real-require: 0.2.0 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + throttle-debounce@5.0.2: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tlds@1.261.0: {} + + tmp@0.2.7: {} + + toggle-selection@1.0.6: {} + + traverse@0.3.9: {} + + tslib@2.8.1: {} + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + uc.micro@2.1.0: {} + + uncrypto@0.1.3: {} + + undici-types@6.21.0: {} + + undici@6.27.0: {} + + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + util-deprecate@1.0.2: {} + + uuid@11.1.1: {} + + uuid@8.3.2: {} + + vite-node@2.1.9(@types/node@20.19.43): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.43): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.21 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@20.19.43): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.43)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@20.19.43) + vite-node: 2.1.9(@types/node@20.19.43) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + xmlchars@2.2.0: {} + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + + zod@3.25.76: {} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..92960bd --- /dev/null +++ b/prisma/schema.prisma @@ -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") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..06c7ad3 --- /dev/null +++ b/prisma/seed.ts @@ -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()); diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/public/.gitkeep @@ -0,0 +1 @@ +{} diff --git a/scripts/analyze-mail-rounds.ts b/scripts/analyze-mail-rounds.ts new file mode 100644 index 0000000..6f79a64 --- /dev/null +++ b/scripts/analyze-mail-rounds.ts @@ -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; + +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), + })), + ); +} diff --git a/scripts/apply-demovip-cc.ts b/scripts/apply-demovip-cc.ts new file mode 100644 index 0000000..d910484 --- /dev/null +++ b/scripts/apply-demovip-cc.ts @@ -0,0 +1,73 @@ +/** + * �?V1.72 文档写入 demovip CC 配置�?env 已对齐时使用)�? * 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(), + }); + + // 文档有真链路的写能力�?live;文档无接口的保�?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(); + }); diff --git a/scripts/bind-instruction-segment-ui.ts b/scripts/bind-instruction-segment-ui.ts new file mode 100644 index 0000000..d9929a9 --- /dev/null +++ b/scripts/bind-instruction-segment-ui.ts @@ -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 = ( + 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 = ( + 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 = ( + 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 = ( + 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 �?restore with fix-mail-business-summary-zh.ts"); + process.exit(1); +} diff --git a/scripts/cc-smoke.ts b/scripts/cc-smoke.ts new file mode 100644 index 0000000..922e31d --- /dev/null +++ b/scripts/cc-smoke.ts @@ -0,0 +1,165 @@ +/** + * CC 烟雾验收 + * + * pnpm cc:smoke # 跟随 .env:mock �?live + * pnpm cc:smoke -- --mode=mock # 强制 mock(不连网�? + * pnpm cc:smoke -- --mode=live # 强制 live:需 CC_MOCK=false + 账号 + * + * Exit: 0 ok | 1 配置错误 | 2 CC 业务失败 | 3 模式不匹�? + */ +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 �?CC_MOCK=true。设 CC_MOCK=false 并配�?CC_USERNAME + 密码后再跑�?, + ); + process.exit(3); + } + if (!status.live_ready) { + console.error( + "FAIL: live 未就�?�?, + status.reason ?? "缺少 CC_USERNAME / CC_PASSWORD_PLAIN|MD5", + ); + process.exit(1); + } + } + + if (requested === "mock" && status.mode !== "mock") { + console.error( + "FAIL: --mode=mock 但当�?CC_MOCK=false。请临时�?CC_MOCK=true,或去掉 --mode�?, + ); + 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 空(不阻�?SaveContainer�?); + } + + 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 �?跳过真网 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�? + 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 �?CC 侧接口可用才算该项完�?, + ); + } else { + console.log( + "PASS: mock SaveContainer + instruction writes �?非设�?TEST 烟雾验收。账号到位后:CC_MOCK=false + pnpm cc:smoke -- --mode=live", + ); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/check-template-recognize.ts b/scripts/check-template-recognize.ts new file mode 100644 index 0000000..c6e7d40 --- /dev/null +++ b/scripts/check-template-recognize.ts @@ -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+洛杉�?40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆�?; +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)), +); diff --git a/scripts/clear-imap-lock.ts b/scripts/clear-imap-lock.ts new file mode 100644 index 0000000..e837d1e --- /dev/null +++ b/scripts/clear-imap-lock.ts @@ -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); +}); diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100644 index 0000000..356bc2c --- /dev/null +++ b/scripts/dev-stack.mjs @@ -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"]); diff --git a/scripts/docker-entrypoint-web.sh b/scripts/docker-entrypoint-web.sh new file mode 100644 index 0000000..8179abe --- /dev/null +++ b/scripts/docker-entrypoint-web.sh @@ -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 diff --git a/scripts/extract-mail-recognize-docx.py b/scripts/extract-mail-recognize-docx.py new file mode 100644 index 0000000..545213c --- /dev/null +++ b/scripts/extract-mail-recognize-docx.py @@ -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"", "\t", xml) +text = re.sub(r"", "\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]) diff --git a/scripts/extract-pdf-text.py b/scripts/extract-pdf-text.py new file mode 100644 index 0000000..5707545 --- /dev/null +++ b/scripts/extract-pdf-text.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +"""Extract plain text from a PDF (pypdf). Usage: python scripts/extract-pdf-text.py """ +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() diff --git a/scripts/fill-xcz-packing-template.ts b/scripts/fill-xcz-packing-template.ts new file mode 100644 index 0000000..9b6b8bb --- /dev/null +++ b/scripts/fill-xcz-packing-template.ts @@ -0,0 +1,119 @@ +/** + * 按新辰泽填写规范生成卡派表(不就�?splice,避免残留旧行)�? + * 用法: 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", + "邮件", + "模板", + "数据模版-新辰�?WHSU8127240.xlsx", +); +const CN = "WHSU8127240"; + +const LAS1_ADDR = [ + "仓库代码�?LAS1", + "收件人: LAS1", + "公司名称�?AMAZON COM SERVICES INC", + "联系电话�?0123456789", + "�?�?区: NV / HENDERSON /", + "邮政编码�?89044-8746", + "收货地址�?12300 Bermuda Road", +].join("\n"); + +const PRIVATE_ADDR = [ + "收件人: Erica Fabian", + "公司名称�?Rapid Fulfillment", + "联系电话�?818-492-2760", + "�?�?区: CA / Pacoima /", + "邮政编码�?91331", + "收货地址�?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"); + + // 复制�?3 行表头(�?+ 合并可忽略) + 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 �?${OUT}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/fix-mail-business-summary-zh.ts b/scripts/fix-mail-business-summary-zh.ts new file mode 100644 index 0000000..0665941 --- /dev/null +++ b/scripts/fix-mail-business-summary-zh.ts @@ -0,0 +1,5 @@ +/** + * Fix MailBusinessSummary Chinese corruption �?delegates to full UTF-8 rewrite. + * Prefer: pnpm fix:ui-zh + */ +import "./rewrite-mail-business-summary"; diff --git a/scripts/fix-wo-btn.ts b/scripts/fix-wo-btn.ts new file mode 100644 index 0000000..09842ad --- /dev/null +++ b/scripts/fix-wo-btn.ts @@ -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*\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, + ), +); diff --git a/scripts/flatten-mail-attachments.ts b/scripts/flatten-mail-attachments.ts new file mode 100644 index 0000000..fa08605 --- /dev/null +++ b/scripts/flatten-mail-attachments.ts @@ -0,0 +1,62 @@ +/** + * 将邮�?/4 子目录内业务附件平铺到邮件根目录�? * 用法: 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 { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function moveUp(subdir: string, mailDir: string): Promise { + 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); + } + // 清空后删子目�? 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 卡转�? }, + { + mail: "邮件4", + nested: "原箱号YT2604021091=FBA199R49LD6-MDW2=76件换标操作指�?, + }, + ]; + + 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; +}); diff --git a/scripts/imap-poll.ts b/scripts/imap-poll.ts new file mode 100644 index 0000000..794b33e --- /dev/null +++ b/scripts/imap-poll.ts @@ -0,0 +1,52 @@ +/** + * 一次�?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( + "无可用邮箱:请在「设�?�?邮箱绑定」配置,或填�?.env �?IMAP_USER / IMAP_PASS�?, + ); + 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(); diff --git a/scripts/imap-smoke.ts b/scripts/imap-smoke.ts new file mode 100644 index 0000000..a805182 --- /dev/null +++ b/scripts/imap-smoke.ts @@ -0,0 +1,53 @@ +/** + * 仅测 IMAP 连通:登录 + SEARCH UNSEEN,不落库�? + * 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(); diff --git a/scripts/ingest-do-upload-ui.ts b/scripts/ingest-do-upload-ui.ts new file mode 100644 index 0000000..beea5d2 --- /dev/null +++ b/scripts/ingest-do-upload-ui.ts @@ -0,0 +1,145 @@ +/** + * 入库「上�?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 = "请查�?DO:柜�?WHSU8127240"; +const BODY = "Dear,\n请查收附�?DO 文件,谢谢�?; +const MESSAGE_ID = ""; +const DO_PDF = path.join( + process.cwd(), + "docx", + "邮件", + "模板", + "WHSU8127240-DO.pdf", +); + +async function attachDo(mailId: bigint): Promise { + 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(); + }); diff --git a/scripts/ingest-forecast-ui.ts b/scripts/ingest-forecast-ui.ts new file mode 100644 index 0000000..4cc12c2 --- /dev/null +++ b/scripts/ingest-forecast-ui.ts @@ -0,0 +1,170 @@ +/** + * 入库「邮�? 完整线程�? 新辰泽卡派模板(历史预报货件表)�? * 最新指令是换标/贴标工单;主题链路上的新增预报仅作历史只读�? * 用法: 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 = ""; +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 { + 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(); + }); diff --git a/scripts/ingest-mail3.ts b/scripts/ingest-mail3.ts new file mode 100644 index 0000000..7ef5dbc --- /dev/null +++ b/scripts/ingest-mail3.ts @@ -0,0 +1,128 @@ +/** + * å°?docx/邮件/邮件3 主题注入本地库并è·?ParsePipeline(主é¢?only,无正文/附件)ã€? + * 用法: pnpm sample:mail3 + * + * 完整样例(含卡转æµ?PDF 入库)请用:pnpm sample:mails + */ +import { createHash } from "crypto"; +import { prisma } from "@/services/db"; +import { ParsePipeline } from "@/services/parse/pipeline"; + +const MAIL3_SUBJECT = + "Fw: 转发:派送要求更æ–? 拆柜清单更新ï¼?DO请查æ”?拆柜清单更新: WHSU5574991+WHL063G550810+船名航次:OOCL SINGAPORE / 065W+ETAï¼?/28+FedEx-29ä»?UPS-102ä»?亚马逊卡æ´?289ä»?私人地址-166ä»?拦截-209ä»?拆柜清单"; + +const MAIL3_BODY = [ + "派送要求更新,请查æ”?DO 与拆柜清单ã€?, + "柜号:WHSU5574991", + "提单:WHL063G550810", + "船名航次:OOCL SINGAPORE / 065W", + "ETAï¼?026-06-28", +].join("\n"); + +const MESSAGE_ID = ""; +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(); + }); diff --git a/scripts/ingest-recognize-gold.ts b/scripts/ingest-recognize-gold.ts new file mode 100644 index 0000000..6d506f7 --- /dev/null +++ b/scripts/ingest-recognize-gold.ts @@ -0,0 +1,198 @@ +/** + * 金样入库:docx/邮件/模板/附件下载_邮件识别 + * 四类识别:新增预�?+ 上传 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+洛杉�?40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆�?; +const BODY = [ + "请查收新增预报:智鸿2+WHLC027G597465+WHSU8127240+洛杉�?40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆�?, + "DO也同步上传至附件请注意查收!", +].join("\n"); +const MESSAGE_ID = ""; + +const CONTENT_TYPES: Record = { + ".xlsx": + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xls": "application/vnd.ms-excel", + ".pdf": "application/pdf", + ".csv": "text/csv", +}; + +async function collectGoldFiles(dir: string): Promise { + 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 { + 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 { + 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(); + }); diff --git a/scripts/ingest-sample-mails.ts b/scripts/ingest-sample-mails.ts new file mode 100644 index 0000000..d8127c4 --- /dev/null +++ b/scripts/ingest-sample-mails.ts @@ -0,0 +1,341 @@ +/** + * å°?docx/邮件/邮件1~4(及可选模板预报)注入本地库并è·?ParsePipelineã€? * 用法: pnpm sample:mails + * + * 目录约定(平铺)ï¼? * docx/邮件/邮件N/ + * *邮箱.pdf / QQ邮箱.pdf â€?QQ 截图(默认不入库ï¼? * *.xlsx / 业务 *.pdf â€?业务附件(按 SampleDef.attachPolicy 入库ï¼? * docx/邮件/模板/ â€?共享预报模板(M_BL / forecast-uiï¼? * + * M1 主题-only(UNKNOWNï¼? * M2 主题+卡派 xlsx(WORK_ORDER/转仓ï¼? * M3 主题 + 卡转æµ?PDF 展示(WORK_ORDER 拆柜清单;解析仍主题短路ï¼? * M4 主题+换标正文 + 换标指令 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/邮件/ 的目录名,如 邮件2 */ + sampleDir: string; + attachPolicy: AttachPolicy; + /** 可选:文件名须包含的关键词(全部命中) */ + nameIncludes?: string[]; +}; + +const SAMPLES: SampleDef[] = [ + { + key: "mail1", + messageId: "", + fromAddr: "cs3@xinfenginc.com", + subject: "Fw: 转发:TIIU8073522-90022", + bodyText: [ + "(样例)主题为柜号线索,完整正文è§?QQ 邮箱截图附件ã€?, + "柜号线索:TIIU8073522", + ].join("\n"), + receivedAt: "2026-07-14T08:02:00.000Z", + sampleDir: "邮件1", + attachPolicy: "none", + }, + { + key: "mail2", + messageId: "", + fromAddr: "op7@xinfenginc.com", + subject: + "Fw: 转发:LINK EVER INC + MATS4583030000+ 柜号:MATU2745683+ ETA : 7/13", + bodyText: "新增转仓,请留意\n柜号:MATU2745683 更新派送单,请查收", + receivedAt: "2026-07-14T08:02:00.000Z", + sampleDir: "邮件2", + attachPolicy: "packing_xlsx", + nameIncludes: ["MATU2745683"], + }, + { + key: "mail3", + messageId: "", + fromAddr: "clx@cnwally.com.cn", + subject: + "Fw: 转发:派送要求更æ–? 拆柜清单更新ï¼?DO请查æ”?拆柜清单更新: WHSU5574991+WHL063G550810+船名航次:OOCL SINGAPORE / 065W+ETAï¼?/28+FedEx-29ä»?UPS-102ä»?亚马逊卡æ´?289ä»?私人地址-166ä»?拦截-209ä»?拆柜清单", + bodyText: [ + "派送要求更新,请查æ”?DO 与拆柜清单ã€?, + "柜号:WHSU5574991", + "提单:WHL063G550810", + "船名航次:OOCL SINGAPORE / 065W", + "ETAï¼?026-06-28", + "FedEx-29ä»?UPS-102ä»?亚马逊卡æ´?289ä»?私人地址-166ä»?拦截-209ä»?, + ].join("\n"), + receivedAt: "2026-07-14T08:02:00.000Z", + sampleDir: "邮件3", + // 卡转æµ?PDF 入库供详情展示;解析仍走主题短路,不吃表æ ? attachPolicy: "business", + }, + { + key: "mail4", + messageId: "", + fromAddr: "xinchenze002@126.com", + subject: + "Fw: 转发:新增预æŠ?36:新辰泽+WHLC027G597465+WHSU8127240+洛杉çŸ?40HQ+EDT2026.04-25 ETA2026.05-15船名航次HMM EMERALD 013E+提拆派组合柜-不带托架", + bodyText: [ + "Dear,", + "原箱号YT2604021091=FBA199R49LD6-MDW2=76件操作指ä»?, + "换标后单号:FBA19HW52S0L-2G1MCB6X-HIA1=76ä»?, + "1:覆盖贴FBA标签,一箱贴两张", + "2:覆盖贴SKU标签,一ç®?张(FNSKU:X003UHDF7Bï¼?04PCS))", + "3:贴好拍照回传等国内客户确认后,再约仓卡派交付!", + "注:回传过来的照片需要清晰拍照SKU上面的字迹,客户确认后再安排卡派交付,不确认不予安排!!ï¼?, + ].join("\n"), + receivedAt: "2026-07-14T08:00:00.000Z", + sampleDir: "邮件4", + attachPolicy: "label_instruction", + }, +]; + +async function resolveSampleDir(sampleDir: string): Promise { + const direct = path.join(DOC_MAIL_ROOT, sampleDir); + try { + const st = await fs.stat(direct); + if (st.isDirectory()) return direct; + } catch { + /* fall through */ + } + // 兜底:在 docx/邮件 下按目录名包含匹é…? 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; +} + +/** 递归收集目录内文件(含一层子目录,兼容未平铺旧结构) */ +async function listFilesRecursive( + root: string, +): Promise> { + const out: Array<{ abs: string; filename: string; rel: string }> = []; + const stack = [root]; + while (stack.length) { + const cur = stack.pop()!; + let entries: Awaited>; + 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 { + 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 { + 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> = []; + + 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(); + }); diff --git a/scripts/lib/load-mailbox-pdf-body.ts b/scripts/lib/load-mailbox-pdf-body.ts new file mode 100644 index 0000000..420b694 --- /dev/null +++ b/scripts/lib/load-mailbox-pdf-body.ts @@ -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 { + 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 { + 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; + } +} diff --git a/scripts/mark-non-business-ignored.ts b/scripts/mark-non-business-ignored.ts new file mode 100644 index 0000000..4c099c3 --- /dev/null +++ b/scripts/mark-non-business-ignored.ts @@ -0,0 +1,46 @@ +/** + * 将库内已拉入的非预报/装箱相关邮件标为 IGNORED(列表默认隐藏)�? + * 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()); diff --git a/scripts/patch-classify-gold.ts b/scripts/patch-classify-gold.ts new file mode 100644 index 0000000..de4aa09 --- /dev/null +++ b/scripts/patch-classify-gold.ts @@ -0,0 +1,76 @@ +/** + * Align classify with 附件下载_邮件识别 gold: + * - 新增预报 / 请查收新增预�?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 (/新增预报|请查收新增预�?.test(text)) { + signals.push({ + signal: "新增预报", + score: 50, + matched: "新增预报", + source: /新增预报|请查收新增预�?.test(subject) ? "subject" : "body", + }); + scores.NEW_CONTAINER += 50; + } + + // 金样:数据模�?/ 卡派清单�?xlsx �?预报�? + 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"); diff --git a/scripts/patch-classify-packing-narrow.ts b/scripts/patch-classify-packing-narrow.ts new file mode 100644 index 0000000..af84117 --- /dev/null +++ b/scripts/patch-classify-packing-narrow.ts @@ -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 = ` // 金样:数据模�?/ 明确预报清单附件 �?NEW_CONTAINER(不把单纯「卡派资料」当预报�? + 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"); diff --git a/scripts/patch-classify-wo-fallback.ts b/scripts/patch-classify-wo-fallback.ts new file mode 100644 index 0000000..bb9b922 --- /dev/null +++ b/scripts/patch-classify-wo-fallback.ts @@ -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"); diff --git a/scripts/patch-date-shell-rule.ts b/scripts/patch-date-shell-rule.ts new file mode 100644 index 0000000..5f3b095 --- /dev/null +++ b/scripts/patch-date-shell-rule.ts @@ -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"); diff --git a/scripts/patch-detail-full-body-ui.ts b/scripts/patch-detail-full-body-ui.ts new file mode 100644 index 0000000..7233917 --- /dev/null +++ b/scripts/patch-detail-full-body-ui.ts @@ -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 = ` + {mail.body_text || + mail.ocr_text || + "(无正�?"} + + ), + }, + ]} + />`; + +const neu = ` + + 入库原文完整保留,解�?OCR 不会覆盖此字段。可滚动查看全部内容�? + +
+                  {(mail.body_text || "").trim()
+                    ? mail.body_text
+                    : "(无正�?"}
+                
+ {(mail.ocr_text || "").trim() ? ( + <> + + OCR 文本(附件识别,与原文分开保存)·{" "} + {(mail.ocr_text || "").length} �? + +
+                      {mail.ocr_text}
+                    
+ + ) : null} + + ), + }, + ]} + />`; + +if (!src.includes('label: "原始正文(排查用�?')) { + 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("", end); +// better: match from 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 \n\n \r?\n\r?\n?\s*\r?\n)(\s*
)/; +if (!re.test(src)) throw new Error("step2 not found"); + +src = src.replace( + re, + `$1
+
+ \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 +
+
    + {PACKING_FILL_TIPS.map((t) => ( +
  1. {t}
  2. + ))} +
+
+$2`, +); + +fs.writeFileSync(path, src); +console.log("ok", src.includes("PACKING_FILL_TIPS.map")); diff --git a/scripts/patch-ingest-full-body.ts b/scripts/patch-ingest-full-body.ts new file mode 100644 index 0000000..b6b5d6a --- /dev/null +++ b/scripts/patch-ingest-full-body.ts @@ -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")); diff --git a/scripts/patch-latest-readonly.ts b/scripts/patch-latest-readonly.ts new file mode 100644 index 0000000..01285a5 --- /dev/null +++ b/scripts/patch-latest-readonly.ts @@ -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(); + 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); diff --git a/scripts/patch-packing-note-tests.ts b/scripts/patch-packing-note-tests.ts new file mode 100644 index 0000000..8fec9a4 --- /dev/null +++ b/scripts/patch-packing-note-tests.ts @@ -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"); + } +} diff --git a/scripts/patch-packing-skip-notes.ts b/scripts/patch-packing-skip-notes.ts new file mode 100644 index 0000000..d18616c --- /dev/null +++ b/scripts/patch-packing-skip-notes.ts @@ -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 �?not a shipment */ +export function isInstructionNoiseRow(raw: Record): 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)"), +}); diff --git a/scripts/patch-preserve-body-text.ts b/scripts/patch-preserve-body-text.ts new file mode 100644 index 0000000..c5cb6f1 --- /dev/null +++ b/scripts/patch-preserve-body-text.ts @@ -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 : "(无主�?"; + // 历史库可能存了原�?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 : "(无主�?"; + // 解析用归一化正文;绝不回写 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 只写�?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 只写�?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 �?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 }"), +}); diff --git a/scripts/patch-resolve-fuller-body.ts b/scripts/patch-resolve-fuller-body.ts new file mode 100644 index 0000000..1307eb7 --- /dev/null +++ b/scripts/patch-resolve-fuller-body.ts @@ -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); diff --git a/scripts/patch-shell-subject-noise.ts b/scripts/patch-shell-subject-noise.ts new file mode 100644 index 0000000..326bc76 --- /dev/null +++ b/scripts/patch-shell-subject-noise.ts @@ -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"); +} diff --git a/scripts/patch-skip-instruction-rows.ts b/scripts/patch-skip-instruction-rows.ts new file mode 100644 index 0000000..7174799 --- /dev/null +++ b/scripts/patch-skip-instruction-rows.ts @@ -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 �?not a shipment */ +export function isInstructionNoiseRow(raw: Record): 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"); + } +} diff --git a/scripts/patch-skip-shell-segments.ts b/scripts/patch-skip-shell-segments.ts new file mode 100644 index 0000000..b841ad1 --- /dev/null +++ b/scripts/patch-skip-shell-segments.ts @@ -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/中间人纯转发壳,无客�?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) || + /新增预报|请查收新增预�?.test(segBody); + const subjectHits = useSubject + ? matchKeywordRules(segSubject, "subject") + : []; + const kinds = new Set([ + ...bodyHits.map((h) => h.uiKind), + ...subjectHits.map((h) => h.uiKind), + ]); + return UI_KIND_ORDER.filter((k) => kinds.has(k)); +}`, + `/** 去掉段内「主�? Re:Re:新增预报」历史主题污�?*/ +function bodyForKindDetect(segBody: string): string { + if (!BODY_ACTION_RE.test(segBody)) return segBody; + return segBody + .split(/\\n/) + .filter( + (line) => + !/(?:主题|主旨|Subject)\\s*[�?].*(?:新增预报|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) || + /请查收新增预�?.test(bodyText); + const subjectHits = useSubject + ? matchKeywordRules(segSubject, "subject") + : []; + const kinds = new Set([ + ...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"), + }), +); diff --git a/scripts/patch-split-segment-kinds.ts b/scripts/patch-split-segment-kinds.ts new file mode 100644 index 0000000..964f41e --- /dev/null +++ b/scripts/patch-split-segment-kinds.ts @@ -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(); + 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(); + 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)); +} + +/** 单段:正文有动作词时,忽�?Re: 链路上的历史「新增预报」主�?*/ +export function detectKindsForSegment( + segSubject: string, + segBody: string, +): InstructionUiKind[] { + if (isNonInstructionSegment(segBody)) return []; + const bodyHits = matchKeywordRules(segBody, "body"); + const useSubject = + !BODY_ACTION_RE.test(segBody) || + /新增预报|请查收新增预�?.test(segBody); + const subjectHits = useSubject + ? matchKeywordRules(segSubject, "subject") + : []; + const kinds = new Set([ + ...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"), + }), +); diff --git a/scripts/patch-strip-conservative.ts b/scripts/patch-strip-conservative.ts new file mode 100644 index 0000000..cbe45ae --- /dev/null +++ b/scripts/patch-strip-conservative.ts @@ -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(); diff --git a/scripts/patch-strip-subject-block.ts b/scripts/patch-strip-subject-block.ts new file mode 100644 index 0000000..9b240a5 --- /dev/null +++ b/scripts/patch-strip-subject-block.ts @@ -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"); +} diff --git a/scripts/patch-ui-customer-fallback.ts b/scripts/patch-ui-customer-fallback.ts new file mode 100644 index 0000000..e83b90b --- /dev/null +++ b/scripts/patch-ui-customer-fallback.ts @@ -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"); diff --git a/scripts/patch-ui-latest-readonly.ts b/scripts/patch-ui-latest-readonly.ts new file mode 100644 index 0000000..0d95541 --- /dev/null +++ b/scripts/patch-ui-latest-readonly.ts @@ -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 ? ( + + {unit.keywords.slice(0, 6).map((k) => ( + {k} + ))} + + ) : null + }`; + const newExtra = ` extra={ + + + {unit.isCurrent ? "\u53ef\u786e\u8ba4" : "\u5386\u53f2\u53ea\u8bfb"} + + {unit.keywords.slice(0, 5).map((k) => ( + {k} + ))} + + }`; + 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 ? ( + void submitWorkOrder()} + > + \u8f6c\u4ed3\u4e0d\u53ef\u7528\uff0c\u63d0\u4ea4\u5de5\u5355 + + ) : ( + + ); + }`, + ` if (canConfirmOps && unit.isCurrent) { + actions = blocked ? ( + + ) : ( + + ); + }`, + ); + + // work_order mode + src = src.replace( + ` setWorkOrderValue((v) => ({ ...v, ...p }))} + /> + ); + if (canConfirmOps && mail.mail_type === "WORK_ORDER") {`, + ` setWorkOrderValue((v) => ({ ...v, ...p }))} + /> + ); + if (canConfirmOps && unit.isCurrent) {`, + ); + + // do_upload + src = src.replace( + ` setDoValue((v) => ({ ...v, ...p }))} + /> + ); + if ( + canConfirmOps && + (mail.mail_type === "DO_UPLOAD" || unit.isCurrent) + ) {`, + ` 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"'), + }); +} diff --git a/scripts/patch-wire-packing-fill-rules.ts b/scripts/patch-wire-packing-fill-rules.ts new file mode 100644 index 0000000..30c3d36 --- /dev/null +++ b/scripts/patch-wire-packing-fill-rules.ts @@ -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[] = []; + // 亚马逊仓必填仓库代码;快�?私人地址可空 + 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("总体积缺�?); + 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,�?/;]+/.test(fbaIdRaw) && /FBA/i.test(fbaIdRaw)) { + warnings.push("MULTI_FBAID_IN_CELL"); + } + if (/[\\s,�?/;]{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")); diff --git a/scripts/purge-all-mails.ts b/scripts/purge-all-mails.ts new file mode 100644 index 0000000..2b2658f --- /dev/null +++ b/scripts/purge-all-mails.ts @@ -0,0 +1,48 @@ +/** + * 清空全部邮件及相关解�?导入/附件记录(保留邮箱账号等设置)�? + * 用法: 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(); + }); diff --git a/scripts/retention-cleanup.ts b/scripts/retention-cleanup.ts new file mode 100644 index 0000000..b986665 --- /dev/null +++ b/scripts/retention-cleanup.ts @@ -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); +}); diff --git a/scripts/rewrite-mail-business-summary.ts b/scripts/rewrite-mail-business-summary.ts new file mode 100644 index 0000000..013bc77 --- /dev/null +++ b/scripts/rewrite-mail-business-summary.ts @@ -0,0 +1,679 @@ +/** + * Rewrite MailBusinessSummary.tsx fully in UTF-8. + * ASCII-only script source; Chinese via \\u escapes only. + * Run: pnpm exec tsx scripts/rewrite-mail-business-summary.ts + */ +import fs from "fs"; +import path from "path"; + +const out = path.join(process.cwd(), "src", "components", "MailBusinessSummary.tsx"); + +const ZH = { + cardTitle: "\u8fd9\u5c01\u90ae\u4ef6\u5728\u5e72\u4ec0\u4e48", + splitPrefix: "\u5df2\u62c6\u51fa ", + splitSuffix: " \u6761\u6307\u4ee4\uff1a", + from: "\u53d1\u4ef6\u4eba", + action: "\u4e1a\u52a1\u52a8\u4f5c", + hint: "\u90ae\u4ef6\u8868\u8ff0", + next: "\u7cfb\u7edf\u4e0b\u4e00\u6b65", + vessel: "\u8239\u540d\u822a\u6b21 ", + woMock: "\u5de5\u5355\u5df2\u63d0\u4ea4\uff08mock\uff09", + woOk: "\u5de5\u5355\u5df2\u63d0\u4ea4", + doMock: "DO \u5df2\u767b\u8bb0\uff08mock\uff09", + doOk: "DO \u5df2\u4e0a\u4f20", + needFba: "\u8bf7\u586b\u5199\u76ee\u6807 FBACode", + xferMock: "\u6279\u91cf\u8f6c\u4ed3\u5df2\u63d0\u4ea4\uff08mock\uff09", + xferOk: "\u6279\u91cf\u8f6c\u4ed3\u5df2\u63d0\u4ea4", + msgType: "\u62c6\u67dc\u65f6\u81ea\u52a8\u8f6c\u4ed3", + gate: "\u95f8\u95e8\uff1a", + semi: "\uff1b", + actXfer: "\u8f6c\u4ed3", + btnWo: "\u786e\u8ba4\u63d0\u4ea4\u5de5\u5355", + btnXfer: "\u786e\u8ba4\u6279\u91cf\u8f6c\u4ed3", + btnDo: "\u786e\u8ba4\u4e0a\u4f20 DO", +}; + +const file = `"use client"; + +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Alert, + App, + Button, + Card, + Descriptions, + Space, + Tag, + Typography, +} from "antd"; +import { + CcDoUploadPanel, + buildCcDoUploadFormValue, +} from "@/components/CcDoUploadPanel"; +import { + CcForecastFormOrder, + buildCcForecastFormValue, +} from "@/components/CcForecastFormOrder"; +import { CcCustomerInstructionPanel } from "@/components/CcCustomerInstructionPanel"; +import { InstructionUnitShell } from "@/components/MailInstructionUnitShell"; +import { + CcTransferIndexTruck, + buildCcTransferFormValue, +} from "@/components/CcTransferIndexTruck"; +import { + CcWorkOrderForm, + buildCcWorkOrderFormValue, +} from "@/components/CcWorkOrderForm"; +import { TypeTag } from "@/components/TypeTag"; +import { + confirmDoUploadApi, + confirmTransferApi, + confirmWorkOrderApi, + getTransferGateApi, + listShippingLinesApi, +} from "@/lib/client-api"; +import { + buildMailIntent, + extractTransferPairs, + filterTransferShipments, +} from "@/services/parse/mail-intent"; +import { extractBlPlusFromMail } from "@/services/parse/bl-plus-template"; +import { + extractForecastInstructionText, + extractMailInstructions, +} from "@/services/parse/split-instructions"; +import type { MailMessage } from "@/types"; +import type { + ContainerHeader, + MailRecord, + MailType, + TypeEvidence, +} from "@/types/mail"; +import type { TransferGateDecision } from "@/services/cc/container-status"; + +function asHeader(mail: MailMessage): ContainerHeader | null { + return mail.parse_result?.container_header ?? null; +} + +function asMailRecord(mail: MailMessage): MailRecord | null { + const direct = mail.parse_result?.mail_record; + if (direct) return direct; + const lineage = mail.parse_result?.lineage as + | { mail_record?: MailRecord } + | undefined; + return lineage?.mail_record ?? null; +} + +type EvidenceExt = TypeEvidence & { + mailbox?: { from?: string; inbox?: string; extracted?: string[] }; +}; + +export function MailBusinessSummary({ + mail, + onConfirmed, +}: { + mail: MailMessage; + onConfirmed?: () => void; +}) { + const { message } = App.useApp(); + const header = asHeader(mail); + const record = asMailRecord(mail); + const evidence = (mail.type_evidence || { + total: 0, + mail_type: mail.mail_type, + signals: [], + }) as EvidenceExt; + + // Stable deps for useEffect + const shipmentsRaw = mail.parse_result?.shipments; + const attachmentsRaw = mail.attachments; + const shipmentsKey = JSON.stringify(shipmentsRaw ?? []); + const filenamesKey = (attachmentsRaw || []).map((a) => a.filename).join("?"); + const shipments = useMemo( + () => shipmentsRaw ?? [], + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by shipmentsKey + [shipmentsKey], + ); + const filenames = useMemo( + () => (attachmentsRaw || []).map((a) => a.filename), + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by filenamesKey + [filenamesKey], + ); + const bodyText = mail.body_text || mail.ocr_text || ""; + const containerNo = header?.F_ContainerNo || mail.container_no || ""; + const workOrderActionsKey = (record?.work_order_actions || []).join("|"); + const intentSignalsKey = JSON.stringify(evidence.signals ?? []); + + const intent = useMemo( + () => + evidence.intent || + buildMailIntent({ + mailType: mail.mail_type as MailType, + subject: mail.subject, + body: bodyText, + filenames, + shipments, + evidence, + containerNo, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps -- evidence default object unstable + [ + evidence.intent, + evidence.mail_type, + evidence.total, + intentSignalsKey, + mail.mail_type, + mail.subject, + bodyText, + filenames, + shipments, + containerNo, + ], + ); + + const summaryText = record?.summary || intent.narrative; + const transferPairs = useMemo( + () => + intent.transfer_pairs?.length + ? intent.transfer_pairs + : extractTransferPairs(shipments), + [intent.transfer_pairs, shipments], + ); + const transferShipments = useMemo( + () => filterTransferShipments(shipments), + [shipments], + ); + + const instructions = useMemo( + () => + extractMailInstructions({ + subject: mail.subject, + body: bodyText, + filenames, + forceForecast: record?.kind === "BL_FORECAST", + workOrderActions: record?.work_order_actions, + }), + [ + mail.subject, + bodyText, + filenames, + record?.kind, + workOrderActionsKey, + ], + ); + + const forecastInstruction = useMemo(() => { + const vessel = record?.modules.vessel_voyage; + return extractForecastInstructionText({ + subject: mail.subject, + body: bodyText, + modulesInstruction: vessel + ? \`${ZH.vessel}\${vessel}\` + : header?.F_Instruction || null, + }); + }, [ + mail.subject, + bodyText, + header?.F_Instruction, + record?.modules.vessel_voyage, + ]); + + const hasForecastUi = instructions.some((u) => u.uiKind === "forecast"); + const hasTransferUi = instructions.some((u) => u.uiKind === "transfer"); + const canConfirmOps = + mail.status === "PENDING_CONFIRM" || mail.status === "FAILED"; + + 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, + ]); + + const transferValue = useMemo( + () => + buildCcTransferFormValue({ + customerName: record?.modules.customer_name, + containerNo, + modules: record?.modules, + shipments: transferShipments, + transferPairs, + }), + [ + record?.modules.customer_name, + containerNo, + record?.modules, + transferShipments, + transferPairs, + ], + ); + + const currentWorkOrderUnit = useMemo( + () => instructions.find((u) => u.uiKind === "work_order" && u.isCurrent), + [instructions], + ); + + const [workOrderValue, setWorkOrderValue] = useState(() => + buildCcWorkOrderFormValue({ + subject: currentWorkOrderUnit?.segmentSubject || mail.subject, + body: currentWorkOrderUnit?.segmentText || bodyText, + filenames, + containerNo, + actions: record?.work_order_actions, + }), + ); + + const [doValue, setDoValue] = useState(() => + buildCcDoUploadFormValue({ + subject: mail.subject, + body: bodyText, + containerNo, + filenames, + }), + ); + + const [gate, setGate] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Reset forms when mail / current work-order segment changes + useEffect(() => { + setWorkOrderValue( + buildCcWorkOrderFormValue({ + subject: currentWorkOrderUnit?.segmentSubject || mail.subject, + body: currentWorkOrderUnit?.segmentText || bodyText, + filenames, + containerNo, + actions: record?.work_order_actions, + }), + ); + setDoValue( + buildCcDoUploadFormValue({ + subject: mail.subject, + body: bodyText, + containerNo, + filenames, + }), + ); + }, [ + mail.id, + mail.version, + mail.subject, + bodyText, + containerNo, + workOrderActionsKey, + filenames, + currentWorkOrderUnit?.id, + currentWorkOrderUnit?.segmentText, + ]); + + useEffect(() => { + if (mail.mail_type !== "TRANSFER" && !hasTransferUi) return; + let cancelled = false; + void getTransferGateApi(mail.id).then((res) => { + if (!cancelled && res.ok) setGate(res.data.gate); + }); + return () => { + cancelled = true; + }; + }, [mail.id, mail.mail_type, hasTransferUi]); + + const [shippingLines, setShippingLines] = useState< + { value: string; label: string }[] + >([]); + useEffect(() => { + if (!hasForecastUi) return; + let cancelled = false; + void listShippingLinesApi().then((res) => { + if (!cancelled && res.ok) { + setShippingLines( + res.data.items.map((item) => ({ value: item.id, label: item.name })), + ); + } + }); + return () => { + cancelled = true; + }; + }, [hasForecastUi]); + + const submitWorkOrder = async () => { + setSubmitting(true); + const res = await confirmWorkOrderApi(mail.id, { + version: mail.version, + entity: workOrderValue, + }); + setSubmitting(false); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success( + res.data.mode === "mock" ? "${ZH.woMock}" : "${ZH.woOk}", + ); + onConfirmed?.(); + }; + + const submitDo = async () => { + setSubmitting(true); + const res = await confirmDoUploadApi(mail.id, { + version: mail.version, + container_no: doValue.F_ContainerNo, + container_task_id: doValue.F_ContainerTaskId || undefined, + filenames: doValue.filenames, + }); + setSubmitting(false); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success( + res.data.mode === "mock" ? "${ZH.doMock}" : "${ZH.doOk}", + ); + onConfirmed?.(); + }; + + const submitTransfer = async () => { + const target = + transferValue.targetFBACode || transferValue.F_ConvertFBACode || ""; + if (!target) { + message.error("${ZH.needFba}"); + return; + } + const rows = transferShipments.length + ? transferShipments + : shipments.slice(0, 1); + setSubmitting(true); + const res = await confirmTransferApi(mail.id, { + version: mail.version, + container_no: + transferValue.F_ContainerNo || + header?.F_ContainerNo || + mail.container_no || + "", + payload: { + keyValue: + gate && "containerTaskId" in gate ? gate.containerTaskId || "" : "", + strDetialList: rows.map((r) => ({ + F_FBACode: r.F_FBACode || "", + F_ConvertFBACode: target, + })), + F_ConvertChargeMode: transferValue.F_ConvertChargeMode || "2", + F_RemovePlanDetail: transferValue.F_RemovePlanDetail ? "1" : "0", + }, + }); + setSubmitting(false); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success( + res.data.mode === "mock" ? "${ZH.xferMock}" : "${ZH.xferOk}", + ); + onConfirmed?.(); + }; + + return ( + <> + + + {summaryText} + + {instructions.length > 1 ? ( +
+ + ${ZH.splitPrefix}{instructions.length}${ZH.splitSuffix} + + + {instructions.map((u) => ( + {u.label} + ))} + +
+ ) : null} + + {evidence.mailbox?.from || mail.from_addr || "-"} + + ), + }, + { + key: "action", + label: "${ZH.action}", + children: ( + + + {intent.action} + + ), + }, + { + key: "hint", + label: "${ZH.hint}", + children: intent.content_hint || "-", + }, + { + key: "next", + label: "${ZH.next}", + span: { xs: 1, sm: 2, md: 2 }, + children: intent.next_step || "-", + }, + ]} + /> +
+ + {instructions.map((unit, index) => { + let body: ReactNode = null; + let actions: ReactNode = null; + + if (unit.uiKind === "forecast") { + body = ( + + ); + } else if (unit.uiKind === "transfer") { + const blocked = gate && !gate.canTransfer; + body = ( + <> + {gate ? ( + + ) : null} + {blocked ? ( + + setWorkOrderValue((v) => ({ ...v, ...p })) + } + /> + ) : ( + + )} + + ); + if (canConfirmOps && unit.isCurrent) { + actions = blocked ? ( + + ) : ( + + ); + } + } else if (unit.uiKind === "work_order") { + const woForUnit = unit.isCurrent + ? workOrderValue + : buildCcWorkOrderFormValue({ + subject: unit.segmentSubject || mail.subject, + body: unit.segmentText || bodyText, + filenames, + containerNo, + }); + body = ( + setWorkOrderValue((v) => ({ ...v, ...p }))} + /> + ); + if (canConfirmOps && unit.isCurrent) { + actions = ( + + ); + } + } 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 = ( + setDoValue((v) => ({ ...v, ...p }))} + /> + ); + if (canConfirmOps && unit.isCurrent) { + actions = ( + + ); + } + } else if (unit.uiKind === "customer_instruction") { + body = ( + + ); + } + + return ( + + {body} + {actions ? ( +
{actions}
+ ) : null} +
+ ); + })} + + ); +} +`; + +fs.writeFileSync(out, file, "utf8"); + +const check = fs.readFileSync(out, "utf8"); +const bad = [...check.matchAll(/\?{3,}/g)].filter((m) => { + const i = m.index || 0; + const line = check.slice(check.lastIndexOf("\n", i) + 1, check.indexOf("\n", i)); + return !line.includes('join("?")'); +}); +console.log( + JSON.stringify( + { + bytes: Buffer.byteLength(check, "utf8"), + hasTitle: check.includes(ZH.cardTitle), + hasFrom: check.includes(ZH.from), + hasBtnWo: check.includes(ZH.btnWo), + hasDearLogic: check.includes("currentWorkOrderUnit"), + leftoverQ: bad.length, + }, + null, + 2, + ), +); +if (!check.includes(ZH.cardTitle) || bad.length) process.exit(1); diff --git a/scripts/scan-mail-samples.ts b/scripts/scan-mail-samples.ts new file mode 100644 index 0000000..d170fd4 --- /dev/null +++ b/scripts/scan-mail-samples.ts @@ -0,0 +1,27 @@ +import fs from "fs"; +import path from "path"; + +const root = path.join(process.cwd(), "docx", "邮件"); + +type Entry = { rel: string; kind: "DIR" | "FILE"; size?: number }; + +function walk(dir: string, out: Entry[]) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + const rel = path.relative(root, full).split(path.sep).join("/"); + if (e.isDirectory()) { + out.push({ rel, kind: "DIR" }); + walk(full, out); + } else { + out.push({ rel, kind: "FILE", size: fs.statSync(full).size }); + } + } +} + +const out: Entry[] = []; +if (!fs.existsSync(root)) { + console.error("MISSING", root); + process.exit(1); +} +walk(root, out); +console.log(JSON.stringify({ root, count: out.length, entries: out }, null, 2)); diff --git a/scripts/verify-reimport-rules.ts b/scripts/verify-reimport-rules.ts new file mode 100644 index 0000000..719e9e3 --- /dev/null +++ b/scripts/verify-reimport-rules.ts @@ -0,0 +1,71 @@ +/** + * After purge+reimport: print rule-check summary for each mail. + */ +import { prisma } from "@/services/db"; +import { extractMailInstructions } from "@/services/parse/split-instructions"; +import { isInstructionNoiseRow } from "@/services/parse/packing-list"; + +async function main() { + const mails = await prisma.mailMessage.findMany({ + orderBy: { id: "asc" }, + include: { parseResult: true, attachments: true }, + }); + console.log(`\n=== reimport check: ${mails.length} mails ===\n`); + + for (const m of mails) { + const ships = (m.parseResult?.shipments as any[]) || []; + const invalid = ships.filter((s) => s.row_status === "INVALID"); + const noteLike = ships.filter( + (s) => + String(s.F_FBACode || "").includes("注意") || + String(s.F_Address || "").includes("注意") || + String(s.F_Transporter || "").includes("注意"), + ); + const units = extractMailInstructions({ + subject: m.subject, + body: m.bodyText || "", + filenames: m.attachments.map((a) => a.filename), + }); + const lineage = m.parseResult?.lineage as { mail_record?: { modules?: { customer_name?: string } } } | null; + const customer = + lineage?.mail_record?.modules?.customer_name || + (m.parseResult?.containerHeader as { F_MemoRemark?: string } | null) + ?.F_MemoRemark || + ""; + + console.log( + JSON.stringify( + { + id: String(m.id), + type: m.mailType, + status: m.status, + subject: m.subject.slice(0, 48), + body_chars: (m.bodyText || "").length, + att: m.attachments.length, + shipments: ships.length, + invalid: invalid.length, + note_rows_in_shipments: noteLike.length, + ui_kinds: units.map((u) => `${u.uiKind}${u.isCurrent ? "*" : ""}`), + customer_hint: String(customer).slice(0, 40), + url: `http://localhost:3100/mails/${m.id}`, + }, + null, + 2, + ), + ); + } + + // sanity: instruction noise helper still works + const noise = isInstructionNoiseRow({ + F_Transporter: "注意�?.ups和fedex Truck", + F_FBACode: "注意�?.ups", + }); + console.log("\nisInstructionNoiseRow smoke:", noise); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..e4266ca --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { App, Button, Card, Form, Input } from "antd"; +import { UI_COPY } from "@/constants/ui-copy"; +import { loginApi, meApi } from "@/lib/client-api"; + +export default function LoginPage() { + const router = useRouter(); + const { message } = App.useApp(); + const [loading, setLoading] = useState(false); + const [form] = Form.useForm<{ username: string; password: string }>(); + + useEffect(() => { + void meApi().then((res) => { + if (res.ok) router.replace("/mails"); + }); + }, [router]); + + const onFinish = async (values: { username: string; password: string }) => { + setLoading(true); + const res = await loginApi(values.username.trim(), values.password); + setLoading(false); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success(`登录成功(${res.data.role})`); + router.replace("/mails"); + }; + + return ( +
+ +

+ {UI_COPY.appTitle} +

+
+ + + + + + + +
+

+ admin/admin123(Admin)· ops/ops123(运营) +

+
+
+ ); +} diff --git a/src/app/(ops)/layout.tsx b/src/app/(ops)/layout.tsx new file mode 100644 index 0000000..ee5c584 --- /dev/null +++ b/src/app/(ops)/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { AuthGuard } from "@/components/AuthGuard"; +import { TopNav } from "@/components/TopNav"; + +export default function OpsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
{children}
+
+ ); +} diff --git a/src/app/(ops)/loading.tsx b/src/app/(ops)/loading.tsx new file mode 100644 index 0000000..90f03a7 --- /dev/null +++ b/src/app/(ops)/loading.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Spin } from "antd"; + +/** 路由切页即时反馈,避免白屏等待 chunk / 编译 */ +export default function OpsLoading() { + return ( +
+ +
+ ); +} diff --git a/src/app/(ops)/logs/page.tsx b/src/app/(ops)/logs/page.tsx new file mode 100644 index 0000000..775ff00 --- /dev/null +++ b/src/app/(ops)/logs/page.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + App, + Button, + Card, + Input, + Select, + Space, + Spin, + Table, + Tabs, + Tag, + Typography, +} from "antd"; +import { ImapPullLogsPanel } from "@/components/ImapPullLogsPanel"; +import { EmptyState } from "@/components/EmptyState"; +import { ErrorBanner } from "@/components/ErrorBanner"; +import { useAuthSession } from "@/components/providers/AuthSessionProvider"; +import { UI_COPY, IMPORT_STATUS_LABEL } from "@/constants/ui-copy"; +import { + listImportsApi, + retryCompensationApi, + retryImportApi, +} from "@/lib/client-api"; +import type { ContainerImport } from "@/types"; + +const IMPORT_STATUS_COLORS: Record = { + SUCCESS: "success", + FAILED: "error", + IMPORTING: "processing", + CONFLICT: "error", + TIMEOUT_UNKNOWN: "warning", + PENDING: "default", +}; + +function ImportLogsPanel() { + const search = useSearchParams(); + const mailId = search.get("mail_id") || undefined; + const { message } = App.useApp(); + const { user } = useAuthSession(); + const isAdmin = user.role === "admin"; + const [status, setStatus] = useState(""); + const [q, setQ] = useState(""); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [retrying, setRetrying] = useState(null); + + const reload = async () => { + setLoading(true); + const res = await listImportsApi({ + status: status || undefined, + mail_id: mailId, + q: q || undefined, + }); + if (res.ok) { + setItems(res.data.items); + setError(null); + } else { + setError(res.error.message); + } + setLoading(false); + }; + + useEffect(() => { + void reload(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status, mailId]); + + useEffect(() => { + if (error) message.error(error); + }, [error, message]); + + return ( + <> + + setQ(e.target.value)} + /> + + + + {error ? : null} + + {!loading && items.length === 0 ? ( + + ) : ( + new Date(v).toLocaleString("zh-CN"), + }, + { + title: "邮件主题", + dataIndex: "mail_subject", + ellipsis: true, + render: (subject: string, row) => ( + {subject} + ), + }, + { + title: "柜号", + dataIndex: "container_no", + width: 140, + render: (v: string) => {v}, + }, + { + title: "状态", + dataIndex: "status", + width: 120, + render: (v: string) => ( + + {IMPORT_STATUS_LABEL[v] || v} + + ), + }, + { + title: "external_id", + dataIndex: "external_id", + width: 160, + render: (v: string | null) => ( + {v || "-"} + ), + }, + { + title: "错误", + dataIndex: "last_error", + ellipsis: true, + render: (v: string | null) => ( + {v || "-"} + ), + }, + { + title: "操作", + width: 140, + render: (_, row) => { + const exhausted = row.compensations?.find( + (c) => c.status === "EXHAUSTED", + ); + const canRetry = [ + "FAILED", + "TIMEOUT_UNKNOWN", + "CONFLICT", + ].includes(row.status); + const key = exhausted ? `c:${exhausted.id}` : `i:${row.id}`; + + if (exhausted) { + if (!isAdmin) { + return ( + + 补偿已耗尽 + + ); + } + return ( + + ); + } + + if (!canRetry) return "-"; + return ( + + ); + }, + }, + ]} + /> + )} + + ); +} + +function LogsContent() { + const search = useSearchParams(); + const router = useRouter(); + const tabParam = search.get("tab"); + const tabFromUrl = tabParam === "pull" ? tabParam : "imports"; + const [tab, setTab] = useState(tabFromUrl); + + useEffect(() => { + setTab(tabFromUrl); + }, [tabFromUrl]); + + const onTabChange = (key: string) => { + setTab(key); + const next = new URLSearchParams(search.toString()); + if (key === "imports") next.delete("tab"); + else next.set("tab", key); + const s = next.toString(); + router.replace(s ? `/logs?${s}` : "/logs"); + }; + + return ( + +
+ + 日志 + + +
+ + + , + }, + { + key: "pull", + label: "拉取记录", + children: , + }, + ]} + /> + +
+ ); +} + +export default function LogsPage() { + return ( + + + + } + > + + + ); +} diff --git a/src/app/(ops)/mails/[id]/confirm/page.tsx b/src/app/(ops)/mails/[id]/confirm/page.tsx new file mode 100644 index 0000000..b2be144 --- /dev/null +++ b/src/app/(ops)/mails/[id]/confirm/page.tsx @@ -0,0 +1,562 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { + Alert, + App, + Button, + Checkbox, + Radio, + Space, + Spin, + Typography, +} from "antd"; +import { LeftOutlined } from "@ant-design/icons"; +import { + CcForecastFormOrder, + buildCcForecastFormValue, + type CcForecastFormValue, +} from "@/components/CcForecastFormOrder"; +import { ConfirmImportModal, ConflictModal } from "@/components/ConfirmImportModal"; +import { CcUiModeSwitch } from "@/components/CcUiModeSwitch"; +import { useAuthSession } from "@/components/providers/AuthSessionProvider"; +import { UI_COPY } from "@/constants/ui-copy"; +import { ccUiModeLabel } from "@/constants/cc-ui-mode"; +import { useImport } from "@/hooks/useImport"; +import { useMailDetail } from "@/hooks/useMailDetail"; +import { useCcUiMode } from "@/hooks/useCcUiMode"; +import { healthApi, listShippingLinesApi } from "@/lib/client-api"; +import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate"; +import { + matchShippingLineId, + shippingLineHintFromBl, +} from "@/services/parse/extract-container-header"; +import type { MailRecordModules } from "@/types/mail"; +import { isValidContainerNo } from "@/utils/iso6346"; + +export default function MailConfirmPage() { + const params = useParams<{ id: string }>(); + const id = params.id; + const router = useRouter(); + const { message } = App.useApp(); + const { user } = useAuthSession(); + const { mail, loading } = useMailDetail(id); + const { submitting, submit } = useImport(id); + const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode(); + + const header = mail?.parse_result?.container_header; + const shipments = useMemo( + () => mail?.parse_result?.shipments ?? [], + [mail?.parse_result?.shipments], + ); + const customerName = + mail?.parse_result?.mail_record?.modules?.customer_name || + ( + mail?.parse_result?.lineage?.mail_record as + | { modules?: { customer_name?: string } } + | undefined + )?.modules?.customer_name || + ""; + + const [form, setForm] = useState(() => + buildCcForecastFormValue({}), + ); + const [step, setStep] = useState(0); + const [selected, setSelected] = useState>(new Set()); + const [ackUnmapped, setAckUnmapped] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [conflictOpen, setConflictOpen] = useState(false); + const [conflictReason, setConflictReason] = useState("CONFLICT_CONTAINER"); + const [errors, setErrors] = useState>({}); + const [containerSource, setContainerSource] = useState< + "body" | "attachment" | null + >(null); + const [shippingLines, setShippingLines] = useState< + { value: string; label: string }[] + >([]); + /** 完整船司记录,用于提单前缀匹配 */ + const [shippingLineRows, setShippingLineRows] = useState< + Array<{ id: string; name: string; code?: string; prefix?: string }> + >([]); + const [shippingLineHint, setShippingLineHint] = useState( + null, + ); + const [shippingLineUnmatched, setShippingLineUnmatched] = useState(false); + const isAdmin = user.role === "admin"; + const [forceImportEnabled, setForceImportEnabled] = useState(false); + + const softLimitExceeded = Boolean( + mail?.parse_result?.lineage?.soft_limit_exceeded, + ); + const containerConflict = mail?.parse_result?.lineage?.CONTAINER_NO_CONFLICT as + | { body?: string; attachment?: string } + | undefined; + const hasContainerConflict = Boolean( + containerConflict?.body && containerConflict?.attachment, + ); + + useEffect(() => { + void healthApi().then((healthRes) => { + if (healthRes.ok) { + setForceImportEnabled(Boolean(healthRes.data.enable_force_import)); + } + }); + }, []); + + useEffect(() => { + void listShippingLinesApi().then((res) => { + if (res.ok) { + setShippingLineRows(res.data.items); + setShippingLines( + res.data.items.map((item) => ({ value: item.id, label: item.name })), + ); + } + }); + }, []); + + // 提单前缀 → 船司列表自动选择(V1.72 F_Prefix / ShippingLineCode) + useEffect(() => { + if (!form.F_BLCopyCode || !shippingLineRows.length) { + setShippingLineUnmatched(false); + return; + } + const hint = shippingLineHintFromBl(form.F_BLCopyCode); + setShippingLineHint(hint); + if (form.F_ShippingLineId) { + setShippingLineUnmatched(false); + return; + } + if (!hint) { + setShippingLineUnmatched(false); + return; + } + const id = matchShippingLineId( + hint, + shippingLineRows.map((l) => ({ + F_Id: l.id, + F_FullName: l.name, + F_EnCode: l.code, + F_Prefix: l.prefix, + })), + { blNo: form.F_BLCopyCode }, + ); + if (id) { + setShippingLineUnmatched(false); + setForm((p) => + p.F_ShippingLineId === id ? p : { ...p, F_ShippingLineId: id }, + ); + } else { + setShippingLineUnmatched(true); + } + }, [form.F_BLCopyCode, form.F_ShippingLineId, shippingLineRows]); + + useEffect(() => { + if (!mail) return; + const record = + mail.parse_result?.mail_record || + ( + mail.parse_result?.lineage as + | { mail_record?: { modules?: MailRecordModules } } + | undefined + )?.mail_record; + setForm( + buildCcForecastFormValue({ + customerName, + header: mail.parse_result?.container_header, + modules: record?.modules, + }), + ); + }, [mail, customerName]); + + const validRows = useMemo( + () => shipments.filter((s) => s.row_status === "VALID"), + [shipments], + ); + const validRowKey = useMemo( + () => validRows.map((r) => r.row_index).join(","), + [validRows], + ); + + useEffect(() => { + if (softLimitExceeded) { + setSelected((prev) => (prev.size === 0 ? prev : new Set())); + return; + } + setSelected((prev) => { + const nextIds = validRowKey + ? validRowKey.split(",").map((x) => Number(x)) + : []; + if ( + prev.size === nextIds.length && + nextIds.every((id) => prev.has(id)) + ) { + return prev; + } + return new Set(nextIds); + }); + }, [mail?.id, validRowKey, softLimitExceeded]); + + useEffect(() => { + setContainerSource(null); + setStep(0); + }, [mail?.id]); + + useEffect(() => { + if (!hasContainerConflict || !containerConflict || !containerSource) return; + const next = + containerSource === "body" + ? containerConflict.body || "" + : containerConflict.attachment || ""; + const upper = next.toUpperCase(); + setForm((p) => + p.F_ContainerNo === upper ? p : { ...p, F_ContainerNo: upper }, + ); + }, [ + containerSource, + hasContainerConflict, + containerConflict?.body, + containerConflict?.attachment, + ]); + + useEffect(() => { + if (!mail || loading) return; + const valid = + mail.parse_result?.shipments.filter((s) => s.row_status === "VALID") + .length || 0; + const allowed = canEnterForecastConfirm({ + mailType: mail.mail_type, + status: mail.status, + validShipmentCount: valid, + allowReadonlyStatuses: true, + }); + + if (!allowed) { + message.warning(UI_COPY.confirmNotAllowed); + router.replace(`/mails/${id}`); + } + }, [mail, loading, id, router, message]); + + const selectedValid = Array.from(selected).filter((idx) => + validRows.some((r) => r.row_index === idx), + ); + + const hasUnmappedSelected = selectedValid.some((idx) => { + const row = shipments.find((s) => s.row_index === idx); + return row?.warnings?.includes("CHANNEL_UNMAPPED"); + }); + + const pageReadonly = + mail?.status === "IMPORTING" || mail?.status === "SUCCESS"; + + const validateStep1 = () => { + const next: Record = {}; + const { F_TransMode, F_OperationType, F_ContainerNo, F_CabinetType } = form; + if (F_TransMode !== 0 && F_TransMode !== 1 && F_TransMode !== 3) { + next.transMode = "请选择运输方式"; + } + if ( + F_OperationType !== 0 && + F_OperationType !== 2 && + F_OperationType !== 4 + ) { + next.operationType = "请选择业务类型"; + } + if (hasContainerConflict && !containerSource) { + next.containerSource = "请选定正文或附件柜号"; + } + if (!F_ContainerNo.trim()) next.containerNo = "柜号必填"; + else if (!isValidContainerNo(F_ContainerNo)) + next.containerNo = "柜号不符合 ISO 6346 格式"; + if (!F_CabinetType.trim()) next.cabinetType = "请选择柜型"; + setErrors(next); + return Object.keys(next).length === 0; + }; + + const validateStep2 = () => { + const next: Record = {}; + if (selectedValid.length === 0) next.rows = "请至少勾选一行有效货件"; + if (hasUnmappedSelected && !ackUnmapped) + next.ack = "请勾选已知晓未映射渠道"; + setErrors((p) => ({ ...p, ...next })); + return Object.keys(next).length === 0; + }; + + const onStepChange = (s: number) => { + if (pageReadonly) { + setStep(s); + return; + } + if (s === 1 && !validateStep1()) { + message.error("请先修正基础信息"); + return; + } + setStep(s); + }; + + const onPrimary = () => { + if (pageReadonly || !mail) return; + if (!validateStep1() || !validateStep2()) { + message.error("请先修正表单校验错误"); + return; + } + setConfirmOpen(true); + }; + + const onConfirmSubmit = async (forceSkip = false) => { + if (!mail) return; + const containerNo = form.F_ContainerNo.toUpperCase(); + const basePayload = { + // 服务端会以库内 version 对齐;此处仍传当前页 version + version: mail.version, + idempotency_key: `${mail.id}:${containerNo}:${selectedValid.join(",")}${forceSkip ? ":force" : ""}`, + F_TransMode: form.F_TransMode, + F_OperationType: form.F_OperationType, + container_header: { + F_ContainerNo: containerNo, + F_CabinetType: form.F_CabinetType, + F_Classis: form.F_Classis, + F_ETA: form.F_ETA || null, + F_ETD: form.F_ETD || null, + F_LoadPort: form.F_LoadPort, + F_Dock: form.F_Dock, + F_BLCopyCode: form.F_BLCopyCode, + F_MemoRemark: form.F_MemoRemark, + F_Instruction: form.F_Instruction, + F_ShippingLineId: form.F_ShippingLineId, + }, + selected_row_indexes: selectedValid, + ack_unmapped_channels: ackUnmapped, + ...(forceSkip ? { force_skip_conflict: true } : {}), + }; + + const res = await submit(basePayload); + + if (!res.ok) { + if ( + res.error.code === "CONFLICT_CONTAINER" || + res.error.code === "CONFLICT_CHECK_FAILED" + ) { + setConfirmOpen(false); + setConflictReason(res.error.code); + setConflictOpen(true); + return; + } + message.error(res.error.message); + setConfirmOpen(false); + setConflictOpen(false); + return; + } + + setConfirmOpen(false); + setConflictOpen(false); + if (res.data.mail_status === "SUCCESS") { + message.success("导入成功"); + router.push(`/logs?mail_id=${mail.id}`); + } else if (res.data.mail_status === "PARTIAL_SUCCESS") { + message.warning("部分成功,请查看导入日志"); + router.push(`/logs?mail_id=${mail.id}`); + } else { + // 业务失败(如 CC 校验)— 展示后端写入的具体原因 + const importErr = + res.data.imports?.[0]?.last_error || + mail.last_error || + "导入失败,可修改后重试"; + message.error(importErr); + router.refresh(); + } + }; + + const canForceSkip = + isAdmin && + forceImportEnabled && + (conflictReason === "CONFLICT_CONTAINER" || + conflictReason === "CONFLICT_CHECK_FAILED"); + + if (loading || !mail) { + return ( +
+ +
+ +
+ ); + } + + if ( + mail.mail_type !== "NEW_CONTAINER" && + mail.mail_type !== "DO_UPLOAD" + ) { + return null; + } + + return ( + + + + + + {mail.subject} + + + {form.F_ContainerNo || "-"} + + + + + + {ccUiModeLabel(ccUiMode)} · SaveContainer + + + + + {mail.status === "IMPORTING" ? ( + + ) : null} + + {shippingLineUnmatched && shippingLineHint ? ( + + ) : null} + + {softLimitExceeded ? ( + + ) : null} + + {hasContainerConflict ? ( + setContainerSource(e.target.value)} + style={{ marginTop: 8 }} + > + + + 正文柜号: + {containerConflict?.body} + + + 附件柜号: + {containerConflict?.attachment} + + + + } + /> + ) : null} + {errors.containerSource ? ( + + {errors.containerSource} + + ) : null} + + setForm((p) => ({ ...p, ...patch }))} + errors={errors} + step={step} + onStepChange={onStepChange} + softLimitExceeded={softLimitExceeded} + containerNoLocked={hasContainerConflict} + onFinish={onPrimary} + finishLoading={submitting} + finishDisabled={ + selectedValid.length === 0 || + (hasContainerConflict && !containerSource) + } + /> + + {hasUnmappedSelected ? ( +
+ setAckUnmapped(e.target.checked)} + > + {UI_COPY.ackUnmapped} + + {errors.ack ? ( +
+ {errors.ack} +
+ ) : null} +
+ ) : null} + {errors.rows ? ( +
+ {errors.rows} +
+ ) : null} + +
+ + 已选 {selectedValid.length} / 有效 {validRows.length} + + +
+ + setConfirmOpen(false)} + onConfirm={() => void onConfirmSubmit(false)} + /> + setConflictOpen(false)} + onForceSkip={() => void onConfirmSubmit(true)} + /> +
+ ); +} diff --git a/src/app/(ops)/mails/[id]/page.tsx b/src/app/(ops)/mails/[id]/page.tsx new file mode 100644 index 0000000..20acbe0 --- /dev/null +++ b/src/app/(ops)/mails/[id]/page.tsx @@ -0,0 +1,509 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { + Alert, + App, + Button, + Card, + Collapse, + Space, + Spin, + Table, + Typography, +} from "antd"; +import { LeftOutlined } from "@ant-design/icons"; +import { ErrorBanner } from "@/components/ErrorBanner"; +import { MailBusinessSummary } from "@/components/MailBusinessSummary"; +import { CcUiModeSwitch } from "@/components/CcUiModeSwitch"; +import { StatusPill } from "@/components/StatusPill"; +import { TypeOverrideModal } from "@/components/TypeOverrideModal"; +import { TypeTag } from "@/components/TypeTag"; +import { useAuthSession } from "@/components/providers/AuthSessionProvider"; +import { UI_COPY, formatMailLastError } from "@/constants/ui-copy"; +import { useMailDetail } from "@/hooks/useMailDetail"; +import { useCcUiMode } from "@/hooks/useCcUiMode"; +import { + healthApi, + ignoreMailApi, + overrideTypeApi, + reparseMailApi, +} from "@/lib/client-api"; +import { buildMailIntent } from "@/services/parse/mail-intent"; +import { sanitizeOcrDisplayText } from "@/services/ocr"; +import { canTransition } from "@/services/state-machine"; +import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate"; +import type { MailType } from "@/types/mail"; + +export default function MailDetailPage() { + const params = useParams<{ id: string }>(); + const id = params.id; + const router = useRouter(); + const { message, modal } = App.useApp(); + const { user } = useAuthSession(); + const { mail, loading, error, reload } = useMailDetail(id); + const { mode: ccUiMode, setMode: setCcUiMode } = useCcUiMode(); + + const [reparseLoading, setReparseLoading] = useState(false); + const [ignoreLoading, setIgnoreLoading] = useState(false); + const [overrideOpen, setOverrideOpen] = useState(false); + const [overrideLoading, setOverrideLoading] = useState(false); + const [enableOverride, setEnableOverride] = useState(false); + const isAdmin = user.role === "admin"; + + useEffect(() => { + void healthApi().then((res) => { + if (res.ok) setEnableOverride(Boolean(res.data.enable_type_override)); + }); + }, []); + + const validCount = + mail?.parse_result?.shipments.filter((s) => s.row_status === "VALID") + .length || 0; + + const canImport = mail + ? canEnterForecastConfirm({ + mailType: mail.mail_type, + status: mail.status, + validShipmentCount: validCount, + }) + : false; + + const disableReason = useMemo(() => { + if (!mail) return ""; + if (canImport) return ""; + if (mail.mail_type === "WORK_ORDER" || mail.mail_type === "TRANSFER") { + return ""; + } + if (mail.mail_type === "DO_UPLOAD" && validCount < 1) { + return "本封无有效货件行,仅可确认上传 DO"; + } + if ( + mail.mail_type !== "NEW_CONTAINER" && + mail.mail_type !== "DO_UPLOAD" + ) { + return UI_COPY.disableNotNew(mail.mail_type); + } + if (mail.status !== "PENDING_CONFIRM" && mail.status !== "FAILED") { + return UI_COPY.disableBadStatus(mail.status); + } + if (validCount < 1) return UI_COPY.disableNoRows; + return ""; + }, [mail, validCount, canImport]); + + const showReparse = mail + ? [ + "PARSE_FAILED", + "REJECTED_VALIDATION", + "PARSED", + "PENDING_CONFIRM", + "FAILED", + ].includes(mail.status) + : false; + + const showIgnore = + isAdmin && mail ? canTransition(mail.status, "IGNORED") : false; + + if (loading) { + return ( +
+ +
+ +
+ ); + } + + if (error || !mail) { + return ( +
+ {UI_COPY.mailNotFound} + +
+ ); + } + + const previewRows = (mail.parse_result?.shipments || []).slice(0, 50); + const ocrDisplay = sanitizeOcrDisplayText(mail.ocr_text || ""); + const intent = + mail.type_evidence?.intent || + buildMailIntent({ + mailType: mail.mail_type, + subject: mail.subject, + body: mail.body_text || mail.ocr_text || "", + filenames: mail.attachments.map((a) => a.filename), + shipments: mail.parse_result?.shipments, + evidence: mail.type_evidence, + containerNo: + mail.parse_result?.container_header?.F_ContainerNo || mail.container_no, + }); + + return ( +
+
+ + +
+
+ + +
+ + {(() => { + const cn = + mail.parse_result?.container_header?.F_ContainerNo || + mail.container_no || + ""; + const bl = mail.parse_result?.container_header?.F_BLCopyCode || ""; + const cust = + mail.parse_result?.mail_record?.modules?.customer_name || ""; + const bits = [cust, cn, bl].filter(Boolean); + if (bits.length >= 2) return bits.join(" · "); + if (cn) return cn; + return mail.subject.length > 48 + ? `${mail.subject.slice(0, 48)}…` + : mail.subject; + })()} + +
+ {mail.subject} +
+
+ {mail.from_addr} + {mail.ingest_source_label ? ( + {mail.ingest_source_label} + ) : null} + {new Date(mail.received_at).toLocaleString("zh-CN")} + #{mail.id} +
+
+ +
+ + + {showReparse ? ( + + ) : null} + {isAdmin && enableOverride ? ( + + ) : null} + {showIgnore ? ( + + ) : null} + + + {!canImport && disableReason ? ( +
{disableReason}
+ ) : null} +
+
+ + {mail.status === "IMPORTING" ? ( + + ) : null} + + {mail.last_error ? ( + + ) : null} + + void reload()} + /> + + + {mail.attachments.length === 0 ? ( + 无附件 + ) : ( +
`${Math.round(v / 1024)} KB`, + }, + { + title: "操作", + width: 72, + render: (_, row) => ( + + ), + }, + ]} + /> + )} + + + {(mail.parse_result?.shipments.length || 0) > 0 && + !(mail.parse_result?.mail_record?.table?.rows?.length || + (mail.parse_result?.lineage as { mail_record?: { table?: { rows?: unknown[] } } } | undefined) + ?.mail_record?.table?.rows?.length) ? ( + + 共 {mail.parse_result?.shipments.length || 0} 行 + {canImport ? ( + <> + {" · "} + + + ) : null} + + } + > +
+ row.row_status === "INVALID" ? "shipment-row-invalid" : "" + } + columns={[ + { title: "#", dataIndex: "row_index", width: 48 }, + { + title: "仓库", + dataIndex: "F_FBACode", + width: 88, + render: (v) => v || "—", + }, + { + title: "渠道", + dataIndex: "F_Transporter", + width: 88, + render: (v) => v || "—", + }, + { + title: "件数", + dataIndex: "F_CTNS", + width: 64, + render: (v) => v ?? "—", + }, + { + title: "FBA", + dataIndex: "F_FBAID", + ellipsis: true, + render: (v) => {v || "—"}, + }, + { + title: "Shipment", + dataIndex: "F_ShipmentID", + ellipsis: true, + render: (v) => {v || "—"}, + }, + { title: "状态", dataIndex: "row_status", width: 72 }, + ]} + /> + + ) : null} + +
+ +
+                    {(mail.body_text || "").trim()
+                      ? mail.body_text
+                      : "(无正文)"}
+                  
+ {ocrDisplay ? ( + <> + + OCR 文本 + +
+                        {ocrDisplay}
+                      
+ + ) : null} +
+ ), + }, + ]} + /> + + + setOverrideOpen(false)} + onSubmit={async (values) => { + setOverrideLoading(true); + const res = await overrideTypeApi( + mail.id, + values.mail_type as MailType, + values.reason.trim(), + mail.version, + ); + setOverrideLoading(false); + if (res.ok) { + message.success("改类型成功"); + setOverrideOpen(false); + reload(); + } else { + message.error(res.error.message); + } + }} + /> + + ); +} diff --git a/src/app/(ops)/mails/page.tsx b/src/app/(ops)/mails/page.tsx new file mode 100644 index 0000000..20d0382 --- /dev/null +++ b/src/app/(ops)/mails/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { App, Button, Card, Input, Select, Space, Typography } from "antd"; +import { ErrorBanner } from "@/components/ErrorBanner"; +import { EmptyState } from "@/components/EmptyState"; +import { MailTable } from "@/components/MailTable"; +import { UI_COPY, MAIL_STATUS_LABEL, MAIL_TYPE_LABEL } from "@/constants/ui-copy"; +import { useMails } from "@/hooks/useMails"; + +export default function MailsPage() { + const { message } = App.useApp(); + const [mailType, setMailType] = useState(""); + const [status, setStatus] = useState(""); + const [q, setQ] = useState(""); + const [qDebounced, setQDebounced] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + + useEffect(() => { + const id = window.setTimeout(() => setQDebounced(q), 300); + return () => window.clearTimeout(id); + }, [q]); + + useEffect(() => { + setPage(1); + }, [mailType, status, qDebounced]); + + const { items, total, loading, error, reload } = useMails({ + mail_type: mailType || undefined, + status: status || undefined, + q: qDebounced || undefined, + page, + pageSize, + }); + + useEffect(() => { + if (error) message.error(error); + }, [error, message]); + + return ( + +
+ + 邮件预报 + + +
+ + + + setStatus(v || "")} + options={Object.entries(MAIL_STATUS_LABEL).map(([value, label]) => ({ + value, + label, + }))} + /> + setQ(e.target.value)} + allowClear + /> + + + + {error ? : null} + + {!loading && items.length === 0 ? ( + reload()} + /> + ) : ( + { + setPage(p); + setPageSize(ps); + }} + /> + )} + +
+ ); +} diff --git a/src/app/(ops)/settings/page.tsx b/src/app/(ops)/settings/page.tsx new file mode 100644 index 0000000..e8d2459 --- /dev/null +++ b/src/app/(ops)/settings/page.tsx @@ -0,0 +1,560 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + Alert, + App, + Button, + Card, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Space, + Switch, + Table, + Tag, + Typography, +} from "antd"; +import { ErrorBanner } from "@/components/ErrorBanner"; +import { CcSettingsCard } from "@/components/CcSettingsCard"; +import { ImapPullSettingsCard } from "@/components/ImapPullSettingsCard"; +import { MailboxBindHelpIcon } from "@/components/MailboxBindHelp"; +import { OauthSettingsButton } from "@/components/OauthSettingsCard"; +import { useAuthSession } from "@/components/providers/AuthSessionProvider"; +import { UI_COPY } from "@/constants/ui-copy"; +import { + createMailboxApi, + deleteMailboxApi, + listMailboxesApi, + listOAuthProvidersApi, + probeMailboxApi, + startOAuthApi, + testMailboxApi, + updateMailboxApi, +} from "@/lib/client-api"; +import type { MailboxAccount } from "@/types"; +import { inferImapPreset } from "@/lib/imap-presets"; +import { useRouter } from "next/navigation"; + +type FormValues = { + name: string; + username: string; + password?: string; + folder: string; + enabled: boolean; + idle_enabled: boolean; + /** 仅未识别域名时使用 */ + host?: string; + port?: number; +}; + +export default function SettingsPage() { + const { message } = App.useApp(); + const router = useRouter(); + const { user } = useAuthSession(); + const [allowed, setAllowed] = useState(null); + const [items, setItems] = useState([]); + const [oauthProviders, setOauthProviders] = useState< + Array<{ id: string; label: string; configured: boolean }> + >([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + const [probing, setProbing] = useState(false); + const [testingId, setTestingId] = useState(null); + const [form] = Form.useForm(); + const watchedUsername = Form.useWatch("username", form); + const detectedPreset = inferImapPreset(watchedUsername || ""); + + const reload = useCallback(async () => { + setLoading(true); + const [res, oauth] = await Promise.all([ + listMailboxesApi(), + listOAuthProvidersApi(), + ]); + setLoading(false); + if (!res.ok) { + setError(res.error.message); + return; + } + setError(null); + setItems(res.data.items); + if (oauth.ok) setOauthProviders(oauth.data.items); + }, []); + + useEffect(() => { + const oauth = new URLSearchParams(window.location.search).get("oauth"); + if (oauth?.startsWith("ok:")) message.success("OAuth 邮箱已绑定"); + else if (oauth?.startsWith("error:")) + message.error(`OAuth 失败:${decodeURIComponent(oauth.slice(6))}`); + }, [message]); + useEffect(() => { + if (user.role !== "admin") { + setAllowed(false); + router.replace("/mails"); + return; + } + setAllowed(true); + void reload(); + }, [reload, router, user.role]); + + const openCreate = () => { + setEditing(null); + setModalOpen(true); + }; + + const openEdit = (row: MailboxAccount) => { + setEditing(row); + setModalOpen(true); + }; + + const fillMailboxForm = (open: boolean) => { + if (!open) { + form.resetFields(); + return; + } + if (editing) { + form.setFieldsValue({ + name: editing.name, + username: editing.username, + password: undefined, + folder: editing.folder, + enabled: editing.enabled, + idle_enabled: editing.idle_enabled !== false, + host: editing.host, + port: editing.port, + }); + } else { + form.setFieldsValue({ + name: "", + username: "", + password: "", + folder: "INBOX", + enabled: true, + idle_enabled: true, + host: undefined, + port: 993, + }); + } + }; + + const buildEndpointPayload = (values: FormValues) => { + const preset = inferImapPreset(values.username); + if (preset) { + return { host: undefined as string | undefined, port: undefined as number | undefined }; + } + return { + host: values.host?.trim() || undefined, + port: values.port, + }; + }; + + const onProbe = async () => { + const values = await form.validateFields([ + "username", + ...(editing ? [] : (["password"] as const)), + ...(!inferImapPreset(form.getFieldValue("username") || "") + ? (["host", "port"] as const) + : []), + ]); + if (!editing && !values.password) { + message.error("请填写授权码/应用专用密码"); + return; + } + setProbing(true); + try { + const endpoint = buildEndpointPayload(values); + const res = await probeMailboxApi({ + username: values.username, + password: values.password, + folder: form.getFieldValue("folder") || "INBOX", + mailbox_id: editing?.id, + ...endpoint, + }); + if (!res.ok) { + message.error(`连通失败:${res.error.message}`); + return; + } + message.success( + `连通成功(${res.data.host}:${res.data.port}),未读 ${res.data.unseen} 封。尚未保存,请点「保存并验证」。`, + ); + } finally { + setProbing(false); + } + }; + + const onSave = async () => { + const values = await form.validateFields(); + setSaving(true); + try { + const endpoint = buildEndpointPayload(values); + if (editing) { + const res = await updateMailboxApi(editing.id, { + name: values.name, + username: values.username, + folder: values.folder, + enabled: values.enabled, + idle_enabled: values.idle_enabled, + verify: true, + ...(values.password ? { password: values.password } : {}), + ...endpoint, + }); + if (!res.ok) { + message.error(res.error.message); + await reload(); + return; + } + message.success( + `已保存且连通验证通过,未读 ${res.data.unseen ?? "—"} 封`, + ); + } else { + if (!values.password) { + message.error("请填写授权码/应用专用密码"); + return; + } + const res = await createMailboxApi({ + name: values.name, + username: values.username, + password: values.password, + folder: values.folder, + enabled: values.enabled, + idle_enabled: values.idle_enabled, + verify: true, + ...endpoint, + }); + if (!res.ok) { + message.error(res.error.message); + await reload(); + return; + } + message.success( + `绑定成功且连通验证通过,未读 ${res.data.unseen ?? "—"} 封`, + ); + } + setModalOpen(false); + await reload(); + } finally { + setSaving(false); + } + }; + + const onToggle = async (row: MailboxAccount, enabled: boolean) => { + const res = await updateMailboxApi(row.id, { enabled }); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success(enabled ? "已启用" : "已停用"); + await reload(); + }; + + const onTest = async (row: MailboxAccount) => { + setTestingId(row.id); + try { + const res = await testMailboxApi(row.id); + if (!res.ok) { + message.error(res.error.message); + await reload(); + return; + } + message.success(`连通成功,未读 ${res.data.unseen} 封`); + await reload(); + } finally { + setTestingId(null); + } + }; + + const onDelete = async (row: MailboxAccount) => { + const res = await deleteMailboxApi(row.id); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success("已删除"); + await reload(); + }; + + const onOAuth = async (provider: string) => { + const res = await startOAuthApi({ provider }); + if (!res.ok) { + message.error(res.error.message); + return; + } + window.location.href = res.data.auth_url; + }; + + const onToggleIdle = async (row: MailboxAccount, idle_enabled: boolean) => { + const res = await updateMailboxApi(row.id, { idle_enabled }); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success(idle_enabled ? "已开 IDLE" : "已关 IDLE(仅轮询)"); + await reload(); + }; + + if (allowed === null) { + return {UI_COPY.authChecking}; + } + if (!allowed) return null; + + return ( + +
+ + {UI_COPY.navSettings} + +
+ + {error ? : null} + + + + + + void reload()} /> + {oauthProviders.map((p) => ( + + ))} + +
+ } + > +
( + {r.auth_type === "OAUTH" ? `OAuth/${r.oauth_provider || "?"}` : "授权码"} + ), + }, + { + title: "服务器", + render: (_, r) => `${r.host}:${r.port}/${r.folder}`, + }, + { + title: "启用", + width: 80, + render: (_, r) => ( + void onToggle(r, v)} + /> + ), + }, + { + title: "IDLE", + width: 80, + render: (_, r) => ( + void onToggleIdle(r, v)} + /> + ), + }, + { + title: "最近测试", + width: 160, + ellipsis: true, + render: (_, r) => { + if (r.last_test_ok == null) return —; + return r.last_test_ok ? ( + 成功 + ) : ( + + 失败 + {r.last_test_error + ? `:${r.last_test_error.slice(0, 40)}` + : ""} + + ); + }, + }, + { + title: "操作", + width: 260, + render: (_, r) => ( + + + + void onDelete(r)} + > + + + + ), + }, + ]} + /> + + + setModalOpen(false)} + destroyOnClose + afterOpenChange={fillMailboxForm} + width={560} + footer={[ + , + , + , + ]} + > + +
+ + + + + + + + + {detectedPreset ? ( + + {detectedPreset.label} + + + {detectedPreset.host}:{detectedPreset.port} + + + {detectedPreset.hint} + + + ) : watchedUsername?.includes("@") ? ( + + + 未识别该邮箱类型,请填写下方 IMAP(少见域名) + + + + ) : ( + + 输入邮箱后自动显示主机;点感叹号查看绑定步骤 + + )} + + + {!detectedPreset && watchedUsername?.includes("@") ? ( + + + + + + + + + ) : null} + + + + {editing ? "授权码(留空则不改)" : "授权码 / 应用专用密码"} + + + + } + rules={editing ? [] : [{ required: true, message: "必填" }]} + extra="QQ/163 填授权码;Gmail 填应用专用密码(带空格也可)。点感叹号看步骤。" + > + + + + + + + + + + + + +
+ + ); +} diff --git a/src/app/api/admin/imap/poll/route.ts b/src/app/api/admin/imap/poll/route.ts new file mode 100644 index 0000000..c7e032d --- /dev/null +++ b/src/app/api/admin/imap/poll/route.ts @@ -0,0 +1,40 @@ +import { fail, ok, requireAdmin } from "@/lib/api"; +import { hasConfiguredMailbox } from "@/services/imap/mailbox-config"; +import { ImapPoller } from "@/services/imap/poller"; +import { readImapRuntimeStatus } from "@/services/imap/runtime-status"; + +/** Admin 手动触发一轮 IMAP 拉取(与 worker 共用 GET_LOCK) */ +export async function POST() { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + if (!(await hasConfiguredMailbox())) { + return fail( + "IMAP_NOT_CONFIGURED", + "请先在「设置 → 邮箱绑定」配置 IMAP,或填写 .env 回退凭证", + 400, + ); + } + + try { + // Admin 手动拉取:等最多 20s,避开与 worker 撞锁 + const result = await ImapPoller.create().tick(20); + const runtime = await readImapRuntimeStatus(); + return ok({ + ...result, + consecutive_fail: runtime.consecutiveFail, + last_error: runtime.lastError, + }); + } catch (err) { + const runtime = await readImapRuntimeStatus(); + return fail( + "IMAP_POLL_FAILED", + err instanceof Error ? err.message : "IMAP poll failed", + 502, + { + consecutive_fail: runtime.consecutiveFail, + last_error: runtime.lastError, + }, + ); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..4c71625 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { fail, ok } from "@/lib/api"; +import { checkSlidingWindow } from "@/lib/rate-limit"; +import { getSession, verifyCredentials } from "@/lib/session"; + +const bodySchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +const LOGIN_LIMIT = 10; +const LOGIN_WINDOW_MS = 60_000; + +function clientKey(req: Request, username: string): string { + const xf = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(); + const ip = xf || req.headers.get("x-real-ip") || "local"; + return `login:${ip}:${username.toLowerCase()}`; +} + +export async function POST(req: Request) { + let json: unknown; + try { + json = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON body", 400); + } + + const parsed = bodySchema.safeParse(json); + if (!parsed.success) { + return fail("VALIDATION", "username and password required", 400); + } + + const rateKey = clientKey(req, parsed.data.username); + if (!checkSlidingWindow(rateKey, LOGIN_LIMIT, LOGIN_WINDOW_MS)) { + return fail("RATE_LIMITED", "Too many login attempts, try again later", 429); + } + + const cred = verifyCredentials(parsed.data.username, parsed.data.password); + if (!cred) { + return fail("INVALID_CREDENTIALS", "Invalid username or password", 401); + } + + const session = await getSession(); + session.username = cred.username; + session.role = cred.role; + await session.save(); + + return ok({ username: cred.username, role: cred.role }); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..560ae18 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,8 @@ +import { ok } from "@/lib/api"; +import { getSession } from "@/lib/session"; + +export async function POST() { + const session = await getSession(); + session.destroy(); + return ok({ loggedOut: true }); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..1932bb4 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,10 @@ +import { fail, ok, requireSession } from "@/lib/api"; + +export async function GET() { + const guard = await requireSession(); + if (guard.response) return guard.response; + return ok({ + username: guard.session.username, + role: guard.session.role, + }); +} diff --git a/src/app/api/compensations/[id]/retry/route.ts b/src/app/api/compensations/[id]/retry/route.ts new file mode 100644 index 0000000..a63c7df --- /dev/null +++ b/src/app/api/compensations/[id]/retry/route.ts @@ -0,0 +1,42 @@ +import { prisma } from "@/services/db"; +import { + ImportRetryError, + retryCompensation, +} from "@/services/import/compensation"; +import { fail, ok, parseBigIntId, requireAdmin, requireSession } from "@/lib/api"; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function POST(_req: Request, ctx: RouteContext) { + const sessionGuard = await requireSession(); + if (sessionGuard.response) return sessionGuard.response; + + const { id } = await ctx.params; + const compensationId = parseBigIntId(id); + if (!compensationId) return fail("VALIDATION", "Invalid compensation id", 400); + + const comp = await prisma.importCompensation.findUnique({ + where: { id: compensationId }, + }); + + let adminReset = false; + if (comp?.status === "EXHAUSTED") { + const adminGuard = await requireAdmin(); + if (adminGuard.response) return adminGuard.response; + adminReset = true; + } + + try { + const result = await retryCompensation({ + compensationId, + actor: sessionGuard.session.username!, + adminReset, + }); + return ok(result); + } catch (err) { + if (err instanceof ImportRetryError) { + return fail(err.code, err.message, err.status, err.details); + } + throw err; + } +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..c333530 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,55 @@ +import { ok } from "@/lib/api"; +import { getEnv } from "@/lib/env"; +import { getCcModeStatus } from "@/services/cc/mode"; +import { hasConfiguredMailbox } from "@/services/imap/mailbox-config"; +import { getImapLockStatus } from "@/services/imap/poller"; +import { + isImapSyncAlert, + readImapRuntimeStatus, +} from "@/services/imap/runtime-status"; +import { prisma } from "@/services/db"; + +export async function GET() { + const env = getEnv(); + const [ + mysql, + imapLock, + configured, + runtime, + mailboxCount, + cc, + ] = await Promise.all([ + prisma + .$queryRaw`SELECT 1` + .then(() => "ok" as const) + .catch(() => "fail" as const), + getImapLockStatus() + .then((v) => v ?? undefined) + .catch(() => undefined), + hasConfiguredMailbox().catch(() => false), + readImapRuntimeStatus(), + prisma.mailboxAccount + .count({ where: { enabled: true } }) + .catch(() => 0), + getCcModeStatus(), + ]); + + const syncAlert = configured && isImapSyncAlert(runtime); + + return ok({ + mysql, + imap_lock: imapLock, + imap_configured: configured, + imap_sync_alert: syncAlert, + imap_consecutive_fail: runtime.consecutiveFail, + imap_last_error: runtime.lastError, + imap_last_success_at: runtime.lastSuccessAt, + imap_mailbox_count: mailboxCount, + cc_mode: cc.mode, + cc_credentials_ready: cc.credentials_ready, + cc_live_ready: cc.live_ready, + cc_reason: cc.reason, + enable_force_import: env.ENABLE_FORCE_IMPORT, + enable_type_override: env.ENABLE_TYPE_OVERRIDE, + }); +} diff --git a/src/app/api/imap-pull-logs/route.ts b/src/app/api/imap-pull-logs/route.ts new file mode 100644 index 0000000..eff4949 --- /dev/null +++ b/src/app/api/imap-pull-logs/route.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { fail, ok, requireSession } from "@/lib/api"; +import { prisma } from "@/services/db"; + +const QuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(50), + result: z.enum(["pulled", "skipped", "error"]).optional(), +}); + +export async function GET(req: Request) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const url = new URL(req.url); + const parsed = QuerySchema.safeParse(Object.fromEntries(url.searchParams)); + if (!parsed.success) { + return fail("VALIDATION", "Invalid query", 400); + } + + const { page, pageSize, result } = parsed.data; + const where = result ? { result } : {}; + + const [total, rows] = await Promise.all([ + prisma.imapPullLog.count({ where }), + prisma.imapPullLog.findMany({ + where, + orderBy: [{ pulledAt: "desc" }, { id: "desc" }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); + + return ok({ + items: rows.map((r) => ({ + id: r.id.toString(), + pulled_at: r.pulledAt.toISOString(), + mailbox_account_id: r.mailboxAccountId + ? r.mailboxAccountId.toString() + : null, + mailbox_name: r.mailboxName, + mailbox_username: r.mailboxUsername, + from_addr: r.fromAddr, + subject: r.subject, + mail_type: r.mailType, + mail_id: r.mailId ? r.mailId.toString() : null, + result: r.result, + reason: r.reason, + })), + total, + page, + pageSize, + }); +} diff --git a/src/app/api/imports/[importId]/retry/route.ts b/src/app/api/imports/[importId]/retry/route.ts new file mode 100644 index 0000000..4e45c8e --- /dev/null +++ b/src/app/api/imports/[importId]/retry/route.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { prisma } from "@/services/db"; +import { + ImportRetryError, + retryContainerImport, +} from "@/services/import/compensation"; +import { + fail, + ok, + parseBigIntId, + requireAdmin, + requireSession, +} from "@/lib/api"; + +const bodySchema = z + .object({ + retry_token: z.string().optional(), + }) + .optional(); + +type RouteContext = { params: Promise<{ importId: string }> }; + +export async function POST(req: Request, ctx: RouteContext) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const { importId } = await ctx.params; + const id = parseBigIntId(importId); + if (!id) return fail("VALIDATION", "Invalid import id", 400); + + let retryToken: string | undefined; + try { + const json = await req.json(); + const parsed = bodySchema.safeParse(json); + if (parsed.success) retryToken = parsed.data?.retry_token; + } catch { + // empty body ok + } + + const exhausted = await prisma.importCompensation.findFirst({ + where: { importId: id, status: "EXHAUSTED" }, + select: { id: true }, + }); + if (exhausted) { + const adminGuard = await requireAdmin(); + if (adminGuard.response) return adminGuard.response; + } + + try { + const result = await retryContainerImport({ + importId: id, + actor: guard.session.username!, + retryToken, + }); + return ok(result); + } catch (err) { + if (err instanceof ImportRetryError) { + return fail(err.code, err.message, err.status, err.details); + } + throw err; + } +} diff --git a/src/app/api/imports/route.ts b/src/app/api/imports/route.ts new file mode 100644 index 0000000..e3b62f6 --- /dev/null +++ b/src/app/api/imports/route.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/services/db"; +import { fail, ok, parseBigIntId, requireSession } from "@/lib/api"; + +const querySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + status: z.string().optional(), + mail_id: z.string().optional(), +}); + +export async function GET(req: Request) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const url = new URL(req.url); + const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams)); + if (!parsed.success) { + return fail("VALIDATION", "Invalid query", 400, parsed.error.flatten()); + } + + const { page, pageSize, status, mail_id } = parsed.data; + const where: Prisma.ContainerImportWhereInput = {}; + if (status) where.status = status; + if (mail_id) { + const mailId = parseBigIntId(mail_id); + if (!mailId) return fail("VALIDATION", "Invalid mail_id", 400); + where.mailId = mailId; + } + + const [total, rows] = await Promise.all([ + prisma.containerImport.count({ where }), + prisma.containerImport.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + mail: { select: { id: true, subject: true, status: true } }, + compensations: { + select: { + id: true, + status: true, + retryCount: true, + maxRetry: true, + }, + }, + }, + }), + ]); + + return ok({ + items: rows.map((r) => ({ + id: r.id.toString(), + mail_id: r.mailId.toString(), + mail_subject: r.mail.subject, + mail_status: r.mail.status, + container_no: r.containerNo, + external_id: r.externalId, + status: r.status, + last_error: r.lastError, + shipments_hash: r.shipmentsHash, + created_at: r.createdAt, + updated_at: r.updatedAt, + compensations: r.compensations.map((c) => ({ + id: c.id.toString(), + status: c.status, + retry_count: c.retryCount, + max_retry: c.maxRetry, + })), + })), + total, + page, + pageSize, + }); +} diff --git a/src/app/api/mails/[id]/attachments/[attId]/download/route.ts b/src/app/api/mails/[id]/attachments/[attId]/download/route.ts new file mode 100644 index 0000000..d41f4c8 --- /dev/null +++ b/src/app/api/mails/[id]/attachments/[attId]/download/route.ts @@ -0,0 +1,50 @@ +import fs from "fs/promises"; +import { prisma } from "@/services/db"; +import { fail, parseBigIntId, requireSession } from "@/lib/api"; +import { resolveDataFile } from "@/lib/safe-path"; + +type RouteContext = { + params: Promise<{ id: string; attId: string }>; +}; + +export async function GET(_req: Request, ctx: RouteContext) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const { id, attId } = await ctx.params; + const mailId = parseBigIntId(id); + const attachmentId = parseBigIntId(attId); + if (!mailId || !attachmentId) { + return fail("VALIDATION", "Invalid id", 400); + } + + const att = await prisma.mailAttachment.findFirst({ + where: { id: attachmentId, mailId }, + }); + if (!att || att.rejected) { + return fail("NOT_FOUND", "Attachment not found", 404); + } + + let abs: string; + try { + abs = resolveDataFile(att.path); + } catch { + return fail("FORBIDDEN", "Invalid attachment path", 403); + } + + let buffer: Buffer; + try { + buffer = await fs.readFile(abs); + } catch { + return fail("NOT_FOUND", "Attachment file missing", 404); + } + + const safeName = encodeURIComponent(att.filename || "attachment"); + return new Response(new Uint8Array(buffer), { + headers: { + "Content-Type": att.contentType || "application/octet-stream", + "Content-Disposition": `attachment; filename*=UTF-8''${safeName}`, + "Content-Length": String(buffer.length), + }, + }); +} diff --git a/src/app/api/mails/[id]/do/confirm/route.ts b/src/app/api/mails/[id]/do/confirm/route.ts new file mode 100644 index 0000000..c1d1615 --- /dev/null +++ b/src/app/api/mails/[id]/do/confirm/route.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { + confirmDoUpload, + DoConfirmError, +} from "@/services/import/confirm-do-upload"; +import { fail, ok, parseBigIntId, requireSession } from "@/lib/api"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const bodySchema = z.object({ + version: z.number().int(), + container_no: z.string().min(1), + container_task_id: z.string().optional(), + filenames: z.array(z.string()).optional(), +}); + +export async function POST(req: Request, ctx: RouteContext) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const mailId = parseBigIntId(id); + if (!mailId) return fail("VALIDATION", "Invalid mail id", 400); + + const raw = await req.json().catch(() => null); + const parsed = bodySchema.safeParse(raw); + if (!parsed.success) { + return fail("VALIDATION", "Invalid body", 400, parsed.error.flatten()); + } + + try { + const result = await confirmDoUpload({ + mailId, + version: parsed.data.version, + actor: guard.session.username, + containerNo: parsed.data.container_no, + containerTaskId: parsed.data.container_task_id, + filenames: parsed.data.filenames, + }); + return ok(result); + } catch (err) { + if (err instanceof DoConfirmError) { + return fail(err.code, err.message, err.httpStatus); + } + throw err; + } +} diff --git a/src/app/api/mails/[id]/ignore/route.ts b/src/app/api/mails/[id]/ignore/route.ts new file mode 100644 index 0000000..a64ba06 --- /dev/null +++ b/src/app/api/mails/[id]/ignore/route.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import { prisma } from "@/services/db"; +import { writeAudit } from "@/services/audit"; +import { assertTransition } from "@/services/state-machine"; +import type { MailStatus } from "@/types/mail"; +import { fail, ok, parseBigIntId, requireAdmin } from "@/lib/api"; + +const bodySchema = z + .object({ + reason: z.string().min(1).max(500).optional(), + }) + .optional(); + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function POST(req: Request, ctx: RouteContext) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const mailId = parseBigIntId(id); + if (!mailId) return fail("VALIDATION", "Invalid mail id", 400); + + let reason = "admin ignore"; + try { + const json = await req.json(); + const parsed = bodySchema.safeParse(json); + if (parsed.success && parsed.data?.reason) reason = parsed.data.reason; + } catch { + // empty body allowed + } + + const mail = await prisma.mailMessage.findUnique({ where: { id: mailId } }); + if (!mail) return fail("NOT_FOUND", "Mail not found", 404); + + const status = mail.status as MailStatus; + if (status === "IGNORED") { + return ok({ id: mailId.toString(), status: "IGNORED" }); + } + + try { + assertTransition(status, "IGNORED"); + } catch { + return fail( + "ILLEGAL_TRANSITION", + `Cannot ignore from status ${status}`, + 409, + ); + } + + const updated = await prisma.mailMessage.update({ + where: { id: mailId }, + data: { status: "IGNORED", version: { increment: 1 } }, + }); + + await writeAudit({ + actor: guard.session.username!, + action: "IGNORE_MAIL", + mailId, + payload: { reason, from_status: status }, + }); + + return ok({ + id: mailId.toString(), + status: updated.status, + version: updated.version, + }); +} diff --git a/src/app/api/mails/[id]/import/route.ts b/src/app/api/mails/[id]/import/route.ts new file mode 100644 index 0000000..451ad3b --- /dev/null +++ b/src/app/api/mails/[id]/import/route.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { + ConfirmImport, + ConfirmImportError, +} from "@/services/import/confirm"; +import { checkSlidingWindow } from "@/lib/rate-limit"; +import { + fail, + isAdmin, + ok, + parseBigIntId, + requireSession, +} from "@/lib/api"; +import { emitResilience } from "@/lib/resilience-metrics"; +import type { OperationType, TransMode } from "@/types/mail"; + +const bodySchema = z.object({ + version: z.number().int().min(1), + idempotency_key: z.string().min(1).max(128), + F_TransMode: z.union([z.literal(0), z.literal(1), z.literal(3)]), + F_OperationType: z.union([z.literal(0), z.literal(2), z.literal(4)]), + container_header: z.record(z.unknown()).default({}), + selected_row_indexes: z.array(z.number().int().min(0)).min(1), + ack_unmapped_channels: z.boolean().optional(), + force_skip_conflict: z.boolean().optional(), +}); + +const RATE_LIMIT = 10; +const RATE_WINDOW_MS = 60_000; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function POST(req: Request, ctx: RouteContext) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const username = guard.session.username!; + if ( + !checkSlidingWindow( + `import:${username}`, + RATE_LIMIT, + RATE_WINDOW_MS, + ) + ) { + emitResilience("security.rate_limit_import", "warn", { username }); + return fail("RATE_LIMITED", "Max 10 imports per minute", 429); + } + + const { id } = await ctx.params; + const mailId = parseBigIntId(id); + if (!mailId) return fail("VALIDATION", "Invalid mail id", 400); + + let json: unknown; + try { + json = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON body", 400); + } + + const parsed = bodySchema.safeParse(json); + if (!parsed.success) { + return fail("VALIDATION", "Invalid body", 400, parsed.error.flatten()); + } + + if (parsed.data.force_skip_conflict && !isAdmin(guard.session)) { + return fail("FORBIDDEN", "Admin required for force_skip_conflict", 403); + } + + try { + const result = await ConfirmImport.execute({ + mailId, + actor: username, + version: parsed.data.version, + idempotencyKey: parsed.data.idempotency_key, + F_TransMode: parsed.data.F_TransMode as TransMode, + F_OperationType: parsed.data.F_OperationType as OperationType, + containerHeader: parsed.data.container_header, + selectedRowIndexes: parsed.data.selected_row_indexes, + ackUnmappedChannels: parsed.data.ack_unmapped_channels, + forceSkipConflict: parsed.data.force_skip_conflict, + }); + return ok({ + mail_status: result.mailStatus, + accepted_count: result.acceptedCount, + imports: result.imports.map((r) => ({ + id: r.id.toString(), + mail_id: r.mailId.toString(), + container_no: r.containerNo, + external_id: r.externalId, + status: r.status, + last_error: r.lastError, + shipments_hash: r.shipmentsHash, + created_at: r.createdAt, + updated_at: r.updatedAt, + })), + }); + } catch (err) { + if (err instanceof ConfirmImportError) { + return fail(err.code, err.message, err.status, err.details); + } + console.error("import route unexpected", err); + return fail( + "INTERNAL", + "Import failed", + 500, + ); + } +} diff --git a/src/app/api/mails/[id]/reparse/route.ts b/src/app/api/mails/[id]/reparse/route.ts new file mode 100644 index 0000000..d00a9cc --- /dev/null +++ b/src/app/api/mails/[id]/reparse/route.ts @@ -0,0 +1,61 @@ +import { prisma } from "@/services/db"; +import { ParsePipeline } from "@/services/parse/pipeline"; +import { assertTransition } from "@/services/state-machine"; +import { writeAudit } from "@/services/audit"; +import type { MailStatus } from "@/types/mail"; +import { fail, ok, parseBigIntId, requireSession } from "@/lib/api"; + +const BLOCKED: MailStatus[] = ["IMPORTING", "SUCCESS", "PARTIAL_SUCCESS"]; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function POST(_req: Request, ctx: RouteContext) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const mailId = parseBigIntId(id); + if (!mailId) return fail("VALIDATION", "Invalid mail id", 400); + + const mail = await prisma.mailMessage.findUnique({ where: { id: mailId } }); + if (!mail) return fail("NOT_FOUND", "Mail not found", 404); + + const status = mail.status as MailStatus; + if (BLOCKED.includes(status)) { + return fail( + "ILLEGAL_TRANSITION", + `Cannot reparse from status ${status}`, + 409, + ); + } + + try { + assertTransition(status, "PARSING"); + } catch { + return fail( + "ILLEGAL_TRANSITION", + `Cannot reparse from status ${status}`, + 409, + ); + } + + await prisma.mailMessage.update({ + where: { id: mailId }, + data: { status: "PARSING", version: { increment: 1 } }, + }); + + await writeAudit({ + actor: guard.session.username!, + action: "REPARSE", + mailId, + }); + + await ParsePipeline.run(mailId); + + const updated = await prisma.mailMessage.findUnique({ where: { id: mailId } }); + return ok({ + id: mailId.toString(), + status: updated?.status, + version: updated?.version, + }); +} diff --git a/src/app/api/mails/[id]/route.ts b/src/app/api/mails/[id]/route.ts new file mode 100644 index 0000000..f0e6e47 --- /dev/null +++ b/src/app/api/mails/[id]/route.ts @@ -0,0 +1,93 @@ +import { prisma } from "@/services/db"; +import { fail, ok, parseBigIntId, requireSession } from "@/lib/api"; +import { + deriveIngestSource, + formatIngestSourceLabel, +} from "@/utils/ingest-source"; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function GET(_req: Request, ctx: RouteContext) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const mailId = parseBigIntId(id); + if (!mailId) return fail("VALIDATION", "Invalid mail id", 400); + + const mail = await prisma.mailMessage.findUnique({ + where: { id: mailId }, + include: { + attachments: true, + parseResult: true, + imports: { orderBy: { createdAt: "desc" } }, + mailboxAccount: { select: { username: true, name: true } }, + }, + }); + + if (!mail) return fail("NOT_FOUND", "Mail not found", 404); + + const ingest_source = deriveIngestSource(); + const mailboxHint = + mail.mailboxAccount?.username || mail.mailboxAccount?.name || null; + + return ok({ + id: mail.id.toString(), + message_id: mail.messageId, + imap_uid: mail.imapUid?.toString() ?? null, + folder: mail.folder, + subject: mail.subject, + from_addr: mail.fromAddr, + ingest_source, + ingest_source_label: formatIngestSourceLabel(ingest_source, mailboxHint), + received_at: mail.receivedAt, + body_text: mail.bodyText, + ocr_text: mail.ocrText, + mail_type: mail.mailType, + status: mail.status, + type_evidence: mail.typeEvidence, + snapshot_path: mail.snapshotPath, + raw_hash: mail.rawHash, + last_error: mail.lastError, + version: mail.version, + created_at: mail.createdAt, + updated_at: mail.updatedAt, + attachments: mail.attachments.map((a) => ({ + id: a.id.toString(), + filename: a.filename, + content_type: a.contentType, + sha256: a.sha256, + path: a.path, + size: a.size, + rejected: a.rejected, + template_id: a.templateId, + created_at: a.createdAt, + })), + parse_result: mail.parseResult + ? { + id: mail.parseResult.id.toString(), + container_header: mail.parseResult.containerHeader, + shipments: mail.parseResult.shipments, + mail_record: + ( + mail.parseResult.lineage as + | { mail_record?: unknown } + | null + | undefined + )?.mail_record ?? null, + lineage: mail.parseResult.lineage, + updated_at: mail.parseResult.updatedAt, + } + : null, + imports: mail.imports.map((i) => ({ + id: i.id.toString(), + container_no: i.containerNo, + external_id: i.externalId, + status: i.status, + last_error: i.lastError, + shipments_hash: i.shipmentsHash, + created_at: i.createdAt, + updated_at: i.updatedAt, + })), + }); +} diff --git a/src/app/api/mails/[id]/transfer/confirm/route.ts b/src/app/api/mails/[id]/transfer/confirm/route.ts new file mode 100644 index 0000000..cf398ad --- /dev/null +++ b/src/app/api/mails/[id]/transfer/confirm/route.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { + confirmBatchTransfer, + TransferConfirmError, +} from "@/services/import/confirm-batch-transfer"; +import { fail, ok, parseBigIntId, requireSession } from "@/lib/api"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const bodySchema = z.object({ + version: z.number().int(), + container_no: z.string().min(1), + payload: z.object({ + keyValue: z.string(), + strDetialList: z.array( + z.object({ + F_ID: z.string().optional(), + F_FBACode: z.string().optional(), + F_ConvertFBACode: z.string().min(1), + }), + ), + F_ConvertChargeMode: z.string(), + F_RemovePlanDetail: z.string(), + }), +}); + +export async function POST(req: Request, ctx: RouteContext) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const mailId = parseBigIntId(id); + if (!mailId) return fail("VALIDATION", "Invalid mail id", 400); + + const raw = await req.json().catch(() => null); + const parsed = bodySchema.safeParse(raw); + if (!parsed.success) { + return fail("VALIDATION", "Invalid body", 400, parsed.error.flatten()); + } + + try { + const result = await confirmBatchTransfer({ + mailId, + version: parsed.data.version, + actor: guard.session.username, + containerNo: parsed.data.container_no, + payload: parsed.data.payload, + }); + return ok(result); + } catch (err) { + if (err instanceof TransferConfirmError) { + return fail(err.code, err.message, err.httpStatus); + } + throw err; + } +} diff --git a/src/app/api/mails/[id]/transfer/gate/route.ts b/src/app/api/mails/[id]/transfer/gate/route.ts new file mode 100644 index 0000000..c63541b --- /dev/null +++ b/src/app/api/mails/[id]/transfer/gate/route.ts @@ -0,0 +1,27 @@ +import { fail, ok, parseBigIntId, requireSession } from "@/lib/api"; +import { evaluateTransferGate } from "@/services/cc/container-status"; +import { prisma } from "@/services/db"; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function GET(_req: Request, ctx: RouteContext) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const mailId = parseBigIntId(id); + if (!mailId) return fail("VALIDATION", "Invalid mail id", 400); + + const mail = await prisma.mailMessage.findUnique({ + where: { id: mailId }, + include: { parseResult: true }, + }); + if (!mail) return fail("NOT_FOUND", "邮件不存在", 404); + + const header = mail.parseResult?.containerHeader as + | { F_ContainerNo?: string } + | undefined; + const cn = (header?.F_ContainerNo || "").trim(); + const gate = await evaluateTransferGate(cn); + return ok({ container_no: cn, gate }); +} diff --git a/src/app/api/mails/[id]/type/route.ts b/src/app/api/mails/[id]/type/route.ts new file mode 100644 index 0000000..54e8387 --- /dev/null +++ b/src/app/api/mails/[id]/type/route.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; +import { prisma } from "@/services/db"; +import { getEnv } from "@/lib/env"; +import { writeAudit } from "@/services/audit"; +import { resolveAfterParse } from "@/services/state-machine"; +import { MAIL_TYPES, type MailStatus } from "@/types/mail"; +import { fail, ok, parseBigIntId, requireAdmin } from "@/lib/api"; +import { emitResilience } from "@/lib/resilience-metrics"; + +const bodySchema = z.object({ + version: z.number().int().min(1), + mail_type: z.enum(MAIL_TYPES as [string, ...string[]]), + reason: z.string().min(1).max(500), +}); + +/** 解析中 / 导入中均禁止改类型(韧性 §6.4) */ +const BUSY: MailStatus[] = ["PARSING", "IMPORTING"]; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function POST(req: Request, ctx: RouteContext) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + const env = getEnv(); + if (!env.ENABLE_TYPE_OVERRIDE) { + emitResilience("security.type_override_denied", "warn", { + reason: "feature_disabled", + }); + return fail("FORBIDDEN", "Type override disabled", 403); + } + + const { id } = await ctx.params; + const mailId = parseBigIntId(id); + if (!mailId) return fail("VALIDATION", "Invalid mail id", 400); + + let json: unknown; + try { + json = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON body", 400); + } + + const parsed = bodySchema.safeParse(json); + if (!parsed.success) { + return fail("VALIDATION", "Invalid body", 400, parsed.error.flatten()); + } + + const mail = await prisma.mailMessage.findUnique({ + where: { id: mailId }, + include: { parseResult: true }, + }); + if (!mail) return fail("NOT_FOUND", "Mail not found", 404); + + if (mail.version !== parsed.data.version) { + return fail("VERSION_CONFLICT", "Version mismatch", 409, { + expected: mail.version, + got: parsed.data.version, + }); + } + + const status = mail.status as MailStatus; + if (["SUCCESS", "IGNORED"].includes(status)) { + return fail("IMMUTABLE_STATUS", `Status ${status} is immutable`, 409); + } + if (BUSY.includes(status)) { + emitResilience("security.type_override_denied", "warn", { + mailId: String(mailId), + status, + reason: "MAIL_BUSY", + }); + return fail("MAIL_BUSY", `Mail is ${status}`, 409); + } + + const shipments = (mail.parseResult?.shipments ?? []) as Array<{ + row_status?: string; + }>; + const validCount = shipments.filter((s) => s.row_status === "VALID").length; + const nextStatus = resolveAfterParse({ + mailType: parsed.data.mail_type, + validCount, + }); + + const updated = await prisma.mailMessage.update({ + where: { id: mailId }, + data: { + mailType: parsed.data.mail_type, + status: nextStatus, + version: { increment: 1 }, + }, + }); + + await writeAudit({ + actor: guard.session.username!, + action: "TYPE_OVERRIDE", + mailId, + payload: { + mail_type: parsed.data.mail_type, + reason: parsed.data.reason, + from_status: status, + to_status: nextStatus, + }, + }); + + return ok({ + id: mailId.toString(), + mail_type: updated.mailType, + status: updated.status, + version: updated.version, + }); +} diff --git a/src/app/api/mails/[id]/work-order/confirm/route.ts b/src/app/api/mails/[id]/work-order/confirm/route.ts new file mode 100644 index 0000000..3de8f76 --- /dev/null +++ b/src/app/api/mails/[id]/work-order/confirm/route.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import { + confirmWorkOrder, + WorkOrderConfirmError, +} from "@/services/import/confirm-work-order"; +import { fail, ok, parseBigIntId, requireSession } from "@/lib/api"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const bodySchema = z.object({ + version: z.number().int(), + entity: z.object({ + F_ReferCode: z.string().optional(), + F_MessageType: z.string().min(1), + F_RefNo: z.string().optional(), + F_MessageContent: z.string().min(1), + F_Remark: z.string().optional(), + F_AttachFile: z.string().optional(), + F_CustomerName: z.string().optional(), + F_UrgencyLevel: z.string().optional(), + }), +}); + +export async function POST(req: Request, ctx: RouteContext) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const mailId = parseBigIntId(id); + if (!mailId) return fail("VALIDATION", "Invalid mail id", 400); + + const raw = await req.json().catch(() => null); + const parsed = bodySchema.safeParse(raw); + if (!parsed.success) { + return fail("VALIDATION", "Invalid body", 400, parsed.error.flatten()); + } + + try { + const result = await confirmWorkOrder({ + mailId, + version: parsed.data.version, + actor: guard.session.username, + entity: parsed.data.entity, + }); + return ok(result); + } catch (err) { + if (err instanceof WorkOrderConfirmError) { + return fail(err.code, err.message, err.httpStatus); + } + throw err; + } +} diff --git a/src/app/api/mails/route.ts b/src/app/api/mails/route.ts new file mode 100644 index 0000000..b182d47 --- /dev/null +++ b/src/app/api/mails/route.ts @@ -0,0 +1,126 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/services/db"; +import { fail, ok, requireSession } from "@/lib/api"; +import { + deriveMailContentKind, + MAIL_CONTENT_KIND_LABEL, + summarizeBody, +} from "@/utils/mailContent"; +import { + deriveIngestSource, + formatIngestSourceLabel, +} from "@/utils/ingest-source"; + +const querySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + mail_type: z.string().optional(), + status: z.string().optional(), + q: z.string().optional(), +}); + +const TERMINAL_IMPORT_STATUSES = new Set([ + "SUCCESS", + "PARTIAL_SUCCESS", + "FAILED", + "IGNORED", +]); + +export async function GET(req: Request) { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const url = new URL(req.url); + const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams)); + if (!parsed.success) { + return fail("VALIDATION", "Invalid query parameters", 400, parsed.error.flatten()); + } + + const { page, pageSize, mail_type, status, q } = parsed.data; + const where: Prisma.MailMessageWhereInput = {}; + + if (mail_type) where.mailType = mail_type; + // 默认隐藏人工忽略 / IMAP 非业务 stub;显式选状态时仍可查 + if (status) where.status = status; + else where.status = { not: "IGNORED" }; + if (q?.trim()) { + where.OR = [ + { subject: { contains: q.trim() } }, + { fromAddr: { contains: q.trim() } }, + { bodyText: { contains: q.trim() } }, + ]; + } + + try { + const [total, rows] = await Promise.all([ + prisma.mailMessage.count({ where }), + prisma.mailMessage.findMany({ + where, + orderBy: [{ receivedAt: "desc" }, { id: "desc" }], + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + subject: true, + fromAddr: true, + receivedAt: true, + updatedAt: true, + mailType: true, + status: true, + threadId: true, + isThreadRoot: true, + parseResult: { select: { containerHeader: true } }, + mailboxAccount: { select: { username: true, name: true } }, + attachments: { + select: { id: true, filename: true, size: true }, + orderBy: { id: "asc" as const }, + }, + }, + }), + ]); + + const items = rows.map((m) => { + const header = m.parseResult?.containerHeader as + | { F_ContainerNo?: string } + | undefined; + const filenames = m.attachments.map((a) => a.filename); + const content_kind = deriveMailContentKind(filenames, "."); + const received = m.receivedAt?.toISOString() ?? null; + const updated = m.updatedAt.toISOString(); + const ingest_source = deriveIngestSource(); + const mailboxHint = + m.mailboxAccount?.username || m.mailboxAccount?.name || null; + + return { + id: m.id.toString(), + subject: m.subject, + from_addr: m.fromAddr, + ingest_source, + ingest_source_label: formatIngestSourceLabel(ingest_source, mailboxHint), + received_at: received, + updated_at: updated, + body_summary: summarizeBody(m.subject), + mail_type: m.mailType, + content_kind, + content_kind_label: MAIL_CONTENT_KIND_LABEL[content_kind], + status: m.status, + container_no: header?.F_ContainerNo ?? null, + thread_id: m.threadId, + is_thread_root: m.isThreadRoot, + attachments: m.attachments.map((a) => ({ + id: a.id.toString(), + filename: a.filename, + size: a.size, + })), + ended_at: TERMINAL_IMPORT_STATUSES.has(m.status) ? updated : null, + }; + }); + + return ok({ items, total, page, pageSize }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Database query failed"; + return fail("DB_ERROR", `数据库不可用: ${message.slice(0, 200)}`, 503); + } +} diff --git a/src/app/api/oauth/callback/[provider]/route.ts b/src/app/api/oauth/callback/[provider]/route.ts new file mode 100644 index 0000000..b4470d6 --- /dev/null +++ b/src/app/api/oauth/callback/[provider]/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { + getOAuthProvider, + parseOAuthState, +} from "@/services/oauth/providers"; +import { upsertOAuthMailbox } from "@/services/imap/mailbox-config"; +import { writeAudit } from "@/services/audit"; + +type Ctx = { params: Promise<{ provider: string }> }; + +/** OAuth 回调:换 token → 写入 mailbox_account → 回设置页 */ +export async function GET(req: Request, ctx: Ctx) { + const { provider: providerId } = await ctx.params; + const url = new URL(req.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const err = url.searchParams.get("error"); + + const redirect = (q: string) => + NextResponse.redirect(new URL(`/settings?oauth=${q}`, url.origin)); + + if (err) return redirect(`error:${err}`); + if (!code || !state) return redirect("error:missing_code"); + + const payload = parseOAuthState(state); + if (!payload || payload.provider !== providerId) { + return redirect("error:bad_state"); + } + // state 须由本系统 admin 发起(含 actor) + if (!payload.actor?.trim()) { + return redirect("error:bad_state"); + } + + const provider = getOAuthProvider(providerId); + if (!provider) return redirect("error:unknown_provider"); + + try { + const tokens = await provider.exchangeCode(code); + if (!tokens.refreshToken) { + return redirect("error:no_refresh_token"); + } + const email = + tokens.email || + payload.email || + `${providerId}-user@unknown`; + const box = await upsertOAuthMailbox({ + name: payload.name || provider.label, + provider: provider.id, + username: email, + host: provider.imapHost, + port: provider.imapPort, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: tokens.expiresIn, + }); + await writeAudit({ + actor: payload.actor, + action: "OAUTH_BIND", + payload: { + mailbox_id: box.id, + provider: provider.id, + username: email, + }, + }); + return redirect(`ok:${box.id}`); + } catch (e) { + const msg = e instanceof Error ? e.message : "oauth_failed"; + return redirect(`error:${encodeURIComponent(msg.slice(0, 80))}`); + } +} diff --git a/src/app/api/oauth/providers/route.ts b/src/app/api/oauth/providers/route.ts new file mode 100644 index 0000000..ce55265 --- /dev/null +++ b/src/app/api/oauth/providers/route.ts @@ -0,0 +1,9 @@ +import { ok, requireAdmin } from "@/lib/api"; +import { listOAuthProviders } from "@/services/oauth/providers"; + +/** GET /api/oauth/providers */ +export async function GET() { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + return ok({ items: await listOAuthProviders() }); +} diff --git a/src/app/api/oauth/start/route.ts b/src/app/api/oauth/start/route.ts new file mode 100644 index 0000000..eac58c9 --- /dev/null +++ b/src/app/api/oauth/start/route.ts @@ -0,0 +1,44 @@ +import { fail, ok, requireAdmin } from "@/lib/api"; +import { + createOAuthState, + getOAuthProvider, + listOAuthProviders, +} from "@/services/oauth/providers"; + +export async function GET() { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + return ok({ items: await listOAuthProviders() }); +} + +export async function POST(req: Request) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + let body: { provider?: string; name?: string }; + try { + body = (await req.json()) as { provider?: string; name?: string }; + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + const provider = getOAuthProvider(body.provider || ""); + if (!provider) return fail("VALIDATION", "Unknown OAuth provider", 400); + + try { + const state = createOAuthState({ + provider: provider.id, + name: body.name || provider.label, + actor: guard.session.username, + }); + return ok({ + auth_url: await provider.authUrl(state), + provider: provider.id, + }); + } catch (err) { + return fail( + "OAUTH_NOT_CONFIGURED", + err instanceof Error ? err.message : "OAuth not configured", + 400, + ); + } +} diff --git a/src/app/api/settings/cc/route.ts b/src/app/api/settings/cc/route.ts new file mode 100644 index 0000000..3fe2b00 --- /dev/null +++ b/src/app/api/settings/cc/route.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import { fail, ok, requireAdmin } from "@/lib/api"; +import { assertSafeCcApiBase } from "@/lib/cc-api-base"; +import { writeAudit } from "@/services/audit"; +import { ccAuth } from "@/services/cc/auth"; +import { invalidateShippingLineCache } from "@/services/cc/shipping-line"; +import { + getCcSettingsPublic, + upsertCcSettings, +} from "@/services/cc/settings-config"; + +const PutSchema = z.object({ + mock: z.boolean(), + api_base: z.string().url().max(512), + saas_header: z.string().min(1).max(64), + login_mark: z.string().uuid(), + username: z.string().max(128).optional().default(""), + password: z.string().max(256).optional(), +}); + +export async function GET() { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + const item = await getCcSettingsPublic(); + return ok(item); +} + +export async function PUT(req: Request) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + + const parsed = PutSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + + const d = parsed.data; + if (!d.mock && !d.username.trim()) { + return fail("VALIDATION", "live 模式请填写 CC 用户名", 400); + } + + let apiBase: string; + try { + apiBase = assertSafeCcApiBase(d.api_base); + } catch (err) { + return fail( + "VALIDATION", + err instanceof Error ? err.message : "非法 CC API 地址", + 400, + ); + } + + const item = await upsertCcSettings({ + mock: d.mock, + apiBase, + saasHeader: d.saas_header, + loginMark: d.login_mark, + username: d.username, + password: d.password, + }); + + await ccAuth.invalidateToken(); + invalidateShippingLineCache(); + + await writeAudit({ + actor: guard.session.username!, + action: "CC_SETTINGS_UPDATE", + payload: { + mock: item.mock, + api_base: item.api_base, + saas_header: item.saas_header, + username: item.username, + has_password: item.has_password, + source: item.source, + }, + }); + + return ok(item); +} diff --git a/src/app/api/settings/cc/test/route.ts b/src/app/api/settings/cc/test/route.ts new file mode 100644 index 0000000..11630e1 --- /dev/null +++ b/src/app/api/settings/cc/test/route.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { fail, ok, requireAdmin } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { testCcLogin } from "@/services/cc/settings-config"; + +const BodySchema = z.object({ + password: z.string().max(256).optional(), +}); + +export async function POST(req: Request) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + let body: unknown = {}; + try { + const text = await req.text(); + if (text.trim()) body = JSON.parse(text); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + + const result = await testCcLogin(parsed.data.password); + await writeAudit({ + actor: guard.session.username!, + action: "CC_SETTINGS_TEST", + payload: { ok: result.ok, message: result.message }, + }); + + if (!result.ok) { + return fail("CC_TEST_FAILED", result.message, 502); + } + return ok({ message: result.message }); +} diff --git a/src/app/api/settings/imap/filters/[id]/route.ts b/src/app/api/settings/imap/filters/[id]/route.ts new file mode 100644 index 0000000..338f0e4 --- /dev/null +++ b/src/app/api/settings/imap/filters/[id]/route.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { fail, ok, parseBigIntId, requireAdmin } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { prisma } from "@/services/db"; + +const PatchSchema = z.object({ + enabled: z.boolean().optional(), + value: z.string().trim().min(1).max(256).optional(), +}); + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function PATCH(req: Request, ctx: RouteContext) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const ruleId = parseBigIntId(id); + if (!ruleId) return fail("VALIDATION", "Invalid filter id", 400); + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + + const parsed = PatchSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + if (parsed.data.enabled === undefined && parsed.data.value === undefined) { + return fail("VALIDATION", "无变更字段", 400); + } + + const existing = await prisma.imapFilterRule.findUnique({ + where: { id: ruleId }, + }); + if (!existing) return fail("NOT_FOUND", "规则不存在", 404); + + let value = parsed.data.value; + if (value !== undefined) { + if ( + existing.kind === "sender_whitelist" || + existing.kind === "sender_blacklist" + ) { + value = value.trim().toLowerCase(); + } else { + value = value.trim(); + } + } + + const row = await prisma.imapFilterRule.update({ + where: { id: ruleId }, + data: { + ...(parsed.data.enabled !== undefined + ? { enabled: parsed.data.enabled } + : {}), + ...(value !== undefined ? { value } : {}), + }, + }); + + await writeAudit({ + actor: guard.session.username!, + action: "IMAP_FILTER_UPDATE", + payload: { + id: row.id.toString(), + kind: row.kind, + value: row.value, + enabled: row.enabled, + }, + }); + + return ok({ + id: row.id.toString(), + kind: row.kind, + value: row.value, + enabled: row.enabled, + created_at: row.createdAt.toISOString(), + }); +} + +export async function DELETE(_req: Request, ctx: RouteContext) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + const { id } = await ctx.params; + const ruleId = parseBigIntId(id); + if (!ruleId) return fail("VALIDATION", "Invalid filter id", 400); + + const existing = await prisma.imapFilterRule.findUnique({ + where: { id: ruleId }, + }); + if (!existing) return fail("NOT_FOUND", "规则不存在", 404); + + await prisma.imapFilterRule.delete({ where: { id: ruleId } }); + await writeAudit({ + actor: guard.session.username!, + action: "IMAP_FILTER_DELETE", + payload: { + id: existing.id.toString(), + kind: existing.kind, + value: existing.value, + }, + }); + + return ok({ id: existing.id.toString(), deleted: true }); +} diff --git a/src/app/api/settings/imap/filters/route.ts b/src/app/api/settings/imap/filters/route.ts new file mode 100644 index 0000000..712bd61 --- /dev/null +++ b/src/app/api/settings/imap/filters/route.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { fail, ok, requireAdmin } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { prisma } from "@/services/db"; +import { FILTER_KINDS } from "@/services/imap/pull-filter"; + +const CreateSchema = z.object({ + kind: z.enum(FILTER_KINDS), + value: z.string().trim().min(1).max(256), + enabled: z.boolean().optional().default(true), +}); + +export async function GET() { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + const rows = await prisma.imapFilterRule.findMany({ + orderBy: [{ kind: "asc" }, { id: "asc" }], + }); + return ok({ + items: rows.map((r) => ({ + id: r.id.toString(), + kind: r.kind, + value: r.value, + enabled: r.enabled, + created_at: r.createdAt.toISOString(), + })), + }); +} + +export async function POST(req: Request) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + + const parsed = CreateSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + + const value = + parsed.data.kind === "sender_whitelist" || + parsed.data.kind === "sender_blacklist" + ? parsed.data.value.trim().toLowerCase() + : parsed.data.value.trim(); + + try { + const row = await prisma.imapFilterRule.create({ + data: { + kind: parsed.data.kind, + value, + enabled: parsed.data.enabled, + }, + }); + await writeAudit({ + actor: guard.session.username!, + action: "IMAP_FILTER_CREATE", + payload: { id: row.id.toString(), kind: row.kind, value: row.value }, + }); + return ok( + { + id: row.id.toString(), + kind: row.kind, + value: row.value, + enabled: row.enabled, + created_at: row.createdAt.toISOString(), + }, + 201, + ); + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return fail("CONFLICT", "同类型规则已存在", 409); + } + throw err; + } +} diff --git a/src/app/api/settings/imap/route.ts b/src/app/api/settings/imap/route.ts new file mode 100644 index 0000000..756cfbe --- /dev/null +++ b/src/app/api/settings/imap/route.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { fail, ok, requireAdmin } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { + POLL_INTERVAL_MS_MAX, + POLL_INTERVAL_MS_MIN, + getImapSettingsPublic, + listImapFilterRules, + upsertImapPollIntervalMs, +} from "@/services/imap/settings-config"; + +const PutSchema = z.object({ + poll_interval_ms: z + .number() + .int() + .min(POLL_INTERVAL_MS_MIN) + .max(POLL_INTERVAL_MS_MAX), +}); + +export async function GET() { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + const settings = await getImapSettingsPublic(); + const rules = await listImapFilterRules(); + return ok({ + ...settings, + poll_interval_ms_min: POLL_INTERVAL_MS_MIN, + poll_interval_ms_max: POLL_INTERVAL_MS_MAX, + filters: rules.map((r) => ({ + id: r.id.toString(), + kind: r.kind, + value: r.value, + enabled: r.enabled, + created_at: r.createdAt.toISOString(), + })), + }); +} + +export async function PUT(req: Request) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + + const parsed = PutSchema.safeParse(body); + if (!parsed.success) { + return fail( + "VALIDATION", + `poll_interval_ms 须在 ${POLL_INTERVAL_MS_MIN}~${POLL_INTERVAL_MS_MAX}(3分钟~7天)`, + 400, + ); + } + + const item = await upsertImapPollIntervalMs(parsed.data.poll_interval_ms); + await writeAudit({ + actor: guard.session.username!, + action: "IMAP_SETTINGS_UPDATE", + payload: { poll_interval_ms: item.poll_interval_ms }, + }); + + const settings = await getImapSettingsPublic(); + return ok(settings); +} diff --git a/src/app/api/settings/mailboxes/[id]/route.ts b/src/app/api/settings/mailboxes/[id]/route.ts new file mode 100644 index 0000000..0b00d28 --- /dev/null +++ b/src/app/api/settings/mailboxes/[id]/route.ts @@ -0,0 +1,135 @@ +import { z } from "zod"; +import { fail, ok, parseBigIntId, requireAdmin } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { + deleteMailbox, + getMailbox, + updateMailbox, + verifySavedMailbox, +} from "@/services/imap/mailbox-config"; + +type RouteContext = { params: Promise<{ id: string }> }; + +const PatchSchema = z.object({ + name: z.string().min(1).max(128).optional(), + username: z.string().email().max(320).optional(), + password: z.string().min(1).max(256).optional(), + folder: z.string().max(64).optional(), + enabled: z.boolean().optional(), + idle_enabled: z.boolean().optional(), + /** 保存后强制连通测试;改凭证/账号时默认建议 true,调用方可显式传 */ + verify: z.boolean().optional().default(false), + host: z.string().max(255).optional(), + port: z.coerce.number().int().min(1).max(65535).optional(), +}); + +export async function GET(_req: Request, ctx: RouteContext) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + const id = parseBigIntId((await ctx.params).id); + if (!id) return fail("VALIDATION", "Invalid id", 400); + const item = await getMailbox(id); + if (!item) return fail("NOT_FOUND", "Mailbox not found", 404); + return ok(item); +} + +export async function PATCH(req: Request, ctx: RouteContext) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + const id = parseBigIntId((await ctx.params).id); + if (!id) return fail("VALIDATION", "Invalid id", 400); + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + const parsed = PatchSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + + const existing = await getMailbox(id); + if (!existing) return fail("NOT_FOUND", "Mailbox not found", 404); + + try { + const item = await updateMailbox(id, { + name: parsed.data.name, + username: parsed.data.username, + password: parsed.data.password, + folder: parsed.data.folder, + enabled: parsed.data.enabled, + idleEnabled: parsed.data.idle_enabled, + host: parsed.data.host, + port: parsed.data.port, + }); + await writeAudit({ + actor: guard.session.username!, + action: "MAILBOX_UPDATE", + payload: { + id: item.id, + fields: Object.keys(parsed.data).filter( + (k) => k !== "password" && k !== "verify", + ), + password_rotated: Boolean(parsed.data.password), + }, + }); + + const shouldVerify = + parsed.data.verify || + Boolean(parsed.data.password) || + Boolean(parsed.data.username); + if (shouldVerify) { + const verified = await verifySavedMailbox(id); + if (!verified.ok) { + await writeAudit({ + actor: guard.session.username!, + action: "MAILBOX_TEST", + payload: { id: item.id, ok: false, error: verified.error }, + }); + return fail( + "IMAP_TEST_FAILED", + `已更新配置但连通失败:${verified.error}`, + 502, + { item }, + ); + } + await writeAudit({ + actor: guard.session.username!, + action: "MAILBOX_TEST", + payload: { id: item.id, ok: true, unseen: verified.unseen }, + }); + return ok({ ...item, last_test_ok: true, unseen: verified.unseen }); + } + + return ok(item); + } catch (err) { + const msg = err instanceof Error ? err.message : "update failed"; + if (msg.includes("Unique constraint") || msg.includes("P2002")) { + return fail("CONFLICT", "该邮箱账号已存在", 409); + } + if (msg.startsWith("UNSUPPORTED_MAIL_DOMAIN:")) { + return fail("VALIDATION", msg.replace("UNSUPPORTED_MAIL_DOMAIN:", ""), 400); + } + return fail("INTERNAL", msg, 500); + } +} + +export async function DELETE(_req: Request, ctx: RouteContext) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + const id = parseBigIntId((await ctx.params).id); + if (!id) return fail("VALIDATION", "Invalid id", 400); + + const existing = await getMailbox(id); + if (!existing) return fail("NOT_FOUND", "Mailbox not found", 404); + + await deleteMailbox(id); + await writeAudit({ + actor: guard.session.username!, + action: "MAILBOX_DELETE", + payload: { id: existing.id, username: existing.username }, + }); + return ok({ id: existing.id }); +} diff --git a/src/app/api/settings/mailboxes/[id]/test/route.ts b/src/app/api/settings/mailboxes/[id]/test/route.ts new file mode 100644 index 0000000..8f9c585 --- /dev/null +++ b/src/app/api/settings/mailboxes/[id]/test/route.ts @@ -0,0 +1,45 @@ +import { fail, ok, parseBigIntId, requireAdmin } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { createImapClientFromConfig } from "@/services/imap/client"; +import { + markMailboxTestResult, + resolveMailboxForTest, +} from "@/services/imap/mailbox-config"; + +type RouteContext = { params: Promise<{ id: string }> }; + +/** 连通性测试:登录 + SEARCH,不落库、不标已读 */ +export async function POST(_req: Request, ctx: RouteContext) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + const id = parseBigIntId((await ctx.params).id); + if (!id) return fail("VALIDATION", "Invalid id", 400); + + const box = await resolveMailboxForTest(id); + if (!box) return fail("NOT_FOUND", "Mailbox not found", 404); + + const client = createImapClientFromConfig(box.config); + try { + await client.connect(); + const unseen = await client.countUnseen(); + await markMailboxTestResult(id, true); + await writeAudit({ + actor: guard.session.username!, + action: "MAILBOX_TEST", + payload: { id: String(id), ok: true, unseen }, + }); + return ok({ ok: true, unseen }); + } catch (err) { + const message = err instanceof Error ? err.message : "TEST_FAILED"; + await markMailboxTestResult(id, false, message); + await writeAudit({ + actor: guard.session.username!, + action: "MAILBOX_TEST", + payload: { id: String(id), ok: false, error: message }, + }); + return fail("IMAP_TEST_FAILED", message, 502); + } finally { + await client.disconnect().catch(() => undefined); + } +} diff --git a/src/app/api/settings/mailboxes/probe/route.ts b/src/app/api/settings/mailboxes/probe/route.ts new file mode 100644 index 0000000..3a37e2d --- /dev/null +++ b/src/app/api/settings/mailboxes/probe/route.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import { fail, ok, requireAdmin } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { probeMailboxCredentials } from "@/services/imap/mailbox-config"; + +const ProbeSchema = z.object({ + username: z.string().email().max(320), + password: z.string().max(256).optional(), + folder: z.string().max(64).optional().default("INBOX"), + mailbox_id: z.string().optional(), + /** 仅未识别域名时可选兜底 */ + host: z.string().max(255).optional(), + port: z.coerce.number().int().min(1).max(65535).optional(), +}); + +/** POST /api/settings/mailboxes/probe — 不落库连通探测 */ +export async function POST(req: Request) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + + const parsed = ProbeSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + + const d = parsed.data; + let mailboxId: bigint | undefined; + if (d.mailbox_id) { + try { + mailboxId = BigInt(d.mailbox_id); + } catch { + return fail("VALIDATION", "Invalid mailbox_id", 400); + } + } + + const result = await probeMailboxCredentials({ + username: d.username, + password: d.password, + folder: d.folder, + host: d.host, + port: d.port, + mailboxId, + }); + + if (!result.ok) { + return fail("IMAP_TEST_FAILED", result.error, 502); + } + + await writeAudit({ + actor: guard.session.username!, + action: "MAILBOX_PROBE", + payload: { + username: d.username, + host: result.host, + ok: true, + unseen: result.unseen, + }, + }); + + return ok({ + ok: true as const, + unseen: result.unseen, + host: result.host, + port: result.port, + }); +} diff --git a/src/app/api/settings/mailboxes/route.ts b/src/app/api/settings/mailboxes/route.ts new file mode 100644 index 0000000..6b9fe01 --- /dev/null +++ b/src/app/api/settings/mailboxes/route.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; +import { fail, ok, requireAdmin } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { + createMailbox, + listMailboxes, + verifySavedMailbox, +} from "@/services/imap/mailbox-config"; + +const CreateSchema = z.object({ + name: z.string().min(1).max(128), + username: z.string().email().max(320), + password: z.string().min(1).max(256), + folder: z.string().max(64).optional().default("INBOX"), + enabled: z.boolean().optional().default(true), + idle_enabled: z.boolean().optional().default(true), + /** 保存后强制连通测试;默认 true */ + verify: z.boolean().optional().default(true), + /** 仅未识别域名时可选 */ + host: z.string().max(255).optional(), + port: z.coerce.number().int().min(1).max(65535).optional(), +}); + +export async function GET() { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + const items = await listMailboxes(); + return ok({ items }); +} + +export async function POST(req: Request) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + + const parsed = CreateSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + + try { + const item = await createMailbox({ + name: parsed.data.name, + username: parsed.data.username, + password: parsed.data.password, + folder: parsed.data.folder, + enabled: parsed.data.enabled, + idleEnabled: parsed.data.idle_enabled, + host: parsed.data.host, + port: parsed.data.port, + }); + + await writeAudit({ + actor: guard.session.username!, + action: "MAILBOX_CREATE", + payload: { id: item.id, username: item.username, host: item.host }, + }); + + if (parsed.data.verify) { + const verified = await verifySavedMailbox(BigInt(item.id)); + if (!verified.ok) { + await writeAudit({ + actor: guard.session.username!, + action: "MAILBOX_TEST", + payload: { id: item.id, ok: false, error: verified.error }, + }); + return fail( + "IMAP_TEST_FAILED", + `已写入配置但连通失败:${verified.error}`, + 502, + { item }, + ); + } + await writeAudit({ + actor: guard.session.username!, + action: "MAILBOX_TEST", + payload: { id: item.id, ok: true, unseen: verified.unseen }, + }); + return ok({ ...item, last_test_ok: true, unseen: verified.unseen }, 201); + } + + return ok(item, 201); + } catch (err) { + if ( + err && + typeof err === "object" && + "code" in err && + (err as { code: string }).code === "P2002" + ) { + return fail("CONFLICT", "该邮箱账号已存在", 409); + } + const msg = err instanceof Error ? err.message : "create failed"; + if (msg.startsWith("UNSUPPORTED_MAIL_DOMAIN:")) { + return fail("VALIDATION", msg.replace("UNSUPPORTED_MAIL_DOMAIN:", ""), 400); + } + return fail("INTERNAL", msg, 500); + } +} diff --git a/src/app/api/settings/oauth/route.ts b/src/app/api/settings/oauth/route.ts new file mode 100644 index 0000000..53c6e7c --- /dev/null +++ b/src/app/api/settings/oauth/route.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; +import { fail, ok, requireAdmin } from "@/lib/api"; +import { writeAudit } from "@/services/audit"; +import { + getOAuthSettingsPublic, + upsertOAuthSettings, +} from "@/services/oauth/settings-config"; + +const PutSchema = z.object({ + public_base_url: z.string().url().max(512), + google_client_id: z.string().max(256).optional().default(""), + google_client_secret: z.string().max(512).optional(), + ms_client_id: z.string().max(256).optional().default(""), + ms_client_secret: z.string().max(512).optional(), + ms_tenant: z.string().max(64).optional().default("common"), +}); + +export async function GET() { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + const item = await getOAuthSettingsPublic(); + return ok(item); +} + +export async function PUT(req: Request) { + const guard = await requireAdmin(); + if (guard.response) return guard.response; + + let body: unknown; + try { + body = await req.json(); + } catch { + return fail("VALIDATION", "Invalid JSON", 400); + } + + const parsed = PutSchema.safeParse(body); + if (!parsed.success) { + return fail("VALIDATION", parsed.error.issues[0]?.message ?? "Invalid", 400); + } + + const d = parsed.data; + const googleClientId = d.google_client_id.replace(/\s+/g, "").trim(); + const msClientId = d.ms_client_id.replace(/\s+/g, "").trim(); + if ( + googleClientId && + !googleClientId.endsWith(".apps.googleusercontent.com") + ) { + return fail( + "VALIDATION", + "Google Client ID 须以 .apps.googleusercontent.com 结尾(请用「Web 应用」OAuth 客户端)", + 400, + ); + } + + const item = await upsertOAuthSettings({ + publicBaseUrl: d.public_base_url, + googleClientId, + googleClientSecret: d.google_client_secret, + msClientId, + msClientSecret: d.ms_client_secret, + msTenant: d.ms_tenant, + }); + + await writeAudit({ + actor: guard.session.username!, + action: "OAUTH_SETTINGS_UPDATE", + payload: { + public_base_url: item.public_base_url, + google_client_id: item.google_client_id ? "(set)" : "", + has_google_secret: item.has_google_secret, + ms_client_id: item.ms_client_id ? "(set)" : "", + has_ms_secret: item.has_ms_secret, + ms_tenant: item.ms_tenant, + source: item.source, + }, + }); + + return ok(item); +} diff --git a/src/app/api/shipping-lines/route.ts b/src/app/api/shipping-lines/route.ts new file mode 100644 index 0000000..0801fba --- /dev/null +++ b/src/app/api/shipping-lines/route.ts @@ -0,0 +1,10 @@ +import { listShippingLines } from "@/services/cc/shipping-lines"; +import { ok, requireSession } from "@/lib/api"; + +export async function GET() { + const guard = await requireSession(); + if (guard.response) return guard.response; + + const items = await listShippingLines(); + return ok({ items }); +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..8705e16 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect } from "react"; +import { Button, Result } from "antd"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+ + 重试 + + } + /> +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..c843a11 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,918 @@ +:root { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --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; + --color-type-new: #2563eb; + --color-type-transfer: #f59e0b; + --color-type-hold: #8b5cf6; + --color-type-label: #ec4899; + --color-type-unknown: #6b7280; + --radius-btn: 6px; + --radius-card: 8px; + --radius-modal: 12px; + --radius-pill: 12px; + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.15); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + min-width: 1280px; + margin: 0; + padding: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +a { + color: var(--color-primary); + text-decoration: none; +} + +a:hover { + color: var(--color-primary-hover); +} + +.ops-main { + margin: 0 auto; + max-width: 1440px; + min-height: calc(100vh - 56px); + padding: 20px 24px 40px; +} + +/* —— 邮件详情:降噪分层 —— */ +.mail-detail { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.mail-detail-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px 24px; + align-items: start; + padding: 16px 18px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); +} + +.mail-detail-hero-back { + grid-column: 1 / -1; + justify-self: start; + margin: 0 0 8px; + height: 36px; + padding: 0 14px; + font-weight: 600; + color: var(--color-text); + background: #fff; + border: 1px solid var(--color-border); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06); +} + +.mail-detail-hero-back:hover { + color: var(--color-primary, #1677ff) !important; + border-color: var(--color-primary, #1677ff); + background: #f5f9ff; +} + +.mail-detail-kicker { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + margin-bottom: 6px; +} + +.mail-detail-title { + margin: 0 !important; + font-size: 18px !important; + line-height: 1.35 !important; + font-weight: 650 !important; + letter-spacing: -0.01em; + color: var(--color-text); +} + +.mail-detail-subject { + margin-top: 4px; + font-size: 12.5px; + color: var(--color-text-secondary); + line-height: 1.4; + max-width: 72ch; +} + +.mail-detail-meta { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + font-size: 12.5px; + color: var(--color-text-secondary); +} + +.mail-detail-meta .mono { + color: #374151; +} + +.mail-detail-actions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + padding-top: 4px; +} + +.cc-ui-mode-switch { + font-weight: 500; +} + +.mail-detail-actions-hint { + max-width: 280px; + text-align: right; + font-size: 12px; + color: var(--color-text-secondary); + line-height: 1.4; +} + +/* 概览:一屏扫清意图,不重复柜/提单正文 */ +.mail-overview { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr); + gap: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + overflow: hidden; +} + +@media (max-width: 1100px) { + .mail-overview { + grid-template-columns: 1fr; + } +} + +.mail-overview-main { + padding: 14px 18px 16px; + border-right: 1px solid var(--color-border); +} + +@media (max-width: 1100px) { + .mail-overview-main { + border-right: none; + border-bottom: 1px solid var(--color-border); + } +} + +.mail-overview-aside { + padding: 14px 18px 16px; + background: #fafbfc; +} + +.mail-overview-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #9ca3af; + margin-bottom: 8px; +} + +.mail-overview-narrative { + margin: 0; + font-size: 14px; + line-height: 1.55; + color: #1f2937; + max-width: 68ch; +} + +.mail-overview-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.mail-overview-chip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 24px; + padding: 0 8px; + border-radius: 4px; + border: 1px solid #e5e7eb; + background: #fff; + font-size: 12px; + color: #374151; +} + +.mail-overview-chip em { + font-style: normal; + color: #9ca3af; + margin-right: 2px; +} + +.mail-overview-facts { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 8px 10px; + font-size: 13px; + align-items: baseline; +} + +.mail-overview-facts dt { + margin: 0; + color: #9ca3af; + font-size: 12px; +} + +.mail-overview-facts dd { + margin: 0; + color: #111827; + min-width: 0; + word-break: break-word; +} + +.mail-overview-next { + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed #e5e7eb; + font-size: 12.5px; + line-height: 1.45; + color: #4b5563; +} + +.mail-overview-next strong { + color: #111827; + font-weight: 600; + margin-right: 6px; +} + +.mail-biz { + display: flex; + flex-direction: column; + gap: 16px; +} + +.mail-biz-units { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mail-unit { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + overflow: hidden; +} + +.mail-unit-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + padding: 10px 16px; + border-bottom: 1px solid var(--color-border); + background: linear-gradient(180deg, #fcfcfd 0%, #f8fafc 100%); +} + +.mail-unit-head-left { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + min-width: 0; +} + +.mail-unit-index { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + height: 22px; + padding: 0 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: #2563eb; +} + +.mail-unit-title { + font-size: 14px; + font-weight: 600; + color: #111827; +} + +.mail-unit-source { + font-size: 12px; + color: #9ca3af; +} + +.mail-unit-head-right { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.mail-unit-body { + padding: 14px 16px 16px; +} + +.mail-unit-summary { + margin: 0 0 12px; + font-size: 12.5px; + line-height: 1.5; + color: #6b7280; +} + +.mail-unit-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #f1f5f9; +} + +/* 表单分区 */ +.cc-fo-section { + margin-bottom: 4px; +} + +.cc-fo-section-title { + margin: 4px 0 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #9ca3af; +} + +.cc-fo-grid-3 { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px 16px; + margin-bottom: 12px; +} + +.cc-fo-grid-2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; + margin-bottom: 12px; +} + +.cc-fo-checks { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 20px; + min-height: 32px; + padding-top: 22px; +} + +.mail-support { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + overflow: hidden; +} + +.mail-support .ant-collapse { + background: transparent; +} + +.mail-support .ant-collapse-header { + font-size: 13px !important; + color: #4b5563 !important; +} + +.mail-support-card .ant-card-head { + min-height: 40px; + padding: 0 16px; + font-size: 13px; +} + +.mail-support-card .ant-card-body { + padding: 12px 16px; +} + +.shipment-row-invalid { + background: #f3f4f6; + color: var(--color-text-secondary); +} + +.sticky-footer { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; + padding: 0 32px; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +/* —— CC FormOrder 视觉 —— */ +.cc-fo-root { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 6px; + overflow: hidden; +} + +.cc-fo-wizard { + display: flex; + list-style: none; + margin: 0; + padding: 0; + overflow: hidden; + background: #f8fafc; + border-bottom: 1px solid #e5e7eb; +} + +.cc-fo-wizard > li { + position: relative; + display: flex; + align-items: center; + height: 36px; + padding: 0 24px 0 28px; + color: #6b7280; + font-size: 13px; + cursor: pointer; + background: transparent; + border-right: 1px solid #e5e7eb; +} + +.cc-fo-wizard > li .step { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-right: 8px; + border-radius: 50%; + background: #d1d5db; + color: #fff; + font-size: 11px; + font-weight: 600; +} + +.cc-fo-wizard > li .chevron { + display: none; +} + +.cc-fo-wizard > li.active { + background: #eff6ff; + color: #1d4ed8; + font-weight: 600; +} + +.cc-fo-wizard > li.active .step { + background: #2563eb; + color: #fff; +} + +.cc-fo-wizard > li.complete { + color: #15803d; +} + +.cc-fo-wizard > li.complete .step { + background: #16a34a; + color: #fff; +} + +.cc-fo-body { + padding: 14px 16px 10px; +} + +.cc-fo-field { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + min-height: 0; +} + +.cc-fo-label { + flex: none; + padding-right: 0; + line-height: 1.3; + text-align: left; + color: #6b7280; + font-size: 12px; + white-space: nowrap; +} + +.cc-fo-control { + flex: 1; + min-width: 0; +} + +.cc-fo-error { + margin-top: 2px; + font-size: 12px; + color: #ff4d4f; +} + +.cc-fo-hr { + margin: 10px 0 14px; + border: none; + border-top: 1px solid #e8e8e8; +} + +.cc-fo-textarea-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 16px; + margin-top: 4px; +} + +.cc-fo-textarea-row .cc-fo-field { + min-height: 0; +} + +.cc-fo-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.cc-fo-tool-btn { + height: 30px; + padding: 0 12px; + border: none; + border-radius: 5px; + background: #3498db; + color: #fff; + font-size: 13px; + cursor: pointer; + line-height: 30px; +} + +.cc-fo-tool-btn:hover { + background: #2980b9; +} + +.cc-fo-grid-wrap { + border: 1px solid #ccc; + background: #fff; +} + +.cc-fo-totals { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 48px; + padding: 6px 120px 6px 8px; + border-top: 1px solid #ddd; + background: #fafafa; + font-size: 13px; + font-weight: 600; +} + +.cc-fo-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 16px 12px; + border-top: 1px solid #eee; + background: #f9f9f9; +} + +.cc-fo-finish.ant-btn-primary { + background: #87b87f !important; + border-color: #87b87f !important; +} + +.cc-fo-finish.ant-btn-primary:hover:not(:disabled) { + background: #629b58 !important; + border-color: #629b58 !important; +} + +.cc-fo-empty { + padding: 28px 16px; + text-align: center; + color: #8c8c8c; + font-size: 13px; + border-top: 1px solid #eee; + background: #fafafa; +} + +/* 只读详情:字色保持可读(非灰显) */ +.cc-fo-readonly .ant-input[disabled], +.cc-fo-readonly .ant-input-disabled, +.cc-fo-readonly .ant-input-textarea textarea[disabled], +.cc-fo-readonly .ant-select-disabled .ant-select-selection-item, +.cc-fo-readonly .ant-checkbox-disabled + span { + color: rgba(0, 0, 0, 0.88) !important; + -webkit-text-fill-color: rgba(0, 0, 0, 0.88); + opacity: 1 !important; +} + +.cc-fo-readonly .ant-input[disabled], +.cc-fo-readonly textarea.ant-input[disabled] { + background: #fff !important; + cursor: default; +} + +.cc-ship-cell, +.cc-ship-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + line-height: 1.35; + color: rgba(0, 0, 0, 0.88); +} + +.cc-ship-cell .mono { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + max-width: 100%; +} + +/* CC IndexTruck 转仓界面复刻 */ +.cc-tr-root { + border: 1px solid #d9d9d9; + border-radius: 2px; + background: #fff; +} + +.cc-tr-toolbar-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 12px; + margin: 12px 0; + padding: 8px 10px; + background: #fff; + border: 1px solid #d9d9d9; +} + +.cc-tr-totals-table { + border-collapse: collapse; + font-size: 12px; + min-width: 420px; +} + +.cc-tr-totals-table th, +.cc-tr-totals-table td { + border: 1px solid #d9d9d9; + padding: 5px 10px; + text-align: center; +} + +.cc-tr-totals-table tbody td { + color: #dc2626; + font-weight: 600; +} + +.cc-tr-btn-group { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-left: auto; +} + +.cc-tr-btn { + height: 28px; + padding: 0 10px; + border: 1px solid #c9c9c9; + border-radius: 2px; + background: linear-gradient(#fff, #f3f3f3); + color: #666; + font-size: 12px; + cursor: not-allowed; + line-height: 26px; +} + +.cc-tr-btn-primary { + border-color: #bfbfbf; + color: #666; +} + +.cc-tr-pairs { + font-size: 13px; + color: #374151; + margin-bottom: 8px; +} + +.cc-tr-pair-tag { + display: inline-block; + margin-left: 8px; + padding: 1px 8px; + background: #fff7ed; + border: 1px solid #f1c27d; + border-radius: 2px; + color: #a16207; + font-size: 12px; +} + +.cc-tr-fbacode-panel { + margin-top: 16px; + padding: 12px; + border: 1px solid #d9d9d9; + border-radius: 2px; + background: #fafafa; +} + +.cc-tr-fbacode-title { + font-weight: 600; + margin-bottom: 8px; + color: #374151; +} + +.cc-tr-hint { + font-size: 13px; + margin-bottom: 6px; +} + +.cc-tr-hint-warn { + color: #ea580c; +} + +.cc-tr-hint-danger { + color: #dc2626; +} + +.cc-tr-scope-hint { + font-size: 13px; + color: #666; + margin-bottom: 12px; +} + +.cc-tr-scope-hint strong { + color: #c2410c; + font-weight: 600; +} + +.cc-tr-titlebar { + margin-bottom: 8px; + color: #333; + font-size: 15px; + font-weight: 600; +} + +.cc-tr-toolbar-lite { + margin-bottom: 10px; + padding: 0; +} + +.cc-out-root .ant-table { + font-size: 12px; +} + +.cc-out-root .ant-table-tbody > tr > td { + padding: 4px 6px; +} + +/* —— 管理端 / 客户端 portal 差异 chrome —— */ +.cc-fo-portal-banner, +.cc-wo-dialog-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin: 0 0 12px; + padding: 8px 12px; + border-radius: 4px; + font-size: 13px; + line-height: 1.4; +} + +.cc-fo-portal-banner.cc-portal-client, +.cc-wo-dialog-head.cc-portal-client { + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #1e40af; +} + +.cc-fo-portal-banner.cc-portal-admin, +.cc-wo-dialog-head.cc-portal-admin { + background: #fff7ed; + border: 1px solid #fed7aa; + color: #9a3412; +} + +.cc-wo-dialog-title { + font-weight: 600; +} + +.cc-wo-dialog-src { + font-size: 11px; + opacity: 0.85; + white-space: nowrap; +} + +.cc-fo-admin-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding: 8px 10px; + background: #fafafa; + border: 1px solid #e5e7eb; + border-radius: 4px; +} + +.cc-fo-admin-toolbar-hint, +.cc-fo-admin-action-hint { + font-size: 12px; + color: #6b7280; +} + +.cc-fo-admin-action-hint { + margin-right: auto; +} + +.cc-fo-root.cc-portal-admin { + border-left: 3px solid #f97316; + padding-left: 8px; +} + +.cc-fo-root.cc-portal-client { + border-left: 3px solid #3b82f6; + padding-left: 8px; +} + +.cc-wo-root.cc-portal-admin { + border-left: 3px solid #f97316; + padding-left: 8px; +} + +.cc-wo-root.cc-portal-client { + border-left: 3px solid #3b82f6; + padding-left: 8px; +} + +.cc-fo-step2-admin { + margin-top: 16px; + padding-top: 12px; + border-top: 1px dashed #e5e7eb; +} + +.cc-fo-packing-tips { + margin-bottom: 10px; + padding: 8px 10px; + font-size: 12px; + line-height: 1.55; + color: #4b5563; + background: rgba(37, 99, 235, 0.06); + border-radius: 6px; +} + +.cc-fo-packing-tips-title { + font-weight: 600; + color: #1f2937; + cursor: help; + width: fit-content; +} + +.cc-fo-packing-tips-list { + display: none; + margin: 6px 0 0; + padding-left: 18px; +} + +.cc-fo-packing-tips:hover .cc-fo-packing-tips-list, +.cc-fo-packing-tips:focus-within .cc-fo-packing-tips-list { + display: block; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..8e480ab --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { AntdProvider } from "@/components/providers/AntdProvider"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "邮件自动预报", + description: "邮件自动预报系统 — 运营前端", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..5b08621 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/mails"); +} diff --git a/src/components/AuthGuard.tsx b/src/components/AuthGuard.tsx new file mode 100644 index 0000000..74dea7e --- /dev/null +++ b/src/components/AuthGuard.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AuthSessionProvider } from "@/components/providers/AuthSessionProvider"; + +/** @deprecated 逻辑已并入 AuthSessionProvider;保留薄包装以兼容既有 import */ +export function AuthGuard({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/components/CcCustomerInstructionPanel.tsx b/src/components/CcCustomerInstructionPanel.tsx new file mode 100644 index 0000000..29d5fb6 --- /dev/null +++ b/src/components/CcCustomerInstructionPanel.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Descriptions } from "antd"; + +export type CcCustomerInstructionValue = { + roundAt?: string; + roundFrom?: string; + subject?: string; + body: string; +}; + +export function CcCustomerInstructionPanel({ + value, +}: { + value: CcCustomerInstructionValue; +}) { + return ( +
+ +
+        {value.body || "(无正文)"}
+      
+
+ ); +} diff --git a/src/components/CcDoUploadPanel.tsx b/src/components/CcDoUploadPanel.tsx new file mode 100644 index 0000000..caea8b6 --- /dev/null +++ b/src/components/CcDoUploadPanel.tsx @@ -0,0 +1,142 @@ +"use client"; + +import type { CSSProperties, ReactNode } from "react"; +import { Input, Table, Typography } from "antd"; +import { + listDoFilenames, + resolveDoContainerNo, +} from "@/services/parse/do-upload-extract"; + +const fieldRow: CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: "8px 12px", + marginBottom: 8, +}; + +function Field({ + label, + children, + required, +}: { + label: ReactNode; + children: ReactNode; + required?: boolean; +}) { + return ( +
+
+ {label} + {required ? * : null} +
+
{children}
+
+ ); +} + +export type CcDoUploadFormValue = { + F_ContainerNo: string; + F_ContainerTaskId: string; + F_DOFile: string; + filenames: string[]; +}; + +export type CcDoUploadPanelProps = { + mode: "readonly" | "edit"; + value: CcDoUploadFormValue; + onChange?: (patch: Partial) => void; +}; + +export function CcDoUploadPanel({ + mode, + value, + onChange, +}: CcDoUploadPanelProps) { + const readonly = mode === "readonly"; + const patch = (p: Partial) => { + if (readonly || !onChange) return; + onChange(p); + }; + + return ( +
+
上传 DO(F_DOFile)
+
+ 完整链路(客户端可预览):SaveFieldValue(F_DOFile=文件夹id){" "} + → annexes/upload 传 PDF → 客户端用{" "} + annexes/list?data=文件夹id 展示。勿把{" "} + F_DOFile 写成单文件 id,否则弹窗「暂无数据」。 +
+ +
+ + patch({ F_ContainerNo: e.target.value })} + /> + + + patch({ F_ContainerTaskId: e.target.value })} + /> + +
+ +
+ + + +
+
+ + {value.filenames.length === 0 ? ( + + 未识别到 DO 附件(期望如 WHSU8127240-DO.pdf) + + ) : ( +
({ name }))} + columns={[{ title: "待上传 DO 附件", dataIndex: "name" }]} + /> + )} + + ); +} + +export function buildCcDoUploadFormValue(input: { + subject?: string; + body?: string | null; + containerNo?: string | null; + filenames?: string[]; + /** 预报成功后的 CC 柜任务 id(SaveContainer data) */ + containerTaskId?: string | null; +}): CcDoUploadFormValue { + const all = input.filenames || []; + const filenames = listDoFilenames(all); + const cn = resolveDoContainerNo({ + containerNo: input.containerNo, + subject: input.subject, + body: input.body, + filenames: filenames.length ? filenames : all, + }); + + return { + F_ContainerNo: cn, + F_ContainerTaskId: (input.containerTaskId || "").trim(), + F_DOFile: "", + filenames, + }; +} diff --git a/src/components/CcForecastFormOrder.tsx b/src/components/CcForecastFormOrder.tsx new file mode 100644 index 0000000..649d472 --- /dev/null +++ b/src/components/CcForecastFormOrder.tsx @@ -0,0 +1,755 @@ +"use client"; + +import { useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { + Button, + Checkbox, + Input, + Select, + Space, +} from "antd"; +import { + CC_SHIPMENT_COLUMNS, + ShipmentVirtualTable, +} from "@/components/ShipmentVirtualTable"; +import { + OPERATION_TYPE_OPTIONS, + TRANS_MODE_OPTIONS, +} from "@/constants/ui-copy"; +import { PACKING_FILL_TIPS } from "@/services/parse/packing-fill-rules"; +import { stripMobileMailChrome } from "@/services/parse/instruction-lexicon"; +import type { OperationType, ShipmentRow, TransMode } from "@/types/mail"; +import type { CcUiMode } from "@/constants/cc-ui-mode"; + +const CABINET_OPTIONS = [ + "20GP", + "20HQ", + "40GP", + "40HQ", + "40HC", + "45HQ", + "45HC", + "45GP", +].map((v) => ({ value: v, label: v })); + +export type CcForecastFormValue = { + customerName: string; + F_TransMode: TransMode; + F_OperationType: OperationType; + F_ContainerNo: string; + F_CabinetType: string; + F_ETD: string; + F_ETA: string; + F_LoadPort: string; + F_Dock: string; + F_BLCopyCode: string; + F_ContainerId: string; + F_ShippingLineId: string; + F_CustomerService: string; + F_OverWeight: boolean; + F_Classis: 0 | 1; + F_Instruction: string; + F_MemoRemark: string; +}; + +export type CcForecastFormOrderProps = { + mode: "readonly" | "edit"; + value: CcForecastFormValue; + shipments: ShipmentRow[]; + shippingLines?: Array<{ value: string; label: string }>; + selected?: Set; + onSelectedChange?: (next: Set) => void; + onChange?: (patch: Partial) => void; + errors?: Record; + step?: number; + onStepChange?: (step: number) => void; + softLimitExceeded?: boolean; + containerNoLocked?: boolean; + onFinish?: () => void; + finishLoading?: boolean; + finishDisabled?: boolean; + finishLabel?: string; + /** 客户端 tab 向导 / 管理端 增行+数据导入工具条 */ + portal?: CcUiMode; +}; + +const fieldRow: CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + gap: "10px 16px", + marginBottom: 12, +}; + +const fieldRow2: CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: "10px 16px", + marginBottom: 12, +}; + +function SectionTitle({ children }: { children: ReactNode }) { + return
{children}
; +} + +function Field({ + label, + required, + error, + children, + style, +}: { + label: ReactNode; + required?: boolean; + error?: string; + children: ReactNode; + style?: CSSProperties; +}) { + return ( +
+
+ {label} + {required ? ( + * + ) : null} +
+
+ {children} + {error ?
{error}
: null} +
+
+ ); +} + +function ChevronSteps({ + step, + onChange, +}: { + step: number; + onChange: (s: number) => void; +}) { + return ( +
    +
  • 0 ? "complete" : ""} + onClick={() => onChange(0)} + > + {step > 0 ? "✓" : "1"} + 基础信息 + +
  • +
  • onChange(1)} + > + 2 + 货件列表 + +
  • +
+ ); +} + +export function CcForecastFormOrder({ + mode, + value, + shipments, + shippingLines = [], + selected, + onSelectedChange, + onChange, + errors = {}, + step: controlledStep, + onStepChange, + softLimitExceeded, + containerNoLocked, + onFinish, + finishLoading, + finishDisabled, + finishLabel = "确认导入", + portal = "admin", +}: CcForecastFormOrderProps) { + const readonly = mode === "readonly"; + const isAdmin = portal === "admin"; + const [innerStep, setInnerStep] = useState(0); + const [queryDraft, setQueryDraft] = useState(""); + const [query, setQuery] = useState(""); + const step = controlledStep ?? innerStep; + const setStep = (s: number) => { + onStepChange?.(s); + if (controlledStep === undefined) setInnerStep(s); + }; + + const patch = (p: Partial) => { + if (!readonly) onChange?.(p); + }; + + const runQuery = () => setQuery(queryDraft); + + const validRows = useMemo( + () => shipments.filter((s) => s.row_status === "VALID"), + [shipments], + ); + + const selectedSet = selected ?? new Set(validRows.map((r) => r.row_index)); + + const filtered = useMemo(() => { + const tokens = query + .split(/[\s,,\n\r]+/) + .map((t) => t.trim().toLowerCase()) + .filter(Boolean); + if (!tokens.length) return shipments; + return shipments.filter((s) => { + const hay = [ + s.F_FBACode, + s.F_ShipmentID, + s.F_FBAID, + s.F_ReferenceId, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return tokens.some((t) => hay.includes(t)); + }); + }, [shipments, query]); + + const selectedRows = useMemo( + () => shipments.filter((s) => selectedSet.has(s.row_index)), + [shipments, selectedSet], + ); + + const totals = useMemo(() => { + const rows = selectedRows.length ? selectedRows : shipments; + let ctns = 0; + let cbm = 0; + let weight = 0; + for (const r of rows) { + if (selectedRows.length && r.row_status !== "VALID") continue; + ctns += r.F_CTNS ?? 0; + cbm += r.F_CBM ?? 0; + weight += r.F_Weight ?? 0; + } + return { + ctns, + cbm: Math.round(cbm * 100) / 100, + weight: Math.round(weight * 100) / 100, + }; + }, [selectedRows, shipments]); + + const ph = "==请选择=="; + + return ( +
+
+ {isAdmin + ? "管理端 · 集装箱修改(增行 / 数据导入 / 模板下载)" + : "客户端 · 新建集装箱(基础信息 Tab + 货件列表 Tab)"} +
+ + {/* 客户端:步骤条;管理端:顶栏工具按钮对齐货件列表 */} + {isAdmin ? ( +
+ + + + + 对齐管理端货件列表操作条(本页预填;提交仍走 SaveContainer) + +
+ ) : ( + { + if (readonly || s === 0 || s === 1) setStep(s); + }} + /> + )} + +
+ {(isAdmin || step === 0) ? ( +
+ {isAdmin ? ( + 修改 · 基础信息 + ) : ( + 基础信息(客户端字段序) + )} + {/* 客户端顺序:运输方式、业务类型、柜号…;管理端带客户行 */} + {isAdmin ? ( +
+ + + + + patch({ F_TransMode: v as TransMode })} + /> + + + + patch({ F_ContainerNo: e.target.value.toUpperCase() }) + } + /> + +
+ )} + + {isAdmin ? ( +
+ + + patch({ F_ContainerNo: e.target.value.toUpperCase() }) + } + /> + + + patch({ F_CabinetType: v })} + /> + + + + + + + patch({ F_BLCopyCode: e.target.value.toUpperCase() }) + } + /> + +
+ )} + +
+ + patch({ F_ETD: e.target.value })} + /> + + + patch({ F_ETA: e.target.value })} + /> + + + patch({ F_LoadPort: e.target.value })} + /> + +
+ +
+ + patch({ F_Dock: e.target.value })} + /> + + {isAdmin ? ( + + + patch({ F_BLCopyCode: e.target.value.toUpperCase() }) + } + /> + + ) : ( + + + + ) : ( + + + patch({ F_CustomerService: e.target.value }) + } + /> + + )} +
+ +
+ {isAdmin ? ( + + + patch({ F_CustomerService: e.target.value }) + } + /> + + ) : null} +
+ {!isAdmin ? ( + + patch({ F_OverWeight: e.target.checked }) + } + > + 是否超重柜 + + ) : null} + + patch({ F_Classis: e.target.checked ? 1 : 0 }) + } + > + 自带车架 + +
+
+ +
+ + patch({ F_Instruction: e.target.value })} + /> + + + patch({ F_MemoRemark: e.target.value })} + /> + +
+
+ ) : null} + + {(isAdmin || step === 1) ? ( +
+ {isAdmin ? ( + 货件列表 + ) : null} +
+
货件填写须知
+
    + {PACKING_FILL_TIPS.map((t) => ( +
  1. {t}
  2. + ))} +
+
+
+ setQueryDraft(e.target.value)} + onPressEnter={(e) => { + e.preventDefault(); + runQuery(); + }} + /> + +
+ +
+ { + if (readonly || !onSelectedChange) return; + const next = new Set(selectedSet); + if (checked) next.add(rowIndex); + else next.delete(rowIndex); + onSelectedChange(next); + }} + /> + {shipments.length === 0 ? ( +
+ 货件列表来自卡派清单等表格附件;当前无解析出行。请检查是否附带 + xlsx/xls,或重新解析。 +
+ ) : null} +
+ 合计: + {totals.ctns} + {totals.cbm} + {totals.weight} +
+
+ + {!readonly && !softLimitExceeded ? ( +
+ + + + + 有效 {validRows.length} / 共 {shipments.length} + + +
+ ) : null} +
+ ) : null} +
+ +
+ {!isAdmin ? ( + <> + + + + ) : ( + 管理端单页:柜头 + 货件一次展示 + )} + {onFinish && !readonly ? ( + + ) : ( + + )} +
+
+ ); +} + +/** 从解析结果组装 FormOrder 初值 */ +export function buildCcForecastFormValue(input: { + customerName?: string | null; + header?: { + F_TransMode?: TransMode | null; + F_OperationType?: OperationType | null; + F_ContainerNo?: string | null; + F_CabinetType?: string | null; + F_ETD?: string | null; + F_ETA?: string | null; + F_LoadPort?: string | null; + F_Dock?: string | null; + F_BLCopyCode?: string | null; + F_ShippingLineId?: string | null; + F_Classis?: 0 | 1 | null; + F_Instruction?: string | null; + F_MemoRemark?: string | null; + } | null; + modules?: { + customer_name?: string; + bl_no?: string; + container_no?: string; + port?: string; + cabinet_type?: string; + etd?: string; + eta?: string; + vessel_voyage?: string; + service?: string; + classis?: 0 | 1; + } | null; +}): CcForecastFormValue { + const h = input.header || {}; + const m = input.modules || {}; + const vesselInst = m.vessel_voyage ? `船名航次 ${m.vessel_voyage}` : ""; + return { + customerName: input.customerName || m.customer_name || "", + F_TransMode: (h.F_TransMode ?? 0) as TransMode, + F_OperationType: (h.F_OperationType ?? 0) as OperationType, + F_ContainerNo: h.F_ContainerNo || m.container_no || "", + F_CabinetType: h.F_CabinetType || m.cabinet_type || "40HQ", + F_ETD: h.F_ETD || m.etd || "", + F_ETA: h.F_ETA || m.eta || "", + F_LoadPort: h.F_LoadPort || "", + F_Dock: h.F_Dock || m.port || "", + F_BLCopyCode: h.F_BLCopyCode || m.bl_no || "", + F_ContainerId: "", + F_ShippingLineId: h.F_ShippingLineId || "", + F_CustomerService: "", + F_OverWeight: false, + F_Classis: (h.F_Classis ?? m.classis ?? 0) as 0 | 1, + F_Instruction: stripMobileMailChrome( + h.F_Instruction || vesselInst || "", + ), + F_MemoRemark: + h.F_MemoRemark || + [m.customer_name, m.service].filter(Boolean).join(" / ") || + "", + }; +} diff --git a/src/components/CcHoldMoveTypePanel.tsx b/src/components/CcHoldMoveTypePanel.tsx new file mode 100644 index 0000000..81800ca --- /dev/null +++ b/src/components/CcHoldMoveTypePanel.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useMemo, type CSSProperties, type ReactNode } from "react"; +import { Checkbox, Input, Select, Table } from "antd"; +import type { ChannelCount } from "@/services/parse/ops-subject-template"; +import { + HOLD_TRANSPORTER_OPTIONS, + type CcHoldFormValue, + type CcHoldShipmentRow, +} from "@/types/cc-ops"; +import type { MailRecordModules, ShipmentRow } from "@/types/mail"; + +const fieldRow: CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: "8px 12px", + marginBottom: 8, +}; + +function Field({ + label, + children, + style, +}: { + label: ReactNode; + children: ReactNode; + style?: CSSProperties; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +export type CcHoldMoveTypePanelProps = { + mode: "readonly" | "edit"; + value: CcHoldFormValue; + rows: CcHoldShipmentRow[]; + onChange?: (patch: Partial) => void; +}; + +export function CcHoldMoveTypePanel({ + mode, + value, + rows, + onChange, +}: CcHoldMoveTypePanelProps) { + const readonly = mode === "readonly"; + const selected = useMemo( + () => new Set(value.selectedKeys), + [value.selectedKeys], + ); + const totalCtns = useMemo( + () => + rows + .filter((r) => selected.has(r.rowKey)) + .reduce((s, r) => s + (r.F_CTNS || 0), 0), + [rows, selected], + ); + + const patch = (p: Partial) => { + if (readonly || !onChange) return; + onChange(p); + }; + + const toggle = (rowKey: string, checked: boolean) => { + const next = new Set(selected); + if (checked) next.add(rowKey); + else next.delete(rowKey); + patch({ selectedKeys: [...next] }); + }; + + const toggleAll = (checked: boolean) => { + patch({ selectedKeys: checked ? rows.map((r) => r.rowKey) : [] }); + }; + + return ( +
+
留仓 · 转渠道(FormMoveType)
+
+ 对齐管理端「转渠道」:将勾选货件的渠道改为目标值(默认「留仓」)。本页仅预填确认,不写 + CC。 +
+ +
+ + patch({ F_ContainerNo: e.target.value })} + /> + + +
+ + + + + + + + + + + + +
勾选行勾选件数
{selected.size}{totalCtns || "—"}
+
+ + 0 && selected.size === rows.length} + indeterminate={ + selected.size > 0 && selected.size < rows.length + } + onChange={(e) => toggleAll(e.target.checked)} + /> + ), + width: 48, + render: (_: unknown, row: CcHoldShipmentRow) => ( + toggle(row.rowKey, e.target.checked)} + /> + ), + }, + { title: "分货Id", dataIndex: "F_ID", width: 120, ellipsis: true }, + { + title: "FBACode", + dataIndex: "F_FBACode", + width: 100, + ellipsis: true, + }, + { + title: "原渠道", + dataIndex: "F_Transporter", + width: 100, + ellipsis: true, + }, + { title: "件数", dataIndex: "F_CTNS", width: 80 }, + { + title: "Customer Ref", + dataIndex: "F_ShipmentID", + width: 140, + ellipsis: true, + }, + { title: "备注", dataIndex: "F_Remark", ellipsis: true }, + ]} + locale={{ emptyText: "无货件行;可从主题渠道汇总生成伪行" }} + /> + + ); +} + +function mapChannelToTransporter(channel: string): string { + const c = channel.trim(); + if (/留仓/i.test(c)) return "留仓"; + if (/拦截|扣货/i.test(c)) return "扣货"; + if (/卡派|亚马逊卡派|TRUCK/i.test(c)) return "TRUCK"; + if (/自提/i.test(c)) return "自提"; + if (/私人/i.test(c)) return "TRUCK"; + if (/UPS/i.test(c)) return "UPS"; + if (/FedEx|FEDEX/i.test(c)) return "FEDEX"; + if (/DHL/i.test(c)) return "DHL"; + if (/USPS/i.test(c)) return "USPS"; + return c.slice(0, 30) || "留仓"; +} + +/** 从解析结果组装留仓转渠道初值 */ +export function buildCcHoldFormValue(input: { + containerNo?: string | null; + modules?: MailRecordModules | null; + shipments?: ShipmentRow[]; + channels?: ChannelCount[]; +}): { value: CcHoldFormValue; rows: CcHoldShipmentRow[] } { + const containerNo = + input.modules?.container_no || input.containerNo || ""; + const shipments = input.shipments || []; + let rows: CcHoldShipmentRow[]; + + if (shipments.length) { + rows = shipments.map((s, i) => ({ + rowKey: `s-${s.row_index ?? i}`, + F_ID: "", + F_FBACode: (s.F_FBACode || "").trim(), + F_Transporter: (s.F_Transporter || "").trim() || "—", + F_CTNS: Number(s.F_CTNS) || 0, + F_ShipmentID: String(s.F_ShipmentID || ""), + F_Remark: String(s.F_Remark || ""), + })); + } else { + rows = (input.channels || []).map((c, i) => ({ + rowKey: `ch-${i}-${c.channel}`, + F_ID: "", + F_FBACode: "", + F_Transporter: mapChannelToTransporter(c.channel), + F_CTNS: c.ctns, + F_ShipmentID: "", + F_Remark: `${c.channel}-${c.ctns}件`, + })); + } + + const preferHold = rows.filter((r) => + /留仓|扣货|拦截/.test(r.F_Transporter + r.F_Remark), + ); + const selectedKeys = (preferHold.length ? preferHold : rows).map( + (r) => r.rowKey, + ); + + return { + value: { + F_Transporter: "留仓", + F_ContainerNo: containerNo, + selectedKeys, + }, + rows, + }; +} diff --git a/src/components/CcOutStockPanel.tsx b/src/components/CcOutStockPanel.tsx new file mode 100644 index 0000000..85fabe2 --- /dev/null +++ b/src/components/CcOutStockPanel.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { useMemo, type CSSProperties, type ReactNode } from "react"; +import { Input, Select, Table } from "antd"; +import { + EXPRESS_TRANSPORTER_OPTIONS, + OUT_STOCK_TYPE_OPTIONS, + type CcOutStockFormValue, + type CcOutStockHeader, + type CcOutStockShipmentRow, +} from "@/types/cc-ops"; +import type { MailRecordModules, ShipmentRow } from "@/types/mail"; + +const fieldRow: CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + gap: "8px 12px", + marginBottom: 8, +}; + +const fieldRowWide: CSSProperties = { + display: "grid", + gridTemplateColumns: "1fr", + gap: "8px 12px", + marginBottom: 8, +}; + +function Field({ + label, + children, + style, +}: { + label: ReactNode; + children: ReactNode; + style?: CSSProperties; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +export type CcOutStockPanelProps = { + mode: "readonly" | "edit"; + value: CcOutStockFormValue; + onChange?: (next: CcOutStockFormValue) => void; +}; + +export function CcOutStockPanel({ + mode, + value, + onChange, +}: CcOutStockPanelProps) { + const readonly = mode === "readonly"; + const header = value.header; + + const patchHeader = (p: Partial) => { + if (readonly || !onChange) return; + onChange({ ...value, header: { ...header, ...p } }); + }; + + const patchRow = (rowKey: string, p: Partial) => { + if (readonly || !onChange) return; + onChange({ + ...value, + shipments: value.shipments.map((r) => + r.rowKey === rowKey ? { ...r, ...p } : r, + ), + }); + }; + + const splitTotal = useMemo( + () => + value.shipments.reduce((s, r) => s + (Number(r.SplitCTNS) || 0), 0), + [value.shipments], + ); + + return ( +
+
出库指令(客户侧 SaveCustOutStock)
+
+ 对齐 cc-client 集装箱货件「出库指令」弹窗。出库件数对应字段{" "} + SplitCTNS。本页仅预填确认,不写 CC。 +
+ +
( + + patchRow(row.rowKey, { SplitCTNS: e.target.value }) + } + /> + ), + }, + { + title: "FBAID", + width: 100, + render: (_: unknown, row: CcOutStockShipmentRow) => ( + + patchRow(row.rowKey, { F_FBAID: e.target.value }) + } + /> + ), + }, + { + title: "PO", + width: 90, + render: (_: unknown, row: CcOutStockShipmentRow) => ( + patchRow(row.rowKey, { F_PO: e.target.value })} + /> + ), + }, + { + title: "内标数", + width: 80, + render: (_: unknown, row: CcOutStockShipmentRow) => ( + + patchRow(row.rowKey, { F_LabelNumbers: e.target.value }) + } + /> + ), + }, + { + title: "外标数", + width: 80, + render: (_: unknown, row: CcOutStockShipmentRow) => ( + + patchRow(row.rowKey, { F_OuterLabelNumber: e.target.value }) + } + /> + ), + }, + { + title: "备注", + width: 120, + render: (_: unknown, row: CcOutStockShipmentRow) => ( + + patchRow(row.rowKey, { F_Remark: e.target.value }) + } + /> + ), + }, + ]} + locale={{ emptyText: "无出库货件行" }} + /> + +
+ 出库件数合计:{splitTotal || "—"} +
+ +
+
出库表头
+
+ + + + + patchHeader({ F_Transporter: v })} + allowClear + /> + + ) : ( +
+ )} +
+ + {header.F_OutStockType === "TRUCK" ? ( + <> +
+ + + patchHeader({ F_FBACode: e.target.value }) + } + /> + + + + patchHeader({ F_HouseNo: e.target.value }) + } + /> + +
+
+
+ + + patchHeader({ F_Address: e.target.value }) + } + /> + +
+ + ) : null} + +
+ + patchHeader({ F_Remark: e.target.value })} + /> + +
+
+
+ ); +} + +function guessOutStockType(text: string): string { + if (/卡派|TRUCK|亚马逊卡派/i.test(text)) return "TRUCK"; + if (/自提/i.test(text)) return "自提"; + if (/销毁/i.test(text)) return "销毁"; + if (/UPS|FEDEX|FedEx|DHL|USPS|快递/i.test(text)) return "快递"; + return ""; +} + +function guessExpressTransporter(text: string): string { + if (/UPS/i.test(text)) return "UPS"; + if (/FedEx|FEDEX/i.test(text)) return "FEDEX"; + if (/DHL/i.test(text)) return "DHL"; + if (/USPS/i.test(text)) return "USPS"; + return ""; +} + +/** 从解析结果组装出库指令初值 */ +export function buildCcOutStockFormValue(input: { + containerNo?: string | null; + modules?: MailRecordModules | null; + shipments?: ShipmentRow[]; + subject?: string; + filenames?: string[]; +}): CcOutStockFormValue { + const containerNo = + input.modules?.container_no || input.containerNo || ""; + const hintText = [ + input.subject || "", + ...(input.filenames || []), + input.modules?.service || "", + ].join(" "); + const outType = guessOutStockType(hintText); + const express = outType === "快递" ? guessExpressTransporter(hintText) : ""; + + const shipments: CcOutStockShipmentRow[] = (input.shipments || []).map( + (s, i) => { + const ctns = Number(s.F_CTNS) || 0; + return { + rowKey: `out-${s.row_index ?? i}`, + F_ContainerNo: containerNo, + F_ID: "", + F_FBACode: (s.F_FBACode || "").trim(), + F_CTNS: ctns, + F_CustomerActualPallet: "", + F_CBM: s.F_CBM != null ? String(s.F_CBM) : "", + F_Weight: s.F_Weight != null ? String(s.F_Weight) : "", + SplitCTNS: ctns > 0 ? String(ctns) : "", + F_FBAID: String(s.F_FBAID || ""), + F_PO: "", + F_LabelNumbers: "", + F_OuterLabelNumber: "", + F_Remark: String(s.F_Remark || ""), + }; + }, + ); + + // 无货件时,用主题仓码造一行空壳便于确认 + if (!shipments.length) { + const wh = /\b([A-Z]{3}\d)\b/i.exec(hintText); + shipments.push({ + rowKey: "out-placeholder", + F_ContainerNo: containerNo, + F_ID: "", + F_FBACode: wh ? wh[1].toUpperCase() : "", + F_CTNS: 0, + F_CustomerActualPallet: "", + F_CBM: "", + F_Weight: "", + SplitCTNS: "", + F_FBAID: "", + F_PO: "", + F_LabelNumbers: "", + F_OuterLabelNumber: "", + F_Remark: "", + }); + } + + return { + header: { + F_LabelFile: "", + F_OutStockType: outType, + F_Transporter: express, + F_FBACode: + outType === "TRUCK" + ? shipments.find((r) => r.F_FBACode)?.F_FBACode || "" + : "", + F_HouseNo: "", + F_Address: "", + F_Remark: "", + }, + shipments, + }; +} diff --git a/src/components/CcSettingsCard.tsx b/src/components/CcSettingsCard.tsx new file mode 100644 index 0000000..6db6816 --- /dev/null +++ b/src/components/CcSettingsCard.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + App, + Button, + Card, + Form, + Input, + Space, + Spin, + Tag, +} from "antd"; +import { + getCcSettingsApi, + testCcSettingsApi, + updateCcSettingsApi, +} from "@/lib/client-api"; +import type { CcSettings } from "@/types"; + +type FormValues = { + mock: boolean; + api_base: string; + saas_header: string; + login_mark: string; + username: string; + password?: string; +}; + +export function CcSettingsCard() { + const { message } = App.useApp(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [meta, setMeta] = useState | null>(null); + const [mockMode, setMockMode] = useState(false); + const [form] = Form.useForm(); + + const reload = useCallback(async () => { + setLoading(true); + const res = await getCcSettingsApi(); + setLoading(false); + if (!res.ok) { + message.error(res.error.message); + return; + } + setMeta({ + source: res.data.source, + has_password: res.data.has_password, + updated_at: res.data.updated_at, + }); + setMockMode(res.data.mock); + form.setFieldsValue({ + mock: res.data.mock, + api_base: res.data.api_base, + saas_header: res.data.saas_header, + login_mark: res.data.login_mark, + username: res.data.username, + password: undefined, + }); + }, [form, message]); + + useEffect(() => { + void reload(); + }, [reload]); + + const onSave = async () => { + const values = await form.validateFields(); + // mock 开关已从设置页移除;保存时沿用当前运行时配置,不在此页改 mock + const mock = mockMode; + if (!mock && !values.username.trim()) { + message.error("请填写用户名"); + return; + } + if (!mock && !meta?.has_password && !values.password?.trim()) { + message.error("请填写密码(首次或尚未配置)"); + return; + } + setSaving(true); + try { + const res = await updateCcSettingsApi({ + mock, + api_base: values.api_base, + saas_header: values.saas_header, + login_mark: values.login_mark, + username: values.username, + ...(values.password?.trim() + ? { password: values.password.trim() } + : {}), + }); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success("CC 配置已保存(立即生效,无需重启)"); + await reload(); + } finally { + setSaving(false); + } + }; + + const onTest = async () => { + const values = form.getFieldsValue(); + setTesting(true); + try { + const res = await testCcSettingsApi({ + password: values.password?.trim() || undefined, + }); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success(res.data.message); + } finally { + setTesting(false); + } + }; + + return ( + + {meta ? ( + + 来源 {meta.source === "db" ? "数据库" : ".env"} + + ) : null} + + {mockMode ? "mock" : "live"} + + + } + > + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/src/components/CcSplitForm.tsx b/src/components/CcSplitForm.tsx new file mode 100644 index 0000000..a23bf97 --- /dev/null +++ b/src/components/CcSplitForm.tsx @@ -0,0 +1,182 @@ +"use client"; + +import type { CSSProperties, ReactNode } from "react"; +import { Input } from "antd"; +import type { CcSplitFormValue } from "@/types/cc-ops"; +import type { MailRecordModules, ShipmentRow } from "@/types/mail"; + +const fieldRow: CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: "8px 12px", + marginBottom: 8, +}; + +function Field({ + label, + children, + hint, +}: { + label: ReactNode; + children: ReactNode; + hint?: string; +}) { + return ( +
+
+ {label} + {hint ? ( + + 上限 {hint} + + ) : null} +
+
{children}
+
+ ); +} + +export type CcSplitFormProps = { + mode: "readonly" | "edit"; + value: CcSplitFormValue; + onChange?: (patch: Partial) => void; +}; + +export function CcSplitForm({ mode, value, onChange }: CcSplitFormProps) { + const readonly = mode === "readonly"; + const patch = (p: Partial) => { + if (readonly || !onChange) return; + onChange(p); + }; + + return ( +
+
拆分(ShipmentManager / Split)
+
+ 对齐管理端数量拆分:填写拆出件数/体积/重量/板数(至少一项 > 0),渠道不变。本页仅预填确认,不写 + CC。 +
+ +
+ + patch({ keyValue: e.target.value })} + /> + + + patch({ F_ContainerNo: e.target.value })} + /> + +
+ +
+ + patch({ F_FBACode: e.target.value })} + /> + +
+
+ +
+
拆出数量
+
+ + patch({ F_CTNS: e.target.value })} + /> + + + patch({ F_CBM: e.target.value })} + /> + +
+
+ + patch({ F_Weight: e.target.value })} + /> + + + patch({ F_BOLActualPallet: e.target.value })} + /> + +
+
+
+ ); +} + +/** 从解析结果组装拆分表单初值(优先第一行货件) */ +export function buildCcSplitFormValue(input: { + containerNo?: string | null; + modules?: MailRecordModules | null; + shipments?: ShipmentRow[]; +}): CcSplitFormValue { + const s0 = input.shipments?.[0]; + const maxCTNS = s0?.F_CTNS ?? null; + const maxCBM = + s0?.F_CBM != null && Number.isFinite(Number(s0.F_CBM)) + ? Number(s0.F_CBM) + : null; + const maxWeight = + s0?.F_Weight != null && Number.isFinite(Number(s0.F_Weight)) + ? Number(s0.F_Weight) + : null; + + return { + keyValue: "", + F_ContainerNo: + input.modules?.container_no || input.containerNo || "", + F_FBACode: (s0?.F_FBACode || "").trim(), + maxCTNS, + maxCBM, + maxWeight, + maxBOLActualPallet: null, + F_CTNS: maxCTNS != null ? String(maxCTNS) : "", + F_CBM: maxCBM != null ? String(maxCBM) : "", + F_Weight: maxWeight != null ? String(maxWeight) : "", + F_BOLActualPallet: "", + }; +} diff --git a/src/components/CcTransferIndexTruck.tsx b/src/components/CcTransferIndexTruck.tsx new file mode 100644 index 0000000..fca3ee6 --- /dev/null +++ b/src/components/CcTransferIndexTruck.tsx @@ -0,0 +1,564 @@ +"use client"; + +import { useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { Checkbox, Input, Select } from "antd"; +import { + CC_TRANSFER_SHIPMENT_COLUMNS, + ShipmentVirtualTable, +} from "@/components/ShipmentVirtualTable"; +import type { TransferPair } from "@/services/parse/mail-intent"; +import type { MailRecordModules, ShipmentRow } from "@/types/mail"; + +const fieldRow: CSSProperties = { + display: "grid", + gridTemplateColumns: "repeat(3, minmax(0, 1fr))", + gap: "8px 12px", + marginBottom: 8, +}; + +const fieldRowWide: CSSProperties = { + display: "grid", + gridTemplateColumns: "2fr 1fr", + gap: "8px 12px", + marginBottom: 8, +}; + +function Field({ + label, + children, + style, +}: { + label: ReactNode; + children: ReactNode; + style?: CSSProperties; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function uniqJoin(values: (string | null | undefined)[], sep = ","): string { + const out: string[] = []; + for (const v of values) { + const t = (v || "").trim(); + if (t && !out.includes(t)) out.push(t); + } + return out.join(sep); +} + +export type CcTransferFormValue = { + F_ContainerNo: string; + F_CustomerName: string; + F_ContainerCode: string; + F_Transporter: string; + F_FBACode: string; + F_ID: string; + F_ContainerId: string; + F_FBAID: string; + F_ShipmentID: string; + F_PO: string; + F_ReferenceId: string; + F_IsPrivate: string; + F_CustomterBolCode: string; + F_Address: string; + F_AreaCode: string; + F_City: string; + F_State: string; + F_ZipCode: string; + F_Contact: string; + F_Mobile: string; + F_Email: string; + F_ConvertFBACode: string; + targetFBACode: string; + F_ConvertChargeMode: string; + F_RemovePlanDetail: boolean; +}; + +export type CcTransferIndexTruckProps = { + mode: "readonly" | "edit"; + value: CcTransferFormValue; + /** 本票需转仓的货件行(备注含「转XXX」) */ + shipments: ShipmentRow[]; + transferPairs?: TransferPair[]; + /** 附件全柜清单总行数(用于范围提示) */ + totalShipmentCount?: number; + onChange?: (patch: Partial) => void; +}; + +const CONVERT_CHARGE_OPTIONS = [ + { value: "", label: "(默认按产品计费)" }, + { value: "1", label: "按原仓计费" }, + { value: "2", label: "按目标仓计费" }, +]; + +export function CcTransferIndexTruck({ + mode, + value, + shipments, + transferPairs = [], + totalShipmentCount, + onChange, +}: CcTransferIndexTruckProps) { + const readonly = mode === "readonly"; + const [queryDraft, setQueryDraft] = useState(""); + const [selected, setSelected] = useState>(() => new Set()); + + const filtered = useMemo(() => { + const q = queryDraft.trim(); + if (!q) return shipments; + const tokens = q + .split(/[\s,,\n]+/) + .map((t) => t.trim().toUpperCase()) + .filter(Boolean); + if (!tokens.length) return shipments; + return shipments.filter((row) => { + const hay = [ + row.F_ShipmentID, + row.F_FBAID, + row.F_FBACode, + row.F_ReferenceId, + ] + .filter(Boolean) + .join(" ") + .toUpperCase(); + return tokens.some((t) => hay.includes(t)); + }); + }, [queryDraft, shipments]); + + const totals = useMemo(() => { + let ctns = 0; + let cbm = 0; + let weight = 0; + for (const r of filtered) { + ctns += r.F_CTNS ?? 0; + cbm += r.F_CBM ?? 0; + weight += r.F_Weight ?? 0; + } + return { + ctns, + cbm: cbm ? `${cbm.toFixed(4)} CBM` : "0 CBM", + weight: weight + ? `${weight.toFixed(4)} KG | ${(weight * 2.20462).toFixed(4)} LBS` + : "0 KG | 0 LBS", + }; + }, [filtered]); + + const patch = (p: Partial) => { + if (readonly || !onChange) return; + onChange(p); + }; + + return ( +
+
+ 人工货件转仓 · 卡车货件(IndexTruck) +
+ {totalShipmentCount != null && totalShipmentCount > shipments.length ? ( +
+ 本票新增转仓 {shipments.length} 行(附件全柜清单{" "} + {totalShipmentCount} 行,已按备注「转XXX」过滤) +
+ ) : shipments.length ? ( +
+ 本票转仓范围 {shipments.length} 行 +
+ ) : null} + +
+
+ + patch({ F_ContainerNo: e.target.value })} + /> + + + patch({ F_CustomerName: e.target.value })} + /> + +
+ +
+ + patch({ F_ContainerCode: e.target.value })} + /> + + + patch({ F_Transporter: e.target.value })} + /> + +
+ +
+ + patch({ F_FBACode: e.target.value })} + /> + + + patch({ F_ID: e.target.value })} + /> + +
+ +
+ + patch({ F_ContainerId: e.target.value })} + /> + + + patch({ F_FBAID: e.target.value })} + /> + +
+ +
+ + patch({ F_ShipmentID: e.target.value })} + /> + + + patch({ F_PO: e.target.value })} + /> + +
+ +
+ + patch({ F_ReferenceId: e.target.value })} + /> + + + patch({ F_IsPrivate: e.target.value })} + /> + + + patch({ F_CustomterBolCode: e.target.value })} + /> + +
+ +
+ + patch({ F_Address: e.target.value })} + /> + + + patch({ F_AreaCode: e.target.value })} + /> + + + patch({ F_City: e.target.value })} + /> + +
+ +
+ + patch({ F_State: e.target.value })} + /> + + + patch({ F_ZipCode: e.target.value })} + /> + + + patch({ F_Contact: e.target.value })} + /> + +
+ +
+ + patch({ F_Mobile: e.target.value })} + /> + + + patch({ F_Email: e.target.value })} + /> + + + patch({ F_ConvertFBACode: e.target.value })} + /> + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
总件数总体积总重量材积板数计重板数实际板数计费板数
{totals.ctns}{totals.cbm}{totals.weight}0000
+ +
+ + + + + +
+ + + {transferPairs.length ? ( +
+ 邮件解析转仓对: + {transferPairs.map((p) => ( + + {p.from} → {p.to}({p.rows} 行) + + ))} +
+ ) : null} + +
+ setQueryDraft(e.target.value)} + /> + +
+ +
+ { + const next = new Set(selected); + if (checked) next.add(rowIndex); + else next.delete(rowIndex); + setSelected(next); + }} + /> + {shipments.length === 0 ? ( +
+ {totalShipmentCount + ? `附件已解析 ${totalShipmentCount} 行,但无备注「转XXX」的转仓行。请核对 xlsx 备注列或手工在 CC 检索。` + : "货件列表来自卡派 xlsx 附件;当前无转仓行。请检查附件或重新解析。"} +
+ ) : null} +
+ +
+
转仓弹窗(FormFBACode)
+
+ “计费FC”是指给货物生成应收费用所用的FC +
+
+ 转仓后,后续拆柜时遇到同样的 FBAId 会自动进行转仓。 +
+
+ + patch({ targetFBACode: e.target.value })} + /> + + + + +
+
+ + patch({ F_CustomerName: e.target.value })} + /> + + + patch({ F_ReferCode: e.target.value })} + /> + +
+
+ + patch({ F_MessageType: v })} + showSearch + allowClear + optionFilterProp="label" + /> + +
+
+ + patch({ F_RefNo: e.target.value })} + /> + +
+
+
+ + patch({ F_MessageContent: e.target.value })} + /> + +
+
+ + + patch({ F_AttachFile: e.target.value })} + /> + + + + +
+
+ + patch({ F_Remark: e.target.value })} + /> + +
+
+ ) : ( + /* —— 客户端 customerMessage 新增 —— */ +
+
+ 对齐客户端:无「客户 / 紧急程度」字段;留言类型取字典 CustomerMessageType。 +
+
+ + patch({ F_ReferCode: e.target.value })} + /> + + + patch({ F_RefNo: e.target.value })} + /> + + + patch({ F_AttachFile: e.target.value })} + /> + +
+
+ + patch({ F_MessageContent: e.target.value })} + /> + +
+
+ + patch({ F_Remark: e.target.value })} + /> + +
+
+ )} +
+ ); +} + +export { extractWorkOrderMessageBody }; + +export function buildCcWorkOrderFormValue(input: { + subject?: string; + body?: string | null; + filenames?: string[]; + containerNo?: string | null; + actions?: string[]; + messageType?: string | null; + remarkExtra?: string | null; + customerName?: string | null; +}): CcWorkOrderFormValue { + const subject = input.subject || ""; + const body = input.body || ""; + const filenames = input.filenames || []; + const collectedHard = collectWorkOrderActions(subject, body, filenames); + const collectedSoft = collectSoftOpsHints(subject, body, filenames); + // 硬词(拦截)与软词(改自提)同时出现时都要进备注,不能只留硬词 + const actions = uniq([ + ...(input.actions || []), + ...collectedHard, + ...collectedSoft, + ]); + const hardActions = collectedHard.length + ? collectedHard + : actions.filter((a) => + ["贴标", "拦截", "拍照", "快递单号"].includes(a), + ); + + const cn = + input.containerNo || + extractContainerFromFirstLines(`${body}\n${subject}`, 2) || + ""; + + const content = + extractWorkOrderMessageBody(body, subject) || + `工单邮件:${actions.join("、") || "(请补充内容)"}`; + + const remarkParts: string[] = []; + if (actions.length) remarkParts.push(`邮件动作:${actions.join("、")}`); + if (input.remarkExtra?.trim()) remarkParts.push(input.remarkExtra.trim()); + + return { + F_ReferCode: cn, + F_MessageType: + (input.messageType || "").trim() || + suggestCcMessageType(hardActions), + F_RefNo: "", + F_MessageContent: content.slice(0, 2000), + F_Remark: remarkParts.join(";"), + F_AttachFile: "", + F_CustomerName: (input.customerName || "").trim() || undefined, + F_UrgencyLevel: "0", + mailActions: actions, + }; +} diff --git a/src/components/ConfirmImportModal.tsx b/src/components/ConfirmImportModal.tsx new file mode 100644 index 0000000..3f4e4cc --- /dev/null +++ b/src/components/ConfirmImportModal.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Modal } from "antd"; +import { UI_COPY } from "@/constants/ui-copy"; + +export function ConfirmImportModal({ + open, + loading, + containerNo, + selectedCount, + transMode, + operationType, + onCancel, + onConfirm, +}: { + open: boolean; + loading: boolean; + containerNo: string; + selectedCount: number; + transMode: number; + operationType: number; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( + +

{UI_COPY.modalImportBody(containerNo.toUpperCase(), selectedCount)}

+

+ TransMode={transMode} · OperationType={operationType} +

+
+ ); +} + +export function ConflictModal({ + open, + reason, + canForceSkip, + loading, + onClose, + onForceSkip, +}: { + open: boolean; + reason: "CONFLICT_CONTAINER" | "CONFLICT_CHECK_FAILED" | string; + canForceSkip: boolean; + loading?: boolean; + onClose: () => void; + onForceSkip?: () => void; +}) { + const isCheckFailed = reason === "CONFLICT_CHECK_FAILED"; + return ( + { + if (canForceSkip && onForceSkip) onForceSkip(); + else onClose(); + }} + > +

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

+ {canForceSkip ? ( +

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

+ ) : null} +
+ ); +} diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..a764403 --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Button, Empty } from "antd"; + +export function EmptyState({ + description, + actionLabel, + onAction, +}: { + description: string; + actionLabel?: string; + onAction?: () => void; +}) { + return ( + + {actionLabel && onAction ? ( + + ) : null} + + ); +} diff --git a/src/components/ErrorBanner.tsx b/src/components/ErrorBanner.tsx new file mode 100644 index 0000000..5e6359f --- /dev/null +++ b/src/components/ErrorBanner.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Alert } from "antd"; + +export function ErrorBanner({ + message, + description, + onClose, +}: { + message: string; + description?: string; + onClose?: () => void; +}) { + return ( + + ); +} diff --git a/src/components/ImapPullLogsPanel.tsx b/src/components/ImapPullLogsPanel.tsx new file mode 100644 index 0000000..3b23dfa --- /dev/null +++ b/src/components/ImapPullLogsPanel.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { App, Button, Select, Space, Table, Tag, Typography } from "antd"; +import { EmptyState } from "@/components/EmptyState"; +import { ErrorBanner } from "@/components/ErrorBanner"; +import { MAIL_TYPE_LABEL, UI_COPY } from "@/constants/ui-copy"; +import { listImapPullLogsApi } from "@/lib/client-api"; +import type { ImapPullLogItem } from "@/types"; + +const RESULT_LABEL: Record = { + pulled: "已拉取", + skipped: "已跳过", + error: "失败", +}; + +const RESULT_COLOR: Record = { + pulled: "success", + skipped: "default", + error: "error", +}; + +const REASON_LABEL: Record = { + WHITELIST: "白名单", + KEYWORD: "关键词", + BUSINESS_FALLBACK: "业务兜底", + SKIP_BLACKLIST: "黑名单", + SKIP_FILTER: "未匹配过滤", + IMAP_PROCESS_ERROR: "处理失败", +}; + +export function ImapPullLogsPanel() { + const { message } = App.useApp(); + const [result, setResult] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [total, setTotal] = useState(0); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + const res = await listImapPullLogsApi({ + page, + pageSize, + result: result || undefined, + }); + setLoading(false); + if (!res.ok) { + setError(res.error.message); + setItems([]); + setTotal(0); + return; + } + setError(null); + setItems(res.data.items); + setTotal(res.data.total); + }, [page, pageSize, result]); + + useEffect(() => { + void reload(); + }, [reload]); + + useEffect(() => { + if (error) message.error(error); + }, [error, message]); + + return ( + <> + + setIntervalMs(v)} + /> + + + + + 过滤规则 + +
+ + + + + + +
+ + {filters.length === 0 ? ( + + 暂无规则(将使用业务相关兜底) + + ) : ( +
+ {grouped.map((g) => ( +
+ + {g.label} + + {g.items.map((f) => ( + void onDelete(f)} + okText="删除" + cancelText="取消" + > + e.stopPropagation()} + > + × + + + } + onClose={(e) => e.preventDefault()} + onClick={() => void onToggle(f)} + style={{ + cursor: "pointer", + marginInlineEnd: 0, + opacity: f.enabled ? 1 : 0.45, + textDecoration: f.enabled ? "none" : "line-through", + userSelect: "none", + }} + title={f.enabled ? "点击停用" : "点击启用"} + > + {f.value} + + ))} +
+ ))} +
+ )} + + ); +} diff --git a/src/components/ImapStatusBanner.tsx b/src/components/ImapStatusBanner.tsx new file mode 100644 index 0000000..668ed9c --- /dev/null +++ b/src/components/ImapStatusBanner.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Alert, App, Button, Space, Tag } from "antd"; +import { useAuthSession } from "@/components/providers/AuthSessionProvider"; +import { healthApi, imapPollApi } from "@/lib/client-api"; +import { requestMailsReload } from "@/lib/mails-events"; + +export function ImapStatusBanner() { + const { message } = App.useApp(); + const { user } = useAuthSession(); + const isAdmin = user.role === "admin"; + const [mysqlOk, setMysqlOk] = useState(null); + const [imapLock, setImapLock] = useState(); + const [configured, setConfigured] = useState(false); + const [syncAlert, setSyncAlert] = useState(false); + const [failCount, setFailCount] = useState(0); + const [lastError, setLastError] = useState(null); + const [polling, setPolling] = useState(false); + const [ccMode, setCcMode] = useState<"mock" | "live" | null>(null); + const [ccLiveReady, setCcLiveReady] = useState(false); + const [ccReason, setCcReason] = useState(null); + + useEffect(() => { + let cancelled = false; + + const refresh = async () => { + const healthRes = await healthApi(); + if (cancelled) return; + if (healthRes.ok) { + setMysqlOk(healthRes.data.mysql === "ok"); + setImapLock(healthRes.data.imap_lock); + setConfigured(Boolean(healthRes.data.imap_configured)); + setSyncAlert(Boolean(healthRes.data.imap_sync_alert)); + setFailCount(healthRes.data.imap_consecutive_fail ?? 0); + setLastError(healthRes.data.imap_last_error ?? null); + setCcMode(healthRes.data.cc_mode ?? null); + setCcLiveReady(Boolean(healthRes.data.cc_live_ready)); + setCcReason(healthRes.data.cc_reason ?? null); + } else { + setMysqlOk(false); + } + }; + + void refresh(); + const timer = window.setInterval(refresh, 30_000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, []); + + const onPoll = async () => { + setPolling(true); + try { + const res = await imapPollApi(); + if (res.ok) { + if (res.data.skipped) { + const reason = res.data.reason ?? "busy"; + if (reason === "lock_busy") { + message.warning("拉取锁占用中(worker 可能正在同步),请稍后再试"); + } else { + message.info(`跳过拉取:${reason}`); + } + } else { + const n = res.data.processed ?? 0; + const cand = res.data.candidates; + message.success( + cand != null + ? `拉取完成:新入库 ${n} 封(本轮候选 ${cand})` + : `拉取完成,新入库 ${n} 封`, + ); + requestMailsReload(); + } + const healthRes = await healthApi(); + if (healthRes.ok) { + setConfigured(Boolean(healthRes.data.imap_configured)); + setSyncAlert(Boolean(healthRes.data.imap_sync_alert)); + setFailCount(healthRes.data.imap_consecutive_fail ?? 0); + setLastError(healthRes.data.imap_last_error ?? null); + setImapLock(healthRes.data.imap_lock); + } + } else { + message.error(res.error.message); + } + } finally { + setPolling(false); + } + }; + + if (mysqlOk === null) { + return 健康检查…; + } + + return ( + + + MySQL {mysqlOk ? "OK" : "异常"} + + {ccMode === "mock" ? ( + + CC mock + + ) : ccMode === "live" ? ( + + {ccLiveReady ? "CC live" : "CC live 缺凭证"} + + ) : null} + {!configured ? ( + + + IMAP 未配置 + + + ) : syncAlert ? ( + + ) : imapLock && imapLock !== "free" ? ( + IMAP 拉取中 + ) : ( + IMAP 正常 + )} + {isAdmin ? ( + + ) : null} + + ); +} diff --git a/src/components/MailBusinessSummary.tsx b/src/components/MailBusinessSummary.tsx new file mode 100644 index 0000000..e29d9f1 --- /dev/null +++ b/src/components/MailBusinessSummary.tsx @@ -0,0 +1,843 @@ +"use client"; + +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { + Alert, + App, + Button, +} from "antd"; +import { + CcDoUploadPanel, + buildCcDoUploadFormValue, +} from "@/components/CcDoUploadPanel"; +import { + CcForecastFormOrder, + buildCcForecastFormValue, +} from "@/components/CcForecastFormOrder"; +import { CcCustomerInstructionPanel } from "@/components/CcCustomerInstructionPanel"; +import { + CcHoldMoveTypePanel, + buildCcHoldFormValue, +} from "@/components/CcHoldMoveTypePanel"; +import { + CcOutStockPanel, + buildCcOutStockFormValue, +} from "@/components/CcOutStockPanel"; +import { + CcSplitForm, + buildCcSplitFormValue, +} from "@/components/CcSplitForm"; +import { InstructionUnitShell } from "@/components/MailInstructionUnitShell"; +import { + CcTransferIndexTruck, + buildCcTransferFormValue, +} from "@/components/CcTransferIndexTruck"; +import { + CcWorkOrderForm, + buildCcWorkOrderFormValue, +} from "@/components/CcWorkOrderForm"; +import { toCcCustomerMessageEntity } from "@/services/cc/customer-message-entity"; +import { sanitizeOcrDisplayText } from "@/services/ocr"; +import { ccUiModeLabel, type CcUiMode } from "@/constants/cc-ui-mode"; +import { + confirmDoUploadApi, + confirmTransferApi, + confirmWorkOrderApi, + getTransferGateApi, + listShippingLinesApi, +} from "@/lib/client-api"; +import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate"; +import { resolveWorkOrderPanelKind } from "@/services/cc/work-order-panel"; +import { + matchShippingLineId, + shippingLineHintFromBl, +} from "@/services/parse/extract-container-header"; +import { + buildMailIntent, + extractTransferPairs, + filterTransferShipments, +} from "@/services/parse/mail-intent"; +import { extractBlPlusFromMail } from "@/services/parse/bl-plus-template"; +import { + extractForecastInstructionText, + extractMailInstructions, +} from "@/services/parse/split-instructions"; +import type { MailMessage } from "@/types"; +import { UI_COPY } from "@/constants/ui-copy"; +import type { + ContainerHeader, + MailRecord, + MailType, + TypeEvidence, +} from "@/types/mail"; +import type { + CcHoldFormValue, + CcHoldShipmentRow, + CcOutStockFormValue, + CcSplitFormValue, +} from "@/types/cc-ops"; +import type { TransferGateDecision } from "@/services/cc/container-status"; + +function asHeader(mail: MailMessage): ContainerHeader | null { + return mail.parse_result?.container_header ?? null; +} + +function asMailRecord(mail: MailMessage): MailRecord | null { + const direct = mail.parse_result?.mail_record; + if (direct) return direct; + const lineage = mail.parse_result?.lineage as + | { mail_record?: MailRecord } + | undefined; + return lineage?.mail_record ?? null; +} + +type EvidenceExt = TypeEvidence & { + mailbox?: { from?: string; inbox?: string; extracted?: string[] }; +}; + +export function MailBusinessSummary({ + mail, + onConfirmed, + ccUiMode = "admin", +}: { + mail: MailMessage; + onConfirmed?: () => void; + /** 默认管理端(ccnew);可切换客户端(cc-client) */ + ccUiMode?: CcUiMode; +}) { + const { message } = App.useApp(); + const router = useRouter(); + const header = asHeader(mail); + const record = asMailRecord(mail); + const evidence = (mail.type_evidence || { + total: 0, + mail_type: mail.mail_type, + signals: [], + }) as EvidenceExt; + + // Stable deps for useEffect + const shipmentsRaw = mail.parse_result?.shipments; + const attachmentsRaw = mail.attachments; + const shipmentsKey = JSON.stringify(shipmentsRaw ?? []); + const filenamesKey = (attachmentsRaw || []).map((a) => a.filename).join("?"); + const shipments = useMemo( + () => shipmentsRaw ?? [], + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by shipmentsKey + [shipmentsKey], + ); + const filenames = useMemo( + () => (attachmentsRaw || []).map((a) => a.filename), + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by filenamesKey + [filenamesKey], + ); + const bodyText = + mail.body_text || sanitizeOcrDisplayText(mail.ocr_text || "") || ""; + const containerNo = header?.F_ContainerNo || mail.container_no || ""; + const workOrderActionsKey = (record?.work_order_actions || []).join("|"); + const intentSignalsKey = JSON.stringify(evidence.signals ?? []); + + const intent = useMemo( + () => + evidence.intent || + buildMailIntent({ + mailType: mail.mail_type as MailType, + subject: mail.subject, + body: bodyText, + filenames, + shipments, + evidence, + containerNo, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps -- evidence default object unstable + [ + evidence.intent, + evidence.mail_type, + evidence.total, + intentSignalsKey, + mail.mail_type, + mail.subject, + bodyText, + filenames, + shipments, + containerNo, + ], + ); + + const transferPairs = useMemo( + () => + intent.transfer_pairs?.length + ? intent.transfer_pairs + : extractTransferPairs(shipments), + [intent.transfer_pairs, shipments], + ); + const transferShipments = useMemo( + () => filterTransferShipments(shipments), + [shipments], + ); + + const instructions = useMemo( + () => + extractMailInstructions({ + subject: mail.subject, + body: bodyText, + filenames, + forceForecast: record?.kind === "BL_FORECAST", + workOrderActions: record?.work_order_actions, + }), + [ + mail.subject, + bodyText, + filenames, + record?.kind, + workOrderActionsKey, + ], + ); + + const forecastInstruction = useMemo(() => { + const vessel = record?.modules.vessel_voyage; + return extractForecastInstructionText({ + subject: mail.subject, + body: bodyText, + modulesInstruction: vessel + ? `船名航次 ${vessel}` + : header?.F_Instruction || null, + }); + }, [ + mail.subject, + bodyText, + header?.F_Instruction, + record?.modules.vessel_voyage, + ]); + + const hasForecastUi = instructions.some((u) => u.uiKind === "forecast"); + const hasTransferUi = instructions.some((u) => u.uiKind === "transfer"); + /** 工单/转仓等:仅待确认/失败可提交 */ + const canConfirmOps = + mail.status === "PENDING_CONFIRM" || mail.status === "FAILED"; + /** DO:预报 SUCCESS 后仍可补传 */ + const canDoConfirm = + canConfirmOps || mail.status === "SUCCESS"; + const validShipmentCount = shipments.filter( + (s) => s.row_status === "VALID", + ).length; + const canForecastImport = canEnterForecastConfirm({ + mailType: mail.mail_type, + status: mail.status, + validShipmentCount, + }); + + const successImportExternalId = + mail.imports?.find((i) => i.status === "SUCCESS" && i.external_id) + ?.external_id || ""; + + const blPlusModules = useMemo( + () => extractBlPlusFromMail(mail.subject, bodyText)?.modules ?? null, + [mail.subject, bodyText], + ); + + const [shippingLines, setShippingLines] = useState< + { value: string; label: string }[] + >([]); + const [forecastShippingId, setForecastShippingId] = useState(""); + const [shippingLineHintLabel, setShippingLineHintLabel] = useState< + string | null + >(null); + + 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, + F_ShippingLineId: + forecastShippingId || base.F_ShippingLineId || "", + }; + }, [ + header, + record?.modules, + record?.modules.customer_name, + blPlusModules, + forecastInstruction, + forecastShippingId, + ]); + + const transferValue = useMemo( + () => + buildCcTransferFormValue({ + customerName: record?.modules.customer_name, + containerNo, + modules: record?.modules, + shipments: transferShipments, + transferPairs, + }), + [ + record?.modules.customer_name, + containerNo, + record?.modules, + transferShipments, + transferPairs, + ], + ); + + const currentWorkOrderUnit = useMemo( + () => instructions.find((u) => u.uiKind === "work_order" && u.isCurrent), + [instructions], + ); + + const [workOrderValue, setWorkOrderValue] = useState(() => + buildCcWorkOrderFormValue({ + subject: currentWorkOrderUnit?.segmentSubject || mail.subject, + body: currentWorkOrderUnit?.segmentText || bodyText, + filenames, + containerNo, + actions: currentWorkOrderUnit?.keywords?.length + ? currentWorkOrderUnit.keywords + : record?.work_order_actions, + customerName: record?.modules.customer_name, + }), + ); + + const [doValue, setDoValue] = useState(() => + buildCcDoUploadFormValue({ + subject: mail.subject, + body: bodyText, + containerNo, + filenames, + containerTaskId: successImportExternalId, + }), + ); + + const [holdBundle, setHoldBundle] = useState(() => + buildCcHoldFormValue({ + containerNo, + modules: record?.modules, + shipments, + }), + ); + const [holdValue, setHoldValue] = useState( + () => holdBundle.value, + ); + const [holdRows, setHoldRows] = useState( + () => holdBundle.rows, + ); + + const [splitValue, setSplitValue] = useState(() => + buildCcSplitFormValue({ + containerNo, + modules: record?.modules, + shipments, + }), + ); + + const [outStockValue, setOutStockValue] = useState(() => + buildCcOutStockFormValue({ + containerNo, + modules: record?.modules, + shipments, + subject: mail.subject, + filenames, + }), + ); + + const [gate, setGate] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Reset forms when mail / current work-order segment changes + useEffect(() => { + setWorkOrderValue( + buildCcWorkOrderFormValue({ + subject: currentWorkOrderUnit?.segmentSubject || mail.subject, + body: currentWorkOrderUnit?.segmentText || bodyText, + filenames, + containerNo, + actions: currentWorkOrderUnit?.keywords?.length + ? currentWorkOrderUnit.keywords + : record?.work_order_actions, + customerName: record?.modules.customer_name, + }), + ); + setDoValue( + buildCcDoUploadFormValue({ + subject: mail.subject, + body: bodyText, + containerNo, + filenames, + containerTaskId: successImportExternalId, + }), + ); + const hold = buildCcHoldFormValue({ + containerNo, + modules: record?.modules, + shipments, + }); + setHoldBundle(hold); + setHoldValue(hold.value); + setHoldRows(hold.rows); + setSplitValue( + buildCcSplitFormValue({ + containerNo, + modules: record?.modules, + shipments, + }), + ); + setOutStockValue( + buildCcOutStockFormValue({ + containerNo, + modules: record?.modules, + shipments, + subject: mail.subject, + filenames, + }), + ); + }, [ + mail.id, + mail.version, + mail.subject, + bodyText, + successImportExternalId, + containerNo, + workOrderActionsKey, + filenames, + currentWorkOrderUnit?.id, + currentWorkOrderUnit?.segmentText, + record?.work_order_actions, + record?.modules, + shipments, + ]); + + useEffect(() => { + if (mail.mail_type !== "TRANSFER" && !hasTransferUi) return; + let cancelled = false; + void getTransferGateApi(mail.id).then((res) => { + if (!cancelled && res.ok) setGate(res.data.gate); + }); + return () => { + cancelled = true; + }; + }, [mail.id, mail.mail_type, hasTransferUi]); + + useEffect(() => { + if (!hasForecastUi) return; + let cancelled = false; + void listShippingLinesApi().then((res) => { + if (cancelled || !res.ok) return; + const items = res.data.items; + setShippingLines( + items.map((item) => ({ value: item.id, label: item.name })), + ); + const bl = header?.F_BLCopyCode || record?.modules?.bl_no || ""; + const existing = header?.F_ShippingLineId || ""; + if (existing) { + setForecastShippingId(existing); + setShippingLineHintLabel(null); + return; + } + const hint = shippingLineHintFromBl(bl); + setShippingLineHintLabel(hint); + if (!hint) return; + const id = matchShippingLineId( + hint, + items.map((i) => ({ + F_Id: i.id, + F_FullName: i.name, + F_EnCode: i.code, + F_Prefix: i.prefix, + })), + { blNo: bl }, + ); + if (id) setForecastShippingId(id); + }); + return () => { + cancelled = true; + }; + }, [ + hasForecastUi, + header?.F_BLCopyCode, + header?.F_ShippingLineId, + record?.modules?.bl_no, + ]); + + const submitWorkOrder = async () => { + const entity = toCcCustomerMessageEntity(workOrderValue); + if (!entity.F_MessageType) { + message.error("请选择留言类型(管理端必填,对应 CC 工单分类)"); + return; + } + if (!entity.F_MessageContent) { + message.error("请填写留言内容(邮件指令正文)"); + return; + } + setSubmitting(true); + const res = await confirmWorkOrderApi(mail.id, { + version: mail.version, + entity, + }); + setSubmitting(false); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success( + res.data.mode === "mock" ? "工单已提交(mock)" : "工单已提交", + ); + onConfirmed?.(); + }; + + const submitDo = async () => { + setSubmitting(true); + const res = await confirmDoUploadApi(mail.id, { + version: mail.version, + container_no: doValue.F_ContainerNo, + container_task_id: doValue.F_ContainerTaskId || undefined, + filenames: doValue.filenames, + }); + setSubmitting(false); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success( + res.data.mode === "mock" ? "DO 已登记(mock)" : "DO 已上传", + ); + onConfirmed?.(); + }; + + const submitTransfer = async () => { + const target = + transferValue.targetFBACode || transferValue.F_ConvertFBACode || ""; + if (!target) { + message.error("请填写目标 FBACode"); + return; + } + const rows = transferShipments.length + ? transferShipments + : shipments.slice(0, 1); + setSubmitting(true); + const res = await confirmTransferApi(mail.id, { + version: mail.version, + container_no: + transferValue.F_ContainerNo || + header?.F_ContainerNo || + mail.container_no || + "", + payload: { + keyValue: + gate && "containerTaskId" in gate ? gate.containerTaskId || "" : "", + strDetialList: rows.map((r) => ({ + F_FBACode: r.F_FBACode || "", + F_ConvertFBACode: target, + })), + F_ConvertChargeMode: transferValue.F_ConvertChargeMode || "2", + F_RemovePlanDetail: transferValue.F_RemovePlanDetail ? "1" : "0", + }, + }); + setSubmitting(false); + if (!res.ok) { + message.error(res.error.message); + return; + } + message.success( + res.data.mode === "mock" ? "批量转仓已提交(mock)" : "批量转仓已提交", + ); + onConfirmed?.(); + }; + + return ( +
+
+ {instructions.map((unit, index) => { + let body: ReactNode = null; + let actions: ReactNode = null; + + if (unit.uiKind === "forecast") { + body = ( + <> + {shippingLineHintLabel && !forecastValue.F_ShippingLineId ? ( + + ) : null} + + + ); + if (canForecastImport && unit.isCurrent) { + actions = ( + + ); + } + } else if (unit.uiKind === "transfer") { + const blocked = gate && !gate.canTransfer; + body = ( + <> + {gate ? ( + + ) : null} + {blocked ? ( + + setWorkOrderValue((v) => ({ ...v, ...p })) + } + /> + ) : ( + + )} + + ); + if (canConfirmOps && unit.isCurrent) { + actions = blocked ? ( + + ) : ( + + ); + } + } else if (unit.uiKind === "work_order") { + // 仅用本指令段的关键词 + 段正文分流,禁止整封 body 污染(如主题链里的「拦截-209件」) + const unitActions = Array.from( + new Set([ + ...(unit.keywords || []), + ...(unit.isCurrent + ? workOrderValue.mailActions || [] + : []), + ]), + ); + const panelKind = resolveWorkOrderPanelKind( + ccUiMode, + unitActions, + [unit.segmentSubject, unit.segmentText, unit.summary] + .filter(Boolean) + .join("\n"), + ); + const woForUnit = unit.isCurrent + ? workOrderValue + : buildCcWorkOrderFormValue({ + subject: unit.segmentSubject || "", + body: unit.segmentText, + filenames: [], + containerNo, + actions: unit.keywords, + customerName: record?.modules.customer_name, + }); + const editMode = + canConfirmOps && unit.isCurrent ? "edit" : "readonly"; + + body = ( + <> + + {panelKind === "hold" ? ( + setHoldValue((v) => ({ ...v, ...p }))} + /> + ) : panelKind === "split" ? ( + setSplitValue((v) => ({ ...v, ...p }))} + /> + ) : panelKind === "outstock" ? ( + + ) : ( + + setWorkOrderValue((v) => ({ ...v, ...p })) + } + /> + )} + + ); + if (canConfirmOps && unit.isCurrent && panelKind === "message") { + actions = ( + + ); + } else if ( + canConfirmOps && + unit.isCurrent && + panelKind !== "message" + ) { + actions = ( + + ); + } + } 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, + containerTaskId: successImportExternalId, + }); + body = ( + <> + {mail.status === "SUCCESS" ? ( + + ) : null} + setDoValue((v) => ({ ...v, ...p }))} + /> + + ); + if (canDoConfirm && unit.isCurrent) { + actions = ( + + ); + } + } else if (unit.uiKind === "customer_instruction") { + body = ( + + ); + } + + return ( + + {body} + {actions ? ( +
{actions}
+ ) : null} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/MailInstructionUnitShell.tsx b/src/components/MailInstructionUnitShell.tsx new file mode 100644 index 0000000..b6d75c3 --- /dev/null +++ b/src/components/MailInstructionUnitShell.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { MailInstructionUnit } from "@/services/parse/split-instructions"; +import { MAIL_TYPE_COLORS } from "@/constants/ui-copy"; +import type { ReactNode } from "react"; + +/** 指令块:左侧条 + 精简标题,参数表放 body,避免再套 Card */ +export function InstructionUnitShell({ + unit, + index, + total, + children, +}: { + unit: MailInstructionUnit; + index: number; + total: number; + children: ReactNode; +}) { + const color = + unit.uiKind === "customer_instruction" + ? "#78716c" + : MAIL_TYPE_COLORS[unit.mailType] || MAIL_TYPE_COLORS.UNKNOWN; + + return ( +
+
+
+ + {index + 1}/{total} + + {unit.label} +
+
+
{children}
+
+ ); +} diff --git a/src/components/MailTable.tsx b/src/components/MailTable.tsx new file mode 100644 index 0000000..08bb9e9 --- /dev/null +++ b/src/components/MailTable.tsx @@ -0,0 +1,196 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Button, Space, Table, Tooltip, Typography } from "antd"; +import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; +import { StatusPill } from "@/components/StatusPill"; +import { TypeTag } from "@/components/TypeTag"; +import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate"; +import type { MailListItem } from "@/types"; +import type { MailStatus, MailType } from "@/types/mail"; + +function formatTs(v: string | null | undefined) { + if (!v) return "-"; + return new Date(v).toLocaleString("zh-CN"); +} + +export function MailTable({ + items, + loading, + total, + page, + pageSize, + onPageChange, +}: { + items: MailListItem[]; + loading?: boolean; + total: number; + page: number; + pageSize: number; + onPageChange?: (page: number, pageSize: number) => void; +}) { + const router = useRouter(); + + const columns: ColumnsType = [ + { + title: "邮件来源", + dataIndex: "from_addr", + width: 220, + ellipsis: true, + fixed: "left", + render: (from: string, row) => ( +
+ + {from || "-"} + + + {row.ingest_source_label} + +
+ ), + }, + { + title: "邮件内容简介", + dataIndex: "body_summary", + width: 260, + render: (summary: string, row) => ( +
+ + {row.subject} + + + {summary} + +
+ ), + }, + { + title: "邮件类型", + dataIndex: "mail_type", + width: 120, + render: (type: MailType) => , + }, + { + title: "内容类型", + dataIndex: "content_kind_label", + width: 110, + }, + { + title: "邮件附件", + dataIndex: "attachments", + width: 180, + render: (atts: MailListItem["attachments"]) => { + if (!atts?.length) { + return 无; + } + const names = atts.map((a) => a.filename); + const shown = names.slice(0, 2).join("、"); + const extra = names.length > 2 ? ` 等${names.length}个` : ""; + return ( + + {shown}{extra} + + ); + }, + }, + { + title: "导入CC状态", + dataIndex: "status", + width: 140, + render: (status: MailStatus) => , + }, + { + title: "起止时间", + key: "time_range", + width: 200, + render: (_, row) => ( +
+
起:{formatTs(row.received_at)}
+
止:{formatTs(row.ended_at)}
+
+ ), + }, + { + title: "操作", + key: "actions", + width: 168, + fixed: "right", + render: (_, row) => { + const canConfirm = canEnterForecastConfirm({ + mailType: row.mail_type, + status: row.status, + // 列表无货件统计;门槛在详情/确认页与 import API + validShipmentCount: 1, + }); + return ( + + + {canConfirm ? ( + + ) : ( + + + + )} + + ); + }, + }, + ]; + + const pagination: TablePaginationConfig = { + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 封`, + pageSizeOptions: ["10", "20", "50"], + onChange: (p, ps) => onPageChange?.(p, ps), + }; + + return ( + ({ + onDoubleClick: () => router.push(`/mails/${row.id}`), + style: { cursor: "pointer" }, + })} + /> + ); +} + +/** 兼容旧引用:主题链接样式(若别处仍用) */ +export function MailSubjectLink({ + id, + subject, +}: { + id: string; + subject: string; +}) { + return {subject}; +} diff --git a/src/components/MailboxBindHelp.tsx b/src/components/MailboxBindHelp.tsx new file mode 100644 index 0000000..13ba854 --- /dev/null +++ b/src/components/MailboxBindHelp.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { ExclamationCircleOutlined } from "@ant-design/icons"; +import { Button, Popover, Typography } from "antd"; +import { + getMailboxBindGuide, + resolveMailboxBindGuideId, + type MailboxBindGuide, + type MailboxBindGuideId, +} from "@/constants/mailbox-bind-guides"; + +function GuideBody({ guide }: { guide: MailboxBindGuide }) { + return ( +
+ + {guide.summary} + + {guide.imapHint ? ( + + IMAP: + {guide.imapHint} + + ) : null} +
    + {guide.steps.map((step, i) => ( +
  1. + {step} +
  2. + ))} +
+ {guide.notes?.length ? ( +
    + {guide.notes.map((n, i) => ( +
  • {n}
  • + ))} +
+ ) : null} + {guide.helpUrl ? ( + + {guide.helpLabel || "官方说明"} + + ) : null} +
+ ); +} + +/** 感叹号:点击后展示该邮箱类型的绑定步骤 */ +export function MailboxBindHelpIcon({ + guideId, + email, + title, +}: { + guideId?: MailboxBindGuideId; + /** 也可传邮箱地址自动识别 */ + email?: string; + title?: string; +}) { + const id = + guideId || + (email ? resolveMailboxBindGuideId(email) : "generic"); + const guide = getMailboxBindGuide(id); + + return ( + } + > + + setOpen(false)} + onOk={() => void onSave()} + confirmLoading={saving} + destroyOnClose + afterOpenChange={fillForm} + width={640} + okText="保存" + > + + Google 须建「Web 应用」OAuth 客户端(不是桌面/Android)。Client ID + 形如 xxx.apps.googleusercontent.com + 。Secret 加密入库,留空不改。回调 URI 须与下方 + 一字不差写进 Google「已授权的重定向 URI」。 + 若浏览器报 invalid_client + ,几乎总是 Client ID 错/删/类型不对,请重新粘贴保存后再点 OAuth · Gmail。 + + + {meta ? ( + + + 来源 {meta.source === "db" ? "数据库" : ".env"} + + + Gmail {meta.google_configured ? "就绪" : "未配齐"} + + + MS {meta.ms_configured ? "就绪" : "未配齐"} + + + ) : null} + + +
+ + + + + {meta ? ( + + 回调 Gmail: + + {meta.callback_gmail} + +
+ 回调 Microsoft: + + {meta.callback_microsoft} + +
+ ) : null} + + + Google(Gmail) + + + + + + + + + Microsoft 365 + + + + + + + + + + +
+
+ + ); +} diff --git a/src/components/ShipmentVirtualTable.tsx b/src/components/ShipmentVirtualTable.tsx new file mode 100644 index 0000000..fe2afb3 --- /dev/null +++ b/src/components/ShipmentVirtualTable.tsx @@ -0,0 +1,522 @@ +"use client"; + +import { useCallback, useMemo, useRef, type ReactNode, type UIEvent } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Checkbox } from "antd"; +import type { ShipmentRow } from "@/types/mail"; + +const ROW_HEIGHT = 48; +const TABLE_HEIGHT = 480; + +export interface ShipmentColumn { + key: string; + title: string; + width: number; + render: (row: ShipmentRow) => ReactNode; +} + +const ALWAYS_VISIBLE_COL_KEYS = new Set(["idx", "ck", "st"]); + +const COL_VALUE_GETTERS: Record unknown> = { + ch: (r) => r.F_Transporter, + sid: (r) => r.F_ShipmentID, + fbaid: (r) => r.F_FBAID, + po: (r) => r.F_PO, + fba: (r) => r.F_FBACode, + addr: (r) => r.F_Address, + ctns: (r) => r.F_CTNS, + cbm: (r) => r.F_CBM, + wt: (r) => r.F_Weight, + pcs: (r) => r.F_Pcs, + city: (r) => r.F_City, + state: (r) => r.F_State, + zip: (r) => r.F_ZipCode, + contact: (r) => r.F_Contact, + mobile: (r) => r.F_Mobile, + email: (r) => r.F_Email, + remark: (r) => r.F_Remark, + ref: (r) => r.F_ReferenceId, + dateB: (r) => r.F_Expected_DeliveryDateB, + dateE: (r) => r.F_Expected_DeliveryDateE, + sku: (r) => r.F_SKU, + desc: (r) => r.F_Description, + en: (r) => r.F_EnglishName, +}; + +function isMeaningfulValue(v: unknown): boolean { + if (v == null) return false; + if (typeof v === "number") return Number.isFinite(v); + const s = String(v).trim(); + return s !== "" && s !== "-" && s !== "/" && s !== "—" && s !== "/"; +} + +/** 附件没有的列不展示,避免满屏「-」 */ +export function filterShipmentColumns( + columns: ShipmentColumn[], + rows: ShipmentRow[], +): ShipmentColumn[] { + if (!rows.length) { + return columns.filter( + (c) => + ALWAYS_VISIBLE_COL_KEYS.has(c.key) || + ["ch", "fba", "ctns", "fbaid", "sid", "addr"].includes(c.key), + ); + } + return columns.filter((col) => { + if (ALWAYS_VISIBLE_COL_KEYS.has(col.key)) return true; + const getter = COL_VALUE_GETTERS[col.key]; + if (!getter) return true; + return rows.some((row) => isMeaningfulValue(getter(row))); + }); +} + +export function ShipmentVirtualTable({ + rows, + columns, + selected, + onToggle, + readonly = false, +}: { + rows: ShipmentRow[]; + columns: ShipmentColumn[]; + selected: Set; + onToggle: (rowIndex: number, checked: boolean) => void; + readonly?: boolean; +}) { + const parentRef = useRef(null); + const headerRef = useRef(null); + const visibleColumns = useMemo( + () => filterShipmentColumns(columns, rows), + [columns, rows], + ); + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 12, + }); + + const totalWidth = visibleColumns.reduce((sum, col) => sum + col.width, 0); + + const syncHeaderScroll = useCallback((e: UIEvent) => { + if (headerRef.current) { + headerRef.current.scrollLeft = e.currentTarget.scrollLeft; + } + }, []); + + return ( +
+
+
+ {visibleColumns.map((col) => ( +
+ {col.title} +
+ ))} +
+
+
+
+ {virtualizer.getVirtualItems().map((vRow) => { + const row = rows[vRow.index]; + const invalid = row.row_status === "INVALID"; + return ( +
+ {visibleColumns.map((col) => ( +
+ {col.key === "ck" ? ( + + onToggle(row.row_index, e.target.checked) + } + /> + ) : ( + col.render(row) + )} +
+ ))} +
+ ); + })} +
+
+
+ ); +} + +export const DEFAULT_SHIPMENT_COLUMNS: ShipmentColumn[] = [ + { key: "ck", title: "☑", width: 48, render: () => null }, + { key: "idx", title: "#", width: 56, render: (row) => row.row_index }, + { key: "fba", title: "仓库ID", width: 90, render: (row) => row.F_FBACode || "-" }, + { + key: "ch", + title: "渠道", + width: 90, + render: (row) => row.F_Transporter || "-", + }, + { key: "ctns", title: "件数", width: 70, render: (row) => row.F_CTNS ?? "-" }, + { + key: "fbaid", + title: "FBA ID", + width: 150, + render: (row) => {row.F_FBAID || "-"}, + }, + { + key: "ref", + title: "Ref", + width: 110, + render: (row) => {row.F_ReferenceId || "-"}, + }, + { + key: "sid", + title: "ShipmentID", + width: 150, + render: (row) => {row.F_ShipmentID || "-"}, + }, + { key: "st", title: "行状态", width: 80, render: (row) => row.row_status }, + { + key: "warn", + title: "警告", + width: 140, + render: (row) => + row.warnings?.join(",") || row.invalid_reasons?.join(",") || "-", + }, +]; + +/** FormOrder 货件列表列(对齐 ccnew FormOrder.js) */ +function cellText(v: unknown, fallback = "-"): string { + const s = v == null || v === "" ? "" : String(v).replace(/\s+/g, " ").trim(); + return s || fallback; +} + +function monoCell(v: unknown) { + const t = cellText(v, ""); + return ( + + {t || "-"} + + ); +} + +function ellipsisCell(v: unknown) { + const t = cellText(v, ""); + return ( + + {t || "-"} + + ); +} + +export const CC_SHIPMENT_COLUMNS: ShipmentColumn[] = [ + { key: "idx", title: "#", width: 44, render: (row) => row.row_index }, + { key: "ck", title: "选", width: 44, render: () => null }, + { + key: "ch", + title: "Move Type", + width: 100, + render: (row) => cellText(row.F_Transporter), + }, + { + key: "sid", + title: "Custmer Ref No.", + width: 120, + render: (row) => monoCell(row.F_ShipmentID), + }, + { + key: "fbaid", + title: "FBAId(Amazon)", + width: 130, + render: (row) => monoCell(row.F_FBAID), + }, + { + key: "po", + title: "PO(Amazon)", + width: 100, + render: (row) => monoCell(row.F_PO), + }, + { + key: "fba", + title: "FBACode", + width: 88, + render: (row) => cellText(row.F_FBACode), + }, + { + key: "addr", + title: "Address", + width: 200, + render: (row) => ellipsisCell(row.F_Address), + }, + { + key: "ctns", + title: "Qty.(CTNS)", + width: 80, + render: (row) => row.F_CTNS ?? "-", + }, + { + key: "cbm", + title: "Vol(CBM)", + width: 80, + render: (row) => row.F_CBM ?? "-", + }, + { + key: "wt", + title: "G.W.(KGS)", + width: 80, + render: (row) => row.F_Weight ?? "-", + }, + { + key: "pcs", + title: "PCS", + width: 64, + render: (row) => row.F_Pcs ?? "-", + }, + { + key: "city", + title: "City", + width: 90, + render: (row) => cellText(row.F_City), + }, + { + key: "state", + title: "State", + width: 70, + render: (row) => cellText(row.F_State), + }, + { + key: "zip", + title: "ZipCode", + width: 80, + render: (row) => cellText(row.F_ZipCode), + }, + { + key: "contact", + title: "Contact", + width: 90, + render: (row) => cellText(row.F_Contact), + }, + { + key: "mobile", + title: "Mobile", + width: 100, + render: (row) => cellText(row.F_Mobile), + }, + { + key: "email", + title: "Email", + width: 140, + render: (row) => ellipsisCell(row.F_Email), + }, + { + key: "remark", + title: "行备注", + width: 160, + render: (row) => ellipsisCell(row.F_Remark), + }, + { + key: "ref", + title: "ReferenceId", + width: 110, + render: (row) => monoCell(row.F_ReferenceId), + }, + { + key: "dateB", + title: "最早送仓日期", + width: 110, + render: (row) => cellText(row.F_Expected_DeliveryDateB), + }, + { + key: "dateE", + title: "最晚送仓日期", + width: 110, + render: (row) => cellText(row.F_Expected_DeliveryDateE), + }, + { + key: "sku", + title: "SKU", + width: 100, + render: (row) => monoCell(row.F_SKU), + }, + { + key: "desc", + title: "品名", + width: 100, + render: (row) => ellipsisCell(row.F_Description), + }, + { + key: "en", + title: "英文名称", + width: 100, + render: (row) => ellipsisCell(row.F_EnglishName), + }, + { + key: "st", + title: "状态", + width: 64, + render: (row) => (row.row_status === "VALID" ? "有效" : "无效"), + }, +]; + +function targetFcFromRemark(remark: string | null | undefined): string { + const m = /转\s*([A-Z]{3}\d)/i.exec(String(remark || "")); + return m ? m[1].toUpperCase() : ""; +} + +/** IndexTruck 货件列表列(对齐 CC 转仓主界面) */ +export const CC_TRANSFER_SHIPMENT_COLUMNS: ShipmentColumn[] = [ + { + key: "idx", + title: "货件Id", + width: 72, + render: (row) => row.row_index, + }, + { + key: "fba", + title: "FBACode", + width: 88, + render: (row) => cellText(row.F_FBACode), + }, + { + key: "prevFc", + title: "转仓前FC", + width: 88, + render: (row) => cellText(row.F_FBACode), + }, + { + key: "targetFc", + title: "目标FC", + width: 80, + render: (row) => cellText(targetFcFromRemark(row.F_Remark), "-"), + }, + { + key: "ctns", + title: "件数", + width: 56, + render: (row) => row.F_CTNS ?? "-", + }, + { + key: "fbaid", + title: "FBA Id", + width: 130, + render: (row) => ( + + {cellText(row.F_FBAID)} + + ), + }, + { + key: "sid", + title: "Customer Ref No.", + width: 120, + render: (row) => ( + + {cellText(row.F_ShipmentID)} + + ), + }, + { + key: "addr", + title: "送货地址", + width: 240, + render: (row) => { + const t = cellText(row.F_Address, ""); + return ( + + {t || "-"} + + ); + }, + }, + { + key: "ref", + title: "ReferenceId", + width: 100, + render: (row) => ( + + {cellText(row.F_ReferenceId)} + + ), + }, + { + key: "remark", + title: "行备注", + width: 140, + render: (row) => { + const t = cellText(row.F_Remark, ""); + return ( + + {t || "-"} + + ); + }, + }, +]; diff --git a/src/components/StatusPill.tsx b/src/components/StatusPill.tsx new file mode 100644 index 0000000..76595b4 --- /dev/null +++ b/src/components/StatusPill.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { LoadingOutlined } from "@ant-design/icons"; +import { Tag } from "antd"; +import type { MailStatus } from "@/types/mail"; +import { MAIL_STATUS_COLORS, MAIL_STATUS_LABEL } from "@/constants/ui-copy"; + +export function StatusPill({ status }: { status: MailStatus }) { + return ( + : undefined} + style={{ marginInlineEnd: 0 }} + > + {MAIL_STATUS_LABEL[status] || status} + + ); +} diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx new file mode 100644 index 0000000..e5bb320 --- /dev/null +++ b/src/components/TopNav.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { Button, Layout, Menu, Space, Tag } from "antd"; +import { ImapStatusBanner } from "@/components/ImapStatusBanner"; +import { + clearAuthSessionMem, + useAuthSession, +} from "@/components/providers/AuthSessionProvider"; +import { UI_COPY } from "@/constants/ui-copy"; +import { logoutApi } from "@/lib/client-api"; + +const { Header } = Layout; + +const OPS_ROUTES = ["/mails", "/logs", "/settings"] as const; + +export function TopNav() { + const pathname = usePathname(); + const router = useRouter(); + const { user } = useAuthSession(); + const [loggingOut, setLoggingOut] = useState(false); + + useEffect(() => { + for (const path of OPS_ROUTES) { + if (path === "/settings" && user.role !== "admin") continue; + router.prefetch(path); + } + }, [router, user.role]); + + const onLogout = async () => { + setLoggingOut(true); + await logoutApi(); + clearAuthSessionMem(); + setLoggingOut(false); + router.replace("/login"); + }; + + const selectedKey = pathname.startsWith("/settings") + ? "/settings" + : pathname.startsWith("/logs") + ? "/logs" + : "/mails"; + + const menuItems = [ + { key: "/mails", label: UI_COPY.navMails }, + { key: "/logs", label: UI_COPY.navLogs }, + ...(user.role === "admin" + ? [{ key: "/settings", label: UI_COPY.navSettings }] + : []), + ]; + + return ( +
+ + + {UI_COPY.appTitle} + + { + if (key && key !== pathname) router.push(key); + }} + style={{ flex: 1, minWidth: 320, border: "none", lineHeight: "54px" }} + /> + + + + + {user.username || "-"} + + {user.role === "admin" ? ( + Admin + ) : null} + + +
+ ); +} diff --git a/src/components/TypeOverrideModal.tsx b/src/components/TypeOverrideModal.tsx new file mode 100644 index 0000000..59f8dac --- /dev/null +++ b/src/components/TypeOverrideModal.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Form, Input, Modal, Select } from "antd"; +import type { MailType } from "@/types/mail"; +import { MAIL_TYPE_LABEL } from "@/constants/ui-copy"; + +export function TypeOverrideModal({ + open, + loading, + currentType, + evidenceTotal, + onCancel, + onSubmit, +}: { + open: boolean; + loading: boolean; + currentType: MailType; + evidenceTotal: number; + onCancel: () => void; + onSubmit: (values: { mail_type: MailType; reason: string }) => void; +}) { + const [form] = Form.useForm<{ mail_type: MailType; reason: string }>(); + + return ( + { + form.resetFields(); + onCancel(); + }} + onOk={() => { + form.validateFields().then((values) => onSubmit(values)); + }} + > +
+ 当前类型:{MAIL_TYPE_LABEL[currentType]} · 证据总分 {evidenceTotal} +
+
+ + + + +
+ ); +} diff --git a/src/components/TypeTag.tsx b/src/components/TypeTag.tsx new file mode 100644 index 0000000..d7033f7 --- /dev/null +++ b/src/components/TypeTag.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { Tag } from "antd"; +import type { MailType } from "@/types/mail"; +import { MAIL_TYPE_COLORS, MAIL_TYPE_LABEL } from "@/constants/ui-copy"; + +export function TypeTag({ type }: { type: MailType }) { + return ( + + {MAIL_TYPE_LABEL[type] || type} + + ); +} diff --git a/src/components/providers/AntdProvider.tsx b/src/components/providers/AntdProvider.tsx new file mode 100644 index 0000000..da9db02 --- /dev/null +++ b/src/components/providers/AntdProvider.tsx @@ -0,0 +1,46 @@ +"use client"; + +// Next 15 App Router 可能以 React 19 canary 跑 antd 静态挂载(message/Modal/Wave) +import "@ant-design/v5-patch-for-react-19"; +import React from "react"; +import { StyleProvider, createCache, extractStyle } from "@ant-design/cssinjs"; +import { useServerInsertedHTML } from "next/navigation"; +import { App, ConfigProvider } from "antd"; +import zhCN from "antd/locale/zh_CN"; + +function AntdRegistry({ children }: { children: React.ReactNode }) { + const cache = React.useMemo(() => createCache(), []); + + useServerInsertedHTML(() => ( +