Ship source, prisma, docs, and gold samples. Ignore local .env, IMAP snapshots, smoke logs, and debug scripts. Co-authored-by: Cursor <cursoragent@cursor.com>main
commit
2b29fa7497
@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
data
|
||||
.env
|
||||
.env*.local
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
@ -0,0 +1,40 @@
|
||||
# deps / build
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
coverage
|
||||
.turbo
|
||||
*.tsbuildinfo
|
||||
|
||||
# local secrets & runtime
|
||||
.env
|
||||
.env*.local
|
||||
!.env.example
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
# local test records / smoke reports
|
||||
*.log
|
||||
data/logs/
|
||||
data/mails/
|
||||
data/imap-runtime.json
|
||||
playwright-report
|
||||
test-results
|
||||
.playwright-mcp
|
||||
node_modules/.vite
|
||||
|
||||
# editor / OS / local AI scratch
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.mypy_cache
|
||||
.cursor/plans
|
||||
agent-transcripts
|
||||
*.tmp
|
||||
_tmp*
|
||||
scripts/_dump-*.ts
|
||||
scripts/_check-*.ts
|
||||
scripts/debug-*.ts
|
||||
docx/_excel_summary.py
|
||||
@ -0,0 +1,31 @@
|
||||
FROM node:20-bookworm AS deps
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY prisma ./prisma
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
|
||||
FROM node:20-bookworm AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm db:generate && pnpm build
|
||||
|
||||
FROM node:20-bookworm AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/src ./src
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder /app/tsconfig.worker.json ./tsconfig.worker.json
|
||||
RUN mkdir -p /app/data && chmod +x /app/scripts/docker-entrypoint-web.sh
|
||||
EXPOSE 3000
|
||||
CMD ["pnpm", "start"]
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_DATABASE: email_forecast
|
||||
MYSQL_USER: app
|
||||
MYSQL_PASSWORD: app
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- "7023:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
command:
|
||||
- --default-authentication-plugin=mysql_native_password
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uapp", "-papp"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
web:
|
||||
build: .
|
||||
command: ["sh", "/app/scripts/docker-entrypoint-web.sh"]
|
||||
ports:
|
||||
- "3100:3100"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: mysql://app:app@mysql:3306/email_forecast
|
||||
SEED_ON_START: ${SEED_ON_START:-true}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 40s
|
||||
|
||||
worker:
|
||||
build: .
|
||||
# tsx 跑源码,避免 dist 中 @/ 别名未重写导致 MODULE_NOT_FOUND
|
||||
command: ["pnpm", "exec", "tsx", "src/worker/index.ts"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: mysql://app:app@mysql:3306/email_forecast
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
REM One-click start (default: local = mysql docker + host pnpm)
|
||||
REM Avoids Docker Hub pull of node images (often blocked).
|
||||
REM Full compose: start-system.cmd -Mode compose
|
||||
cd /d "%~dp0.."
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-system.ps1" %*
|
||||
if errorlevel 1 pause
|
||||
@ -0,0 +1,378 @@
|
||||
# email-forecast one-click start
|
||||
# Stop old services / free ports, then start stack.
|
||||
#
|
||||
# Default: local (mysql container + host pnpm dev/worker)
|
||||
# - avoids rebuilding web/worker images (Docker Hub often blocked in CN)
|
||||
# Compose full stack:
|
||||
# .\docs\start-system.ps1 -Mode compose
|
||||
#
|
||||
# NOTE: Keep this file ASCII-only (Windows PowerShell 5.1 + UTF-8 without BOM).
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet("compose", "local")]
|
||||
[string]$Mode = "local",
|
||||
|
||||
[switch]$NoBuild,
|
||||
|
||||
[switch]$NoFallback,
|
||||
|
||||
[int]$WebPort = 3100,
|
||||
|
||||
[int]$MysqlPort = 7023,
|
||||
|
||||
[int]$HealthTimeoutSec = 120
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-RepoRoot {
|
||||
$here = $PSScriptRoot
|
||||
if (-not $here) {
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
}
|
||||
$root = Resolve-Path (Join-Path $here "..")
|
||||
if (-not (Test-Path (Join-Path $root "package.json"))) {
|
||||
throw "Repo root not found (missing package.json). Keep this script under docs/."
|
||||
}
|
||||
return $root.Path
|
||||
}
|
||||
|
||||
function Write-Step([string]$msg) {
|
||||
Write-Host ""
|
||||
Write-Host "==> $msg" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Stop-PortListeners([int]$Port) {
|
||||
try {
|
||||
$conns = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
$conns = $null
|
||||
}
|
||||
if (-not $conns) {
|
||||
Write-Host " port $Port is free"
|
||||
return
|
||||
}
|
||||
$pids = $conns | Select-Object -ExpandProperty OwningProcess -Unique
|
||||
foreach ($procId in $pids) {
|
||||
if (-not $procId -or $procId -eq 0) { continue }
|
||||
try {
|
||||
$p = Get-Process -Id $procId -ErrorAction SilentlyContinue
|
||||
$name = if ($p) { $p.ProcessName } else { "?" }
|
||||
Write-Host " kill PID=$procId ($name) on port $Port"
|
||||
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
Write-Host " cannot kill PID=$procId : $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
function Stop-NodeDevProcesses {
|
||||
$markers = @(
|
||||
"next dev",
|
||||
"next start",
|
||||
"src/worker/index.ts",
|
||||
"src\worker\index.ts",
|
||||
"email-forecast"
|
||||
)
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$cmd = $_.CommandLine
|
||||
if (-not $cmd) { return $false }
|
||||
if ($cmd -notmatch "node|tsx|pnpm") { return $false }
|
||||
foreach ($m in $markers) {
|
||||
if ($cmd -like "*$m*") { return $true }
|
||||
}
|
||||
return $false
|
||||
} |
|
||||
ForEach-Object {
|
||||
Write-Host " kill node-related PID=$($_.ProcessId)"
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-DotEnv([string]$RepoRoot) {
|
||||
$envFile = Join-Path $RepoRoot ".env"
|
||||
$example = Join-Path $RepoRoot ".env.example"
|
||||
if (-not (Test-Path $envFile)) {
|
||||
if (Test-Path $example) {
|
||||
Copy-Item $example $envFile
|
||||
Write-Host " created .env from .env.example"
|
||||
} else {
|
||||
Write-Host " WARN: missing .env" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-HttpOk([string]$Url, [int]$TimeoutSec) {
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5
|
||||
if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 300) {
|
||||
return $true
|
||||
}
|
||||
} catch {
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Wait-MysqlReady([int]$MysqlPort, [int]$TimeoutSec = 120) {
|
||||
Write-Step "wait MySQL healthy on port $MysqlPort"
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
$portReady = $false
|
||||
$healthReady = $false
|
||||
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (-not $portReady) {
|
||||
$listening = Get-NetTCPConnection -LocalPort $MysqlPort -State Listen -ErrorAction SilentlyContinue
|
||||
if ($listening) {
|
||||
$portReady = $true
|
||||
Write-Host " port $MysqlPort listening"
|
||||
}
|
||||
}
|
||||
|
||||
# Prefer compose healthcheck (accepts connections + auth), not just TCP
|
||||
$status = ""
|
||||
try {
|
||||
$cid = (& docker compose ps -q mysql 2>$null | Select-Object -First 1)
|
||||
if ($cid) {
|
||||
$status = (& docker inspect -f "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" $cid 2>$null)
|
||||
if ($status) { $status = $status.Trim() }
|
||||
}
|
||||
} catch {
|
||||
$status = ""
|
||||
}
|
||||
|
||||
if ($status -eq "healthy" -or $status -eq "running") {
|
||||
# "running" without health still needs ping; healthy is enough
|
||||
if ($status -eq "healthy") {
|
||||
$healthReady = $true
|
||||
Write-Host " mysql health=healthy"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Fallback: mysqladmin inside container (stderr warning must not stop script)
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$null = & docker compose exec -T mysql mysqladmin ping -h 127.0.0.1 -uapp -papp --silent 2>$null
|
||||
$pingOk = ($LASTEXITCODE -eq 0)
|
||||
$ErrorActionPreference = $prevEap
|
||||
if ($pingOk) {
|
||||
$healthReady = $true
|
||||
Write-Host " mysqladmin ping ok"
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
if (-not $portReady) {
|
||||
throw "MySQL port $MysqlPort not listening"
|
||||
}
|
||||
if (-not $healthReady) {
|
||||
throw "MySQL not healthy within ${TimeoutSec}s (port up but server not ready — prisma P1017)"
|
||||
}
|
||||
|
||||
# Brief settle: fresh container may still drop first connections
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
function Invoke-PrismaDbPushRetry([int]$MaxAttempts = 8) {
|
||||
Write-Step "prisma db push + seed"
|
||||
$ok = $false
|
||||
for ($i = 1; $i -le $MaxAttempts; $i++) {
|
||||
pnpm exec prisma db push --skip-generate
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$ok = $true
|
||||
break
|
||||
}
|
||||
Write-Host " prisma db push attempt $i/$MaxAttempts failed (exit=$LASTEXITCODE), retry in 3s..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
if (-not $ok) { throw "prisma db push failed after $MaxAttempts attempts" }
|
||||
pnpm db:seed
|
||||
if ($LASTEXITCODE -ne 0) { throw "db:seed failed" }
|
||||
}
|
||||
|
||||
function Start-LocalStack {
|
||||
param(
|
||||
[string]$RepoRoot,
|
||||
[int]$WebPort,
|
||||
[int]$MysqlPort,
|
||||
[int]$HealthTimeoutSec
|
||||
)
|
||||
|
||||
Write-Step "start mysql container only (no web/worker image build)"
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
& docker compose up -d mysql 2>&1 | ForEach-Object { Write-Host $_ }
|
||||
$upExit = $LASTEXITCODE
|
||||
$ErrorActionPreference = $prevEap
|
||||
if ($upExit -ne 0) {
|
||||
throw @"
|
||||
mysql start failed (exit=$upExit).
|
||||
If pull mysql:8.0 also fails, Docker Hub is unreachable.
|
||||
Fix: configure registry mirror in Docker Desktop, or use a VPN, then:
|
||||
docker pull mysql:8.0
|
||||
"@
|
||||
}
|
||||
|
||||
Wait-MysqlReady -MysqlPort $MysqlPort -TimeoutSec 120
|
||||
|
||||
$env:DATABASE_URL = "mysql://app:app@localhost:$MysqlPort/email_forecast"
|
||||
|
||||
Invoke-PrismaDbPushRetry -MaxAttempts 8
|
||||
|
||||
$logDir = Join-Path $RepoRoot "data\logs"
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
# Start-Process forbids the same path for stdout and stderr
|
||||
$webOut = Join-Path $logDir "web-dev.out.log"
|
||||
$webErr = Join-Path $logDir "web-dev.err.log"
|
||||
$workerOut = Join-Path $logDir "worker.out.log"
|
||||
$workerErr = Join-Path $logDir "worker.err.log"
|
||||
|
||||
Write-Step "start pnpm dev:web / pnpm worker (host Node, no Docker Hub)"
|
||||
foreach ($f in @($webOut, $webErr, $workerOut, $workerErr)) {
|
||||
if (Test-Path $f) { Remove-Item $f -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
# Turbopack cache mixed with webpack causes /_next/_next/static/chunks 404 + ChunkLoadError
|
||||
$nextDir = Join-Path $RepoRoot ".next"
|
||||
$turboMarker = Join-Path $nextDir "static\chunks"
|
||||
if ((Test-Path $nextDir) -and (Get-ChildItem $turboMarker -Filter "turbopack-*" -ErrorAction SilentlyContinue)) {
|
||||
Remove-Item $nextDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host " cleared stale Turbopack .next cache"
|
||||
}
|
||||
|
||||
# Prefer cmd.exe wrapper: pnpm is often a .CMD shim; Start-Process + redirect is flaky on shims
|
||||
$devArgs = "/c pnpm run dev:web > `"$webOut`" 2> `"$webErr`""
|
||||
$workerArgs = "/c pnpm worker > `"$workerOut`" 2> `"$workerErr`""
|
||||
Start-Process -FilePath "cmd.exe" -ArgumentList $devArgs -WorkingDirectory $RepoRoot -WindowStyle Hidden
|
||||
Start-Process -FilePath "cmd.exe" -ArgumentList $workerArgs -WorkingDirectory $RepoRoot -WindowStyle Hidden
|
||||
|
||||
Write-Step "wait health http://127.0.0.1:$WebPort/api/health"
|
||||
$ok = Wait-HttpOk -Url "http://127.0.0.1:$WebPort/api/health" -TimeoutSec $HealthTimeoutSec
|
||||
if (-not $ok) {
|
||||
Write-Host "health check timeout. logs:" -ForegroundColor Yellow
|
||||
Write-Host " $webOut / $webErr"
|
||||
Write-Host " $workerOut / $workerErr"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Dev 按需编译:登录后再预热运营页,否则 /mails 会落到登录页、切模块仍要编译 10s
|
||||
Write-Step "warmup Next.js routes (first compile)"
|
||||
$webSession = $null
|
||||
$adminUser = if ($env:APP_ADMIN_USER) { $env:APP_ADMIN_USER } else { "admin" }
|
||||
$adminPass = if ($env:APP_ADMIN_PASS) { $env:APP_ADMIN_PASS } else { "admin123" }
|
||||
try {
|
||||
$loginJson = (@{ username = $adminUser; password = $adminPass } | ConvertTo-Json -Compress)
|
||||
$null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort/api/auth/login" -Method POST -Body $loginJson -ContentType "application/json" -UseBasicParsing -TimeoutSec 60 -SessionVariable webSession
|
||||
Write-Host " warmed login session"
|
||||
} catch {
|
||||
Write-Host " login warmup skipped (pages still compile on first click)"
|
||||
}
|
||||
$warmupPaths = @(
|
||||
"/login",
|
||||
"/mails",
|
||||
"/logs",
|
||||
"/settings",
|
||||
"/api/mails",
|
||||
"/api/imports",
|
||||
"/api/settings/mailboxes",
|
||||
"/api/settings/oauth",
|
||||
"/api/settings/cc",
|
||||
"/api/audits"
|
||||
)
|
||||
foreach ($p in $warmupPaths) {
|
||||
try {
|
||||
if ($webSession) {
|
||||
$null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort$p" -UseBasicParsing -TimeoutSec 180 -WebSession $webSession
|
||||
} else {
|
||||
$null = Invoke-WebRequest -Uri "http://127.0.0.1:$WebPort$p" -UseBasicParsing -TimeoutSec 180
|
||||
}
|
||||
Write-Host " warmed $p"
|
||||
} catch {
|
||||
Write-Host " warmup $p done (status may be non-200)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host " web log: $webOut / $webErr"
|
||||
Write-Host " worker log: $workerOut / $workerErr"
|
||||
}
|
||||
|
||||
function Start-ComposeStack {
|
||||
param(
|
||||
[switch]$NoBuild,
|
||||
[int]$WebPort,
|
||||
[int]$HealthTimeoutSec
|
||||
)
|
||||
|
||||
Write-Step "start compose (mysql + web + worker images)"
|
||||
if ($NoBuild) {
|
||||
docker compose up -d
|
||||
} else {
|
||||
docker compose up --build -d
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "docker compose up failed (exit=$LASTEXITCODE)"
|
||||
}
|
||||
|
||||
Write-Step "wait health http://127.0.0.1:$WebPort/api/health"
|
||||
$ok = Wait-HttpOk -Url "http://127.0.0.1:$WebPort/api/health" -TimeoutSec $HealthTimeoutSec
|
||||
docker compose ps
|
||||
if (-not $ok) {
|
||||
Write-Host "health check timeout. run: pnpm compose:logs" -ForegroundColor Yellow
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
# ---------- main ----------
|
||||
$Root = Get-RepoRoot
|
||||
Set-Location $Root
|
||||
Write-Host "repo: $Root"
|
||||
Write-Host "mode: $Mode (default local = no node image pull)"
|
||||
|
||||
Write-Step "stop old services / free ports"
|
||||
try {
|
||||
# stop web/worker containers but keep volume; full down then local mysql up is ok
|
||||
docker compose -f (Join-Path $Root "docker-compose.yml") down --remove-orphans 2>$null | Out-Null
|
||||
Write-Host " docker compose down done"
|
||||
} catch {
|
||||
Write-Host " docker compose down skipped: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Stop-NodeDevProcesses
|
||||
Stop-PortListeners -Port $WebPort
|
||||
# do not kill 7023 if we will reuse mysql quickly; still free stale non-docker holders
|
||||
Stop-PortListeners -Port $MysqlPort
|
||||
|
||||
Ensure-DotEnv -RepoRoot $Root
|
||||
|
||||
if ($Mode -eq "compose") {
|
||||
try {
|
||||
Start-ComposeStack -NoBuild:$NoBuild -WebPort $WebPort -HealthTimeoutSec $HealthTimeoutSec
|
||||
} catch {
|
||||
Write-Host ""
|
||||
Write-Host "compose failed: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
Write-Host "Likely cause: cannot reach registry-1.docker.io (node image pull)." -ForegroundColor Yellow
|
||||
if ($NoFallback) { throw }
|
||||
Write-Host "Auto-fallback to local mode (mysql + host pnpm)..." -ForegroundColor Cyan
|
||||
Start-LocalStack -RepoRoot $Root -WebPort $WebPort -MysqlPort $MysqlPort -HealthTimeoutSec $HealthTimeoutSec
|
||||
}
|
||||
} else {
|
||||
Start-LocalStack -RepoRoot $Root -WebPort $WebPort -MysqlPort $MysqlPort -HealthTimeoutSec $HealthTimeoutSec
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "OK - system started (web + worker)" -ForegroundColor Green
|
||||
Write-Host " open: http://localhost:$WebPort"
|
||||
Write-Host " login: admin/admin123 or ops/ops123"
|
||||
Write-Host " worker: IMAP poll / parse / retention (see data\logs\worker.*.log in local mode)"
|
||||
Write-Host " stop local: kill node on port $WebPort (and worker); docker compose stop mysql"
|
||||
Write-Host " full compose (needs Docker Hub): .\docs\start-system.ps1 -Mode compose"
|
||||
Write-Host " clear imap lock: pnpm exec tsx scripts/clear-imap-lock.ts"
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "M1",
|
||||
"subject_contains": "TIIU8073522-90022",
|
||||
"mail_type": "UNKNOWN",
|
||||
"shipments_expected": 0,
|
||||
"importable": false
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "M2",
|
||||
"subject_contains": "MATU2745683",
|
||||
"mail_type": "WORK_ORDER",
|
||||
"legacy_mail_type": "TRANSFER",
|
||||
"container_no": "MATU2745683",
|
||||
"shipments_expected": 327,
|
||||
"sample_rows": [
|
||||
{ "F_FBACode": "ABQ2", "F_Transporter": "TRUCK", "F_CTNS": 1, "F_FBAID": "FBA19G5XJLV4" },
|
||||
{ "F_FBACode": "FTW1", "F_Transporter": "TRUCK", "F_CTNS": 4, "F_FBAID": "FBA19GLHTHQ2" }
|
||||
],
|
||||
"importable_default": false,
|
||||
"importable_after_admin_type_override_to_NEW": true,
|
||||
"note": "转仓关键词 → WORK_ORDER 标准记录;导入后置"
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "M3",
|
||||
"subject_contains": "WHSU5574991",
|
||||
"mail_type": "INSTRUCTION_HOLD_SPLIT",
|
||||
"importable": false
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "M5",
|
||||
"subject_contains": "新增预报",
|
||||
"mail_type": "NEW_CONTAINER",
|
||||
"container_no": "MATU2745683",
|
||||
"shipments_expected": 327,
|
||||
"importable": true,
|
||||
"gate": "上线门禁:主题含新增预报 + 卡派清单,无需 Admin 改类型",
|
||||
"header_expected": {
|
||||
"F_CabinetType": "40HQ",
|
||||
"F_ETA": "2026-08-15",
|
||||
"F_ETD": "2026-07-20",
|
||||
"F_LoadPort": "上海",
|
||||
"F_Dock": "洋山",
|
||||
"F_BLCopyCode": "BLMATU2745683",
|
||||
"shipping_line_hint": "MSC"
|
||||
},
|
||||
"sample_rows": [
|
||||
{ "F_FBACode": "ABQ2", "F_Transporter": "TRUCK", "F_CTNS": 1 },
|
||||
{ "F_FBACode": "FTW1", "F_Transporter": "TRUCK", "F_CTNS": 4 }
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "M_WORK_ORDER",
|
||||
"subject_contains": "转仓",
|
||||
"mail_type": "WORK_ORDER",
|
||||
"record_kind": "WORK_ORDER",
|
||||
"work_order_actions_any": ["转仓", "贴标", "拦截", "拍照", "快递单号"],
|
||||
"importable": false,
|
||||
"auto_exec": false,
|
||||
"note": "工单关键词覆盖;本期只落 mail_record 表格,不写 CC"
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@ -0,0 +1,19 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// 开发态隐藏左下角 Next.js / Dev Tools 指示标
|
||||
devIndicators: false,
|
||||
transpilePackages: [
|
||||
"antd",
|
||||
"@ant-design/icons",
|
||||
"@ant-design/cssinjs",
|
||||
"@ant-design/v5-patch-for-react-19",
|
||||
],
|
||||
// 缩小 antd / icons 的编译与打包面,显著降低 dev 首访 compile 时间
|
||||
experimental: {
|
||||
optimizePackageImports: ["antd", "@ant-design/icons"],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "email-forecast",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-stack.mjs",
|
||||
"dev:web": "next dev -p 3100",
|
||||
"dev:turbo": "next dev --turbopack -p 3100",
|
||||
"build": "next build && tsc -p tsconfig.worker.json",
|
||||
"start": "next start -p 3100",
|
||||
"worker": "tsx src/worker/index.ts",
|
||||
"dev:stack": "node scripts/dev-stack.mjs",
|
||||
"fix:ui-zh": "tsx scripts/rewrite-mail-business-summary.ts",
|
||||
"compose:up": "docker compose up --build -d",
|
||||
"compose:up:core": "docker compose up -d mysql worker",
|
||||
"compose:logs": "docker compose logs -f --tail=100",
|
||||
"compose:ps": "docker compose ps",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:migrate:dev": "prisma migrate dev",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:push": "prisma db push",
|
||||
"cc:smoke": "tsx scripts/cc-smoke.ts",
|
||||
"imap:smoke": "tsx scripts/imap-smoke.ts",
|
||||
"imap:poll": "tsx scripts/imap-poll.ts",
|
||||
"sample:mail3": "tsx scripts/ingest-mail3.ts",
|
||||
"sample:mails": "tsx scripts/ingest-sample-mails.ts",
|
||||
"sample:do": "tsx scripts/ingest-do-upload-ui.ts",
|
||||
"sample:recognize": "tsx scripts/ingest-recognize-gold.ts",
|
||||
"sample:flatten": "tsx scripts/flatten-mail-attachments.ts",
|
||||
"sample:scan": "tsx scripts/scan-mail-samples.ts",
|
||||
"retention:cleanup": "tsx scripts/retention-cleanup.ts",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/cssinjs": "^1.22.0",
|
||||
"@ant-design/icons": "^5.5.2",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"antd": "^5.22.6",
|
||||
"exceljs": "^4.4.0",
|
||||
"imapflow": "^1.0.181",
|
||||
"iron-session": "^8.0.4",
|
||||
"mailparser": "^3.7.2",
|
||||
"mysql2": "^3.23.1",
|
||||
"next": "^15.1.3",
|
||||
"pino": "^9.6.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"undici": "^6.21.0",
|
||||
"uuid": "^11.0.3",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/mailparser": "^3.4.5",
|
||||
"@types/mysql": "^2.15.27",
|
||||
"@types/node": "^20.17.10",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 60_000,
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:3100",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://127.0.0.1:3100",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1 @@
|
||||
{}
|
||||
@ -0,0 +1,38 @@
|
||||
import fs from "fs";
|
||||
import {
|
||||
extractMailInstructions,
|
||||
splitBodySegments,
|
||||
} from "../src/services/parse/split-instructions";
|
||||
|
||||
const raw = JSON.parse(
|
||||
fs.readFileSync("data/logs/mail-pdf-extract.json", "utf8"),
|
||||
) as Record<string, string[]>;
|
||||
|
||||
function bodyOf(keyPart: string): string {
|
||||
const key = Object.keys(raw).find((k) => k.includes(keyPart));
|
||||
if (!key) return "";
|
||||
return raw[key].join("\n");
|
||||
}
|
||||
|
||||
for (const name of ["邮件2", "邮件3", "邮件4"]) {
|
||||
const body = bodyOf(name);
|
||||
const segs = splitBodySegments(body);
|
||||
console.log(`\n==== ${name} segs=${segs.length} bodyLen=${body.length}`);
|
||||
segs.forEach((s, i) => {
|
||||
console.log(` ${i}: ${s.replace(/\s+/g, " ").slice(0, 100)}`);
|
||||
});
|
||||
const units = extractMailInstructions({
|
||||
subject: "",
|
||||
body,
|
||||
filenames: [],
|
||||
});
|
||||
console.log(
|
||||
" units",
|
||||
units.map((u) => ({
|
||||
kind: u.uiKind,
|
||||
cur: u.isCurrent,
|
||||
seg: u.segmentIndex,
|
||||
kw: u.keywords.slice(0, 4),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { IMAP_LOCK } from "../src/services/imap/poller";
|
||||
import {
|
||||
isMysqlNamedLockHeld,
|
||||
killMysqlNamedLockHolder,
|
||||
} from "../src/services/db-lock";
|
||||
|
||||
async function main() {
|
||||
const before = await isMysqlNamedLockHeld(IMAP_LOCK);
|
||||
console.log("before", before);
|
||||
if (before !== "free") {
|
||||
console.log(await killMysqlNamedLockHolder(IMAP_LOCK));
|
||||
}
|
||||
console.log("after", await isMysqlNamedLockHeld(IMAP_LOCK));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Local stack: Next.js web + IMAP worker in one process group.
|
||||
* Usage: pnpm dev / pnpm dev:stack
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const children = [];
|
||||
|
||||
function start(label, args) {
|
||||
const child = spawn("pnpm", args, {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: process.env,
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
console.error(`[dev-stack] ${label} exited code=${code} signal=${signal}`);
|
||||
shutdown(code ?? 1);
|
||||
});
|
||||
children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
function shutdown(code = 0) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
for (const child of children) {
|
||||
if (!child.killed) {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
// Give children a moment, then force-exit so orphaned cmd.exe on Windows dies with parent intent
|
||||
setTimeout(() => process.exit(code), 500).unref();
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
console.log("[dev-stack] starting web (next) + worker …");
|
||||
// Use dev:web so this is never recursive with package.json "dev" = this script
|
||||
start("web", ["run", "dev:web"]);
|
||||
start("worker", ["run", "worker"]);
|
||||
@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
echo "[web] prisma db push…"
|
||||
pnpm exec prisma db push --skip-generate
|
||||
if [ "${SEED_ON_START:-false}" = "true" ]; then
|
||||
echo "[web] seed…"
|
||||
pnpm db:seed || true
|
||||
fi
|
||||
echo "[web] next start"
|
||||
exec pnpm start
|
||||
@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import zipfile, re, html, os
|
||||
|
||||
docx = r"docx/邮件/模板/附件下载_邮件识别/邮件识别.docx"
|
||||
with zipfile.ZipFile(docx) as z:
|
||||
xml = z.read("word/document.xml").decode("utf-8")
|
||||
text = re.sub(r"<w:tab[^/]*/>", "\t", xml)
|
||||
text = re.sub(r"</w:p>", "\n", text)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = html.unescape(text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
out = r"data/logs/mail-recognize-docx.txt"
|
||||
os.makedirs("data/logs", exist_ok=True)
|
||||
open(out, "w", encoding="utf-8").write(text)
|
||||
print(out, "chars", len(text))
|
||||
print(text[:4000])
|
||||
@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Extract plain text from a PDF (pypdf). Usage: python scripts/extract-pdf-text.py <pdf>"""
|
||||
import sys
|
||||
from pypdf import PdfReader
|
||||
|
||||
def main():
|
||||
path = sys.argv[1]
|
||||
reader = PdfReader(path)
|
||||
parts = []
|
||||
for page in reader.pages:
|
||||
t = page.extract_text() or ""
|
||||
if t.strip():
|
||||
parts.append(t)
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
print("\n".join(parts))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,34 @@
|
||||
import fs from "fs";
|
||||
|
||||
const p = "src/components/MailBusinessSummary.tsx";
|
||||
let t = fs.readFileSync(p, "utf8");
|
||||
|
||||
const btnWo = "\u786e\u8ba4\u63d0\u4ea4\u5de5\u5355";
|
||||
|
||||
t = t.replace(
|
||||
/(mail\.mail_type === "WORK_ORDER"\) \{\s*actions = \(\s*<Button[\s\S]*?>\s*)\?{2,}(\s*<\/Button>)/,
|
||||
`$1${btnWo}$2`,
|
||||
);
|
||||
|
||||
// Comments with ? are fine; flag remaining UI ?
|
||||
const bad = t
|
||||
.split("\n")
|
||||
.map((l, i) => ({ i: i + 1, l }))
|
||||
.filter(
|
||||
(x) =>
|
||||
/\?{3,}/.test(x.l) &&
|
||||
!x.l.includes("eslint") &&
|
||||
!x.l.trim().startsWith("//"),
|
||||
);
|
||||
|
||||
fs.writeFileSync(p, t, "utf8");
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
btnOk: t.includes(btnWo),
|
||||
badLines: bad.map((x) => `${x.i}: ${x.l.trim()}`),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Load full QQ mailbox PDF text for sample mail body (一字不落).
|
||||
*/
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function listPdfs(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(cur, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(cur, e.name);
|
||||
if (e.isDirectory()) stack.push(full);
|
||||
else if (/\.pdf$/i.test(e.name) && /邮箱/i.test(e.name)) out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Prefer *邮箱.pdf under sample dir; return extracted text or null */
|
||||
export async function loadMailboxPdfBody(
|
||||
sampleDirAbs: string,
|
||||
): Promise<string | null> {
|
||||
const pdfs = await listPdfs(sampleDirAbs);
|
||||
if (!pdfs.length) return null;
|
||||
// Prefer QQ邮箱 / N邮箱 over other PDFs
|
||||
pdfs.sort((a, b) => {
|
||||
const score = (p: string) => {
|
||||
const n = path.basename(p);
|
||||
if (/^QQ/i.test(n)) return 0;
|
||||
if (/^\d邮箱/i.test(n)) return 1;
|
||||
return 2;
|
||||
};
|
||||
return score(a) - score(b);
|
||||
});
|
||||
const target = pdfs[0];
|
||||
try {
|
||||
const script = path.join(process.cwd(), "scripts", "extract-pdf-text.py");
|
||||
const { stdout } = await execFileAsync(
|
||||
"python",
|
||||
[script, target],
|
||||
{ maxBuffer: 12_000_000, encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
const t = (stdout || "").replace(/\r\n/g, "\n").trim();
|
||||
return t.length > 20 ? t : null;
|
||||
} catch (err) {
|
||||
console.warn("loadMailboxPdfBody failed", target, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import fs from "fs";
|
||||
|
||||
const path = "src/services/parse/classify.ts";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
|
||||
// normalize for matching
|
||||
const nl = src.includes("\r\n") ? "\r\n" : "\n";
|
||||
|
||||
const softNeedle = `for (const kw of ["\u62c6\u67dc\u6e05\u5355", "\u5361\u8f6c\u6d77", "\u6d3e\u9001\u8981\u6c42", "\u6539\u81ea\u63d0", "\u7559\u4ed3"]) {`;
|
||||
const softRepl = `for (const kw of ["\u62c6\u67dc\u6e05\u5355", "\u5361\u8f6c\u6d77", "\u6d3e\u9001\u8981\u6c42", "\u6539\u81ea\u63d0", "\u66f4\u65b0\u6d3e\u9001\u5355", "\u7559\u4ed3"]) {`;
|
||||
if (!src.includes(softNeedle)) throw new Error("soft list missing");
|
||||
src = src.replace(softNeedle, softRepl);
|
||||
|
||||
const oldTail = ` if (bestScore < 40) best = "UNKNOWN";${nl}${nl} return { total: bestScore, mail_type: best, signals, scores };${nl}}`;
|
||||
const newTail = ` if (bestScore < 40) best = "UNKNOWN";${nl}${nl} // mail1-like: container / booking only -> WORK_ORDER${nl} if (${nl} best === "UNKNOWN" &&${nl} (/[A-Z]{4}\\d{7}/.test(text) || /\u9884\u7ea6\u7801/.test(text))${nl} ) {${nl} best = "WORK_ORDER";${nl} bestScore = 40;${nl} signals.push({${nl} signal: "\u65e0\u5173\u952e\u8bcd\u515c\u5e95",${nl} score: 40,${nl} matched: iso?.[0] || "\u9884\u7ea6\u7801",${nl} source: "body",${nl} });${nl} }${nl}${nl} return { total: bestScore, mail_type: best, signals, scores };${nl}}`;
|
||||
|
||||
if (!src.includes(oldTail)) throw new Error("tail missing: " + JSON.stringify(src.slice(src.indexOf("bestScore < 40"), src.indexOf("bestScore < 40") + 100)));
|
||||
src = src.replace(oldTail, newTail);
|
||||
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("classify ok");
|
||||
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* DO_UPLOAD + bl-plus: keep customer_name from 客户+提单+柜号 template
|
||||
*/
|
||||
import fs from "fs";
|
||||
|
||||
const path = "src/services/parse/work-order-record.ts";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
|
||||
const old = ` if (input.mailType === "NEW_CONTAINER" && input.blModules) {
|
||||
return buildBlForecastRecord({
|
||||
modules: {
|
||||
...input.blModules,
|
||||
container_no:
|
||||
input.blModules.container_no || input.containerNo || undefined,
|
||||
},
|
||||
shipments,
|
||||
source: shipments.length ? "mixed" : "plus_template",
|
||||
plusPayload: input.plusPayload || undefined,
|
||||
});
|
||||
}`;
|
||||
|
||||
const neu = ` // DO \u4e3b\u7c7b\u578b\u65f6\u4ecd\u4fdd\u7559\u63d0\u5355\u6a21\u677f\uff08\u5ba2\u6237\u540d\u7b49\uff09\u2014\u2014\u91d1\u6837\u9884\u62a5+DO \u540c\u5c01
|
||||
if (
|
||||
input.blModules &&
|
||||
(input.mailType === "NEW_CONTAINER" || input.mailType === "DO_UPLOAD")
|
||||
) {
|
||||
const record = buildBlForecastRecord({
|
||||
modules: {
|
||||
...input.blModules,
|
||||
container_no:
|
||||
input.blModules.container_no || input.containerNo || undefined,
|
||||
},
|
||||
shipments,
|
||||
source: shipments.length ? "mixed" : "plus_template",
|
||||
plusPayload: input.plusPayload || undefined,
|
||||
});
|
||||
if (input.mailType === "DO_UPLOAD") {
|
||||
return {
|
||||
...record,
|
||||
summary: record.summary.replace(
|
||||
"\u65b0\u589e\u9884\u62a5\uff08\u63d0\u5355\u6a21\u677f\uff09",
|
||||
"\u4e0a\u4f20DO\uff08\u542b\u9884\u62a5\u6a21\u677f\uff09",
|
||||
),
|
||||
};
|
||||
}
|
||||
return record;
|
||||
}`;
|
||||
|
||||
if (!src.includes(old)) {
|
||||
// try CRLF
|
||||
const oldCrlf = old.replace(/\n/g, "\r\n");
|
||||
if (src.includes(oldCrlf)) {
|
||||
src = src.replace(oldCrlf, neu.replace(/\n/g, "\r\n"));
|
||||
} else {
|
||||
console.error("block not found");
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
src = src.replace(old, neu);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("buildMailRecord patched");
|
||||
@ -0,0 +1,47 @@
|
||||
import fs from "fs";
|
||||
|
||||
const path = "src/components/CcForecastFormOrder.tsx";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
|
||||
if (src.includes("PACKING_FILL_TIPS")) {
|
||||
console.log("already");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
src = src.replace(
|
||||
/} from "@\/constants\/ui-copy";\r?\n/,
|
||||
`} from "@/constants/ui-copy";\r\nimport { PACKING_FILL_TIPS } from "@/services/parse/packing-fill-rules";\r\n`,
|
||||
);
|
||||
|
||||
const re =
|
||||
/(<div className="cc-fo-step2">\r?\n)(\s*<div className="cc-fo-toolbar">)/;
|
||||
if (!re.test(src)) throw new Error("step2 not found");
|
||||
|
||||
src = src.replace(
|
||||
re,
|
||||
`$1 <div
|
||||
className="cc-fo-packing-tips"
|
||||
style={{
|
||||
marginBottom: 10,
|
||||
padding: "8px 10px",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.55,
|
||||
color: "#4b5563",
|
||||
background: "rgba(37,99,235,0.06)",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4, color: "#1f2937" }}>
|
||||
\u8d27\u4ef6\u586b\u5199\u987b\u77e5\uff08\u5df2\u5199\u5165\u7cfb\u7edf\u6821\u9a8c\uff1b\u6a21\u677f\u300c\u6ce8\u610f\u300d\u8bf4\u660e\u884c\u4e0d\u5165\u8868\uff09
|
||||
</div>
|
||||
<ol style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{PACKING_FILL_TIPS.map((t) => (
|
||||
<li key={t}>{t}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
$2`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("ok", src.includes("PACKING_FILL_TIPS.map"));
|
||||
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Wire sample ingest to use full mailbox PDF text as bodyText.
|
||||
*/
|
||||
import fs from "fs";
|
||||
|
||||
const path = "scripts/ingest-sample-mails.ts";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
|
||||
if (!src.includes("loadMailboxPdfBody")) {
|
||||
src = src.replace(
|
||||
`import { sha256 } from "@/utils/hash";`,
|
||||
`import { sha256 } from "@/utils/hash";
|
||||
import { loadMailboxPdfBody } from "./lib/load-mailbox-pdf-body";`,
|
||||
);
|
||||
}
|
||||
|
||||
// bump hash version so re-ingest refreshes body
|
||||
src = src.replace(
|
||||
'`.update(`sample|${sample.key}|${sample.subject}|${sample.bodyText}`)',
|
||||
'`.update(`sample|${sample.key}|${sample.subject}|${sample.bodyText}|fullbody-v1`)',
|
||||
);
|
||||
// the above might be wrong quoting - fix:
|
||||
src = src.replace(
|
||||
"sample|${sample.key}|${sample.subject}|${sample.bodyText}`",
|
||||
"sample|${sample.key}|${sample.subject}|${sample.bodyText}|fullbody-v1`",
|
||||
);
|
||||
|
||||
const oldLoop = ` for (const sample of SAMPLES) {
|
||||
const mailId = await upsertMail(sample);
|
||||
const sampleDir = await resolveSampleDir(sample.sampleDir);`;
|
||||
|
||||
const neuLoop = ` for (const sample of SAMPLES) {
|
||||
const sampleDir = await resolveSampleDir(sample.sampleDir);
|
||||
let bodyText = sample.bodyText;
|
||||
if (sampleDir) {
|
||||
const pdfBody = await loadMailboxPdfBody(sampleDir);
|
||||
if (pdfBody && pdfBody.length > bodyText.length) {
|
||||
bodyText = pdfBody;
|
||||
console.log(
|
||||
\`\${sample.key}: body from mailbox PDF (\${pdfBody.length} chars)\`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const mailId = await upsertMail({ ...sample, bodyText });`;
|
||||
|
||||
if (!src.includes(oldLoop)) {
|
||||
console.error("loop missing");
|
||||
process.exit(1);
|
||||
}
|
||||
src = src.replace(oldLoop, neuLoop);
|
||||
|
||||
// remove duplicate sampleDir resolve
|
||||
src = src.replace(
|
||||
` const mailId = await upsertMail({ ...sample, bodyText });
|
||||
|
||||
let attached: string[] = [];
|
||||
if (!sampleDir) {
|
||||
console.warn(\`\${sample.key}: sample dir not found: \${sample.sampleDir}\`);
|
||||
} else if (sample.attachPolicy !== "none") {
|
||||
const files = await listFilesRecursive(sampleDir);`,
|
||||
` const mailId = await upsertMail({ ...sample, bodyText });
|
||||
|
||||
let attached: string[] = [];
|
||||
if (!sampleDir) {
|
||||
console.warn(\`\${sample.key}: sample dir not found: \${sample.sampleDir}\`);
|
||||
} else if (sample.attachPolicy !== "none") {
|
||||
const files = await listFilesRecursive(sampleDir);`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("ingest wired", src.includes("loadMailboxPdfBody"), src.includes("fullbody-v1"));
|
||||
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Product rules:
|
||||
* 1) Only latest instruction segment confirmable; history readonly
|
||||
* 2) Soft keywords stay work_order
|
||||
* 3) No-keyword mail (mail1) -> work_order fallback
|
||||
*/
|
||||
import fs from "fs";
|
||||
|
||||
const path = "src/services/parse/split-instructions.ts";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
|
||||
const marker = " // 2) ";
|
||||
const idx = src.indexOf(marker);
|
||||
if (idx < 0) throw new Error("section 2 marker missing");
|
||||
|
||||
const attMarker = " // 3) ";
|
||||
const attIdx = src.indexOf(attMarker, idx);
|
||||
if (attIdx < 0) throw new Error("section 3 marker missing");
|
||||
|
||||
const sortMarker = " // \u5c55\u793a\u987a\u5e8f\uff1a\u5f53\u524d\u6bb5\u4f18\u5148";
|
||||
const sortIdx = src.indexOf(sortMarker);
|
||||
if (sortIdx < 0) throw new Error("sort marker missing");
|
||||
|
||||
const section2 = ` // 2) \u6b63\u6587\u6309\u5bf9\u8bdd\u6bb5\u62c6\u5206\uff1b\u4ec5\u300c\u6700\u65b0\u4e00\u6761\u6709\u6307\u4ee4\u7684\u6bb5\u300d\u53ef\u786e\u8ba4\uff0c\u5386\u53f2\u53ea\u8bfb
|
||||
const segments = splitBodySegments(body);
|
||||
let currentSegIndex: number | null = null;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (isNonInstructionSegment(seg)) continue;
|
||||
const kinds = detectKindsForSegment(extractSegmentSubject(seg, ""), seg);
|
||||
if (kinds.length) {
|
||||
currentSegIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// \u65e0\u5173\u952e\u8bcd\u6bb5\uff1a\u9996\u4e2a\u975e\u58f3\u6bb5\u4f5c\u4e3a\u515c\u5e95\u5de5\u5355\u8f7d\u4f53
|
||||
if (currentSegIndex === null) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
if (!isNonInstructionSegment(segments[i])) {
|
||||
currentSegIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const kindsInCurrentBody = new Set<InstructionUiKind>();
|
||||
segments.forEach((seg, i) => {
|
||||
if (isNonInstructionSegment(seg)) return;
|
||||
const segSubject = extractSegmentSubject(seg, "");
|
||||
const kinds = detectKindsForSegment(segSubject, seg);
|
||||
const isCurrent = currentSegIndex !== null && i === currentSegIndex;
|
||||
if (!kinds.length) {
|
||||
// \u90ae\u4ef61 \u7b49\uff1a\u65e0\u56db\u7c7b\u5173\u952e\u8bcd \u2192 \u6700\u65b0\u6bb5\u515c\u5e95\u4e3a\u5de5\u5355
|
||||
if (isCurrent) {
|
||||
kindsInCurrentBody.add("work_order");
|
||||
push(
|
||||
makeUnit({
|
||||
uiKind: "work_order",
|
||||
source: "body_segment",
|
||||
segmentIndex: i,
|
||||
isCurrent: true,
|
||||
text: seg,
|
||||
segmentSubject: segSubject || undefined,
|
||||
extraKeywords: ["\u65e0\u5173\u952e\u8bcd\u515c\u5e95"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const kind of kinds) {
|
||||
if (isCurrent) kindsInCurrentBody.add(kind);
|
||||
push(
|
||||
makeUnit({
|
||||
uiKind: kind,
|
||||
source: "body_segment",
|
||||
segmentIndex: i,
|
||||
isCurrent,
|
||||
text: seg,
|
||||
segmentSubject: segSubject || undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
`;
|
||||
|
||||
// Replace from section 2 through just before section 3
|
||||
src = src.slice(0, idx) + section2 + src.slice(attIdx);
|
||||
|
||||
// Re-find markers after splice
|
||||
const attIdx2 = src.indexOf(attMarker);
|
||||
const pushAttRe =
|
||||
/push\(\s*makeUnit\(\{\s*uiKind: kind,\s*source: "attachment",\s*segmentIndex: -2,\s*isCurrent: true,\s*text: f,\s*extraKeywords: \[f\.slice\(0, 40\)\],\s*\}\),\s*\);/;
|
||||
|
||||
if (!pushAttRe.test(src.slice(attIdx2, attIdx2 + 800))) {
|
||||
throw new Error("attachment push block not found");
|
||||
}
|
||||
|
||||
src = src.replace(
|
||||
pushAttRe,
|
||||
`const attachCurrent =
|
||||
kindsInCurrentBody.size === 0 || !kindsInCurrentBody.has(kind);
|
||||
push(
|
||||
makeUnit({
|
||||
uiKind: kind,
|
||||
source: "attachment",
|
||||
segmentIndex: -2,
|
||||
isCurrent: Boolean(attachCurrent),
|
||||
text: f,
|
||||
extraKeywords: [f.slice(0, 40)],
|
||||
}),
|
||||
);`,
|
||||
);
|
||||
|
||||
const sortIdx2 = src.indexOf(sortMarker);
|
||||
if (sortIdx2 < 0) throw new Error("sort marker missing after edit");
|
||||
|
||||
const fallback = ` // \u6574\u5c01\u4ecd\u65e0\u5355\u5143 \u2192 \u5de5\u5355\u515c\u5e95
|
||||
if (!units.length && (subject.trim() || body.trim())) {
|
||||
push(
|
||||
makeUnit({
|
||||
uiKind: "work_order",
|
||||
source: "record",
|
||||
segmentIndex: -3,
|
||||
isCurrent: true,
|
||||
text: body.trim() || subject,
|
||||
segmentSubject: subject || undefined,
|
||||
extraKeywords: ["\u65e0\u5173\u952e\u8bcd\u515c\u5e95"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
src = src.slice(0, sortIdx2) + fallback + src.slice(sortIdx2);
|
||||
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("patched", path);
|
||||
@ -0,0 +1,57 @@
|
||||
import fs from "fs";
|
||||
|
||||
// packing-list test
|
||||
{
|
||||
const path = "tests/unit/packing-list.test.ts";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
if (!src.includes("skips \u6ce8\u610f instruction footer")) {
|
||||
const insert = `
|
||||
it("skips \u6ce8\u610f instruction footer in \u667a\u9e3f gold xlsx", async () => {
|
||||
const fs = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
const file = path.join(
|
||||
process.cwd(),
|
||||
"docx",
|
||||
"\u90ae\u4ef6",
|
||||
"\u6a21\u677f",
|
||||
"\u9644\u4ef6\u4e0b\u8f7d_\u90ae\u4ef6\u8bc6\u522b",
|
||||
"\u667a\u9e3f2+WHLC027G597465+WHSU8127240+\u6d1b\u6749\u77f6+40HQ+EDT2026.04-25 ETA2026.05-15\u8239\u540d\u822a\u6b21HMM EMERALD 013E+\u63d0\u62c6\u6d3e.xlsx",
|
||||
);
|
||||
const buf = await fs.readFile(file);
|
||||
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
|
||||
expect(parsed.errorCode).toBeUndefined();
|
||||
expect(parsed.shipments.length).toBe(1);
|
||||
expect(parsed.shipments[0].row_status).toBe("VALID");
|
||||
expect(parsed.shipments[0].F_Transporter).toBe("\u5b58\u4ed3");
|
||||
expect(parsed.shipments[0].F_CTNS).toBe(1041);
|
||||
expect(
|
||||
parsed.shipments.every((s) => !String(s.F_FBACode || "").includes("\u6ce8\u610f")),
|
||||
).toBe(true);
|
||||
});
|
||||
`;
|
||||
src = src.replace(/\n\}\);\s*$/, `${insert}\n});\n`);
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("packing test added");
|
||||
}
|
||||
}
|
||||
|
||||
// channel-map test
|
||||
{
|
||||
const path = "tests/unit/channel-map.test.ts";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
if (!src.includes("ignores Truck inside instruction note")) {
|
||||
src = src.replace(
|
||||
/\n\}\);\s*$/,
|
||||
`
|
||||
it("ignores Truck inside instruction note", () => {
|
||||
const note =
|
||||
"\u6ce8\u610f\uff1a1.ups\u548cfedex\u7684\u4ef6... \u5982UPS/FEDEX/USPS/Truck \u6216 \u5361\u6d3e";
|
||||
expect(mapChannel(note).transporter).toBe("");
|
||||
});
|
||||
});
|
||||
`,
|
||||
);
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("channel test added");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import fs from "fs";
|
||||
|
||||
const path = "src/utils/mail-body-text.ts";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
const start = src.indexOf("export function resolveParsedMailBody");
|
||||
if (start < 0) throw new Error("missing");
|
||||
|
||||
const neu = `export function resolveParsedMailBody(parsed: {
|
||||
text?: string | false | null;
|
||||
html?: string | false | null;
|
||||
}): string {
|
||||
const text =
|
||||
typeof parsed.text === "string" ? parsed.text.trim() : "";
|
||||
const htmlRaw =
|
||||
typeof parsed.html === "string" ? parsed.html : "";
|
||||
const fromHtml = htmlRaw ? htmlToPlainText(htmlRaw) : "";
|
||||
|
||||
if (!text && !fromHtml) return "";
|
||||
if (!text) return fromHtml;
|
||||
if (!fromHtml) return text;
|
||||
|
||||
// Prefer longer side so troubleshooting body is not truncated
|
||||
if (fromHtml.length > text.length + 40) return fromHtml;
|
||||
if (text.length > fromHtml.length + 40) return text;
|
||||
|
||||
const textHead = text.slice(0, Math.min(48, text.length));
|
||||
if (textHead && fromHtml.includes(textHead) && fromHtml.length >= text.length) {
|
||||
return fromHtml;
|
||||
}
|
||||
const htmlHead = fromHtml.slice(0, Math.min(48, fromHtml.length));
|
||||
if (htmlHead && text.includes(htmlHead) && text.length >= fromHtml.length) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Both long and not nested: keep both parts
|
||||
if (
|
||||
text.length > 80 &&
|
||||
fromHtml.length > 80 &&
|
||||
!fromHtml.includes(textHead) &&
|
||||
!text.includes(htmlHead)
|
||||
) {
|
||||
return \`\${text}\\n\\n---\\n\\n\${fromHtml}\`;
|
||||
}
|
||||
|
||||
return fromHtml.length >= text.length ? fromHtml : text;
|
||||
}
|
||||
`;
|
||||
|
||||
src = src.slice(0, start) + neu;
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("resolve ok, len", src.length);
|
||||
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Fix subject-line stripping: optional colon + multi-line subject block
|
||||
*/
|
||||
import fs from "fs";
|
||||
|
||||
const SUBJECT_LINE_RE =
|
||||
"/(?:\\u4e3b\\u9898|\\u4e3b\\u65e8|Subject)\\s*[\\uFF1A:]?/i";
|
||||
|
||||
const helper = `/** Drop subject / \\u4e3b\\u65e8 block (optional colon, multi-line wrap) */\nfunction stripSubjectLines(text: string): string {\n const lines = text.split(/\\n/);\n const out: string[] = [];\n let inSubject = false;\n for (const line of lines) {\n if (/(?:\\u4e3b\\u9898|\\u4e3b\\u65e8|Subject)\\s*[\\uFF1A:]?/i.test(line)) {\n inSubject = true;\n continue;\n }\n if (inSubject) {\n const t = line.trim();\n if (!t) {\n inSubject = false;\n continue;\n }\n if (\n /^(?:\\u5bc4\\u4ef6\\u4eba|\\u53d1\\u4ef6\\u4eba|\\u6536\\u4ef6\\u4eba|\\u6284\\u9001|\\u65e5\\u671f|\\u53d1\\u9001\\u65f6\\u95f4|From|To|Cc|Date|Sent|Dear|----)/i.test(\n t,\n ) ||\n /^date@/i.test(t) ||\n t === "date" ||\n /^[\\u5728\\\\s]*\\d{4}/.test(t)\n ) {\n inSubject = false;\n out.push(line);\n continue;\n }\n continue;\n }\n out.push(line);\n }\n return out.join("\\n");\n}\n`;
|
||||
|
||||
// Simpler: write file with real unicode via \u in the script string that becomes Chinese
|
||||
const stripFn = `
|
||||
function stripSubjectLines(text: string): string {
|
||||
const lines = text.split(/\\n/);
|
||||
const out: string[] = [];
|
||||
let inSubject = false;
|
||||
for (const line of lines) {
|
||||
// "\\u4e3b\\u9898 Re:..." often has NO colon after \\u4e3b\\u9898
|
||||
if (/(?:\u4e3b\u9898|\u4e3b\u65e8|Subject)\\s*[\uFF1A:]?/i.test(line)) {
|
||||
inSubject = true;
|
||||
continue;
|
||||
}
|
||||
if (inSubject) {
|
||||
const t = line.trim();
|
||||
if (!t) {
|
||||
inSubject = false;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
/^(?:\u5bc4\u4ef6\u4eba|\u53d1\u4ef6\u4eba|\u6536\u4ef6\u4eba|\u6284\u9001|\u65e5\u671f|\u53d1\u9001\u65f6\u95f4|From|To|Cc|Date|Sent|Dear|----)/i.test(
|
||||
t,
|
||||
) ||
|
||||
/^date@/i.test(t) ||
|
||||
t === "date" ||
|
||||
/^\u5728\\s*\\d{4}/.test(t)
|
||||
) {
|
||||
inSubject = false;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\\n");
|
||||
}
|
||||
`.trimStart();
|
||||
|
||||
{
|
||||
const path = "src/services/parse/instruction-lexicon.ts";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
const a = src.indexOf("function stripSubjectLines");
|
||||
const b = src.indexOf("export function isForwardShellSegment");
|
||||
if (a < 0 || b < 0) throw new Error("markers");
|
||||
// keep any comment before function - find from /** Drop or function
|
||||
let start = src.lastIndexOf("/** Drop subject", a);
|
||||
if (start < 0) start = a;
|
||||
src =
|
||||
src.slice(0, start) +
|
||||
"/** Drop subject/\u4e3b\u65e8 block (optional colon; multi-line wrap) */\n" +
|
||||
stripFn +
|
||||
"\n" +
|
||||
src.slice(b);
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("lexicon stripSubjectLines updated");
|
||||
}
|
||||
|
||||
{
|
||||
const path = "src/services/parse/split-instructions.ts";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
const neu = `function bodyForKindDetect(segBody: string): string {
|
||||
// reuse same multi-line subject strip as lexicon (inline copy to avoid circular import)
|
||||
const lines = segBody.split(/\\n/);
|
||||
const out: string[] = [];
|
||||
let inSubject = false;
|
||||
for (const line of lines) {
|
||||
if (/(?:\u4e3b\u9898|\u4e3b\u65e8|Subject)\\s*[\uFF1A:]?/i.test(line)) {
|
||||
inSubject = true;
|
||||
continue;
|
||||
}
|
||||
if (inSubject) {
|
||||
const t = line.trim();
|
||||
if (!t) {
|
||||
inSubject = false;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
/^(?:\u5bc4\u4ef6\u4eba|\u53d1\u4ef6\u4eba|\u6536\u4ef6\u4eba|\u6284\u9001|\u65e5\u671f|\u53d1\u9001\u65f6\u95f4|From|To|Cc|Date|Sent|Dear|----)/i.test(
|
||||
t,
|
||||
) ||
|
||||
/^date@/i.test(t) ||
|
||||
t === "date" ||
|
||||
/^\\u5728\\s*\\d{4}/.test(t) ||
|
||||
/^\u5728\\s*\\d{4}/.test(t)
|
||||
) {
|
||||
inSubject = false;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\\n");
|
||||
}
|
||||
`;
|
||||
|
||||
const a = src.indexOf("function bodyForKindDetect");
|
||||
const b = src.indexOf("export function detectKindsForSegment");
|
||||
if (a < 0 || b < 0) throw new Error("bounds");
|
||||
src = src.slice(0, a) + neu + "\n" + src.slice(b);
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("split bodyForKindDetect updated");
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* UI fallback: if mail_record missing customer, extract from subject bl-plus
|
||||
*/
|
||||
import fs from "fs";
|
||||
|
||||
const path = "src/components/MailBusinessSummary.tsx";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
|
||||
if (!src.includes("extractBlPlusFromMail")) {
|
||||
const importNeedle = `import {
|
||||
extractForecastInstructionText,
|
||||
extractMailInstructions,
|
||||
} from "@/services/parse/split-instructions";`;
|
||||
const importRepl = `import { extractBlPlusFromMail } from "@/services/parse/bl-plus-template";
|
||||
import {
|
||||
extractForecastInstructionText,
|
||||
extractMailInstructions,
|
||||
} from "@/services/parse/split-instructions";`;
|
||||
if (!src.includes(importNeedle)) throw new Error("import block missing");
|
||||
src = src.replace(importNeedle, importRepl);
|
||||
}
|
||||
|
||||
const oldForecast = ` const forecastValue = useMemo(() => {
|
||||
const base = buildCcForecastFormValue({
|
||||
customerName: record?.modules.customer_name,
|
||||
header,
|
||||
modules: record?.modules,
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
F_Instruction: forecastInstruction || base.F_Instruction,
|
||||
};
|
||||
}, [
|
||||
header,
|
||||
record?.modules,
|
||||
record?.modules.customer_name,
|
||||
forecastInstruction,
|
||||
]);`;
|
||||
|
||||
const neuForecast = ` const blPlusModules = useMemo(
|
||||
() => extractBlPlusFromMail(mail.subject, bodyText)?.modules ?? null,
|
||||
[mail.subject, bodyText],
|
||||
);
|
||||
|
||||
const forecastValue = useMemo(() => {
|
||||
const modules = {
|
||||
...(blPlusModules || {}),
|
||||
...(record?.modules || {}),
|
||||
};
|
||||
const base = buildCcForecastFormValue({
|
||||
customerName:
|
||||
record?.modules.customer_name ||
|
||||
blPlusModules?.customer_name ||
|
||||
undefined,
|
||||
header,
|
||||
modules,
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
F_Instruction: forecastInstruction || base.F_Instruction,
|
||||
};
|
||||
}, [
|
||||
header,
|
||||
record?.modules,
|
||||
record?.modules.customer_name,
|
||||
blPlusModules,
|
||||
forecastInstruction,
|
||||
]);`;
|
||||
|
||||
if (!src.includes(oldForecast)) {
|
||||
// CRLF
|
||||
const oldC = oldForecast.replace(/\n/g, "\r\n");
|
||||
if (!src.includes(oldC)) {
|
||||
console.error("forecastValue block missing");
|
||||
process.exit(1);
|
||||
}
|
||||
src = src.replace(oldC, neuForecast.replace(/\n/g, "\r\n"));
|
||||
} else {
|
||||
src = src.replace(oldForecast, neuForecast);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("MailBusinessSummary customer fallback ok");
|
||||
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* UI: only unit.isCurrent confirmable; history readonly + tag
|
||||
*/
|
||||
import fs from "fs";
|
||||
|
||||
// --- Shell ---
|
||||
{
|
||||
const path = "src/components/MailInstructionUnitShell.tsx";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
const oldExtra = ` extra={
|
||||
unit.keywords.length ? (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{unit.keywords.slice(0, 6).map((k) => (
|
||||
<Tag key={k}>{k}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : null
|
||||
}`;
|
||||
const newExtra = ` extra={
|
||||
<Space wrap size={[4, 4]}>
|
||||
<Tag color={unit.isCurrent ? "processing" : "default"}>
|
||||
{unit.isCurrent ? "\u53ef\u786e\u8ba4" : "\u5386\u53f2\u53ea\u8bfb"}
|
||||
</Tag>
|
||||
{unit.keywords.slice(0, 5).map((k) => (
|
||||
<Tag key={k}>{k}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
}`;
|
||||
if (!src.includes(oldExtra)) throw new Error("shell extra block missing");
|
||||
src = src.replace(oldExtra, newExtra);
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("shell ok");
|
||||
}
|
||||
|
||||
// --- MailBusinessSummary confirm gates ---
|
||||
{
|
||||
const path = "src/components/MailBusinessSummary.tsx";
|
||||
let src = fs.readFileSync(path, "utf8");
|
||||
|
||||
// transfer: only current can confirm / edit WO fallback
|
||||
src = src.replace(
|
||||
` {blocked ? (
|
||||
<CcWorkOrderForm
|
||||
mode={canConfirmOps ? "edit" : "readonly"}`,
|
||||
` {blocked ? (
|
||||
<CcWorkOrderForm
|
||||
mode={canConfirmOps && unit.isCurrent ? "edit" : "readonly"}`,
|
||||
);
|
||||
|
||||
src = src.replace(
|
||||
` if (canConfirmOps) {
|
||||
actions = blocked ? (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
onClick={() => void submitWorkOrder()}
|
||||
>
|
||||
\u8f6c\u4ed3\u4e0d\u53ef\u7528\uff0c\u63d0\u4ea4\u5de5\u5355
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
onClick={() => void submitTransfer()}
|
||||
>
|
||||
\u786e\u8ba4\u6279\u91cf\u8f6c\u4ed3
|
||||
</Button>
|
||||
);
|
||||
}`,
|
||||
` if (canConfirmOps && unit.isCurrent) {
|
||||
actions = blocked ? (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
onClick={() => void submitWorkOrder()}
|
||||
>
|
||||
\u8f6c\u4ed3\u4e0d\u53ef\u7528\uff0c\u63d0\u4ea4\u5de5\u5355
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
onClick={() => void submitTransfer()}
|
||||
>
|
||||
\u786e\u8ba4\u6279\u91cf\u8f6c\u4ed3
|
||||
</Button>
|
||||
);
|
||||
}`,
|
||||
);
|
||||
|
||||
// work_order mode
|
||||
src = src.replace(
|
||||
` <CcWorkOrderForm
|
||||
mode={canConfirmOps ? "edit" : "readonly"}
|
||||
value={{
|
||||
...woForUnit,
|
||||
// keep edits on shared state when confirming primary WO
|
||||
...(mail.mail_type === "WORK_ORDER" && unit.isCurrent
|
||||
? workOrderValue
|
||||
: {}),
|
||||
}}
|
||||
onChange={(p) => setWorkOrderValue((v) => ({ ...v, ...p }))}
|
||||
/>
|
||||
);
|
||||
if (canConfirmOps && mail.mail_type === "WORK_ORDER") {`,
|
||||
` <CcWorkOrderForm
|
||||
mode={canConfirmOps && unit.isCurrent ? "edit" : "readonly"}
|
||||
value={{
|
||||
...woForUnit,
|
||||
...(unit.isCurrent ? workOrderValue : {}),
|
||||
}}
|
||||
onChange={(p) => setWorkOrderValue((v) => ({ ...v, ...p }))}
|
||||
/>
|
||||
);
|
||||
if (canConfirmOps && unit.isCurrent) {`,
|
||||
);
|
||||
|
||||
// do_upload
|
||||
src = src.replace(
|
||||
` <CcDoUploadPanel
|
||||
mode={canConfirmOps ? "edit" : "readonly"}
|
||||
value={
|
||||
mail.mail_type === "DO_UPLOAD" && unit.isCurrent
|
||||
? doValue
|
||||
: doForUnit
|
||||
}
|
||||
onChange={(p) => setDoValue((v) => ({ ...v, ...p }))}
|
||||
/>
|
||||
);
|
||||
if (
|
||||
canConfirmOps &&
|
||||
(mail.mail_type === "DO_UPLOAD" || unit.isCurrent)
|
||||
) {`,
|
||||
` <CcDoUploadPanel
|
||||
mode={canConfirmOps && unit.isCurrent ? "edit" : "readonly"}
|
||||
value={unit.isCurrent ? doValue : doForUnit}
|
||||
onChange={(p) => setDoValue((v) => ({ ...v, ...p }))}
|
||||
/>
|
||||
);
|
||||
if (canConfirmOps && unit.isCurrent) {`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(path, src);
|
||||
console.log("summary ok", {
|
||||
transferCur: src.includes("canConfirmOps && unit.isCurrent"),
|
||||
woMode: src.includes('mode={canConfirmOps && unit.isCurrent ? "edit"'),
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue