feat(deploy): 财务上线数据阶段编排与运维加固
- 新增 deploy/post-deploy.sh:回填 → 业务配置 → 同步 → 验收四阶段编排,支持 --only/--dry-run/窗口参数 - 新增 tools 镜像与 scripts/verify-finance-launch.sql(15 项上线验收) - 验收口径:成本闸门二选一、要求已过账凭证、备用金支出关联、成本覆盖 WARN - 加固:参数校验、工具镜像每次刷新、docker run 兜底超时、迁移前缀探测、验收失败退出码 2 - 工具镜像预建 /app/data,消除日志 EACCES - 回填后与 restore-db 恢复后自动 VACUUM (ANALYZE),修复订单列表分页 COUNT 全表扫描(41 万单实测 27s → 0.17s) - 文档:41 万单同步用 7 天窗 + 180s 超时;回填/恢复后刷新统计
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# 财务运维工具镜像(一次性/按需容器)
|
||||
#
|
||||
# 为什么需要它:生产 backend 镜像为了体积与安全做了两件事——
|
||||
# 1) `npm prune --omit=dev`(删掉 tsx 等 devDependencies)
|
||||
# 2) 只 `COPY dist`(tsconfig.build.json 的 rootDir=src,scripts/ 不进 dist)
|
||||
# 因此 `docker compose exec backend npm run revenue:backfill` 在生产**必然失败**。
|
||||
# 本镜像保留源码 + devDependencies,专门用来跑 scripts/ 下的回填/同步脚本。
|
||||
#
|
||||
# 构建(仓库根目录):
|
||||
# docker compose --profile tools build tools
|
||||
# 或:
|
||||
# docker build -f deploy/docker/tools.Dockerfile -t order-site/tools:latest .
|
||||
#
|
||||
# 直接执行单个脚本(不需要 compose):
|
||||
# docker run --rm --network order_site_default --env-file .env \
|
||||
# order-site/tools:latest npm run revenue:backfill -- --dryRun
|
||||
#
|
||||
# 说明:脚本只读写数据库,不写文件;镜像不挂载业务数据卷。
|
||||
|
||||
ARG BASE_REGISTRY=docker.m.daocloud.io
|
||||
|
||||
FROM ${BASE_REGISTRY}/library/node:22-bookworm
|
||||
|
||||
ARG DEBIAN_MIRROR=mirrors.aliyun.com
|
||||
ARG NPM_REGISTRY=https://registry.npmmirror.com
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
NPM_CONFIG_REGISTRY=${NPM_REGISTRY} \
|
||||
TZ=Asia/Shanghai \
|
||||
NODE_ENV=development
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 先装依赖再拷源码,命中 Docker 层缓存:只改脚本时不必重装 node_modules。
|
||||
COPY apps/backend/package.json apps/backend/package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --include=dev
|
||||
|
||||
COPY --chown=node:node apps/backend/ ./
|
||||
|
||||
# 脚本运行时会加载 backend 的 logger,它会把日志写到 $DATA_ROOT/logs。
|
||||
# 镜像以 node 用户运行,必须提前建好该目录并授权,否则每条日志都会刷
|
||||
# EACCES: permission denied, mkdir '/app/data'。
|
||||
RUN mkdir -p /app/data && chown -R node:node /app/data
|
||||
|
||||
USER node
|
||||
|
||||
# 不带命令运行时列出可用脚本,避免误用。
|
||||
CMD ["npm", "run"]
|
||||
Executable
+484
@@ -0,0 +1,484 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# 财务上线「数据阶段」编排脚本
|
||||
#
|
||||
# 位置:在 deploy/ubuntu-deploy.sh 成功之后执行(代码与迁移已生效、backend 已 ready)。
|
||||
#
|
||||
# 它把以前散落在文档里的脚本步骤串成一条可重复执行的流水线:
|
||||
# ① 存量订单项收入回填 revenue:backfill
|
||||
# ② 履约方式回填 fulfillment:backfill
|
||||
# ③ 财务历史同步(分窗口) finance:sync --mode=all
|
||||
# ④ 上线验收(只读 SQL) scripts/verify-finance-launch.sql
|
||||
#
|
||||
# 设计原则:
|
||||
# * 幂等:每一步都能重跑,中断后直接再跑一次即可;
|
||||
# * 串行:回填与同步都会争 order_items 行锁,**不要并发**;
|
||||
# * 只读验收:验收脚本不写业务表,随时可跑;
|
||||
# * 不碰人工步骤:备用金/积分/商品目录/人工 SKU/总账同步仍需在后台完成,脚本末尾会打印清单。
|
||||
#
|
||||
# 常用:
|
||||
# bash deploy/post-deploy.sh --dry-run # 只打印计划与待回填量
|
||||
# bash deploy/post-deploy.sh --no-verify # 回填 + 同步,跳过验收(日常重跑)
|
||||
# bash deploy/post-deploy.sh # 全流程(默认最近 90 天)
|
||||
# bash deploy/post-deploy.sh --from=2026-07-01 --to=2026-10-27
|
||||
# bash deploy/post-deploy.sh --only=backfill # 只跑 ①②
|
||||
# bash deploy/post-deploy.sh --only=sync # 只跑 ③
|
||||
# bash deploy/post-deploy.sh --only=verify # 只跑 ④
|
||||
# bash deploy/post-deploy.sh --no-build --no-verify
|
||||
#
|
||||
# 依赖:docker compose(或 docker-compose)已在项目根目录可用,且 .env 存在。
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
TOOLS_IMAGE="${TOOLS_IMAGE:-order-site/tools:latest}"
|
||||
TOOLS_DOCKERFILE="deploy/docker/tools.Dockerfile"
|
||||
VERIFY_SQL="scripts/verify-finance-launch.sql"
|
||||
|
||||
WINDOW_DAYS="${WINDOW_DAYS:-30}"
|
||||
BATCH_SIZE="${BATCH_SIZE:-5000}"
|
||||
MAX_BATCHES="${MAX_BATCHES:-1000}"
|
||||
MAX_BACKFILL_ROUNDS="${MAX_BACKFILL_ROUNDS:-5}"
|
||||
|
||||
DRY_RUN=false
|
||||
SKIP_BACKFILL=false
|
||||
SKIP_SYNC=false
|
||||
NO_VERIFY=false
|
||||
NO_BUILD=false
|
||||
ONLY=""
|
||||
FROM_DATE=""
|
||||
TO_DATE=""
|
||||
LAUNCH_AT=""
|
||||
|
||||
log() { printf '\033[1;34m[post-deploy]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[post-deploy]\033[0m %s\n' "$*"; }
|
||||
fail() {
|
||||
printf '\033[1;31m[post-deploy]\033[0m %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:bash deploy/post-deploy.sh [选项]
|
||||
|
||||
选项:
|
||||
--dry-run 只打印计划 + 只读统计,不写库、不构建镜像
|
||||
--from=YYYY-MM-DD 财务同步起始日(默认:今天往前 90 天)
|
||||
--to=YYYY-MM-DD 财务同步结束日(默认:今天)
|
||||
--window-days=N 同步窗口天数(默认 30;41 万单规模建议 7)
|
||||
--batch-size=N 回填批大小(默认 5000)
|
||||
--max-batches=N 单次回填最大批数(默认 1000)
|
||||
--launch-at=TIMESTAMP 验收基准时间(默认:本次执行时间),用于「上线后」类检查
|
||||
--only=backfill|sync|verify 只跑指定阶段
|
||||
--skip-backfill 跳过 ①②(回填)
|
||||
--skip-sync 跳过 ③(财务历史同步)
|
||||
--no-verify 跳过 ④(验收)
|
||||
--no-build 不构建/刷新工具镜像(镜像不存在时直接失败)
|
||||
-h, --help 显示本帮助
|
||||
|
||||
环境变量:
|
||||
POST_DEPLOY_COMPOSE_FILE 指定 compose 文件(默认 docker-compose.yml)
|
||||
TOOLS_IMAGE 工具镜像名(默认 order-site/tools:latest)
|
||||
WINDOW_DAYS / BATCH_SIZE / MAX_BATCHES 同上对应选项的默认值
|
||||
|
||||
首次上线顺序(避免商品未归一先同步产生大量异常):
|
||||
① bash deploy/post-deploy.sh --only=backfill
|
||||
(结束后自动 VACUUM ANALYZE order_items/orders,约 30–60 秒,不阻塞读写)
|
||||
② 后台完成业务配置 1)–4)(商品目录必须先于财务同步)
|
||||
③ bash deploy/post-deploy.sh --only=sync
|
||||
④ 后台完成业务配置 5)(总账同步)
|
||||
⑤ bash deploy/post-deploy.sh --only=verify
|
||||
|
||||
业务配置(后台 UI):
|
||||
1) 钱包管理 → 补建客服账户 → 拨付备用金
|
||||
2) 商品与成本 → 积分钱包 → 发放三平台积分
|
||||
3) 商品与成本 → 标准商品 → 模板初始化 / 按工单回填 / 归一人工商品
|
||||
4) 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置
|
||||
5) 财务 → 总账与对账 → 同步销售事实到总账 → 试算平衡 → 按需关账
|
||||
详见 docs/财务上线初始化与验收-2026-10-26.md
|
||||
|
||||
退出码:0 = 完成且验收无 FAIL;1 = 执行失败;2 = 验收未通过。
|
||||
存量初始化完成后,日常重跑可直接 `bash deploy/post-deploy.sh`(回填 + 同步 + 验收一条龙)。
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------- 参数解析 ----------
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--skip-backfill) SKIP_BACKFILL=true ;;
|
||||
--skip-sync) SKIP_SYNC=true ;;
|
||||
--no-verify) NO_VERIFY=true ;;
|
||||
--no-build) NO_BUILD=true ;;
|
||||
--from=*) FROM_DATE="${1#*=}" ;;
|
||||
--to=*) TO_DATE="${1#*=}" ;;
|
||||
--launch-at=*) LAUNCH_AT="${1#*=}" ;;
|
||||
--window-days=*) WINDOW_DAYS="${1#*=}" ;;
|
||||
--batch-size=*) BATCH_SIZE="${1#*=}" ;;
|
||||
--max-batches=*) MAX_BATCHES="${1#*=}" ;;
|
||||
--only=*) ONLY="${1#*=}" ;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) fail "未知参数:$1(--help 查看用法)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
case "$ONLY" in
|
||||
'' | backfill | sync | verify) ;;
|
||||
*) fail "--only 只能是 backfill / sync / verify(收到:${ONLY})" ;;
|
||||
esac
|
||||
|
||||
# 数值参数必须在进入循环前校验:WINDOW_DAYS<=0 会让分窗循环原地打转。
|
||||
validate_integer() {
|
||||
local name="$1" value="$2" min="$3" max="$4"
|
||||
[[ "$value" =~ ^[0-9]+$ ]] || fail "${name} 需要整数(收到:${value})"
|
||||
local number=$((10#$value))
|
||||
if ((number < min || number > max)); then
|
||||
fail "${name} 需要在 ${min}–${max} 之间(收到:${value})"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_integer "--window-days" "$WINDOW_DAYS" 1 400
|
||||
validate_integer "--batch-size" "$BATCH_SIZE" 1 50000
|
||||
validate_integer "--max-batches" "$MAX_BATCHES" 1 10000
|
||||
validate_integer "MAX_BACKFILL_ROUNDS" "$MAX_BACKFILL_ROUNDS" 1 100
|
||||
|
||||
should_run() {
|
||||
[[ -z "$ONLY" || "$ONLY" == "$1" ]]
|
||||
}
|
||||
|
||||
# ---------- 日期工具(兼容 GNU date 与 macOS BSD date) ----------
|
||||
|
||||
if date -u -d '2026-01-01 + 1 day' +%F >/dev/null 2>&1; then
|
||||
DATE_STYLE=gnu
|
||||
else
|
||||
DATE_STYLE=bsd
|
||||
fi
|
||||
|
||||
date_today() { date +%F; }
|
||||
|
||||
date_add_days() {
|
||||
local base="$1" days="$2" signed="$2"
|
||||
if [[ "$days" == "0" ]]; then
|
||||
printf '%s\n' "$base"
|
||||
return 0
|
||||
fi
|
||||
# BSD date 必须显式带符号(-v+30d / -v-90d),且 -v 必须写在 -f 之前。
|
||||
[[ "$days" != -* ]] && signed="+$days"
|
||||
if [[ "$DATE_STYLE" == gnu ]]; then
|
||||
date -d "$base $days days" +%F
|
||||
else
|
||||
date -j -v"${signed}d" -f '%F' "$base" +%F
|
||||
fi
|
||||
}
|
||||
|
||||
date_days_ago() { date_add_days "$(date_today)" "-$1"; }
|
||||
|
||||
is_iso_date() { [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; }
|
||||
|
||||
# ---------- compose / docker 封装 ----------
|
||||
|
||||
COMPOSE_FILE_OVERRIDE="${POST_DEPLOY_COMPOSE_FILE:-}"
|
||||
|
||||
if [[ -n "$COMPOSE_FILE_OVERRIDE" ]]; then
|
||||
COMPOSE_CMD=(docker compose -f "$COMPOSE_FILE_OVERRIDE")
|
||||
elif docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE_CMD=(docker-compose)
|
||||
else
|
||||
fail "找不到 docker compose(或 docker-compose)"
|
||||
fi
|
||||
|
||||
compose() { "${COMPOSE_CMD[@]}" "$@"; }
|
||||
|
||||
HAS_TOOLS_SERVICE=false
|
||||
PROFILE_SUPPORTED=false
|
||||
TOOLS_IMAGE_READY=false
|
||||
NETWORK=""
|
||||
|
||||
detect_capabilities() {
|
||||
if compose --profile tools config --services 2>/dev/null | grep -qx 'tools'; then
|
||||
HAS_TOOLS_SERVICE=true
|
||||
fi
|
||||
if compose --profile tools config >/dev/null 2>&1; then
|
||||
PROFILE_SUPPORTED=true
|
||||
fi
|
||||
}
|
||||
|
||||
detect_network() {
|
||||
local cid
|
||||
cid="$(compose ps -q postgres 2>/dev/null | head -1 || true)"
|
||||
if [[ -n "$cid" ]]; then
|
||||
NETWORK="$(docker inspect "$cid" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
if [[ -z "$NETWORK" ]]; then
|
||||
NETWORK="$(basename "$ROOT_DIR" | tr '[:upper:]' '[:lower:]')_default"
|
||||
fi
|
||||
}
|
||||
|
||||
env_database_url() {
|
||||
local value=""
|
||||
if [[ -f .env ]]; then
|
||||
value="$(grep -E '^DATABASE_URL=' .env | head -1 | cut -d= -f2- || true)"
|
||||
fi
|
||||
if [[ -z "$value" ]]; then
|
||||
value="postgres://postgres:postgres@postgres:5432/order_site"
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
# ---------- psql 封装(全部在 postgres 容器内执行) ----------
|
||||
|
||||
psql_scalar() {
|
||||
local sql="$1"
|
||||
compose exec -T -e SQL_QUERY="$sql" postgres sh -lc \
|
||||
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAX -v ON_ERROR_STOP=1 -c "$SQL_QUERY"'
|
||||
}
|
||||
|
||||
# ---------- 工具镜像 ----------
|
||||
|
||||
ensure_tools_image() {
|
||||
# 全流程里 backfill 和 sync 各调用一次,同一次运行只构建一遍。
|
||||
[[ "$TOOLS_IMAGE_READY" == true ]] && return 0
|
||||
|
||||
local image_exists=false
|
||||
if docker image inspect "$TOOLS_IMAGE" >/dev/null 2>&1; then
|
||||
image_exists=true
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
[[ "$image_exists" == true ]] || warn "工具镜像 $TOOLS_IMAGE 不存在(dry-run 不构建);正式执行时会自动构建"
|
||||
TOOLS_IMAGE_READY=true
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$NO_BUILD" == true ]]; then
|
||||
[[ "$image_exists" == true ]] || fail "工具镜像 $TOOLS_IMAGE 不存在,且指定了 --no-build"
|
||||
log "沿用现有工具镜像 ${TOOLS_IMAGE}(--no-build)"
|
||||
TOOLS_IMAGE_READY=true
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 每次都重建:有层缓存时通常只要几秒,但能保证 git pull 后跑的是当前脚本,
|
||||
# 而不是上次部署留在服务器上的旧 tools:latest。
|
||||
log "刷新工具镜像 ${TOOLS_IMAGE}(首次约 2–4 分钟,之后走层缓存)"
|
||||
if [[ "$HAS_TOOLS_SERVICE" == true && "$PROFILE_SUPPORTED" == true ]]; then
|
||||
compose --profile tools build tools
|
||||
else
|
||||
docker build -f "$TOOLS_DOCKERFILE" -t "$TOOLS_IMAGE" .
|
||||
fi
|
||||
TOOLS_IMAGE_READY=true
|
||||
}
|
||||
|
||||
run_tool() {
|
||||
local script="$1"
|
||||
shift
|
||||
if [[ "$HAS_TOOLS_SERVICE" == true && "$PROFILE_SUPPORTED" == true ]]; then
|
||||
compose --profile tools run --rm --no-deps -T tools npm run "$script" -- "$@"
|
||||
else
|
||||
docker run --rm \
|
||||
--network "$NETWORK" \
|
||||
--env-file .env \
|
||||
-e DATABASE_URL="$(env_database_url)" \
|
||||
-e DATABASE_STATEMENT_TIMEOUT_MS="${DATABASE_STATEMENT_TIMEOUT_MS:-60000}" \
|
||||
-e TZ="${TZ:-Asia/Shanghai}" \
|
||||
"$TOOLS_IMAGE" \
|
||||
npm run "$script" -- "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# 回填脚本在「还有剩余」时返回 2,语义是“继续跑下一轮”,不是失败。
|
||||
run_backfill() {
|
||||
local script="$1"
|
||||
shift
|
||||
local round=1 code=0
|
||||
while :; do
|
||||
log " 第 ${round} 轮:npm run ${script} -- $*"
|
||||
set +e
|
||||
run_tool "$script" "$@"
|
||||
code=$?
|
||||
set -e
|
||||
if [[ $code -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ $code -eq 2 && $round -lt $MAX_BACKFILL_ROUNDS ]]; then
|
||||
warn " 仍有剩余(退出码 2),继续下一轮"
|
||||
round=$((round + 1))
|
||||
continue
|
||||
fi
|
||||
if [[ $code -eq 2 ]]; then
|
||||
fail "${script} 跑满 ${MAX_BACKFILL_ROUNDS} 轮仍有剩余;调大 --max-batches 后重跑"
|
||||
fi
|
||||
fail "${script} 失败(退出码 ${code})"
|
||||
done
|
||||
}
|
||||
|
||||
# ---------- 各阶段 ----------
|
||||
|
||||
PENDING_REVENUE="?"
|
||||
PENDING_FULFILLMENT="?"
|
||||
|
||||
preflight() {
|
||||
log "预检"
|
||||
[[ -f .env ]] || fail "项目根目录缺少 .env(生产由 ubuntu-deploy.sh 检查)"
|
||||
[[ -f "$VERIFY_SQL" ]] || fail "缺少验收脚本 $VERIFY_SQL"
|
||||
|
||||
local running
|
||||
running="$(compose ps -q postgres 2>/dev/null | head -1 || true)"
|
||||
[[ -n "$running" ]] || fail "postgres 容器未运行:先执行 bash deploy/ubuntu-deploy.sh"
|
||||
|
||||
detect_capabilities
|
||||
detect_network
|
||||
|
||||
local migrations
|
||||
migrations="$(psql_scalar "SELECT (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^103_'))::int + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^104_'))::int + (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^105_'))::int" | tr -d '[:space:]')"
|
||||
if [[ "$migrations" != "3" ]]; then
|
||||
fail "迁移 103–105 未全部应用(当前 ${migrations}/3):先确认 deploy/ubuntu-deploy.sh 成功、backend 日志无迁移错误"
|
||||
fi
|
||||
|
||||
PENDING_REVENUE="$(psql_scalar "SELECT count(*) FROM order_items WHERE revenue_basis = 'unknown'" | tr -d '[:space:]')"
|
||||
PENDING_FULFILLMENT="$(psql_scalar "SELECT count(*) FROM order_items WHERE fulfillment_mode IS NULL" | tr -d '[:space:]')"
|
||||
|
||||
log " postgres 运行中 / 迁移 103–105 已应用"
|
||||
log " 待回填收入项:${PENDING_REVENUE} 行;待回填履约方式:${PENDING_FULFILLMENT} 行"
|
||||
log " 工具镜像:${TOOLS_IMAGE}(services=${HAS_TOOLS_SERVICE}, network=${NETWORK})"
|
||||
}
|
||||
|
||||
step_backfill() {
|
||||
log "① 回填存量订单项收入(revenue:backfill)"
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
if docker image inspect "$TOOLS_IMAGE" >/dev/null 2>&1; then
|
||||
run_tool revenue:backfill --dryRun || warn "dry-run 预览失败(不影响后续)"
|
||||
else
|
||||
log " [dry-run] 跳过执行(工具镜像未构建)"
|
||||
fi
|
||||
else
|
||||
run_backfill revenue:backfill --batchSize="$BATCH_SIZE" --maxBatches="$MAX_BATCHES"
|
||||
fi
|
||||
|
||||
log "② 回填履约方式(fulfillment:backfill,只填 NULL)"
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
log " [dry-run] 跳过执行"
|
||||
else
|
||||
run_backfill fulfillment:backfill --batchSize="$BATCH_SIZE" --maxBatches="$MAX_BATCHES"
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" != true ]]; then
|
||||
refresh_stats
|
||||
fi
|
||||
}
|
||||
|
||||
# 回填会 UPDATE 40 万行 order_items:既产生大量死元组,又给新列 fulfillment_mode
|
||||
# 留下空统计。autovacuum 追不上时,订单列表的 COUNT 会被估算成 orders 全表扫描,
|
||||
# 冷缓存实测 20 秒级(测试库曾因此 500)。这里在回填后立刻补一次,VACUUM 只拿
|
||||
# SHARE UPDATE EXCLUSIVE,不阻塞读写。
|
||||
refresh_stats() {
|
||||
log "②' 刷新统计信息与可见性(VACUUM ANALYZE order_items/orders)"
|
||||
if ! compose exec -T postgres sh -lc \
|
||||
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE) order_items" -c "VACUUM (ANALYZE) orders"'; then
|
||||
warn "VACUUM ANALYZE 失败:统计信息未刷新,订单列表 COUNT 可能变慢;请手动补跑后再放开后台"
|
||||
fi
|
||||
}
|
||||
|
||||
step_sync() {
|
||||
local from="$FROM_DATE" to="$TO_DATE" win_to
|
||||
local products
|
||||
products="$(psql_scalar 'SELECT count(*) FROM product_catalog' | tr -d '[:space:]')"
|
||||
if [[ "$products" == "0" ]]; then
|
||||
warn "商品目录为空:finance:sync 会为未归一订单项生成大量 product_mapping_missing 异常;建议先完成商品初始化再跑 --only=sync"
|
||||
fi
|
||||
log "③ 财务历史同步(finance:sync --mode=all),区间 ${from} → ${to},每窗 ${WINDOW_DAYS} 天"
|
||||
while :; do
|
||||
win_to="$(date_add_days "$from" $((WINDOW_DAYS - 1)))"
|
||||
if [[ "$win_to" > "$to" ]]; then
|
||||
win_to="$to"
|
||||
fi
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
log " [dry-run] npm run finance:sync -- --mode=all --from=${from} --to=${win_to}"
|
||||
else
|
||||
log " 窗口 ${from} → ${win_to}"
|
||||
run_tool finance:sync --mode=all --from="$from" --to="$win_to"
|
||||
fi
|
||||
[[ "$win_to" == "$to" ]] && break
|
||||
from="$(date_add_days "$win_to" 1)"
|
||||
done
|
||||
}
|
||||
|
||||
step_verify() {
|
||||
log "④ 上线验收(只读)"
|
||||
local code=0
|
||||
set +e
|
||||
compose exec -T -e LAUNCH_AT="$LAUNCH_AT" postgres sh -lc \
|
||||
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 -v launch_at="$LAUNCH_AT" -f -' \
|
||||
<"$VERIFY_SQL"
|
||||
code=$?
|
||||
set -e
|
||||
if [[ $code -ne 0 ]]; then
|
||||
warn "验收未通过(psql 退出码 ${code}):按上面 FAIL 项的「处理建议」逐条处理后,重跑 --only=verify"
|
||||
return 1
|
||||
fi
|
||||
log "验收全部通过"
|
||||
}
|
||||
|
||||
manual_checklist() {
|
||||
cat <<'EOF'
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
剩余人工步骤(后台 UI,脚本无法代替):
|
||||
1) 钱包管理 → 补建客服账户 → 拨付备用金
|
||||
2) 商品与成本 → 积分钱包 → 发放三平台积分
|
||||
3) 商品与成本 → 标准商品 →「从接单模板初始化」→「按工单回填商品」→「归一人工商品」
|
||||
4) 商品与成本 → 人工 SKU 配置 →「扫描」并逐项配置
|
||||
5) 财务 → 总账与对账 →「同步销售事实到总账」→ 试算平衡 → 按需关账
|
||||
首次上线顺序:--only=backfill → 完成 1)–4) → --only=sync → 完成 5) → --only=verify
|
||||
手册:docs/财务上线初始化与验收-2026-10-26.md
|
||||
────────────────────────────────────────────────────────────
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------- 主流程 ----------
|
||||
|
||||
main() {
|
||||
if [[ -z "$FROM_DATE" ]]; then FROM_DATE="$(date_days_ago 90)"; fi
|
||||
if [[ -z "$TO_DATE" ]]; then TO_DATE="$(date_today)"; fi
|
||||
if [[ -z "$LAUNCH_AT" ]]; then LAUNCH_AT="$(date '+%Y-%m-%d %H:%M:%S%z')"; fi
|
||||
|
||||
is_iso_date "$FROM_DATE" || fail "--from 需要 YYYY-MM-DD(收到:${FROM_DATE})"
|
||||
is_iso_date "$TO_DATE" || fail "--to 需要 YYYY-MM-DD(收到:${TO_DATE})"
|
||||
[[ "$FROM_DATE" < "$TO_DATE" || "$FROM_DATE" == "$TO_DATE" ]] || fail "--from 不能晚于 --to"
|
||||
|
||||
log "计划:dry-run=${DRY_RUN} only=${ONLY:-all} from=${FROM_DATE} to=${TO_DATE} window=${WINDOW_DAYS}d launch-at=${LAUNCH_AT}"
|
||||
|
||||
preflight
|
||||
|
||||
if should_run backfill && [[ "$SKIP_BACKFILL" != true ]]; then
|
||||
ensure_tools_image
|
||||
step_backfill
|
||||
fi
|
||||
|
||||
if should_run sync && [[ "$SKIP_SYNC" != true ]]; then
|
||||
ensure_tools_image
|
||||
step_sync
|
||||
fi
|
||||
|
||||
local verify_code=0
|
||||
if should_run verify && [[ "$NO_VERIFY" != true ]]; then
|
||||
step_verify || verify_code=1
|
||||
fi
|
||||
|
||||
manual_checklist
|
||||
|
||||
if [[ $verify_code -ne 0 && "$DRY_RUN" != true ]]; then
|
||||
warn "数据阶段已完成,但验收未通过:按上面的 FAIL 项处理后重跑 bash deploy/post-deploy.sh --only=verify"
|
||||
exit 2
|
||||
fi
|
||||
log "完成"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -199,6 +199,26 @@ restore_configs() {
|
||||
rm -rf -- "${staging}"
|
||||
}
|
||||
|
||||
# pg_restore 不会带 pg_statistic,恢复后 planner 会按默认选择率选计划
|
||||
# (41 万单实测会把订单列表 COUNT 走成 orders 全表扫描,冷缓存 20 秒以上)。
|
||||
# 同时 VACUUM 重建可见性映射,让 index-only scan 真正只读索引。
|
||||
refresh_stats() {
|
||||
local database_url
|
||||
database_url="$(read_env_value DATABASE_URL)"
|
||||
log "刷新统计信息与可见性(VACUUM ANALYZE),恢复后首次查询才不会全表扫描"
|
||||
if [[ -z "${database_url}" || "${database_url}" == *"@postgres:"* ]]; then
|
||||
if ! docker compose exec -T postgres sh -c \
|
||||
'psql -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-order_site}" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE)"'; then
|
||||
warn "VACUUM ANALYZE 失败。恢复已完成,但请手动执行后再开放后台,否则列表查询可能很慢。"
|
||||
fi
|
||||
else
|
||||
if ! docker compose exec -T -e DB_RESTORE_URL="${database_url}" postgres sh -c \
|
||||
'psql "$DB_RESTORE_URL" -v ON_ERROR_STOP=1 -c "VACUUM (ANALYZE)"'; then
|
||||
warn "VACUUM ANALYZE 失败。恢复已完成,但请手动执行后再开放后台,否则列表查询可能很慢。"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
restore_live_database() {
|
||||
local key="$1" target="$2" config_target="${3:-}"
|
||||
|
||||
@@ -235,6 +255,8 @@ restore_live_database() {
|
||||
fail "迁移失败。请查看日志;数据已恢复到备份时点。"
|
||||
fi
|
||||
|
||||
refresh_stats
|
||||
|
||||
if [[ -n "${config_target}" ]]; then
|
||||
restore_configs "${config_target}"
|
||||
fi
|
||||
|
||||
@@ -141,6 +141,40 @@ services:
|
||||
volumes:
|
||||
- ./apps/backend/data:/app/data
|
||||
|
||||
# 财务运维工具容器:跑 scripts/ 下的回填/同步脚本。
|
||||
#
|
||||
# 生产 backend 镜像 `npm prune --omit=dev` 且只 COPY dist,没有 tsx 与 scripts/,
|
||||
# 所以回填脚本必须用这个镜像执行。默认不随 `up` 启动(profiles: tools)。
|
||||
#
|
||||
# 用法:
|
||||
# docker compose --profile tools run --rm --no-deps tools npm run revenue:backfill -- --dryRun
|
||||
# 或者直接用编排脚本:
|
||||
# bash deploy/post-deploy.sh --dry-run
|
||||
tools:
|
||||
profiles: ["tools"]
|
||||
image: ${TOOLS_IMAGE:-order-site/tools:latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/docker/tools.Dockerfile
|
||||
args:
|
||||
BASE_REGISTRY: ${BASE_REGISTRY:-docker.m.daocloud.io}
|
||||
DEBIAN_MIRROR: ${DEBIAN_MIRROR:-mirrors.aliyun.com}
|
||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
|
||||
restart: "no"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@postgres:5432/order_site}
|
||||
DATABASE_SSL: ${DATABASE_SSL:-false}
|
||||
# 回填按批提交,单条语句可能比 Web 请求慢;这里比后端默认值放宽。
|
||||
DATABASE_STATEMENT_TIMEOUT_MS: ${DATABASE_STATEMENT_TIMEOUT_MS:-60000}
|
||||
DATA_ROOT: /app/data
|
||||
logging: *json-log-rotation
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
|
||||
+7
-1
@@ -97,7 +97,13 @@ crontab -e
|
||||
bash deploy/restore-db.sh
|
||||
```
|
||||
|
||||
流程:下载最新备份并强制校验 sha256 → 停 backend/web 并确认已停止 → `pg_restore --clean --if-exists` → 执行迁移补差 → 重启并等待健康检查。任一步失败会尝试恢复原先运行的服务,并明确报告数据库可能处于部分恢复状态。执行前需交互输入 `RESTORE` 确认(非交互终端用 `RESTORE_CONFIRM=1` 或 `--yes`)。
|
||||
流程:下载最新备份并强制校验 sha256 → 停 backend/web 并确认已停止 → `pg_restore --clean --if-exists` → 执行迁移补差 → `VACUUM (ANALYZE)` 刷新统计信息与可见性 → 重启并等待健康检查。任一步失败会尝试恢复原先运行的服务,并明确报告数据库可能处于部分恢复状态。执行前需交互输入 `RESTORE` 确认(非交互终端用 `RESTORE_CONFIRM=1` 或 `--yes`)。
|
||||
|
||||
> 恢复后必须 `VACUUM (ANALYZE)`:dump 不带 `pg_statistic`,没有统计信息时订单列表的 `COUNT` 会被规划成 `orders` 全表扫描,41 万单冷缓存实测 14–27 秒(默认语句超时 15 秒会直接 500)。`restore-db.sh` 已自动执行;手工 `pg_restore` 请补跑:
|
||||
>
|
||||
> ```bash
|
||||
> docker compose exec -T postgres psql -U postgres -d order_site -c 'VACUUM (ANALYZE)'
|
||||
> ```
|
||||
|
||||
如需同时恢复 `apps/backend/data` 配置:
|
||||
|
||||
|
||||
@@ -167,6 +167,15 @@ npm run fulfillment:backfill # 只填 NULL(可选,报表用)
|
||||
npm run finance:sync -- --mode=all --from=2026-07-10 --to=2026-10-22
|
||||
```
|
||||
|
||||
> 回填完成后必须刷新统计信息,否则订单列表的分页总数会被规划成 `orders` 全表扫描:
|
||||
>
|
||||
> ```sql
|
||||
> VACUUM (ANALYZE) order_items;
|
||||
> VACUUM (ANALYZE) orders;
|
||||
> ```
|
||||
>
|
||||
> `bash deploy/post-deploy.sh --only=backfill` 已自动执行这一步;手工回填请补跑。
|
||||
|
||||
验证:
|
||||
|
||||
```sql
|
||||
@@ -174,6 +183,15 @@ SELECT COUNT(*) FROM order_items WHERE revenue_basis = 'unknown'; -- 退款上
|
||||
```
|
||||
|
||||
长区间按 30 天窗口分段执行(默认 `DATABASE_STATEMENT_TIMEOUT_MS=15000`)。
|
||||
|
||||
> 41 万单规模的实测口径:30 天窗口的单条语句会超过 60 秒,被 statement timeout 打断。
|
||||
> 这种体量请用 **7 天窗口 + 180 秒超时**:
|
||||
>
|
||||
> ```bash
|
||||
> DATABASE_STATEMENT_TIMEOUT_MS=180000 bash deploy/post-deploy.sh \
|
||||
> --only=sync --from=2026-09-27 --to=2026-10-27 --window-days=7
|
||||
> ```
|
||||
|
||||
注意:人工历史成本按既定口径**不补录**,历史人工毛利仍不可用。
|
||||
|
||||
---
|
||||
|
||||
+52
-7
@@ -219,19 +219,64 @@ docker compose ps
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
## 财务/成本上线后的初始化(迁移 102–111)
|
||||
## 财务/成本上线后的初始化(迁移 103–105)
|
||||
|
||||
代码与迁移只负责建表;**备用金账户、积分余额、人工 SKU 配置不会自动创建**。
|
||||
首次上线后按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 执行:
|
||||
代码与迁移只负责建表;**存量订单回填、备用金账户、积分余额、商品目录、人工 SKU 不会自动创建**。
|
||||
部署成功后按「回填 → 商品初始化 → 财务同步 → 验收」四步走:
|
||||
|
||||
### 1. 数据回填
|
||||
|
||||
```bash
|
||||
bash deploy/post-deploy.sh --dry-run # 先看计划与待回填量
|
||||
bash deploy/post-deploy.sh --only=backfill # ① 收入回填 + ② 履约方式回填
|
||||
```
|
||||
|
||||
> 不要在商品目录为空时直接跑 `--only=sync`:财务同步会为每个未归一订单项写一条
|
||||
> `product_mapping_missing` 成本异常(生产规模 90 天实测约 36 万条),后台异常队列会被淹没。
|
||||
|
||||
脚本包含 `revenue:backfill`、`fulfillment:backfill`、`finance:sync` 与只读验收四段;任何一步都能重跑,
|
||||
`--only=backfill|sync|verify` 可单独执行。
|
||||
|
||||
> 生产 backend 镜像没有 `tsx` 与 `scripts/`,脚本通过 `tools` 镜像执行
|
||||
> ([deploy/docker/tools.Dockerfile](../deploy/docker/tools.Dockerfile),`docker compose --profile tools`)。
|
||||
|
||||
### 2. 业务配置:后台 UI
|
||||
|
||||
1. 钱包管理 → 补建客服备用金账户 → 按需拨付;
|
||||
2. 商品与成本 → 积分钱包 → 给客服发放三平台积分;
|
||||
3. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项);
|
||||
4. 打开「财务 → 总账与对账」,同步总账、检查试算平衡;按需关账/导入平台账单;
|
||||
5. 用手册 §二 的验收 SQL 检查:配置覆盖、人工成本快照、`manual_cost_missing`、销售事实与总账对平。
|
||||
3. 商品与成本 → 标准商品 → 从接单模板初始化 → 按工单回填商品 → 归一人工商品;
|
||||
4. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项)。
|
||||
|
||||
> 第 3 步的商品目录/工单回填必须先于下一节 `--only=sync`。商品目录为空时同步会为每个
|
||||
> 未归一订单项写一条 `product_mapping_missing` 异常(实测 90 天约 36 万条)。
|
||||
|
||||
### 3. 财务同步
|
||||
|
||||
商品归一完成后执行:
|
||||
|
||||
```bash
|
||||
bash deploy/post-deploy.sh --only=sync # ③ 成本快照 / 销售事实
|
||||
```
|
||||
|
||||
> 工具镜像每次运行都会重建(层缓存下只需几秒),确保 `git pull` 后执行的是当前脚本。
|
||||
|
||||
### 4. 总账与验收
|
||||
|
||||
5. 财务 → 总账与对账 → 同步销售事实到总账 → 检查试算平衡;按需关账/导入平台账单。
|
||||
|
||||
```bash
|
||||
bash deploy/post-deploy.sh --only=verify # ④ 只读验收
|
||||
```
|
||||
|
||||
退出码:`0` = 完成且验收无 FAIL、`1` = 执行失败、`2` = 验收未通过。
|
||||
15 项检查全部 PASS 才算上线完成。若 15 号「近 7 天非人工销售事实成本覆盖」显示 WARN,
|
||||
说明自动发货/接单平台仍有成本缺口,需要先归一商品并配置成本版本;WARN 不阻断验收,
|
||||
但会直接影响毛利口径。
|
||||
|
||||
存量初始化完成后,日常重跑可直接 `bash deploy/post-deploy.sh`(回填 + 同步 + 验收一条龙)。
|
||||
|
||||
> 历史人工成本不补录;人工成本监控从第一次创建人工履约配置的时间开始。
|
||||
> 定时任务 `cost-finance-sync` 每 5 分钟运行,负责履约归集、人工 SKU 回填、成本快照与销售事实。
|
||||
> 定时任务 `cost-finance-sync` 每 5 分钟运行,但**只覆盖最近 7 天**;更早历史靠 `post-deploy.sh` 的分窗同步。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
-- 财务上线验收(只读)
|
||||
--
|
||||
-- 用途:部署 + 数据初始化完成后,一次跑完所有闸门,输出 PASS / FAIL 清单。
|
||||
-- 不写任何业务表;结果只落在会话级临时表 _launch_checks。
|
||||
--
|
||||
-- 用法:
|
||||
-- # 1) 通过编排脚本(推荐,自动带上 launch_at)
|
||||
-- bash deploy/post-deploy.sh --only=verify --launch-at='2026-10-27 18:00:00+08'
|
||||
--
|
||||
-- # 2) 手工执行(变量必须传,否则用脚本内的兜底时间)
|
||||
-- docker compose exec -T postgres sh -lc \
|
||||
-- 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
|
||||
-- -v ON_ERROR_STOP=1 -v launch_at="2026-10-27 18:00:00+08" -f -' \
|
||||
-- < scripts/verify-finance-launch.sql
|
||||
--
|
||||
-- 退出码:0 = 无 FAIL(WARN 不阻断);3 = 存在 FAIL(末尾 RAISE EXCEPTION)。
|
||||
-- 前置条件:迁移 103–105 已应用;否则先跑 deploy/ubuntu-deploy.sh。
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
\if :{?launch_at}
|
||||
\else
|
||||
-- 与 post-deploy.sh 一致:默认取本次执行时间(容器 TZ=Asia/Shanghai)。
|
||||
\set launch_at `date '+%Y-%m-%d %H:%M:%S%z'`
|
||||
\endif
|
||||
|
||||
\echo ''
|
||||
\echo '==================== 财务上线验收 ===================='
|
||||
\echo '上线时间(launch_at): ' :launch_at
|
||||
\echo ''
|
||||
|
||||
-- 建表与 15 条检查的 INSERT 不输出到终端(错误仍会进 stderr)。
|
||||
\o /dev/null
|
||||
SET client_min_messages = warning;
|
||||
DROP TABLE IF EXISTS _launch_checks;
|
||||
|
||||
CREATE TEMP TABLE _launch_checks (
|
||||
seq int,
|
||||
id text,
|
||||
title text,
|
||||
actual text,
|
||||
expected text,
|
||||
status text,
|
||||
hint text
|
||||
);
|
||||
|
||||
-- 01 迁移基线 --------------------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 1, '01', '迁移基线 103–105 已应用',
|
||||
v::text, '3',
|
||||
CASE WHEN v = 3 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'后端未完成迁移:docker compose logs backend | grep -i migration'
|
||||
FROM (
|
||||
-- 按版本前缀逐个判断,避免后续 105_xxx_hotfix 之类增量迁移把计数顶成 4。
|
||||
SELECT (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^103_'))::int
|
||||
+ (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^104_'))::int
|
||||
+ (EXISTS (SELECT 1 FROM schema_migrations WHERE COALESCE(filename, name) ~ '^105_'))::int AS v
|
||||
) s;
|
||||
|
||||
-- 02 存量订单项收入回填 ----------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 2, '02', '存量订单项收入已回填(revenue_basis)',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'跑 npm run revenue:backfill(deploy/post-deploy.sh 会自动执行);未回填时退款按钮置灰'
|
||||
FROM (
|
||||
SELECT count(*) AS v FROM order_items WHERE revenue_basis = 'unknown'
|
||||
) s;
|
||||
|
||||
-- 03 履约方式回填 ----------------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 3, '03', '履约方式已回填(fulfillment_mode)',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'跑 npm run fulfillment:backfill;NULL 会让订单/成本页口径缺失'
|
||||
FROM (
|
||||
SELECT count(*) AS v FROM order_items WHERE fulfillment_mode IS NULL
|
||||
) s;
|
||||
|
||||
-- 04 履约方式取值合法 ------------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 4, '04', '履约方式取值合法',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'存在未知 fulfillment_mode,检查回填脚本与历史脏数据'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM order_items
|
||||
WHERE fulfillment_mode IS NOT NULL
|
||||
AND fulfillment_mode NOT IN ('work_order', 'manual', 'legacy_platform', 'affiliate_dash')
|
||||
) s;
|
||||
|
||||
-- 05 商品目录 --------------------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 5, '05', '商品目录已初始化(product_catalog)',
|
||||
v::text, '> 0',
|
||||
CASE WHEN v > 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'商品与成本 → 标准商品 →「从接单模板初始化」/「按工单回填商品」/「归一人工商品」'
|
||||
FROM (SELECT count(*) AS v FROM product_catalog) s;
|
||||
|
||||
-- 06 有效成本 --------------------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 6, '06', '有效成本已生成(版本定价或正金额快照)',
|
||||
'版本 ' || priced_versions::text || ' 个 / 正金额快照 ' || positive_snapshots::text || ' 条',
|
||||
'至少一项 > 0',
|
||||
CASE WHEN priced_versions > 0 OR positive_snapshots > 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'迁移只建 0 元占位版本;工单奖励/实际成本会写入快照。两项都为 0 时:先配置成本版本,或确认工单奖励已回写'
|
||||
FROM (
|
||||
SELECT
|
||||
(SELECT count(*) FROM cost_versions
|
||||
WHERE status = 'active' AND (amount_fen > 0 OR rate_bps > 0)) AS priced_versions,
|
||||
(SELECT count(*) FROM cost_snapshots
|
||||
WHERE superseded_at IS NULL AND total_amount_fen > 0) AS positive_snapshots
|
||||
) s;
|
||||
|
||||
-- 07 客服备用金账户 --------------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 7, '07', '客服备用金账户已补建',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'钱包管理 → 客服账户 →「补建客服账户」(POST /admin/fund/accounts/ensure-agents)'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM admin_users au
|
||||
LEFT JOIN fund_accounts fa
|
||||
ON fa.owner_admin_user_id = au.id
|
||||
AND fa.account_type = 'agent'
|
||||
WHERE au.role = 'support'
|
||||
AND fa.id IS NULL
|
||||
) s;
|
||||
|
||||
-- 08 上线后人工项必须有履约配置 -------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 8, '08', '上线后人工项已匹配履约配置',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'商品与成本 → 人工 SKU 配置 →「扫描」并逐项配置;保存后会自动回填近 30 天'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND oi.created_at >= :'launch_at'::timestamptz
|
||||
AND oi.fulfillment_config_id IS NULL
|
||||
) s;
|
||||
|
||||
-- 09 上线后人工项必须有成本快照(关账门槛) -------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 9, '09', '上线后人工项均已确认履约(关账门槛)',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'待办量:有未确认人工单就会 FAIL,不宜当日常健康检查;订单列表/详情 →「确认履约」后写 manual_case 快照并扣备用金/积分'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND oi.created_at >= :'launch_at'::timestamptz
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.order_item_id = oi.id
|
||||
AND cs.superseded_at IS NULL
|
||||
)
|
||||
) s;
|
||||
|
||||
-- 10 人工快照唯一性 --------------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 10, '10', '一个订单项最多一条有效人工快照',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'出现重复快照说明并发确认未被幂等拦截,需排查 manual_case 写入路径'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM (
|
||||
SELECT order_item_id
|
||||
FROM cost_snapshots
|
||||
WHERE subject_type = 'manual_case' AND superseded_at IS NULL
|
||||
GROUP BY 1
|
||||
HAVING count(*) > 1
|
||||
) dup
|
||||
) s;
|
||||
|
||||
-- 11 快照金额 = 备用金支出(现金路径) ------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 11, '11', '人工快照金额与备用金支出一致',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'金额不一致通常是确认事务部分失败;查 cost_snapshots / manual_expenses / fund_entries 三处'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM cost_snapshots cs
|
||||
-- 只核对现金路径:metadata.fundExpenseId 精确定位备用金支出,
|
||||
-- 避免同一订单项有多条 manual_expenses(如另外登记的人工支出)时行放大误报。
|
||||
JOIN manual_expenses me
|
||||
ON me.id = NULLIF(cs.metadata_json->>'fundExpenseId', '')::bigint
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.superseded_at IS NULL
|
||||
AND cs.created_at >= :'launch_at'::timestamptz
|
||||
AND cs.total_amount_fen <> me.amount_fen
|
||||
) s;
|
||||
|
||||
-- 12 总账借贷平衡 ----------------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 12, '12', '总账借贷平衡(全部已过账凭证)',
|
||||
v::text || '(已过账凭证 ' || posted::text || ' 张)', '借贷为 0 且至少有 1 张凭证',
|
||||
CASE WHEN v = 0 AND (posted > 0 OR facts = 0) THEN 'PASS' ELSE 'FAIL' END,
|
||||
'财务 → 总账与对账 →「同步销售事实到总账」;有销售事实但 0 张凭证同样视为未完成(空表求和恒为 0,不能直接判 PASS)'
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(abs(sum(l.debit_fen) - sum(l.credit_fen)), 0)::bigint AS v,
|
||||
count(DISTINCT e.id) AS posted,
|
||||
(SELECT count(*) FROM sales_daily_facts) AS facts
|
||||
FROM finance_journal_lines l
|
||||
JOIN finance_journal_entries e ON e.id = l.entry_id AND e.status = 'posted'
|
||||
) s;
|
||||
|
||||
-- 13 已关账期间不应再产生新凭证 -------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 13, '13', '已关账期间无新增凭证',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'检查关账后是否有人重跑同步;以 closed_at 为基准,正常关账前生成的凭证不算违规'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM finance_journal_entries e
|
||||
JOIN finance_periods p
|
||||
ON p.period_key = e.period_key
|
||||
AND p.status = 'closed'
|
||||
WHERE p.closed_at IS NOT NULL
|
||||
AND e.created_at > p.closed_at
|
||||
) s;
|
||||
|
||||
-- 14 销售事实已生成 --------------------------------------------------------
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 14, '14', '销售事实已生成(sales_daily_facts)',
|
||||
v::text, '> 0',
|
||||
CASE WHEN v > 0 THEN 'PASS' ELSE 'FAIL' END,
|
||||
'跑 finance:sync --mode=facts(post-deploy.sh 会按窗口执行);定时任务只覆盖最近 7 天'
|
||||
FROM (SELECT count(*) AS v FROM sales_daily_facts) s;
|
||||
|
||||
-- 15 近 7 天成本覆盖(仅提示,不阻断上线) ---------------------------------
|
||||
-- 说明:历史人工成本按既定口径不补录,这里只看非人工;自动发货/接单平台
|
||||
-- 的成本缺口会直接影响毛利,必须在上线前处理,但不用它单独卡住验收。
|
||||
INSERT INTO _launch_checks
|
||||
SELECT 15, '15', '近 7 天非人工销售事实成本覆盖(仅提示)',
|
||||
v::text, '0',
|
||||
CASE WHEN v = 0 THEN 'PASS' ELSE 'WARN' END,
|
||||
'存在成本缺失的非人工销售事实:先归一商品并配置成本版本;历史人工成本按既定口径不补录,故不计入'
|
||||
FROM (
|
||||
SELECT count(*) AS v
|
||||
FROM sales_daily_facts
|
||||
WHERE stat_date >= current_date - 6
|
||||
AND fulfillment_mode <> 'manual'
|
||||
AND cost_missing_count > 0
|
||||
) s;
|
||||
|
||||
\o
|
||||
\echo '【闸门清单】'
|
||||
SELECT seq AS "#",
|
||||
status,
|
||||
id,
|
||||
title AS "检查项",
|
||||
actual AS "实际",
|
||||
expected AS "期望"
|
||||
FROM _launch_checks
|
||||
ORDER BY seq;
|
||||
|
||||
\echo ''
|
||||
\echo '【未通过 / 提示项的处理建议】'
|
||||
SELECT id,
|
||||
status AS "状态",
|
||||
title AS "检查项",
|
||||
hint AS "处理建议"
|
||||
FROM _launch_checks
|
||||
WHERE status <> 'PASS'
|
||||
ORDER BY seq;
|
||||
|
||||
\echo ''
|
||||
\echo '【汇总】'
|
||||
SELECT count(*) FILTER (WHERE status = 'PASS') AS "通过",
|
||||
count(*) FILTER (WHERE status = 'FAIL') AS "未通过",
|
||||
count(*) FILTER (WHERE status = 'WARN') AS "提示",
|
||||
count(*) AS "合计"
|
||||
FROM _launch_checks;
|
||||
|
||||
\echo ''
|
||||
\echo '【数据规模参考】'
|
||||
SELECT (SELECT count(*) FROM orders) AS "订单",
|
||||
(SELECT count(*) FROM order_items) AS "订单项",
|
||||
(SELECT count(*) FROM product_catalog) AS "商品",
|
||||
(SELECT count(*) FROM cost_snapshots WHERE superseded_at IS NULL) AS "有效成本快照",
|
||||
(SELECT count(*) FROM sales_daily_facts) AS "销售事实行",
|
||||
(SELECT count(*) FROM finance_journal_entries) AS "总账凭证",
|
||||
(SELECT count(*) FROM cost_exceptions WHERE status = 'open') AS "未处理成本异常",
|
||||
(SELECT count(*) FROM order_items WHERE revenue_basis = 'unknown') AS "待回填收入项";
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
failed_count int;
|
||||
failed_list text;
|
||||
BEGIN
|
||||
SELECT count(*), string_agg(id || ' ' || title, ';' ORDER BY seq)
|
||||
INTO failed_count, failed_list
|
||||
FROM _launch_checks
|
||||
WHERE status = 'FAIL';
|
||||
|
||||
IF failed_count > 0 THEN
|
||||
RAISE EXCEPTION '财务上线验收未通过(% 项):%', failed_count, failed_list;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '财务上线验收全部通过';
|
||||
END $$;
|
||||
Reference in New Issue
Block a user