Compare commits

..

1 Commits

Author SHA1 Message Date
jingchuan
f53a956df9 fix(oa-board): refine overview and detail tabs 2026-06-15 17:49:44 +08:00
4309 changed files with 31282 additions and 698190 deletions

217
AGENTS.md
View File

@ -1,167 +1,102 @@
# AGENTS.md
适用范围:`D:\AI Code\3danzhuang-wuyongjie-mainline` 全仓库。
未单独说明时,先遵循当前 agent 自己的全局规则(ZCode:`C:\Users\54972\.zcode\v2\AGENTS.md`;Codex:`C:\Users\54972\.codex\AGENTS.md`)。
这份文件只记录本仓库当前有效的补充规则和核查入口,不保存密码、密钥、数据库连接串或对象存储密钥。
未单独说明时,先遵循全局规则:`C:\Users\54972\.codex\AGENTS.md`。
这份文件只补充本仓库特有规则,不重复全局通用要求。
重要边界:`D:\AI Code\3danzhuang` 是历史旧现场;当前正式工作目录以 `D:\AI Code\3danzhuang-wuyongjie-mainline` 为准。除非用户明确要求,不要混用旧目录、旧服务器别名、旧部署脚本或旧截图结论。
重要边界:`D:\AI Code\3danzhuang` 是历史旧现场;当前正式工作目录以 `D:\AI Code\3danzhuang-wuyongjie-mainline` 为准,除非用户明确要求,不要混用旧目录。
## 1. 当前项目真相
## 1. 默认执行口径
- 外层目录 `D:\AI Code\3danzhuang-wuyongjie-mainline` 是 Git 仓库根目录。
- 主应用目录是 `furniture-3d-system/`;用户未指定目录时,默认在这里执行代码、测试、构建和脚本。
- 技术栈以当前 `furniture-3d-system/package.json` 为准(核心:Next.js App Router + React + TypeScript + Prisma/PostgreSQL + Jest + Playwright + Tailwind CSS + Three.js/R3F;另有 ioredis、puppeteer、exceljs/xlsx、zod、zustand 等)。package.json 变动后文档不自动同步,以实时核查为准。
- `CLAUDE.md`、旧 README、`docs/历史归档/` 和早期部署文档里可能仍有旧苹果风格、MinIO、旧 Docker 部署、旧模型数量、旧 Product/SKU 口径等信息;只能当历史参考,不能覆盖当前代码、workflow 文档和实时命令结果。
- 工作树可能有用户或其他协作者留下的未跟踪文件、日志、贴图和本地产物。只处理本次任务明确相关的文件,不要顺手清理或回退无关现场。
- 用户未指定目录时,默认在 `furniture-3d-system/` 执行。
- 当任务发生在 `furniture-3d-system/` 时,默认按当前会话直接开发。
- 常规任务分支在完成必要验证、确认范围和目标后,可以按团队协作标准流程主动 `commit / push`。
- `main`、生产发布、破坏性操作、删除数据、改远端历史等高风险动作仍必须由用户明确授权。
- 禁止破坏性操作,除非用户明确授权。
- 没有“规范审查结论”,任务不算完成。
## 2. 开工前固定入口
## 2. 三条硬规则
当任务发生在 `furniture-3d-system/` 时,改代码前必须先读这 3 份当前协作入口:
- 必须符合企业级架构标准。
- 必须符合统一视觉标准。
- 必须优先做根因修复,不能拿补丁冒充完成。
## 3. 固定协作入口
当任务发生在 `furniture-3d-system/` 时,人和 AI 都必须先读取下面 3 份协作文档,再开始改代码:
- `furniture-3d-system/docs/workflow/团队协作模式.md`
- `furniture-3d-system/docs/workflow/模块边界说明.md`
- `furniture-3d-system/docs/workflow/主线同步操作手册.md`
按任务类型额外读取:
如果任务和包装协作有关,再额外读取:
- 生产、部署、线上排障:`furniture-3d-system/docs/workflow/生产部署入口说明.md`,并核对 `scripts/deploy-yydd-prod-from-git.ps1` 与其调用的 `scripts/create-yydd-prod-deploy.ps1` 当前默认参数。
- 前端、视觉、交互:`furniture-3d-system/docs/设计规范/白曜石工坊统一视觉标准.md` 和 `furniture-3d-system/docs/workflow/规范审查清单.md`。
- 包装、仓库、轻仓库协作:再读包装/仓库相关交接文档和 `src/app/warehouse`、`src/app/api/warehouse`、`src/app/api/pre-skus/[id]/packaging/route.ts` 的当前实现。
- 数据库、主数据、迁移:优先读 `docs/架构设计/内部主数据巡检基线-20260529.md`、`docs/架构设计/数据库混乱点治理台账.md` 和相关 `db:foundation:*` 脚本说明。
- `furniture-3d-system/docs/workflow/同事交接模板.md`
- `furniture-3d-system/docs/workflow/同事本地AGENTS模板.md`
- `furniture-3d-system/docs/开发指南/包装方案增强/新平台包装底座-同事AI对接说明-20260331.md`
如果文档和代码冲突,按这个顺序判断:当前代码和脚本、当前线上/命令返回、当前 workflow 文档、历史文档或旧记忆。
## 4. 协作与分支口径
## 3. Git 与主线协作
- `main` 是最终发布主线,由 `WuYongJie` 收口;不要直接把半成品推到 `main`。
- `origin/codex/xuxiake-mainline` 是日常共享开发主线和默认同步基线。
- `main` 是最终发布主线,不能随便直接推;生产发布必须另行确认。
- `origin/codex/xuxiake-mainline` 是当前共享开发主线和默认同步基线。
- 新功能、优化和修复默认从最新 `origin/codex/xuxiake-mainline` 开任务分支,再按协作流程回流。
- 新分支统一使用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`,不要再新建 `feature/...`、`packaging/...`、`dev`、`codex/xuxiake-dev` 这类泛分支名。
- 长期共享分支同步共享开发主线时固定使用 `merge origin/codex/xuxiake-mainline`;只有确认无人共用的临时个人任务分支才使用 `rebase origin/codex/xuxiake-mainline`。
- 每次开工或接续旧任务时,先看 `git status --short --branch`;涉及主线同步时再看 `git rev-list --left-right --count HEAD...origin/codex/xuxiake-mainline`,不要把“当前所在分支”误当成“已同步共享主线”。
- 用户问“xuxiake 主线有没有新提交”时,必须先 `git fetch origin`,再基于最新远端状态回答。
- 不要在脏工作树上直接 merge/rebase。先看 `git status --short`,分清本次改动、用户改动、本地产物和无关未跟踪文件。
- 常规任务完成必要验证后,可以按协作流程推进 `commit / push`;`main`、生产发布、破坏性操作、删除数据、改远端历史等高风险动作必须有用户明确授权。
- 新分支统一使用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`。
- 不再使用 `feature/packaging`、`packaging/...`、`codex/xuxiake-dev` 这类泛分支名。
- 长期共享分支同步当前共享开发主线时固定使用 `merge`。
- 临时个人任务分支只有在确认无人共用时才使用 `rebase`。
### 主线部署标准流程(每次"主线部署"都走这一套)
## 5. 白曜石工坊视觉口径
本仓库有两根主线,都不直接提交:`main` 是生产部署唯一来源(由 `WuYongJie` 收口),`origin/codex/xuxiake-mainline` 是共享开发次主线(日常回流基线)。
- `furniture-3d-system/docs/设计规范/白曜石工坊统一视觉标准.md` 是当前新平台第一视觉标准。
- 新平台主链页面,以及 `/admin`、`/users` 等后台管理页面,默认都要向白曜石工坊口径统一。
- 白曜石工坊默认是浅色工作台;不再提供深浅色主题切换入口,也不要把后台页面继续做成默认黑底。
- 页面应保持工作台感、低噪音、克制、层级清楚,不能退回普通 SaaS 卡片墙或营销式大卡片布局。
- `Inspector(检视器)`、数据列表、表格、长文本和表单密集区必须优先保证可读性,不能用毛玻璃承载正文。
- 毛玻璃只允许用于顶栏、HUD、浮动工具条和轻量状态浮层。
- 板材、五金、组件、包装等资产应按 `Meta Component(元组件)` 表达,而不是普通图片卡片。
- 苹果风格视觉标准已降级为历史参考,不再作为新平台主链、后台管理页面、弹窗或验收的视觉依据。
每次用户要求"主线部署",严格按以下顺序,不跳步、不直接支线 → main:
## 6. 前端责任口径
1. **在支线开发** → 提交到任务分支(如 `codex/wuyongjie-xxx`),不碰两根主线。
2. **fetch xuxiake** → 必须先 `git fetch origin`,看 `origin/codex/xuxiake-mainline` 有没有新提交。
3. **有新提交** → `merge origin/codex/xuxiake-mainline` 拉下来处理冲突(长期共享分支用 merge,不 rebase);只处理冲突文件,不扩范围。
4. **合并到 xuxiake** → 把支线合进 `origin/codex/xuxiake-mainline`,并 `push` 更新次主线。
5. **合并到 main** → 把 xuxiake 合进 `main`,并 `push origin main`(本步需用户授权)。
6. **用 main 部署** → 走 `scripts/deploy-yydd-prod-from-git.ps1`(默认源 `origin/main`),部署后核对 commit / 进程 / 端口 / 日志 / 关键页面(本步需用户授权)。
- 未明确指定其他协作者时,前端、视觉、交互、`3D(立体)` 表现层和系统联调默认由当前 Codex 全权负责。
- 前端实现必须遵守白曜石工坊视觉标准、模块边界和最小验证要求。
- 处理前端页面时,优先用当前代码、实际页面表现、截图结论和设计规范判断,不再套用旧模型时期的保守分工。
- 复杂任务可以主动拆计划、并行排查和验证,但不能因为模型更强就扩大改动范围或跳过规范审查。
发布前必须确认 `git ls-remote origin refs/heads/main`、本地 `origin/main`、目标部署 commit 三者一致。
## 7. 截图和上下文续接
## 4. 生产与部署
- 工程对话可以正常使用截图,但截图只用于当前判断。
- 压缩上下文、写会话恢复摘要或给新对话续接时,只保留文字结论、关键决策、当前状态和下一步。
- 不继承、不复述 `data:image base64(图片编码文本)`,也不要把图片原始内容写进摘要。
- 如果同一问题需要连续大量截图,优先开短图像对话处理,最后只把文字结论合回主工程对话。
- 当前正式网址:`https://cp.jiansun.vip/`。
- 当前权威生产 SSH 入口:`root@ltai5tdb2ogyj59fquyedh.yydd.ai`,端口 `60022`。
- 当前生产部署目录:`/opt/furniture-3d-system-prod`。
- 当前 PM2 进程名:`furniture-3d-system-prod`。
- 当前应用本机端口:`3001`。
- 默认生产部署来源:`origin/main`。
- 当前主部署入口是外层 `scripts/deploy-yydd-prod-from-git.ps1`;它默认部署 `origin/main`,并调用 `scripts/create-yydd-prod-deploy.ps1` 生成发布包/远端发布脚本。不要把下层打包脚本单独当成唯一部署入口。
- 发布前必须确认 `git ls-remote origin refs/heads/main`、本地 `origin/main`、目标部署 commit 三者一致;本地 `main` 是否领先或落后远端不能替代 `origin/main` 的真实发布来源。
- 旧 `192.168.58.20`、本机泛别名 `prod`、旧 `gongxiangp2 / youyidian-3d-material` 生产信息都不能套用到本项目。
- 生产动作前至少实时确认:目标主机、远端目录、PM2 进程、`current` 软链接、监听端口和 `https://cp.jiansun.vip/api/health`。
- 生产数据库、迁移、主数据地基相关动作先只读核查;需要写入时必须有备份、目标库身份、确认口令和可回滚路径。
- 对象存储当前要按 `src/lib/storage.ts`、`storage:*` 脚本、七牛迁移/GC 文档和生产环境变量实时核对;不要把历史 MinIO/S3 备份脚本当成当前生产存储真相。
## 8. 敏感信息和交接材料
## 5. 当前主链与模块边界
当前新平台数据主链按这条线理解:
`资产中心 -> 辅助装配 -> 草稿组 -> 产品资料/正式入库 -> 仓库管理/轻仓库`
当前全局工作台导航还包含首页、OA 新品流程看板、仓库质检、AI 出图工作台等运营入口。导航入口不等于数据主链,不要把页面入口和主数据归属混成一件事。
主链默认入口和目录:
- 首页与全局壳:`src/app/page.tsx`、`src/components/layout`
- 资产中心:`src/app/asset-center`、`src/app/api/asset-center`
- 辅助装配:`src/app/assisted-assembly`、`src/app/api/assisted-assembly`
- 草稿组:`src/app/draft-groups`、`src/app/api/draft-groups`
- 产品资料/正式入库:`src/app/intake`、`src/app/api/intake`
- 仓库管理/轻仓库:`src/app/warehouse`、`src/app/api/warehouse`
- 仓库质检:`src/app/warehouse-quality`、`src/app/api/warehouse-quality`
- AI 出图工作台:`src/app/ai-render-workbench`
- 审批工作流:`src/app/approval-workflows`
- 素材库:`src/app/materials`
- 移动端:`src/app/mobile`
- 平台组件库:`src/components/platform`
- 主链规则与类型:`src/lib/open-product`、`src/lib/planning`
业务真相:
- 资产族:`AssetFamily`
- 规格款:`AssetVariant`
- 草稿组:`DraftGroup`
- 准 SKU:`PreSku`
- 入库结果:正式 `Product / ProductSKU`(`SKU` 是业务概念,Prisma 模型名是 `ProductSKU`)
- 产品资料侧非集成开品:`IntakeNonIntegratedProduct / IntakeNonIntegratedSkuItem`
旧 CAD 兼容链继续存在,但不是新平台主数据源:
- 页面:`src/app/products`、`src/app/products/[id]`、`src/app/products/[id]/assembly`、`src/app/products/[id]/skus/[skuId]`
- 旧链模型和服务包括 `Product / ProductSKU / Panel / Hole / ProductHardware`、`assembly.service`、`panel.service`、`hole.service` 等。
- 旧链可以继续服务历史产品、CAD 导入、装配步骤和兼容发布后能力,但不要反向主导资产中心、草稿组或产品资料主链。
共享接缝默认谨慎处理:
- `prisma/schema.prisma`
- `prisma/migrations`
- `src/app/layout.tsx`
- `src/app/intake`
- `src/app/draft-groups/[id]`
- `src/app/api/platform/schemas.ts`
- `src/server/services/draft-group.service.ts`
- `src/server/services/intake.service.ts`
- `src/server/repositories/draft-group.repository.ts`
改这些地方前先说明原因、影响范围和验证计划;不要为了局部 UI 或临时兼容绕过模块边界。
## 6. 架构硬规则
- API Route 负责收参、鉴权和返回,不直接堆业务规则或 Prisma 查询。
- Service 负责业务规则、权限校验、事务编排、缓存和跨模块协调。
- Repository 负责数据访问。
- 任何新接口必须有明确输入输出 DTO/校验口径。
- 不是必须的话,不要使用兜底逻辑;原生主逻辑能做好,就先把主逻辑、状态契约和数据来源修正确。兜底只能用于明确的兼容、降级或应急场景,不能用来掩盖本应修好的业务规则、接口契约或前端状态问题。
- 任何 Prisma 模型调整必须带迁移,并补齐主数据/迁移核查;生产/准生产禁止用 `prisma db push` 替代迁移。
- 影响命名、BOM、包装、安装步骤、SKU、barcode、批次、入库承接的数据问题,优先从数据源和业务规则修,不在页面上压报错或绕校验。
- SPU、SKU、barcode、批次不能混用:SPU 是父级产品款,SKU 是 SPU 下的具体子项(Prisma 模型名 `ProductSKU`),barcode 是绑定到最终 SKU 的业务定位值;基于某 SKU 派生的新 SKU 必须仍属于同一 SPU,并按业务要求继承或明确指定批次。
## 7. 前端与视觉硬规则
- 白曜石工坊统一视觉标准是本项目新平台第一视觉标准;默认是浅色工作台,不再提供深浅色主题切换入口。
- 本项目有自己的组件库与 `Meta Component(元组件)` 体系;前端实现必须优先复用 `src/components/platform`、资产中心已沉淀的工作台表达和现有元组件,不允许绕开组件库另起一套孤立 UI。
- 当用户指定 Stitch 设计稿或已有 Stitch 设计稿可参考时,Stitch 是当前功能的视觉规范与验收标准;实现方式是在本项目组件库/元组件体系内对齐 Stitch 的密度、层级、控件形态、留白、字号、圆角、阴影和低噪音表达,而不是无视组件库硬抄静态稿。
- 资产中心页面是当前项目内的优先参考样板;涉及资产、图片、SKU、AI 白底图、3D 预览、工作区和检视器时,默认参考资产中心的工作台感、元组件表达、低噪音操作面板和重点图片区。
- `Inspector(检视器)`、数据列表、表格、长文本和表单密集区必须优先保证可读性;毛玻璃只允许用于顶栏、HUD、浮动工具条和轻量状态浮层,不承载正文。
- 板材、五金、组件、包装等资产必须按元组件表达,不能退回普通图片卡片或营销式大卡片布局。
- 现有 `apple-*` token、深色 `ObsidianControlShell`、历史主题 CSS 只能作为兼容遗留;新功能不得以它们作为视觉方向。
- 前端改造不得把现有后台习惯、普通 SaaS 审美或共享组件默认皮肤当成最终依据;如果组件库默认视觉和 Stitch/资产中心标准冲突,应在组件库体系内做专用变体、重写皮肤或局部重构。
- 没有出现在设计稿、组件规范或业务必要路径里的说明文案、辅助卡片、重复统计块、解释区,不要为了“更易懂”自行添加;工作区必须抓重点,图片类功能要让图片和关键动作成为第一层级。
- 涉及前端动画、入退场、滚动联动、时间线、拖拽、SVG 或动效性能时,先判断是否有助于理解和操作;需要时优先按技术栈调用对应 GSAP Skill,并继续遵守 Impeccable。React 必须使用 `useGSAP` 或带 `gsap.context().revert()` 的完整清理,滚动相关实例必须销毁并在布局变化时正确刷新;优先动画 `transform/opacity`,尊重 `prefers-reduced-motion`,不得为装饰滥用动效。
- 前端修改后优先用真实页面、截图、1920×1080 视口和必要的移动端视口验证,不只看代码。
## 8. 验证、收口与规范审查
- 修改后必须做最小但有效的验证;不能验证就明确说明未验证原因和风险。
- 常用验证命令以当前 `package.json` 为准,优先选和改动范围匹配的最小集合:`npm run type-check`、`npm run build`、`npm test -- <相关测试>`、`npm run lint`;对象存储用 `npm run storage:gc:audit` / `npm run storage:gc:apply`,主数据用 `npm run db:foundation:*` 相关只读或授权脚本。
- 前端体验类改动要结合浏览器实际操作和截图验证;涉及 3D/canvas 时要确认画面非空、尺寸正确、交互可用。
- 生产验证必须包含真实健康检查和日志/PM2/端口等现场证据,不能只说"本地构建通过"。
- 规范审查固定只查三件事:架构有没有被破坏、视觉有没有跑偏、这次是不是根因修复。结论只能是 `passed` / `passed with warnings` / `rejected`;只要架构违规、视觉违规或明显补丁式修复,就必须 `rejected`。非独立复审时必须写明"这是自审,不是独立复审"。统一清单:`furniture-3d-system/docs/workflow/规范审查清单.md`。没有规范审查结论,任务不算完成。
## 9. 敏感信息和交接
- 账号密码、访问密钥、数据库连接串、对象存储密钥等敏感信息只能放在本地安全材料里,不写入 Git、共享文档、普通交接文本或长期记忆;生产域名、PM2 名称、部署目录这类非密钥运行入口可以写进仓库文档,但必须保持最新。
- 账号、密码、服务器地址、访问密钥、数据库连接、S3/MinIO 密钥等敏感信息,只能放在本地续接材料里。
- 含敏感信息的摘要、日志或交接文件不能提交 Git,不能发给同事,不能写进共享文档。
- 仓库内协作文档只写非敏感入口、流程和职责;凭据类信息必须用 `[REDACTED_SECRET]` 或本地路径说明替代。
- 发现摘要、日志或交接文件混有明文凭据时,先提示风险,再决定是否拆成本地敏感版和仓库协作版。
- 给下一个 agent 或同事交接时,写清:本次只改了什么、没改什么、验证跑了什么、风险是什么、下一步要看哪些文件。
- 发现摘要文件里混有明文凭据时,默认先提示风险,再决定是否拆成本地敏感版和仓库协作版。
## 9. 规范审查
规范审查固定只查三件事:
- 架构有没有被破坏
- 视觉有没有跑偏
- 这次是不是根因修复
结论只能是:
- `passed`
- `passed with warnings`
- `rejected`
只要架构违规、视觉违规,或明显是补丁式修复,就必须 `rejected`。
如果这次不是独立复审,必须明确写出“这是自审,不是独立复审”。
统一清单:`furniture-3d-system/docs/workflow/规范审查清单.md`
## 10. 标准来源
- `furniture-3d-system/docs/设计规范/白曜石工坊统一视觉标准.md`
- `furniture-3d-system/docs/workflow/规范审查清单.md`

View File

@ -1,226 +0,0 @@
# 本地运行
NODE_ENV=development
NEXT_PUBLIC_APP_URL=http://localhost:3000
# 数据库
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DB_NAME?schema=public
# 员工工时花名只读人员配置来源(可选;开发环境配置后会合并正式人员配置)
SUPPLY_CHAIN_PRODUCT_ARCHIVE_READ_DATABASE_URL=postgresql://READ_USER:READ_PASSWORD@READ_HOST:5432/READ_DB
SUPPLY_CHAIN_PRODUCT_ARCHIVE_READ_DATABASE_SSL_REJECT_UNAUTHORIZED=true
# 产品全量档案的聚水潭 SQL Server 只读来源
JST_REPORT_DB_HOST=
JST_REPORT_DB_PORT=1433
JST_REPORT_DB_NAME=
JST_REPORT_DB_SCHEMA=rpt
JST_REPORT_DB_USER=
JST_REPORT_DB_PASSWORD=
JST_REPORT_DB_ENCRYPT=true
JST_REPORT_DB_TRUST_SERVER_CERTIFICATE=true
# 加工单在途排除的终态;其他状态均按未完成加工单计算 qty-io_qty。
MODULAR_PROCESSING_WORKORDER_EXCLUDED_STATUSES=Finished,Cancelled,Canceled,Closed,Completed
SUPPLY_CHAIN_LIMIT_SNAPSHOT_DIR=
# 换货规则表 app.jst_sku_replace_rule 与 rpt 视图使用独立授权;留空时复用上面的主机、库和账号。
JST_SKU_REPLACE_RULE_SCHEMA=app
JST_SKU_REPLACE_RULE_DB_USER=
JST_SKU_REPLACE_RULE_DB_PASSWORD=
# 停产下架链接日快照最大导入延迟(小时);超时保留已有链接。
JST_DISCONTINUATION_LINK_MAX_AGE_HOURS=26
# 数据库链接快照仅用于诊断;低于已有链接该比例时拒绝覆盖。
JST_DM_MIN_EXISTING_COVERAGE_RATIO=0.5
# 停产下架服务器 Chrome / RPA。
JST_RPA_BROWSER_URL=http://127.0.0.1:9222
JST_RPA_BROWSER_SERVICE=furniture-jst-rpa-browser.service
JST_RPA_PROFILE_DIR=
JST_RPA_CHROME_EXECUTABLE=
JST_RPA_HEADLESS=true
JST_RPA_DEBUG_PORT=9222
JST_RPA_PYTHON=/usr/bin/python3
# 聚水潭账号密码登录,仅配置在服务器私有环境文件,禁止提交真实值。
JST_RPA_LOGIN_ID=
JST_RPA_LOGIN_PASSWORD=
JST_RPA_ATTEMPTS=3
JST_RPA_FALLBACK_BATCH_SIZE=10000
# 限价流程历史快照首次构建的追溯起点;后续只按更新时间增量同步。
LIMIT_PRICE_HISTORY_SOURCE_START=2026-01-01T00:00:00
# 物流规则和省份占比快照目录;正式站建议指向 shared/freight-config,本地留空。
FREIGHT_CONFIG_DATA_DIR=
# 产品限价页面已确认记录来源库(限价历史抽屉展示来源记录时使用)。
PROD_ARCHIVE_DB_HOST=
PROD_ARCHIVE_DB_PORT=5432
PROD_ARCHIVE_DB_NAME=
PROD_ARCHIVE_DB_USER=
PROD_ARCHIVE_DB_PASSWORD=
PROD_ARCHIVE_DB_SSL=true
# OA 新品流程看板数据源。默认 mysql;切换到 JSReportReplica 时设为 mssql。
OA_BOARD_SOURCE_DRIVER=mysql
# MySQL 来源兼容配置。
OA_BOARD_SOURCE_DB_HOST=
OA_BOARD_SOURCE_DB_PORT=3306
OA_BOARD_SOURCE_DB_NAME=
OA_BOARD_SOURCE_DB_USER=
OA_BOARD_SOURCE_DB_PASSWORD=
OA_BOARD_SOURCE_DB_CHARSET=utf8mb4
OA_BOARD_SOURCE_DB_SSL=false
# SQL Server 来源:JSReportReplica.rpt 的 ding_Workflow、ding_WorkflowScheme、ding_WorkflowTask 只读视图。
# 使用 mssql 来源时,快照仍写入下方配置的企划 MySQL 库。
OA_BOARD_MSSQL_HOST=
OA_BOARD_MSSQL_PORT=1433
OA_BOARD_MSSQL_DATABASE=JSReportReplica
OA_BOARD_MSSQL_SCHEMA=rpt
OA_BOARD_MSSQL_USERNAME=
OA_BOARD_MSSQL_PASSWORD=
OA_BOARD_MSSQL_ENCRYPT=false
OA_BOARD_MSSQL_TRUST_SERVER_CERTIFICATE=true
OA_BOARD_MSSQL_CONNECTION_TIMEOUT_MS=15000
OA_BOARD_MSSQL_REQUEST_TIMEOUT_MS=120000
# OA 看板快照目标(MySQL)。留空时兼容复用 PLANNING_DATABASE_URL / PLANNING_DB_*。
# 配置此组变量后,可保证切换 SQL Server 来源时所有快照、超时跟进和日报数据仍保存在企划库。
OA_BOARD_SNAPSHOT_DATABASE_URL=
OA_BOARD_SNAPSHOT_DB_HOST=
OA_BOARD_SNAPSHOT_DB_PORT=3306
OA_BOARD_SNAPSHOT_DB_NAME=
OA_BOARD_SNAPSHOT_DB_USER=
OA_BOARD_SNAPSHOT_DB_PASSWORD=
OA_BOARD_SNAPSHOT_DB_CHARSET=utf8mb4
OA_BOARD_SNAPSHOT_DB_SSL=false
# HR 员工目录(SQL Server)。OA 快照仅在本地员工目录无法匹配时使用,
# 用于将 ding_WorkflowTask.userId 解析成当前负责人的姓名。
HR_SQLSERVER_HOST=
HR_SQLSERVER_PORT=1433
HR_SQLSERVER_DATABASE=
HR_SQLSERVER_USERNAME=
HR_SQLSERVER_PASSWORD=
HR_SQLSERVER_ENCRYPT=false
HR_SQLSERVER_TRUST_SERVER_CERTIFICATE=true
# 产品资料供应商与仓库目录(SQL Server 报表库,只读)
JST_PLANNING_SQLSERVER_HOST=
JST_PLANNING_SQLSERVER_PORT=1433
JST_PLANNING_SQLSERVER_DATABASE=
JST_PLANNING_SQLSERVER_USERNAME=
JST_PLANNING_SQLSERVER_PASSWORD=
JST_PLANNING_SQLSERVER_SCHEMA=rpt
JST_PLANNING_SQLSERVER_ENCRYPT=false
JST_PLANNING_SQLSERVER_TRUST_SERVER_CERTIFICATE=true
# 登录和令牌
JWT_SECRET=replace-with-random-secret
JWT_EXPIRES_IN=7d
# 钉钉登录(OAuth 应用;只用于登录授权和用户信息获取)
DINGTALK_LOGIN_CLIENT_ID=
DINGTALK_LOGIN_CLIENT_SECRET=
DINGTALK_CALLBACK_ORIGIN=
# 钉钉开放平台 API(通讯录、待办、审批等服务端调用)
DINGTALK_API_CLIENT_ID=
DINGTALK_API_CLIENT_SECRET=
# 钉钉待办:OA 阶段超时预警,以及预计出货日前 15 天的品牌策划主管提醒。
DINGTALK_TODO_ENABLED=false
DINGTALK_TODO_DETAIL_BASE_URL=
DINGTALK_TODO_SOURCE_APP=oa-board
DINGTALK_TODO_ESCALATION_ENABLED=false
DINGTALK_TODO_ESCALATION_DAYS=3
# DINGTALK_AGENT_ID=
# 清货池多包标记钉钉通知:填写在职员工目录中的姓名,默认推送卷帘大将。
SUPPLY_CHAIN_CLEARANCE_COMBO_NOTIFICATION_RECIPIENT=卷帘大将
# 由壹点通知中心(仅服务端使用,项目密钥禁止提交或暴露给浏览器)
YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center
YYD_NOTIFICATION_PROJECT_CODE=furniture_platform
YYD_NOTIFICATION_PROJECT_SECRET=
YYD_NOTIFICATION_TIMEOUT_MS=10000
# 店铺账号新增时,梦汐/扬灵群使用的钉钉“由壹点”群机器人。
# webhook 地址和签名密钥只配置在本地/正式站运行环境,不要提交真实值。
DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_URL=
DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_SECRET=
DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_TIMEOUT_MS=10000
STORE_ACCOUNT_NOTIFICATION_RETRY_SECRET=
# 产品开发待办定时任务(仅服务端/部署 cron 使用,须与其他任务密钥分开)
PRODUCT_DEVELOPMENT_TODO_CRON_SECRET=
# 对象存储
# 必填,不能留空。当前生产桶使用 qiniu;只有明确切换适配器时才改为 s3/minio。
STORAGE_PROVIDER=qiniu
OBJECT_STORAGE_FILE_PATH=dev
STORAGE_FILE_PATH=dev
# 七牛云 Kodo
QINIU_ACCESS_KEY=replace-with-qiniu-access-key
QINIU_SECRET_KEY=replace-with-qiniu-secret-key
QINIU_BUCKET=sucaiku-js
QINIU_PUBLIC_DOMAIN=https://qny.jiansun.vip
QINIU_FILE_PATH=dev
QINIU_USE_HTTPS=true
QINIU_PRIVATE_BUCKET=true
QINIU_PRIVATE_URL_EXPIRES_SECONDS=7200
# S3/MinIO 兼容适配器
# STORAGE_PROVIDER=s3
S3_ENDPOINT=http://127.0.0.1:9000
S3_PUBLIC_ENDPOINT=http://127.0.0.1:9000
S3_ACCESS_KEY=replace-with-s3-access-key
S3_SECRET_KEY=replace-with-s3-secret-key
S3_BUCKET=replace-with-s3-bucket
S3_FILE_PATH=dev
S3_REGION=us-east-1
S3_ENABLE_PATH_STYLE=true
# 外部 AI 生图
EXTERNAL_IMAGE_GENERATE_TIMEOUT_MS=180000
# BOM 核算竞品分析
# 留空表示分析服务尚未接入;本地开发可显式填 fixture 验证完整工作台。
# 生产环境禁止使用 fixture。
BOM_COSTING_ANALYSIS_PROVIDER=
# 真实竞品分析使用 market-detail-multimodal;Key 只放本地/生产环境变量。
BOM_COSTING_MARKET_API_BASE_URL=https://qh.yydd.ai
BOM_COSTING_MARKET_API_KEY=replace-with-market-open-api-key
# 复用 AI 服务配置面板中已激活的多模态模型连接。
BOM_COSTING_AI_SERVICE_TYPE=prompt-optimizer
BOM_COSTING_MARKET_TIMEOUT_MS=60000
# 最终结构与 BOM 汇总默认最多等待 110 秒;GPT-5 兼容网关会强制收紧到该上限,避免上游两分钟截断造成任务假死。
BOM_COSTING_AI_TIMEOUT_MS=110000
BOM_COSTING_AI_MAX_IMAGES=20
# GPT-5 兼容网关的最终 BOM 汇总最多使用 12000 个输出额度,避免冗长回复拖垮整项任务。
BOM_COSTING_AI_MAX_TOKENS=12000
BOM_COSTING_AI_REASONING_EFFORT=low
BOM_COSTING_MODELING_PROVIDER=
# BOM 核算实时同款采购预估(1688 官方图搜)。
# 网关固定为 https://skills-gateway.1688.com/api/find_product/1.0.0。
# AK 只放服务器私密环境变量,服务端会按 x-csk-* 协议签名;实时同款价固定 × 90%
# 是整件采购预估,历史 BOM 仅保留为结构参考。不要把 AK 写入 Git 或 NEXT_PUBLIC_* 变量。
ALIBABA_1688_IMAGE_SEARCH_AK=replace-with-1688-image-search-ak
# 仅在官方网关地址调整时设置;系统会拒绝非官方 HTTPS 地址。
ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL=https://skills-gateway.1688.com/api/find_product/1.0.0
ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS=45000
ALIBABA_1688_IMAGE_SEARCH_MIN_SIMILARITY=0.78
ALIBABA_1688_IMAGE_SEARCH_PAGE_SIZE=10
ALIBABA_1688_IMAGE_SEARCH_PURCHASE_AMOUNT=1
ALIBABA_1688_IMAGE_SEARCH_TAGS=4306497
# 素材库白底图回传
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_ENABLED=false
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL=https://example.com
MATERIAL_LIBRARY_WHITE_BACKGROUND_API_KEY=replace-with-material-library-api-key
# 检测报告查询(不填则复用上面素材平台的 BaseUrl / API Key;都没有时「是否有检测报告」显示为空)
MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL=
MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_KEY=
# 产品全量档案以图搜图(模型文件放在仓库外,不提交到 Git)
IMAGE_SEARCH_MODEL_PATH=
IMAGE_SEARCH_MODEL_SOURCE_BASE_URL=https://www.modelscope.cn/models/Xenova/dinov2-small/resolve/master
IMAGE_SEARCH_MIN_SHAPE_COSINE=0.78
IMAGE_SEARCH_MIN_SHAPE_MARGIN=0.08

View File

@ -1 +0,0 @@
*.sh text eol=lf

View File

@ -2,7 +2,6 @@
# dependencies
/node_modules
/node_modules.pnpm-mixed
/.pnp
.pnp.*
.yarn/*
@ -16,9 +15,6 @@
# next.js
/.next/
/.next-*/
/.next-oa-preview*/
/.next-oa-webpack/
/out/
/.data/
@ -37,7 +33,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
@ -50,7 +45,6 @@ next-env.d.ts
.database-backups/
.master-data-audits/
.storage-migration-audits/
.storage-gc-audits/
.master-data-cleanup/
# local workspace artifacts
@ -73,15 +67,3 @@ typecheck-latest.log
.local-test-*.log
tsconfig.typecheck.generated.json
local-packages/npm-cache/cache/_logs/
# 脚本监控 Agent 本机凭据
agents/script-monitor/config.json
# 下载中心本地运行时产物
.download-center/
# 限价每日快照(本机/生产运行产物,不入库)
/data/limit-price-snapshots/
# 产品档案对账诊断报告
/data/archive-reconcile-diagnostics/

View File

@ -1,30 +0,0 @@
# 供应链脚本监控 Agent
每台运行脚本的机器部署一份 Agent。Agent 只主动向产品平台发起 HTTP 请求,不需要为被监控机器开放入站端口。
## 接入
1. 在供应链中心的“脚本监控”页面点击“接入机器”,生成独立的 `agentId` 和 `agentToken`。
2. 复制本目录到目标机器,把 `config.example.json` 复制为 `config.json`,填入凭据、内外网地址和脚本任务说明。
3. 先运行 `node agent.mjs` 确认上报,再使用 `pm2 start ecosystem.config.cjs` 常驻。
需要在平台点击“立即运行”的脚本,必须在 `config.json` 对应任务中显式配置
`"allowManualRun": true`。平台只会下发脚本 `key`,Agent 始终执行本机配置里预存的
`command`,不会接收平台传来的任意命令。Agent 默认每 5 秒主动领取一次执行请求,
因此内网机器不需要开放入站端口。
`connectionMode` 支持:
- `intranet`:只走内网地址。
- `internet`:只走公网地址,生产必须使用 HTTPS。
- `auto`:优先内网,失败后自动尝试公网。
## 记录真实运行结果
对于需要记录开始时间、耗时、退出码和输出的任务,用包装器代替原命令:
```bash
node /opt/furniture-script-monitor/run-script.mjs supply-chain-archive-sync
```
包装器执行 `config.json` 中同一 `key` 的 `command`,并在开始与结束时上报。Agent Token 只保存在目标机器的 `config.json`,该文件已被仓库忽略。

View File

@ -1,410 +0,0 @@
import { createHash } from 'node:crypto'
import { execFileSync, spawn } from 'node:child_process'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { hostname, platform, release } from 'node:os'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
export const AGENT_VERSION = '1.0.0'
const AGENT_DIR = dirname(fileURLToPath(import.meta.url))
function requiredText(value, label) {
if (typeof value !== 'string' || !value.trim()) throw new Error(`缺少 ${label}`)
return value.trim()
}
export function loadConfig(configPath = process.env.SCRIPT_MONITOR_CONFIG || resolve(AGENT_DIR, 'config.json')) {
if (!existsSync(configPath)) {
throw new Error(`配置文件不存在:${configPath},请复制 config.example.json 为 config.json`)
}
const config = JSON.parse(readFileSync(configPath, 'utf8'))
config.agentId = requiredText(config.agentId, 'agentId')
config.agentToken = requiredText(config.agentToken, 'agentToken')
config.connectionMode = String(config.connectionMode || 'auto').toLowerCase()
if (!['intranet', 'internet', 'auto'].includes(config.connectionMode)) {
throw new Error('connectionMode 必须是 intranet、internet 或 auto')
}
config.reportIntervalSec = Math.max(15, Number(config.reportIntervalSec) || 60)
config.requestTimeoutMs = Math.max(1000, Number(config.requestTimeoutMs) || 10000)
config.commandPollIntervalSec = Math.min(60, Math.max(2, Number(config.commandPollIntervalSec) || 5))
config.scripts = Array.isArray(config.scripts) ? config.scripts : []
return config
}
function normalizeCenterUrl(value) {
if (typeof value !== 'string' || !value.trim()) return null
const url = new URL(value.trim())
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`不支持的中心地址协议:${url.protocol}`)
return url.toString().replace(/\/$/, '')
}
export function resolveCenterEndpoints(config) {
const intranet = normalizeCenterUrl(config.centerUrls?.intranet)
const internet = normalizeCenterUrl(config.centerUrls?.internet)
const endpoints = []
if (config.connectionMode !== 'internet' && intranet) endpoints.push({ kind: 'INTRANET', url: intranet })
if (config.connectionMode !== 'intranet' && internet) endpoints.push({ kind: 'INTERNET', url: internet })
if (endpoints.length === 0) throw new Error('当前连接模式没有可用的 centerUrls 地址')
return endpoints
}
export async function postToCenter(config, route, payload) {
let lastError = null
for (const endpoint of resolveCenterEndpoints(config)) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs)
try {
const response = await fetch(`${endpoint.url}${route}`, {
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${config.agentToken}`,
'x-script-monitor-agent-id': config.agentId,
},
body: JSON.stringify({
...payload,
...(route.endsWith('/report')
? { connectionKind: payload.connectionKind ?? endpoint.kind }
: {}),
}),
})
if (!response.ok) {
const message = await response.text().catch(() => '')
throw new Error(`HTTP ${response.status}${message ? `:${message.slice(0, 240)}` : ''}`)
}
return { endpoint, body: await response.json().catch(() => null) }
} catch (error) {
lastError = error
} finally {
clearTimeout(timeout)
}
}
throw lastError ?? new Error('没有可用的中心地址')
}
function hashKey(value) {
return createHash('sha256').update(value).digest('hex').slice(0, 20)
}
function redactCommand(command) {
return String(command || '')
.replace(/([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY)[A-Z0-9_]*=)([^\s]+)/gi, '$1[REDACTED]')
.replace(/(https?:\/\/[^:/\s]+:)[^@/\s]+@/gi, '$1[REDACTED]@')
.slice(0, 2000)
}
function readPm2() {
try {
const raw = execFileSync('pm2', ['jlist'], { encoding: 'utf8', timeout: 5000 })
return JSON.parse(raw)
} catch {
return []
}
}
function scanPm2() {
return readPm2().map((processInfo) => {
const name = String(processInfo.name || '未命名 PM2 进程')
const status = processInfo.pm2_env?.status === 'online'
? 'RUNNING'
: processInfo.pm2_env?.status === 'stopped'
? 'DISABLED'
: processInfo.pm2_env?.status === 'errored'
? 'FAILED'
: 'UNKNOWN'
return {
key: `pm2:${name}`,
name,
taskDescription: '',
jobType: 'PM2',
schedule: null,
commandPreview: redactCommand(processInfo.pm2_env?.pm_exec_path || ''),
expectedIntervalSec: null,
status,
isEnabled: status !== 'DISABLED',
canManualRun: false,
lastStartedAt: processInfo.pm2_env?.pm_uptime
? new Date(processInfo.pm2_env.pm_uptime).toISOString()
: null,
lastResultSummary: status === 'RUNNING'
? `PM2 在线,PID ${processInfo.pid || '-'}`
: `PM2 状态:${processInfo.pm2_env?.status || 'unknown'}`,
}
})
}
function parseCronLine(line) {
const value = line.trim()
if (!value || value.startsWith('#')) return null
const special = value.match(/^@(reboot|yearly|annually|monthly|weekly|daily|hourly)\s+(.+)$/)
const regular = value.match(/^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$/)
const schedule = special ? `@${special[1]}` : regular?.[1]
const command = special?.[2] ?? regular?.[2]
if (!schedule || !command) return null
const tag = command.match(/#\s*([\w.-]{3,})\s*$/)?.[1]
const name = tag || command.split(/\s+/).filter(Boolean).slice(-1)[0] || 'cron task'
return {
key: `cron:${hashKey(`${schedule}\n${command}`)}`,
name,
taskDescription: '',
jobType: 'CRON',
schedule,
commandPreview: redactCommand(command.replace(/\s+#.*$/, '')),
expectedIntervalSec: null,
status: 'UNKNOWN',
isEnabled: true,
canManualRun: false,
}
}
function scanCron() {
if (platform() === 'win32') return []
try {
const raw = execFileSync('crontab', ['-l'], { encoding: 'utf8', timeout: 5000 })
return raw.split(/\r?\n/).map(parseCronLine).filter(Boolean)
} catch {
return []
}
}
function checkProcess(name) {
try {
if (platform() === 'win32') {
const output = execFileSync('tasklist', ['/FI', `IMAGENAME eq ${name}`], { encoding: 'utf8', timeout: 4000 })
return output.toLowerCase().includes(String(name).toLowerCase())
}
execFileSync('pgrep', ['-f', '--', name], { stdio: 'ignore', timeout: 4000 })
return true
} catch {
return false
}
}
function checkConfiguredScript(script) {
if (script.enabled === false) return { status: 'DISABLED', summary: '监控已停用' }
const check = script.resultCheck
if (!check) return { status: 'UNKNOWN', summary: null }
if (check.type === 'file') {
try {
const stat = statSync(requiredText(check.path, 'resultCheck.path'))
const ageSec = Math.max(0, Math.round((Date.now() - stat.mtimeMs) / 1000))
const fresh = !check.maxAgeSec || ageSec <= Number(check.maxAgeSec)
const largeEnough = check.minBytes == null || stat.size >= Number(check.minBytes)
return {
status: fresh && largeEnough ? 'SUCCESS' : 'FAILED',
summary: `文件 ${stat.size} B,${ageSec} 秒前更新`,
}
} catch (error) {
return { status: 'FAILED', summary: `文件检查失败:${error.message}` }
}
}
if (check.type === 'systemd') {
try {
const service = requiredText(check.service, 'resultCheck.service')
const state = execFileSync('systemctl', ['is-active', service], { encoding: 'utf8', timeout: 4000 }).trim()
return { status: state === 'active' ? 'RUNNING' : 'FAILED', summary: `systemd:${state}` }
} catch {
return { status: 'FAILED', summary: 'systemd 服务未运行' }
}
}
if (check.type === 'process') {
const running = checkProcess(requiredText(check.name, 'resultCheck.name'))
return { status: running ? 'RUNNING' : 'FAILED', summary: running ? '进程正在运行' : '未找到目标进程' }
}
return { status: 'UNKNOWN', summary: '未识别的结果检查类型' }
}
function normalizeConfiguredScript(script) {
const key = requiredText(script.key, 'scripts[].key')
const result = checkConfiguredScript(script)
return {
key,
name: requiredText(script.name, `scripts[${key}].name`),
taskDescription: String(script.taskDescription || '').slice(0, 2000),
jobType: String(script.jobType || 'OTHER').toUpperCase(),
schedule: script.schedule ? String(script.schedule).slice(0, 240) : null,
commandPreview: redactCommand(script.commandPreview || script.command || ''),
expectedIntervalSec: script.expectedIntervalSec ? Number(script.expectedIntervalSec) : null,
status: result.status,
isEnabled: script.enabled !== false,
canManualRun: script.allowManualRun === true && Boolean(script.command),
lastResultSummary: result.summary,
}
}
export function collectScripts(config) {
const byKey = new Map()
if (config.autoDiscover?.cron !== false) {
for (const script of scanCron()) byKey.set(script.key, script)
}
if (config.autoDiscover?.pm2 !== false) {
for (const script of scanPm2()) byKey.set(script.key, script)
}
for (const script of config.scripts) {
const normalized = normalizeConfiguredScript(script)
byKey.set(normalized.key, { ...(byKey.get(normalized.key) || {}), ...normalized })
}
return [...byKey.values()]
}
export async function reportSnapshot(config) {
const scripts = collectScripts(config)
const result = await postToCenter(config, '/api/internal/script-monitor/report', {
hostname: hostname(),
os: `${platform()} ${release()}`,
agentVersion: AGENT_VERSION,
reportIntervalSec: config.reportIntervalSec,
scripts,
})
console.log(`[script-monitor] 已通过${result.endpoint.kind === 'INTRANET' ? '内网' : '公网'}上报 ${scripts.length} 个脚本`)
}
function keepTail(current, chunk, limit = 12000) {
const next = current + chunk.toString()
return next.length > limit ? next.slice(-limit) : next
}
async function reportManualRun(config, command, input) {
return postToCenter(config, '/api/internal/script-monitor/run', {
commandId: command.id,
scriptKey: command.scriptKey,
scriptName: command.scriptName,
taskDescription: command.taskDescription || '',
jobType: String(command.jobType || 'OTHER').toUpperCase(),
...input,
})
}
export async function executeManualCommand(config, command) {
const script = config.scripts.find((item) => item.key === command.scriptKey)
const startedAt = new Date()
const externalRunId = `manual:${command.id}`
if (!script?.command || script.allowManualRun !== true) {
const message = 'Agent 配置未开放该脚本的手动运行权限'
await reportManualRun(config, command, {
externalRunId,
status: 'FAILED',
trigger: 'manual',
startedAt: startedAt.toISOString(),
finishedAt: new Date().toISOString(),
durationMs: 0,
exitCode: 1,
resultSummary: message,
errorTail: message,
})
return
}
await reportManualRun(config, command, {
externalRunId,
status: 'RUNNING',
trigger: 'manual',
startedAt: startedAt.toISOString(),
})
let outputTail = ''
let errorTail = ''
try {
const child = spawn(script.command, {
cwd: script.cwd ? resolve(script.cwd) : process.cwd(),
env: process.env,
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout.on('data', (chunk) => {
outputTail = keepTail(outputTail, chunk)
process.stdout.write(chunk)
})
child.stderr.on('data', (chunk) => {
errorTail = keepTail(errorTail, chunk)
process.stderr.write(chunk)
})
const exitCode = await new Promise((resolveExit, reject) => {
child.once('error', reject)
child.once('close', (code) => resolveExit(code ?? 1))
})
const finishedAt = new Date()
await reportManualRun(config, command, {
externalRunId,
status: exitCode === 0 ? 'SUCCESS' : 'FAILED',
trigger: 'manual',
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
exitCode,
resultSummary: exitCode === 0 ? '手动运行成功' : `手动运行失败,退出码 ${exitCode}`,
outputTail,
errorTail,
})
} catch (error) {
const finishedAt = new Date()
await reportManualRun(config, command, {
externalRunId,
status: 'FAILED',
trigger: 'manual',
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
exitCode: 1,
resultSummary: `手动运行启动失败:${error.message}`,
errorTail: error.message,
})
}
}
export async function pollManualCommand(config) {
const result = await postToCenter(
config,
'/api/internal/script-monitor/commands/claim',
{},
)
const command = result.body?.data?.command
if (!command) return false
await executeManualCommand(config, command)
return true
}
async function main() {
const config = loadConfig()
const run = async () => {
try {
await reportSnapshot(config)
} catch (error) {
console.error('[script-monitor] 上报失败:', error.message)
}
}
let commandBusy = false
const poll = async () => {
if (commandBusy) return
commandBusy = true
try {
await pollManualCommand(config)
} catch (error) {
console.error('[script-monitor] 手动任务轮询失败:', error.message)
} finally {
commandBusy = false
}
}
await run()
await poll()
const timer = setInterval(run, config.reportIntervalSec * 1000)
const commandTimer = setInterval(poll, config.commandPollIntervalSec * 1000)
const stop = () => {
clearInterval(timer)
clearInterval(commandTimer)
process.exit(0)
}
process.on('SIGINT', stop)
process.on('SIGTERM', stop)
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch((error) => {
console.error('[script-monitor] 启动失败:', error.message)
process.exit(1)
})
}

View File

@ -1,79 +0,0 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { executeManualCommand } from './agent.mjs'
function config(overrides = {}) {
return {
agentId: 'agent-1',
agentToken: 'secret-token',
connectionMode: 'internet',
centerUrls: { internet: 'https://monitor.example.test' },
requestTimeoutMs: 5000,
scripts: [{
key: 'local-check',
name: '本机检查',
taskDescription: '验证手动触发',
jobType: 'MANUAL',
allowManualRun: true,
command: `${JSON.stringify(process.execPath)} -e "console.log('manual-ok')"`,
}],
...overrides,
}
}
function command() {
return {
id: 'command-1',
scriptKey: 'local-check',
scriptName: '本机检查',
taskDescription: '验证手动触发',
jobType: 'MANUAL',
}
}
test('manual command executes only the local configured command and reports its result', async () => {
const previousFetch = globalThis.fetch
const reports = []
globalThis.fetch = async (_url, init) => {
reports.push(JSON.parse(init.body))
return new Response(JSON.stringify({ success: true, data: { ok: true } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
try {
await executeManualCommand(config(), command())
} finally {
globalThis.fetch = previousFetch
}
assert.equal(reports.length, 2)
assert.equal(reports[0].status, 'RUNNING')
assert.equal(reports[1].status, 'SUCCESS')
assert.equal(reports[1].exitCode, 0)
assert.match(reports[1].outputTail, /manual-ok/)
assert.equal(reports[1].commandId, 'command-1')
})
test('manual command is rejected when allowManualRun is not explicitly enabled', async () => {
const previousFetch = globalThis.fetch
const reports = []
globalThis.fetch = async (_url, init) => {
reports.push(JSON.parse(init.body))
return new Response(JSON.stringify({ success: true, data: { ok: true } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
}
const blockedConfig = config()
blockedConfig.scripts[0].allowManualRun = false
try {
await executeManualCommand(blockedConfig, command())
} finally {
globalThis.fetch = previousFetch
}
assert.equal(reports.length, 1)
assert.equal(reports[0].status, 'FAILED')
assert.match(reports[0].resultSummary, /未开放/)
})

View File

@ -1,36 +0,0 @@
{
"agentId": "PASTE_AGENT_ID_HERE",
"agentToken": "PASTE_AGENT_TOKEN_HERE",
"agentName": "仓库服务器 01",
"connectionMode": "auto",
"centerUrls": {
"intranet": "http://10.0.0.20:3001",
"internet": "https://cp.jiansun.vip"
},
"reportIntervalSec": 60,
"commandPollIntervalSec": 5,
"requestTimeoutMs": 10000,
"autoDiscover": {
"cron": true,
"pm2": true
},
"scripts": [
{
"key": "supply-chain-archive-sync",
"name": "供应链产品档案同步",
"taskDescription": "同步聚水潭商品档案、仓库和供应商扩展字段",
"jobType": "CRON",
"schedule": "*/15 * * * *",
"expectedIntervalSec": 900,
"allowManualRun": true,
"command": "npm run sync:supply-chain-product-archive",
"cwd": "/opt/furniture-3d-system-prod/current",
"resultCheck": {
"type": "file",
"path": "/opt/furniture-3d-system-prod/shared/logs/product-archive-sync.log",
"maxAgeSec": 1800,
"minBytes": 1
}
}
]
}

View File

@ -1,15 +0,0 @@
module.exports = {
apps: [{
name: 'furniture-script-monitor-agent',
script: 'agent.mjs',
cwd: __dirname,
instances: 1,
exec_mode: 'fork',
autorestart: true,
watch: false,
env: {
NODE_ENV: 'production',
SCRIPT_MONITOR_CONFIG: `${__dirname}/config.json`,
},
}],
}

View File

@ -1,82 +0,0 @@
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { resolve } from 'node:path'
import { loadConfig, postToCenter } from './agent.mjs'
function keepTail(current, chunk, limit = 12000) {
const next = current + chunk.toString()
return next.length > limit ? next.slice(-limit) : next
}
async function sendRun(config, script, input) {
return postToCenter(config, '/api/internal/script-monitor/run', {
scriptKey: script.key,
scriptName: script.name,
taskDescription: script.taskDescription || '',
jobType: String(script.jobType || 'OTHER').toUpperCase(),
...input,
})
}
async function main() {
const scriptKey = process.argv[2]
if (!scriptKey) throw new Error('用法:node run-script.mjs <script-key>')
const config = loadConfig()
const script = config.scripts.find((item) => item.key === scriptKey)
if (!script) throw new Error(`配置中不存在脚本:${scriptKey}`)
if (!script.command) throw new Error(`脚本 ${scriptKey} 未配置 command`)
const externalRunId = `${scriptKey}:${randomUUID()}`
const startedAt = new Date()
await sendRun(config, script, {
externalRunId,
status: 'RUNNING',
trigger: 'wrapper',
startedAt: startedAt.toISOString(),
}).catch((error) => {
console.warn('[script-monitor-run] 开始状态上报失败,不阻断原脚本:', error.message)
})
let outputTail = ''
let errorTail = ''
const child = spawn(script.command, {
cwd: script.cwd ? resolve(script.cwd) : process.cwd(),
env: process.env,
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout.on('data', (chunk) => {
outputTail = keepTail(outputTail, chunk)
process.stdout.write(chunk)
})
child.stderr.on('data', (chunk) => {
errorTail = keepTail(errorTail, chunk)
process.stderr.write(chunk)
})
const exitCode = await new Promise((resolveExit, reject) => {
child.once('error', reject)
child.once('close', (code) => resolveExit(code ?? 1))
})
const finishedAt = new Date()
await sendRun(config, script, {
externalRunId,
status: exitCode === 0 ? 'SUCCESS' : 'FAILED',
trigger: 'wrapper',
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
exitCode,
resultSummary: exitCode === 0 ? '脚本执行成功' : `脚本退出码 ${exitCode}`,
outputTail,
errorTail,
}).catch((error) => {
console.warn('[script-monitor-run] 运行结果上报失败,保留原退出码:', error.message)
})
process.exitCode = exitCode
}
main().catch((error) => {
console.error('[script-monitor-run] 执行失败:', error.message)
process.exitCode = 1
})

View File

@ -1,257 +0,0 @@
{
"version": 1,
"generatedAt": "2026-10-26T08:52:26.453Z",
"sourceFile": "各省发货占比.xlsx",
"totalShipment": 10983200,
"rowCount": 31,
"rows": [
{
"province": "安徽",
"shipmentCount": 426181,
"totalShipment": 10983200,
"shipmentShare": 0.038803,
"region": "一区",
"regionShare": 0.214
},
{
"province": "江苏",
"shipmentCount": 800646,
"totalShipment": 10983200,
"shipmentShare": 0.072897,
"region": "一区",
"regionShare": 0.214
},
{
"province": "上海",
"shipmentCount": 325564,
"totalShipment": 10983200,
"shipmentShare": 0.029642,
"region": "一区",
"regionShare": 0.214
},
{
"province": "浙江",
"shipmentCount": 797004,
"totalShipment": 10983200,
"shipmentShare": 0.072566,
"region": "一区",
"regionShare": 0.214
},
{
"province": "北京",
"shipmentCount": 290861,
"totalShipment": 10983200,
"shipmentShare": 0.026482,
"region": "二区",
"regionShare": 0.485
},
{
"province": "福建",
"shipmentCount": 467880,
"totalShipment": 10983200,
"shipmentShare": 0.0426,
"region": "二区",
"regionShare": 0.485
},
{
"province": "广东",
"shipmentCount": 1820992,
"totalShipment": 10983200,
"shipmentShare": 0.165798,
"region": "二区",
"regionShare": 0.485
},
{
"province": "河北",
"shipmentCount": 424406,
"totalShipment": 10983200,
"shipmentShare": 0.038641,
"region": "二区",
"regionShare": 0.485
},
{
"province": "河南",
"shipmentCount": 546529,
"totalShipment": 10983200,
"shipmentShare": 0.04976,
"region": "二区",
"regionShare": 0.485
},
{
"province": "湖北",
"shipmentCount": 378469,
"totalShipment": 10983200,
"shipmentShare": 0.034459,
"region": "二区",
"regionShare": 0.485
},
{
"province": "湖南",
"shipmentCount": 386336,
"totalShipment": 10983200,
"shipmentShare": 0.035175,
"region": "二区",
"regionShare": 0.485
},
{
"province": "江西",
"shipmentCount": 271676,
"totalShipment": 10983200,
"shipmentShare": 0.024736,
"region": "二区",
"regionShare": 0.485
},
{
"province": "山东",
"shipmentCount": 626419,
"totalShipment": 10983200,
"shipmentShare": 0.057034,
"region": "二区",
"regionShare": 0.485
},
{
"province": "天津",
"shipmentCount": 115475,
"totalShipment": 10983200,
"shipmentShare": 0.010514,
"region": "二区",
"regionShare": 0.485
},
{
"province": "广西",
"shipmentCount": 397325,
"totalShipment": 10983200,
"shipmentShare": 0.036176,
"region": "三区",
"regionShare": 0.251
},
{
"province": "贵州",
"shipmentCount": 242963,
"totalShipment": 10983200,
"shipmentShare": 0.022121,
"region": "三区",
"regionShare": 0.251
},
{
"province": "黑龙",
"shipmentCount": 166557,
"totalShipment": 10983200,
"shipmentShare": 0.015165,
"region": "三区",
"regionShare": 0.251
},
{
"province": "吉林",
"shipmentCount": 135735,
"totalShipment": 10983200,
"shipmentShare": 0.012358,
"region": "三区",
"regionShare": 0.251
},
{
"province": "辽宁",
"shipmentCount": 257754,
"totalShipment": 10983200,
"shipmentShare": 0.023468,
"region": "三区",
"regionShare": 0.251
},
{
"province": "山西",
"shipmentCount": 177480,
"totalShipment": 10983200,
"shipmentShare": 0.016159,
"region": "三区",
"regionShare": 0.251
},
{
"province": "陕西",
"shipmentCount": 281956,
"totalShipment": 10983200,
"shipmentShare": 0.025672,
"region": "三区",
"regionShare": 0.251
},
{
"province": "四川",
"shipmentCount": 532179,
"totalShipment": 10983200,
"shipmentShare": 0.048454,
"region": "三区",
"regionShare": 0.251
},
{
"province": "云南",
"shipmentCount": 341852,
"totalShipment": 10983200,
"shipmentShare": 0.031125,
"region": "三区",
"regionShare": 0.251
},
{
"province": "重庆",
"shipmentCount": 222288,
"totalShipment": 10983200,
"shipmentShare": 0.020239,
"region": "三区",
"regionShare": 0.251
},
{
"province": "甘肃",
"shipmentCount": 144419,
"totalShipment": 10983200,
"shipmentShare": 0.013149,
"region": "四区",
"regionShare": 0.05
},
{
"province": "海南",
"shipmentCount": 148380,
"totalShipment": 10983200,
"shipmentShare": 0.01351,
"region": "四区",
"regionShare": 0.05
},
{
"province": "内蒙",
"shipmentCount": 166636,
"totalShipment": 10983200,
"shipmentShare": 0.015172,
"region": "四区",
"regionShare": 0.05
},
{
"province": "宁夏",
"shipmentCount": 47493,
"totalShipment": 10983200,
"shipmentShare": 0.004324,
"region": "四区",
"regionShare": 0.05
},
{
"province": "青海",
"shipmentCount": 39031,
"totalShipment": 10983200,
"shipmentShare": 0.003554,
"region": "四区",
"regionShare": 0.05
},
{
"province": "西藏",
"shipmentCount": 441,
"totalShipment": 10983200,
"shipmentShare": 0.00004,
"region": "未分区",
"regionShare": 0.0002471046689489402
},
{
"province": "新疆",
"shipmentCount": 2273,
"totalShipment": 10983200,
"shipmentShare": 0.000207,
"region": "未分区",
"regionShare": 0.0002471046689489402
}
]
}

View File

@ -1,18 +0,0 @@
[Unit]
Description=Furniture JST logistics rule daily snapshot
Wants=network-online.target furniture-jst-rpa-browser.service
After=network-online.target furniture-jst-rpa-browser.service
Conflicts=furniture-discontinuation-daily.service plm-barcode-sync.service
[Service]
Type=oneshot
WorkingDirectory=/opt/furniture-3d-system-prod/current/furniture-3d-system
Environment=NODE_ENV=production
Environment=TZ=Asia/Shanghai
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=JST_RPA_BROWSER_URL=http://127.0.0.1:9222
Environment=FREIGHT_CONFIG_DATA_DIR=/opt/furniture-3d-system-prod/shared/freight-config
EnvironmentFile=/opt/furniture-3d-system-prod/shared/.env
ExecStart=/usr/bin/env node node_modules/tsx/dist/cli.mjs scripts/sync-jst-logistics-rules.ts
TimeoutStartSec=30min
Nice=5

View File

@ -1,11 +0,0 @@
[Unit]
Description=Run JST logistics rule snapshot daily
[Timer]
OnCalendar=*-*-* 09:30:00 Asia/Shanghai
Persistent=true
AccuracySec=30s
Unit=furniture-jst-logistics-snapshot.service
[Install]
WantedBy=timers.target

View File

@ -1,19 +0,0 @@
[Unit]
Description=Furniture supply-chain daily limit price snapshot
Wants=network-online.target furniture-jst-rpa-browser.service
After=network-online.target furniture-jst-rpa-browser.service furniture-jst-logistics-snapshot.service
[Service]
Type=oneshot
WorkingDirectory=/opt/furniture-3d-system-prod/current/furniture-3d-system
Environment=NODE_ENV=production
Environment=TZ=Asia/Shanghai
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Environment=JST_RPA_BROWSER_URL=http://127.0.0.1:9222
Environment=FREIGHT_CONFIG_DATA_DIR=/opt/furniture-3d-system-prod/shared/freight-config
Environment=SUPPLY_CHAIN_LIMIT_SNAPSHOT_DIR=/opt/furniture-3d-system-prod/shared/limit-price-snapshots
Environment=LIMIT_SYNC_SKIP_EXCEL=1
EnvironmentFile=/opt/furniture-3d-system-prod/shared/.env
ExecStart=/usr/bin/env node scripts/run-limit-price-daily-snapshot.mjs
TimeoutStartSec=2h
Nice=10

View File

@ -1,12 +0,0 @@
[Unit]
Description=Run furniture supply-chain daily limit price snapshot
[Timer]
OnCalendar=*-*-* 09:30:00 Asia/Shanghai
Persistent=true
RandomizedDelaySec=60
AccuracySec=30s
Unit=furniture-limit-price-snapshot.service
[Install]
WantedBy=timers.target

View File

@ -1,109 +0,0 @@
# 1688代发异常事项:退货地址推送接口
## 交付与启用
本接口供外部系统按 SPU 推送退货地址。正式站发布包含该接口的版本并配置独立密钥后,对方即可按本文调用。
部署人员在生产运行环境配置 `DROPSHIP_RETURN_ADDRESS_API_KEY`,使用独立随机密钥(建议至少32个随机字节),由双方通过安全渠道交接;密钥不进入 Git、对接文档、URL 或普通日志。未配置时接口返回503。无需提供数据库账号,无需平台用户登录。
## 请求
- 方法:`POST`
- 正式地址:`https://cp.jiansun.vip/api/external/supply-chain/dropship-return-addresses`
- 本地联调:`http://localhost:3001/api/external/supply-chain/dropship-return-addresses`
- 请求头:`Content-Type: application/json`、`x-api-key: <对接密钥>`
- 每批1–100条;单条推送也使用 `items` 数组。
```json
{
"items": [
{
"spu": "DF-C1YMJ0009",
"returnAddress": "收件人:张三;电话:138xxxxxxxx;地址:江苏省……"
}
]
}
```
| 字段 | 必填 | 规则 |
| --- | --- | --- |
| `spu` | 是 | 1–120位字母、数字、下划线或连字符;自动去除首尾空格并转为大写 |
| `returnAddress` | 是 | 去除首尾空格后1–500字;收件人、电话、详细地址合并为一段文字 |
仅接收上述字段。额外传入发票类型、安装方式等字段,该条会失败,不会改动这些字段。
调用示例(密钥由调用方运行环境注入):
```bash
curl --request POST \
'https://cp.jiansun.vip/api/external/supply-chain/dropship-return-addresses' \
--header 'Content-Type: application/json' \
--header "x-api-key: ${DROPSHIP_RETURN_ADDRESS_API_KEY}" \
--data '{"items":[{"spu":"DF-C1YMJ0009","returnAddress":"收件人:张三;电话:138xxxxxxxx;地址:江苏省……"}]}'
```
## 写入规则
1. 一SPU一退货地址,同SPU下所有SKU共用;按档案中的DF代发范围核验,不受当月业绩筛选影响。
2. SPU必须已存在于产品档案。没有维护记录时自动创建维护记录;不会创建产品档案。
3. 新地址覆盖该SPU原有地址(包括人工填写的地址),以数据库实际处理顺序为准。
4. 只更新退货地址和维护人/时间,保留发票类型、整装/拆装;新增维护记录时其他字段使用原有空值默认值。
5. 空地址拒绝,不能通过这个接口清空地址。重复推送同一地址返回 `UNCHANGED`,不重复写入或变更时间。
6. 同批重复SPU(含大小写/首尾空格差异)的重复项全部失败,其余条目继续处理;每批按输入顺序返回结果。
7. 每条独立保存,某条失败不撤销已经成功的条目。实际地址变化写入1688代发异常事项操作日志,标记“外部退货地址同步”,包含前后值与请求编号。
8. 当前页面默认只显示有业绩SPU;无业绩产品推送成功后,切换“全部SPU”查看。
## 响应
HTTP200表示批次已处理,**仍须检查 `data.failedCount` 和每条 `status`**。
```json
{
"success": true,
"data": {
"total": 2,
"successCount": 1,
"failedCount": 1,
"updatedCount": 1,
"unchangedCount": 0,
"results": [
{
"index": 1,
"spu": "DF-C1YMJ0009",
"status": "UPDATED",
"updatedAt": "2026-10-24T04:00:00.000Z"
},
{
"index": 2,
"spu": "DF-MISSING",
"status": "FAILED",
"code": "SPU_NOT_FOUND",
"message": "产品档案中不存在该代发 SPU"
}
]
},
"meta": {
"requestId": "服务端请求编号",
"timestamp": "2026-10-24T04:00:00.000Z"
}
}
```
`index` 从1开始,对应请求数组顺序;`UPDATED` 表示新增或更新成功,`UNCHANGED` 表示地址已一致。两种均计入 `successCount`。时间使用UTC ISO8601格式。
| 每条失败码 | 含义与处理 |
| --- | --- |
| `INVALID_ITEM` | 字段格式、地址为空/过长或包含额外字段;修正数据后重推 |
| `DUPLICATE_SPU` | 同批存在重复SPU;去重后重推 |
| `SPU_NOT_FOUND` | 不存在对应DF代发档案;核对SPU或等待档案同步后重推 |
| `WRITE_FAILED` | 保存失败;重试该条,持续失败时附 `meta.requestId` 联系管理员 |
| HTTP状态 | 含义 |
| --- | --- |
| 200 | 批次已处理,检查逐条结果 |
| 400 | 非法JSON、缺少items、空批次、超过100条或多余顶层字段;整批不写入 |
| 401 | 缺少密钥或密钥错误;整批不写入 |
| 503 | 服务端未配置对接密钥;整批不写入 |
| 500 | 系统错误;提供请求编号排查 |
网络超时或500时可能已有部分条目保存。可重推相同地址;如果期间出现更新,应重新取得上游最新地址再推送,避免旧批次覆盖新地址。调用方不应并发推送同一SPU。

View File

@ -1,153 +0,0 @@
# 财务系统店铺密码直接显示契约
状态:生产接口已上线;2026-09-09 按当前明文存储规则修正 `passwordConfigured`。
## 存储事实与授权边界
- 密码真相源是 `public."StoreAccount"."passwordCiphertext"`。字段名是历史遗留;当前规则将新值以明文保存,读取链路对历史 `v1:` AES-256-GCM 值保留兼容。
- 存量 `v1:` 值已由 `scripts/migrate-store-account-plaintext-20260908.js` 迁移。`STORE_ACCOUNT_ENCRYPTION_KEY` 仅用于可能残留或恢复出的历史 `v1:` 值,不参与当前明文值读取。
- `passwordConfigured` 只表示存储值是否非空,不校验字符集或密码强度;因此包含 `+`、`/`、`=` 等字符的密码也会正确返回 true。它不返回密码本身。
- 普通 `store_account_reader` 可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。
- 本接口只信任专用服务身份 `finance-store-accounts`。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取 `requesterId`。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。
- 不提供密码列表或批量导出。财务页面可在已通过自身页面权限校验后,自动读取当前可见页最多 50 个账号;CSV 和共享镜像仍只读取 `passwordConfigured`。复制操作继续走单账号接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。
## HTTP 契约
生产配置完成后的地址:
`POST https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal`
`POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal`
`id` 为 `accounts_v1.id`,仅接受单个 1–128 位字母、数字、下划线或连字符。无查询参数;禁止在 URL、请求体、请求头中传待读取密码。
请求头:
```http
Authorization: Bearer <专用服务令牌>
Content-Type: application/json
```
请求体(严格拒绝额外字段,最大 1024 字节,1 秒内读取完毕):
```json
{ "requesterId": "finance-user-123", "purpose": "view" }
```
`purpose` 仅允许 `view` 或 `copy`。`requesterId` 是财务会话中的稳定人员标识,1–128 位 `[A-Za-z0-9_.:@-]`。不接受批量 ID、查询参数、普通登录 Cookie/JWT、普通数据库凭据或其他内部任务密钥。
当前页批量请求严格只接受以下字段,正文最大 8192 字节;`accountIds` 为 1 至 50 个互不重复且符合单账号 ID 规则的值:
```json
{ "requesterId": "finance-user-123", "purpose": "view", "accountIds": ["account-1", "account-2"] }
```
成功 `200`:
```json
{ "requestId": "<服务端生成的 UUID>", "password": "<仅本次响应体中的密码>" }
```
当前页批量成功的 HTTP 状态为 `200`,每个账号分别返回 `200/404/429/503`。每项都有独立 UUID;只有状态 `200` 的项包含密码,并按请求顺序完整返回:
```json
{
"requestId": "<批次 UUID>",
"items": [
{ "accountId": "account-1", "requestId": "<项目 UUID>", "status": 200, "password": "<密码>" },
{ "accountId": "account-2", "requestId": "<项目 UUID>", "status": 404 }
]
}
```
所有状态均返回 `Cache-Control: no-store`、`Pragma: no-cache`、`X-Content-Type-Options: nosniff`。`X-Request-Id` 与响应体、审计的追踪 ID 相同。不会在响应头中放密码。
错误体:
```json
{ "requestId": "<UUID>", "error": { "code": "FORBIDDEN", "message": "请求被拒绝" } }
```
| HTTP | code | 含义 |
|---|---|---|
| 403 | FORBIDDEN | 服务认证失败、非 HTTPS、非法 ID/JSON/用途、额外参数或非 POST |
| 404 | PASSWORD_NOT_FOUND | 账号不存在或没有可用密码,两者不区分 |
| 429 | RATE_LIMITED | 任一限流窗口超限 |
| 503 | UNAVAILABLE | 密钥/解密、数据库、限流或审计不可用;不返回内部异常 |
审计或数据库故障优先返回 503,绝不在无法持久化成功审计时返回密码。数据库完全不可用时无法保存该次失败审计,由网关仅记录追踪 ID、状态与时间供运维关联。成功审计表示服务器已授权并生成响应,不等同于浏览器已经复制成功。
## 限流和超时
- 单账号数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。公网 TLS 网关保留 1 KiB 敏感请求正文上限,财务端把 50 行当前页拆成两个最多 25 个账号的批次;批次标记因此按服务 20 次/分钟、1000 次/24 小时、同一人员 10 次/分钟限流,等价于原每页额度。批次中的每个账号仍受同一账号 5 次/分钟限制。查看和复制共享账号额度,轮换令牌不重置服务身份或额度。
- 已认证的成功、404、参数拒绝和解密失败均占额度;429 不延长窗口。额度检查与审计在同一数据库事务中用事务级锁串行化,跨进程/重启共享状态。
- 429 不携带剩余数量或精确窗口,避免推测其他用户行为。客户端不得自动重试密码读取;提示用户稍后再试,持续超限联系管理员。
- 财务客户端设置总超时 10 秒、禁用自动重定向和重试。后端池上限 4、连接获取 1 秒、SQL 执行 1.5 秒/客户端查询 2 秒、锁等待 1 秒,路由运行预算 10 秒。网关必须配置请求体 1 秒和总请求截止,防止慢请求占用。
- 入口网关还需按来源 IP 对全部请求(包括未授权请求)限制为 60 次/分钟,返回 429 JSON 和 `no-store`;这项入口防滥用配置属于上线门禁。应用层数据库限流负责已认证的敏感读取。
## 环境变量(仅名称及要求)
| 名称 | 所在系统与要求 |
|---|---|
| STORE_ACCOUNT_ENCRYPTION_KEY | 仅用于兼容历史 `v1:` 密文,至少 32 字符;生产不回退到 JWT_SECRET |
| STORE_ACCOUNT_FINANCE_TOKEN | 上游专用服务令牌,密码学随机至少 32 字节,base64url 编码,43–128 字符;缺失时拒绝全部调用 |
| STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS | 上游可选旧令牌,仅用于短时轮换;主令牌缺失时旧令牌也不生效 |
| STORE_ACCOUNT_REVEAL_EDGE_TOKEN | 上游与可信 TLS 网关之间的独立随机凭据,base64url 43–128 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 |
| DATABASE_URL | 上游现有数据库连接;仅专用不输出查询/异常的连接池读取密文、写审计 |
| STORE_ACCOUNT_REVEAL_URL | 财务后端配置完整 HTTPS 地址模板 |
| STORE_ACCOUNT_REVEAL_VISIBLE_PAGE_URL | 财务后端配置当前页批量 HTTPS 地址;必须使用精确 `visible-page` 路径 |
| STORE_ACCOUNT_REVEAL_TOKEN | 财务后端保存与上游专用令牌对应的值 |
| STORE_REVEAL_TEST_ADMIN_URL | 仅本地集成测试;必须指向回环地址、有建测试库/角色权限的 PostgreSQL |
| STORE_REVEAL_TEST_POSTGRES_CONTAINER | 仅本地日志验收,指定本地 PostgreSQL 容器以只读扫描测试期间日志 |
## HTTPS、日志和缓存上线门禁
1. 财务后端至公开入口及跨主机的所有代理链路必须使用验证证书的 HTTPS,禁止 HTTP 跳转、跳过证书验证、明文 FRP/跨主机上游。当前 PR 不修改生产 TLS 拓扑;上线前逐跳核实,不满足即不启用令牌。
2. 应用不以 URL 或未经认证的转发头证明 TLS。必须由受控网关在实际 HTTPS 入口覆盖 `X-Forwarded-Proto: https` 及 `X-Store-Reveal-Edge-Token`(值来自独立 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN`),剥除客户端原有同名头;HTTP 入口直接拒绝,不能注入上述凭据。应用对网关凭据做定时安全比较,缺失/错误或协议不是 https 时拒绝。凭据仅上游和网关持有,不提供给财务系统;配置文件 0600,不通过 `nginx -T` 等诊断输出展示。仍需封闭应用直连并逐跳验证 TLS,独立凭据不代替网络加密。
3. 对精确路径 `/internal/store-accounts/*/password/reveal` 禁止网关缓存、响应落盘缓冲、CDN缓存、请求/响应体及 Authorization 采集;包括 APM、WAF、异常上报、链路追踪和调试抓包。网关日志只允许时间、方法、路径(不带参数)、状态、耗时、追踪 ID。不要采集响应对象。
4. 网关自己的 403/404/429/503 也使用脱敏 JSON、`Cache-Control: no-store` 和追踪 ID;不得返回上游诊断页。禁止将该路由接入通用 `withApi`/request logger;目前路由直接返回白名单响应,不记录异常对象。
5. 数据库查询结果包含当前明文密码;严禁记录 SQL 结果、绑定参数或异常对象,不要启用该连接池的参数/结果追踪。密码写入及历史格式迁移期间同样关闭 PostgreSQL/pgAudit 的绑定参数和错误参数记录。
6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器在进入已授权页面后只读取当前可见页,避免 localStorage/sessionStorage/Service Worker 持久化;页面隐藏或离开时清除页面密码,不把密码放入通知、埋点或消息队列。
## 视图与审计迁移
初始迁移:`prisma/migrations/20260908120000_store_account_password_reveal/migration.sql`。明文状态修复:`prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql`。
- 新增 `StoreAccountPasswordAccessAudit`,固定字段为请求追踪 ID、人员标识、服务身份、账号 ID、用途、HTTP状态、数据库时间;不关联用户/店铺外键,以保留未授权、不存在和删除账号的失败审计。状态 200 表示成功,其他状态表示失败。
- 只在原视图存在时,以原视图查询作为子查询,在末尾追加 `passwordConfigured boolean`,保留原字段、筛选、每行内容、ID、所有者与 ACL;`EXISTS` 不会产生重复行。未配置外部视图的环境不擅自创建外部读入口。
- `passwordConfigured` 的当前定义为 `passwordCiphertext <> ''`。修复迁移仅替换这个布尔表达式,并在事务内核对原字段逐行内容、视图所有者和 ACL 未变;不读取或输出密码。
- 明文迁移已由独立脚本完成。本状态修复不改任何密码或银行账号数据,也不增加普通只读账号权限。审计数据至少保留 365 天。
迁移前由 DBA 只读确认目标库、视图定义/所有者/ACL、字段顺序、行数与唯一性、密码聚合、普通读取账号和其成员角色权限。若生产仍以本次基线验收,COUNT 与 DISTINCT id 必须均为 958;若业务新增/删除则暂停并重新确认基线,不改数据凑数。
备份与执行顺序(生产必须在明确批准后执行):
1. 备份当前 `main`/部署 SHA、现有视图 DDL/ACL/所有者及 `_prisma_migrations` 状态。生成目标库 `pg_dump -Fc` 备份并验证可读取;备份进入生产主机权限 0700 目录,文件 0600,不进入 Git 或共享日志。备份可能含密文,按敏感材料管理。
2. 本次不修改任何密码、银行卡号或服务令牌;备份不得进入 Git 或普通日志。
3. 执行明文状态修复迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒);不回放未审查的其他迁移。
4. 在同一只读快照比较迁移前后原字段行集、顺序/类型、行数/唯一性和权限;运行 `scripts/sql/check-store-account-password-reveal.sql`,确认最后字段 boolean,报告 true/false/null 聚合。预计当前数据为 957/1/0,但必须执行后实测。
5. 按项目主线发布流程部署,完成逐跳 TLS、网关日志/限流/缓存检查后再配令牌。生产虚构账号的创建、调用与删除属于额外生产写入,需单独批准;不要调用真实账号作为测试。
回滚:先移除财务令牌阻止新调用并回退应用 SHA。新增状态列与审计表可安全保留以避免丢失审计;旧应用不依赖它们。需要完整结构回退时,经 DBA 检查依赖后,在事务中以备份 DDL 重建旧视图并恢复原所有者/ACL(PostgreSQL 不允许 `CREATE OR REPLACE VIEW` 删除尾列,禁止随意 `DROP ... CASCADE`)。审计记录保留,不直接删表;按项目规则处理已应用迁移历史,禁止直接删除 `_prisma_migrations` 记录或用 `db push`。任何完整结构回退先在隔离库演练。
## 密钥轮换
服务令牌:在受限凭据系统生成新随机令牌;旧值暂放 `STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS`,新值放主变量,重启上游;更新财务后端,使用虚构账号验收;移除旧值并重启。两值对应同一服务身份,审计/额度不变。怀疑泄露时立即撤销旧值,宁可短暂拒绝请求。
网关凭据:暂停此接口,在网关及上游同时更新 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN` 后验收并恢复;不支持旧值并行。不得将网关凭据与服务令牌设成相同值。
历史兼容密钥:当前明文规则不需要为新值轮换数据密钥。若确认数据库中不存在 `v1:` 值并且备份保留策略允许,可另行评估退役历史解密密钥;退役前必须检查备份恢复场景,不能直接删除。
## 本次验证记录
- 当前页批量扩展的服务、HTTP 路由和代理边界共 3 个测试套件、41 项测试通过;`npm run type-check` 通过。测试覆盖 1–50 个账号、重复账号拒绝、部分 404/429/503、批次与每账号审计,以及失败时不返回明文。
- 单元/HTTP/代理路由测试:34 项通过,含伪造 HTTPS URL/转发头和错误网关凭据拒绝。
- 追加现有代理路由的 7 项回归,与 10 项集成测试合计 6 套件、51 项全部通过。
- 隔离本地 PostgreSQL 集成测试:10 项通过,覆盖完整加解密与 HTTP handler、已提交审计、403/404/429/no-store、并发/跨实例限流、分钟/24小时配额、审计失败拒绝泄露、原字段及筛选保留、958/958 与 boolean 957/1/0、普通只读角色拒绝基表/密码/审计读取。
- 开启本地测试连接的 PostgreSQL statement 日志,只在内存扫描 `docker logs`;应用日志、审计行、SQL请求与真实数据库日志均未检出随机测试密码。测试无需打印测试密码,断言只输出布尔结果。
- Type-check 通过;范围内 ESLint 通过。全仓库主数据边界巡检有 6 项已有违规,均位于本次未修改模块;不得据此声称全仓库通过。
- 2026-09-09 生产只读复核确认:958 行、958 个唯一 ID、957 条非空密码、1 条空密码;旧正则导致状态误报为全 false。本迁移用于恢复预期聚合 true/false/null = 957/1/0。
运行:`npx jest --runInBand --runTestsByPath src/server/services/store-account-password-reveal.service.test.ts src/proxy-store-account-reveal.test.ts "src/app/internal/store-accounts/[id]/password/reveal/route.test.ts" tests/integration/store-account-password-reveal.test.ts`。未配置本地测试库变量时,集成套件明确 skip,不能算真实数据库验收通过。
收口:**passed with warnings**。这是自审,不是独立复审。Route → Service → Repository 边界保持;未修改 UI;状态字段与当前明文存储规则对齐,专用认证、审计和限流边界不变。

View File

@ -1,101 +0,0 @@
# 1688 Dropship Integration
## Scope
The module at `/supply-chain/new-products/1688-dropship-data` includes the DF
product table, date and multi-select column filters, real-performance ordering,
and daily/weekly/monthly trend details with prior-month comparisons.
It reads the shared product archive and daily sales fact table. The shared
`barcode + statDate` key, unmatched source barcodes, synchronization lock,
staging, and atomic publication are preserved. No second daily table or
archive foreign key is introduced.
## Field Contract
| Display | Source / calculation |
| --- | --- |
| Sales amount | `sales_amount` -> daily `salesAmount` |
| Real performance | `real_performance` |
| After-sale amount | `after_sale_amount` |
| After-sale cost rate | After-sale amount / real performance; blank for zero denominator |
| Gross profit | Real performance - clear sales cost - send freight |
| SKU operator | DF barcode/day distinct `ider`, latest nonempty value in selected period |
| Total cost | Latest available daily `unitCost + estimatedFreight` in selected period; missing components are displayed as zero |
| Unit cost | Latest available daily DF cost from `goods_count = 1` observations; the existing product cost excludes freight; missing values are displayed as zero |
| Estimated freight | Average `send_freight` for the same DF barcode/day where `goods_count = 1`; the stored value remains null when no quantity-one observations exist and the page displays zero |
The archive's existing `shipAmount` continues to use `clear_goods_cost`.
It must not be substituted for the dropship module's `salesAmount`.
Cost ties use quantity-one frequency, total frequency, then descending unit cost. The page displays total cost before unit cost and estimated freight.
## Deployment Prerequisites
Code publication is not production deployment or database/data synchronization.
No credentials, database dumps, local accounts, or local environment files are
included in this change.
1. Confirm the target PostgreSQL identity, backup, and migration readiness using
the repository deployment/database workflow. Apply migration
`20260912120000_add_daily_sales_dropship_fields` after the existing mainline
daily-sales migration. Do not replay the old local prototype's daily-table
creation migrations against the mainline database.
2. Generate the Prisma client and build the application with the target's
existing environment configuration.
3. Run the existing `npm run sync:supply-chain-product-archive-daily --` command
with `--backfill`, `--apply`,
`--confirm=sync-supply-chain-product-archive-sales-performance-daily`,
`--expected-database-identity=...`, and
`--expected-source-database-identity=...` using verified identities.
The upstream requires read access to the configured
`JST_REPORT_DB_SCHEMA.dws_sn_sales_full_info_di`; credentials remain in the
target environment's `JST_REPORT_DB_*` variables.
4. Check the completed sales sync run, historic DF `salesAmount` completeness,
module view permission `newProduct1688DropshipData`, images, filters, totals,
and trend comparisons. The existing daily schedule then maintains the data.
The migrations add `skuOperator` (empty by default), `unitCost` (nullable),
`estimatedFreight` (nullable), and `salesAmount` (nullable). Historical facts need a backfill; the ordinary
seven-day refresh does not populate all older dates. Until backfilled, unknown
sales totals display as `--`, not zero or shipped amount.
Filtering uses the existing cache service with user-scoped keys and a 24-hour
TTL. Multi-process deployments should provide the existing shared Redis cache.
The module uses its own authenticated DF-only image route under
`/api/supply-chain/1688-dropship-data/source-image`. Its view permission does not
require granting access to the full product archive; the shared image service
and its source/URL restrictions are retained.
Rollback can restore the prior application version while retaining the additive
columns; do not drop the daily table or discard its history.
## Validation And Review
Independent review conclusion: `passed with warnings`. Architecture and
root-cause handling passed, including the corrected image-permission boundary.
The existing visual implementation was retained; target-environment screenshots
and real-data acceptance were not performed in this integration.
Final integration includes shared-mainline update `5156ead4`, retaining its
separate dropship-issues entry and permissions. After merging, 20 focused Jest
suites (171 tests), TypeScript checking, relevant ESLint, and the production
build passed. The build retains two unrelated broad-file-pattern warnings in
the existing OCR route and DWG wrapper.
The integration was checked with focused Jest suites for the page, trends,
filters, permissions, image route, service/repository mapping, and shared daily
sync; TypeScript checking; changed-file ESLint; and a production build using
isolated placeholder configuration (no live database). An in-memory PostgreSQL
compatibility replay confirmed that the additive migration retains existing
DF/non-DF facts and the original primary key. Image-route tests exercise the
real request/auth wrapper, including module-only access and denied requests.
The repository-wide `db:verify-master-data:code` and
`db:verify-master-data:truth` audits still fail on existing baseline issues.
Independent comparison with clean shared-mainline commit `38259cc4` found no
new blocking findings: code-audit finding content is unchanged apart from
schema line offsets, and the truth-audit JSON is identical. These global audit
failures are not presented as passing checks.
No target deployment, live target database migration/backfill, or live-data
browser validation of the integrated version is included in this publication.
The existing local test application and its prototype database are unchanged.

View File

@ -1,77 +1,64 @@
# 主线同步操作手册
适用对象:会用 Codex 协作、但不一定熟悉 Git 的同事。
目标:让每个分支都能跟上共享开发主线,不把旧代码、旧冲突和旧部署口径拖到最后。
目标:当共享开发主线持续更新时,你仍然能把自己的分支保持在最新状态,不把代码做旧,不把冲突拖到最后一天。
---
## 1. 先记住一句话
不要直接改 `main`。
日常开发从最新 `origin/codex/xuxiake-mainline` 开自己的任务分支,并且经常把共享开发主线同步进来。
你不要直接改 `main`。
你只在自己的分支里开发,然后经常把最新的 `origin/codex/xuxiake-mainline` 同步进来。
当前分工:
当前推荐分工:
- `main` 是最终发布主线,由 `WuYongJie` 收口。
- `origin/codex/xuxiake-mainline` 是共享开发主线和默认同步基线。
- 其他协作者在自己的长期共享分支或临时任务分支开发。
- `WuYongJie` 负责最终发布主线 `main`
- 日常开发默认以 `origin/codex/xuxiake-mainline` 为共享开发主线
- 其他协作者在自己的长期共享分支或临时任务分支开发
分支命名:
分支命名固定规则:
- 新分支用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`。
- 不再使用 `codex/xuxiake-dev`、`feature/packaging`、`packaging/...` 这类泛分支名。
- 分支名要能看出负责人和工作域。
- 新分支统一用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`
- `main` 只由 `WuYongJie` 收口,其他协作者只在自己的分支里同步和开发
- 长期共享分支和临时任务分支都要把当前工作域写进分支名
- 不再使用 `codex/xuxiake-dev` 这种看不出职责的泛分支名
---
## 2. 每次开工先查真实状态
## 2. 你每天就做这 3 件事
在 `furniture-3d-system/` 所在仓库根目录执行:
### 开工前
```bash
git fetch origin
git status --short --branch
git rev-list --left-right --count HEAD...origin/codex/xuxiake-mainline
```
先同步一次最新主线。
怎么看:
### 主负责人刚推了重要改动后
- `git status --short --branch` 会显示当前分支和未提交改动。
- `rev-list` 左边数字是当前分支独有提交数。
- `rev-list` 右边数字是 `origin/codex/xuxiake-mainline` 独有提交数。
- 右边大于 0,说明你落后共享开发主线。
- 左边大于 0,说明你当前分支有自己的提交。
再同步一次,不要继续拿旧代码往下写。
如果只是想确认远端最新提交,不要靠记忆,直接查:
### 你准备提交前
```bash
git fetch origin
git ls-remote origin refs/heads/codex/xuxiake-mainline refs/heads/main
```
最后再同步一次,避免你推上去的分支已经落后很多。
---
## 3. 有未提交改动时不要同步
## 3. 默认操作步骤
先看:
先加一个固定前置动作:
```bash
git status --short
```
如果不是空的,先处理当前改动:
如果这里不是空的,先把当前改动处理好再同步主线:
- 要保留:先提交到当前分支。
- 只是临时现场:先 `stash`,并写清楚是什么现场。
- 是别人或其他 agent 的文件:不要擅自回退,不要擅自删除。
- 要保留并提交:先提交到你当前分支
- 只是临时改动:先暂存(stash)再继续
不要在一堆未提交改动上直接 `merge` 或 `rebase`。
不要在“有一堆未提交改动”时直接 `merge origin/codex/xuxiake-mainline` 或 `rebase origin/codex/xuxiake-mainline`。
---
## 4. 长期共享分支用 merge
长期共享分支是别人可能会继续接手或反复同步的分支。固定用 `merge`:
如果你现在在长期共享分支,就把下面这 5 行命令原样交给 Codex 执行:
```bash
git fetch origin
@ -80,153 +67,118 @@ git merge origin/codex/xuxiake-mainline
git push origin <你的长期共享分支>
```
白话意思:
这组命令的白话意思:
1. 刷新远端状态。
2. 回到你的长期共享分支。
3. 把最新共享开发主线合进来。
4. 推回远端。
长期共享分支不要 `rebase`,不要 `push --force`。
1. 先刷新远端分支信息
2. 切回你的长期共享分支
3. 把最新共享开发主线合进这个共享分支
4. 再把共享分支推上去
---
## 5. 个人任务分支可以 rebase
## 4. 如果你临时开了个人任务分支
只有确认这个分支没有别人共用时,才可以用:
只有在你从长期共享分支再开出个人任务分支时,才用下面这套:
```bash
git fetch origin
git checkout <你的个人任务分支>
git checkout <你的分支名>
git rebase origin/codex/xuxiake-mainline
git push --force-with-lease origin <你的个人任务分支>
git push --force-with-lease origin <你的分支名>
```
怎么选:
它的特点:
- 长期共享分支:用 `merge`。
- 只有你自己使用的短期任务分支:可以用 `rebase`。
- 以后要交给别人继续接的分支:按长期共享分支处理。
- 好处:任务分支提交更直
- 代价:会改写历史,不适合长期共享分支
`--force-with-lease` 只允许用在个人任务分支,不能用在共享分支、`main` 或 `origin/codex/xuxiake-mainline`。
怎么选最简单:
- 长期共享分支固定用 `merge`
- 只有你一个人在用的短期任务分支,才用 `rebase`
- 如果这个分支会被别人继续接手或反复同步,就按长期共享分支处理,不能为了图直线历史去改写公共提交
---
## 6. 什么时候必须同步
## 5. 什么时候说明你已经落后主线了
出现下面任意情况,先同步再继续:
出现下面任意一种情况,就不要继续写了,先同步:
- 用户问“xuxiake 主线有没有新提交”。
- 你准备开始一个新任务。
- 主负责人刚推了会影响你模块的改动。
- 你准备提交或部署。
- 你发现页面表现和主线最新效果不一致。
- 你已经半天或一天没同步过。
同步前仍然要先处理未提交改动。
- 主负责人刚改了你也会碰到的模块
- Codex 提示分支落后
- 你打开页面后,发现和主线最新效果不一样
- 你准备提交,但已经隔了半天或一天没同步
---
## 7. 冲突处理
## 6. 如果同步时出现冲突
同步时遇到冲突,不要全选自己的版本,也不要全选主线版本。
不要慌,按这个顺序处理:
处理顺序:
1. 先停下来,不要乱点保存
2. 看冲突文件是不是你这次本来就在改的模块
3. 如果不是你负责的模块,先问主负责人
4. 如果是你负责的模块,就保留你的目标改动,再把主线的新内容接进去
5. 处理完后再继续让 Codex 帮你完成 `rebase` 或 `merge`
1. 先停下来,看冲突文件属于哪个模块。
2. 如果不是你负责的模块,先问主负责人。
3. 如果是你负责的模块,优先保留主线最新结构,再把当前分支的目标改动接进去。
4. 只处理冲突文件,不趁机改无关模块。
5. 处理后跑最小验证,再继续 merge 或 rebase。
可以直接让 Codex 按下面口令处理:
可以直接把下面这段话发给 Codex:
```text
我在同步 `origin/codex/xuxiake-mainline` 到当前分支时遇到冲突。
请只处理冲突文件,不扩范围,不回退无关改动。
优先保留主线最新结构,再把当前分支的目标改动接进去。
处理后告诉我需要继续执行什么命令和验证。
我在同步 `origin/codex/xuxiake-mainline` 到我的分支时遇到冲突了。
请只帮我处理这几个冲突文件,不要扩范围,不要改无关模块。
优先保留主线最新结构,再把我这个分支的目标改动接进去。
处理后请告诉我还需要执行什么命令。
```
---
## 8. 主线负责人怎么收分支
## 7. 绝对不要这样做
当同事分支已经推上来后,`WuYongJie` 按这个顺序看:
1. 看交接说明,确认本次做了什么、没做什么、验证跑了什么。
2. 看分支是否同步过最新 `origin/codex/xuxiake-mainline`。
3. 看改动是否超出任务范围。
4. 看是否碰到共享接缝。
5. 决定是 `merge`、`cherry-pick`,还是手工整合。
最简单判断:
- 分支干净、范围对、验证齐:`merge`。
- 只有部分提交该进主线:`cherry-pick`。
- 目标能收,但历史太乱或冲突太多:手工整合。
- 没同步、没说明、没验证:先不收。
共享接缝要多看一层:
- `prisma/schema.prisma`
- `prisma/migrations/`
- `src/app/layout.tsx`
- `src/app/intake/`
- `src/app/draft-groups/[id]/`
- `src/app/api/platform/schemas.ts`
- `src/server/services/draft-group.service.ts`
- `src/server/services/intake.service.ts`
- `src/server/repositories/draft-group.repository.ts`
- 不要直接在 `main` 上开发
- 不要长时间不同步主线
- 不要等“全部做完了”才第一次同步
- 不要把 `dev` 当万能长期分支名
- 不要对长期共享分支执行 `git rebase origin/codex/xuxiake-mainline`
- 不要看到冲突就随手全选自己的版本
- 不要在没看懂的情况下执行 `git reset --hard`
---
## 9. 发布前的主线核验
## 8. 主线负责人怎么收同事分支
生产发布必须由用户明确授权。
当同事分支已经推上来后,`WuYongJie` 固定按下面顺序做:
发布代码默认来自 `origin/main`,部署入口以仓库根目录下的 `scripts/deploy-yydd-prod-from-git.ps1` 为准,底层打包脚本是 `scripts/create-yydd-prod-deploy.ps1`。
1. 先看交接说明,确认本次只做什么、没做什么、验证跑了什么
2. 看分支有没有同步最新 `origin/codex/xuxiake-mainline`
3. 看改动有没有超出任务范围,或者碰到共享接缝
4. 再决定这次是 `merge`、`cherry-pick`,还是手工整合
部署前至少核对:
最简单判断法:
```bash
git fetch origin
git ls-remote origin refs/heads/main
git rev-parse origin/main
```
- 分支干净、范围对、验证齐:`merge`
- 只有部分提交该进主线:`cherry-pick`
- 目标能收,但提交历史太乱或冲突太多:手工整合
- 没同步、没说明、没验证:先不收
如果本地 `origin/main` 和远端 `refs/heads/main` 不一致,先处理同步问题,不要部署。
如果同事改到了 `draft-group / intake / schema.prisma / src/app/draft-groups/[id]` 这类共享接缝,再多看一步:
部署后不能只看公网 `/api/health`。还要按生产部署手册核对:
- 生产当前 commit。
- PM2 进程状态。
- 应用端口。
- 最近错误日志。
- 关键页面和关键接口。
- 为什么非改不可
- 这次改动是不是只为当前任务服务
- 有没有把包装逻辑带进正式入库或轻仓库主链
---
## 10. 绝对不要这样做
## 9. 给同事最短口令版
- 不要直接在 `main` 上开发。
- 不要长时间不同步共享开发主线。
- 不要等全部做完才第一次同步。
- 不要用 `dev` 这种看不出职责的长期分支名。
- 不要对长期共享分支执行 `rebase`。
- 不要对共享分支 `push --force`。
- 不要看到冲突就全选自己的版本。
- 不要在没看懂的情况下执行 `git reset --hard`。
- 不要把生产账号密码、访问密钥、数据库连接串、对象存储密钥写进 Git 文档;非密钥运行入口必须先核对当前现场再写。
---
## 11. 最短口令版
如果你懒得记,只记这一段:
```text
每天开工前先 fetch,再看 status 和当前分支相对 `origin/codex/xuxiake-mainline` 的 ahead/behind。
长期共享分支同步主线用 merge。
只有个人临时任务分支才可以 rebase。
冲突时只处理冲突文件,优先保留主线最新结构,再接回自己的目标改动。
生产部署前必须核对 origin/main 和远端 main,部署后要核对 commit、进程、端口、日志和关键页面。
每天开工前,先 fetch 远端,再把 `origin/codex/xuxiake-mainline` merge 进你的长期共享分支。
只有你临时开的个人任务分支才用 rebase。
如果冲突了,不要硬处理,先让 Codex 只处理冲突文件。
```
补充说明:
- 历史文档里如果还出现固定包装分支名,那是旧协作阶段留下的例子,不再代表当前唯一允许的长期分支。
- 当前哪条分支算“长期共享分支”,以最新交接、当前任务边界和实际协作方式为准。

View File

@ -1,78 +0,0 @@
# 产品全量档案同步性能治理
## 范围与业务规则
本次治理覆盖源库集中取数、匹配索引、事务批量写入、来源快照、断点续跑与日销优先调度。保留字段来源、负责人优先级、只填空值、首次来源初始化不记变更日志等既有规则。
公司 SQL Server 只读,使用个人账号。验证写入只发生在本机 PostgreSQL 55432 的独立测试 schema;public 业务表只读。本轮没有生产配置、定时器或生产数据变更,没有提交、推送、main 合并或部署。
## 来源读取
- 一轮先固定档案 id、条码、SKU、供应商、仓库及初始图片状态,再集中读取价格、公共库存、清仓、库存监控、供应商、仓库、图片七类来源。最多两个来源同时读取,每个来源尝试独占一个连接池,池大小为 1。
- 请求通过 OPENJSON 传入,不受原每 200 条重复查询限制。库存监控仍按原始目标批次匹配和选最新记录,保留条码优先、SKU 唯一兜底的口径。
- 图片保留 DF 全量刷新、nonDF 仅补当前无图档案的范围。主视图 jst_skumap 按 shop_id、shop_i_id、shop_sku_id 三列主键每页 50,000 条读取编码,再按命中的主键精确读取图片;另外两张图片表分别以哈希匹配读取请求编码,并读取 DF 范围;重复命中由最新非空选择去重。使用 COMPRESS 与 UTF-16 JSON 无损传输,跨页、跨表选择最新非空图片,保留时间的亚毫秒顺序,不截断图片。
- 每个来源的成功结果独立原子保存,带 SHA-256、条数和行结构校验。重启复用已完成来源,失败来源重新读取;来源未读完不发布部分结果。图片三张表还会分别保存已完成结果,失败后只重读未完成的表;主表内部分页尚不持久化中间页。
- 每次请求有完整传输的墙钟限时:图片 600 秒,其他来源 300 秒。PRODUCT_ARCHIVE_SNAPSHOT_QUERY_TIMEOUT_MS 可统一覆盖,范围 1,000~600,000 毫秒。超时取消请求,以 ETIMEOUT 进入原有 30 秒、120 秒、300 秒退避重试;认证、权限、结构错误不重试。
- 每批写前重新读取当前人员与图片,保留人工负责人保护;条码、SKU、供应商或仓库改变时拒绝旧快照。原先已有图的 nonDF 档案中途被清空,也要求新的 run key,避免使用未请求对应图片的旧快照。
- 各来源在不同时间读取,不构成跨公司库与应用库的同一时刻事务快照。后续新 run key 会重新读取来源。
## 匹配、写入与调度
- 来源数组建立索引,共享图片索引跨批复用。保留原 find 的首条命中与原始来源顺序。
- 每批最多 200 档案,在一个事务中按 id 排序锁定当前行,更新变化字段、字段来源、来源记录和逐字段日志。日志失败时整批回滚。来源日期传值、明确 null、未传三种状态保持原行为。
- Service 计算计划,Repository 负责数据访问与事务;Repository 只通过参数接收 planner,不在运行时调用 Service。
- PostgreSQL advisory lock 命名空间 90710:3 为写入轮次,4 为日销意向,5 为全量任务排他。日销先登记意向,后台当前批完成后让行。同库重复全量任务返回 SKIPPED_RUNNING;连接中断后拒绝继续写。
- 主补全与 JST 基础档案按批让行。负责人流程与生产类型阶段以整阶段持锁。JST 某项写入失败时停止启动新项,等待同批已发出写入结束后再释放锁,不保存失败批次进度。
- 匹配 mapper 保留原映射职责;类型、索引、快照、编排和事务已按职责拆分。没有新增界面或视觉样式。
## 运行与续跑
入口:scripts/sync-supply-chain-product-archive-enrichment.ts。保留 --apply、固定确认词、目标库及来源身份门禁。
- 默认 run key 为北京时间日期;相同配置重启继续已提交批次,已完成阶段跳过。
- 跨天续跑使用原 run key,例如 --run-key=2026-10-21。
- 同日需要重新读取最新来源时使用新 key,例如 --run-key=2026-10-21-second,保留原断点。
- 目标库、来源身份、关键代码或批次参数变化会拒绝旧断点;损坏文件不会静默按空来源继续。
- 断点经临时文件、fsync、原子 rename 保存。批次提交后、保存前中断会重放该批,通过比较当前值避免重复记录未变化字段。
- 默认目录:本地 D:\ChanPing\.local-services\checkpoints\product-archive-sync;生产发布结构下 shared/product-archive-sync-checkpoints。可用 PRODUCT_ARCHIVE_SYNC_CHECKPOINT_DIR 指定。
- ARCHIVE_SOURCE_SNAPSHOT 输出来源读取与复用状态,ARCHIVE_PICTURE_PAGE 输出主表扫描及命中进度,ARCHIVE_SYNC_BATCH 与 ARCHIVE_SYNC_PHASE 输出批次和阶段计时。
- 本机 Node、Jest、TypeScript、ESLint 都通过 .local-services/scripts/invoke-node-hidden.ps1 启动。
## 验证记录(2026-10-21)
全部写入位于本机独立副本 archive_full_1792578871629。基础来源真实读取 81,070 条,副本从 75,527 档案增至 81,074 条。随后覆盖全部档案,对修改前 mapper/applyPatch 构建的独立 oracle 逐批核对档案业务字段、来源业务字段和逐字段日志。排除 id、自动更新时间、同步时间等每轮必然不同的元数据。
- 全部 81,074 档案通过;比较 1,092,416 条来源记录及 15,290 条新增逐字段日志,结果一致。
- 首、中、末三个 200 条批次的六类来源共 18 项新旧取数对比通过。
- 图片主表 24,581,427 行按主键读至表尾,和独立 COUNT_BIG 结果一致;命中并精确回查 64,382 行,三表完成后得到 32,944 个最新非空图片编码。
- 13 组、97 项相关测试通过;TypeScript、源码 ESLint、diff --check 通过;3001 健康接口 database=connected、api=running。
- 在真实本机 PostgreSQL 验证日志失败整批回滚、日销在后台批次间优先、失败释放锁及同库全量排他。测试也覆盖后表失败复用主表、来源快照损坏、断点恢复、超时取消和 JST 失败等待已发出写入结束。
### 分阶段计时
以下是同一独立副本的分阶段实测,包含真实公司库读取与本地写入。部分来源读取在前置阶段完成,并在完整比对时复用,故这是分阶段累计,不是一次连续正式站整轮耗时。为了便于评估,累计值单独加上已提前读取的六类来源时间,且未扣除生产并发取数可能节省的重叠时间;旧逻辑 oracle 比对开销不计入执行成绩。
| 阶段 | 实测 |
| --- | ---: |
| 基础商品来源 81,070 条 | 173.81 秒 |
| 基础档案写入(更新 75,523,新增 5,547) | 270.33 秒 |
| 负责人流程首次应用(变更 2,317) | 51.36 秒 |
| 提前读取的六类非图片来源,分别累计 | 204.30 秒 |
| 三表图片完整取数 | 806.17 秒 |
| 共享数据、补全匹配和写入(复用来源,扣除 oracle 比对) | 81.08 秒 |
| 产品类型实际同步 | 39.68 秒 |
| 分阶段累计 | 27.11 分钟 |
图片主表单独耗时 787.83 秒,三表总图片来源 806.17 秒。完整补全验证实际墙钟 5.77 分钟,其中旧逻辑比对 4.42 分钟。
另一个 1,000 档案旧新写入试验中,匹配加写入从 44.13 秒降至 0.92 秒,约 48 倍;该局部试验不含源库,不能作为整轮加速倍数。
### 适用限制与收口
公司库负载和网络有波动。先前相同来源的读取曾明显变慢,不能据本地累计值承诺正式站固定耗时。完整生产成绩需正式发布后实测。负责人和产品类型阶段以整阶段让行;图片恢复以单表完成为边界,未完成的主表仍需重读。增量镜像、公司库索引与 DDL 不在本次范围。
本轮未提交、推送、合并 main 或部署,生产写入为 0。提交状态:可提交;main 标记:可合并 main。源码、测试及本说明共 31 个文件,登记为 awaiting_review。
规范结论:passed with warnings,这是自审,不是独立复审。架构职责保持;无视觉改动;修复重复扫描、线性查找、逐条事务和失败后重读已完成来源的原因。警告为尚无连续生产整轮计时及上述阶段让行限制。
审计证据:D:\ChanPing\.local-services\audits\archive-sync-performance-20261021\complete-review-result.json。

View File

@ -1,54 +0,0 @@
# 产品全量档案图片索引操作说明
全量档案本地图片索引只读取 `SupplyChainProductArchive.image`。上游「我司 SKU 命中」继续使用现有独立图搜服务。
## 更新规则
- 有图片且没有索引:建立索引。
- 图片地址变化、旧索引失效、模型版本或向量规格变化:重新索引。
- 图片为空或地址已经变化:旧向量退出本地搜索。任务会把旧记录标为 `STALE`。
- 下载或计算失败:保存 `FAILED` 和原因,六小时后再次尝试;图片地址再次变化则立即处理。
- 图片内容相同、地址不同:复用向量并更新地址与状态,避免反复处理同一张图片。
- 下载、计算期间图片改变或产品删除:提交前锁定并核对档案行,丢弃过期结果,下一轮处理最新图片。
- 所有索引任务使用同一 PostgreSQL 会话锁,避免手动补索引、同步触发与定时任务并发计算。
- 正常增量任务不会重新下载全部已就绪图片;同一 URL 下替换了图片内容时,使用完整内容核验命令。
## 本地命令
在主应用目录执行,模型沿用现有 `IMAGE_SEARCH_MODEL_PATH` 或 `.local-services/models/dinov2-small`。
```bash
# 每次最多处理 200 条缺失、换图或到期重试记录
npm run image-search:index
# 补齐当前全部待处理记录,并立即重试历史失败
npm run image-search:index -- --limit=0 --retry-now
# 下载所有带图档案,核验同一 URL 下的图片内容变化
npm run image-search:index -- --all --limit=0
```
命令持续输出已处理、成功、复用、失败、跳过、过期结果丢弃和失效条数;失败行输出条码与原因。最后输出本轮结果及待处理数量。失败仍会进入后续重试,本轮失败数必须单独关注,`remaining` 只计算已到重试时间的记录。
## 生产启用
代码必须先进入被授权的 `origin/main`,再经统一部署入口发布。安装定时器与首次补索引按用户授权执行,不能把本地开发完成等同于生产已启用。
安装入口:`scripts/install-product-archive-image-index-timer.sh`。它要求现有生产模型文件齐全,并让任务显式使用共享模型目录。当前应用启动命令已注入模型路径,无须为本功能重写应用环境。
- 服务:`furniture-product-archive-image-index.service`。
- 定时器:`furniture-product-archive-image-index.timer`,每五分钟检查,单次最多处理 200 条,失败六小时后重试。
- 两个现有全量同步服务分别增加本功能自己的 `image-index.conf`,同步成功后异步启动索引服务。
- 前台保存、导入及其他来源的图片变化由五分钟定时检查覆盖;索引下载与推理不阻塞保存请求。
- 通过 `PRODUCT_ARCHIVE_IMAGE_INDEX_LIMIT` 可在安装时调整单次处理上限。
- 脚本和日志通过发布目录与 systemd 管理,不手工修改发布目录。
```bash
bash scripts/install-product-archive-image-index-timer.sh
systemctl status furniture-product-archive-image-index.timer --no-pager
journalctl -u furniture-product-archive-image-index.service -n 50 --no-pager
```
首次补齐历史缺口可等待定时任务分批完成;需要一次补完时,手动索引命令必须指向共享模型路径。运行中的另一索引任务会返回 `LOCKED`,稍后重试。
验收应核对有图档案与索引的关联,而不是只看 `READY` 行数:当前地址一致、状态 `READY`、模型版本与 768 维向量均符合要求才是可用索引。历史 404 或不可读取地址不能靠索引任务生成图片,需要按条码定位源图片问题。

View File

@ -11,7 +11,6 @@
- 所属模块:
- 负责人:
- 所在分支:
- 同步基线:`origin/codex/xuxiake-mainline`
- 计划开始时间:
---
@ -60,11 +59,6 @@
-
### SPU / SKU / barcode / 批次
- 是否涉及:
- 如果涉及,四者分别是什么:
### 文档
-
@ -148,15 +142,8 @@
- 至少跑 `npm run type-check`
- 按需跑相关测试
- 能跑就跑 `npm run build`
- 前端改动按 1920x1080 做浏览器自查
- 涉及部署时核对 `origin/main`、PM2、端口、日志和关键页面
需要同步更新的文档:
- <文档路径 1>
- <文档路径 2>
规范审查:
- 架构有没有被破坏
- 视觉有没有跑偏
- 这次是不是根因修复
```

View File

@ -5,16 +5,14 @@
---
## 1. 先把这几句话说清楚
## 1. 先把这 5 句话说清楚
- 这次要做的模块是什么
- 现在做到哪一步了
- 你接下来只需要做什么
- 哪些目录不能碰
- 你应该在哪个分支继续做
- 是否已经同步 `origin/codex/xuxiake-mainline`
- 做完后怎么验证
- 规范审查结论是什么
---
@ -23,20 +21,14 @@
```text
任务名称:<名称>
目标分支:<固定写你要推到哪个分支,例如 codex/<负责人>-<工作域> 或 codex/<负责人>-<工作域>-<任务简称>>
同步基线:origin/codex/xuxiake-mainline
当前状态:<已完成到哪里>
你接手的部分:<只写这次要做的内容>
分支名称:<默认写 codex/<负责人>-<工作域>;如果是临时小任务,再写 codex/<负责人>-<工作域>-<任务简称>>
本次只改:<一句话说清本次改动范围>
本次明确没改:<一句话写清这次没碰什么>
是否碰共享接缝:<没有 / 有,改了哪些,为什么非改不可>
是否涉及 SPU / SKU / barcode / 批次:<没有 / 有,分别是什么,不能混写>
先看这几个文件:
- AGENTS.md
- docs/workflow/团队协作模式.md
- docs/workflow/模块边界说明.md
- docs/workflow/主线同步操作手册.md
- <文件 1>
- <文件 2>
- <文件 3>
@ -54,16 +46,11 @@
- `npm run type-check`
- `npm test -- --runTestsByPath <补具体文件>`
- `npm run build`
- 前端改动按 1920x1080 浏览器自查
- 人工打开:<页面路径>
风险与待主线决定:
- <风险 1>
- <是否建议 merge / cherry-pick / 手工整合>
规范审查:
- 这是自审,不是独立复审
- 结论:<passed / passed with warnings / rejected>
```
---
@ -79,16 +66,7 @@
当前背景:
<用 3 到 5 行说清楚现在已经做到哪一步>
开工前请先执行:
- `git fetch origin`
- `git status --short --branch`
- `git rev-list --left-right --count HEAD...origin/codex/xuxiake-mainline`
先看这些文件:
- AGENTS.md
- docs/workflow/团队协作模式.md
- docs/workflow/模块边界说明.md
- docs/workflow/主线同步操作手册.md
- <文件 1>
- <文件 2>
- <文件 3>
@ -110,7 +88,6 @@
- 至少跑 `npm run type-check`
- 按需跑相关测试
- 能跑就跑 `npm run build`
- 如果是前端改动,请按 1920x1080 做浏览器自查
如果你当前在长期共享分支上,请先同步共享开发主线再继续开发:
- `git fetch origin`
@ -127,7 +104,6 @@
2. 是否完成
3. 还有没有风险
4. 本次建议主线怎么收:`merge` / `cherry-pick` / `手工整合`
5. 规范审查结论:`passed` / `passed with warnings` / `rejected`
```
---
@ -143,6 +119,4 @@
- 没写有没有碰共享接缝
- 没写建议主线怎么收这次分支
- 没提醒对方先同步最新 `origin/codex/xuxiake-mainline`
- 没写清 SPU / SKU / barcode / 批次是否涉及
- 没写规范审查结论
- 没同步文档,导致下一个人继续靠猜

View File

@ -24,7 +24,7 @@
- 先读 `docs/workflow/团队协作模式.md`
- 先读 `docs/workflow/模块边界说明.md`
- 先读 `docs/workflow/主线同步操作手册.md`
- 前端、部署、数据库、包装等任务按任务类型额外读对应文档
- 包装协作额外读包装 AI 对接说明
这样就能做到:
@ -127,7 +127,7 @@ furniture-3d-system-codex-workspace/
- 默认用中文,面向非程序员表达
- 先看哪些文档
- 这位同事当前负责哪些模块
- 这位同事当前只负责哪些模块
- 哪些目录绝对不要碰
- 开工前先同步 `origin/codex/xuxiake-mainline`
- 结果汇报格式要简单
@ -151,10 +151,10 @@ furniture-3d-system-codex-workspace/
同事本地规则,放自己工作区外层 `AGENTS.md`:
- 他当前负责的具体模块
- 他当前只负责包装相关模块
- 先同步主线再开发
- 不扩范围
- 不擅自改主导航、辅助装配、草稿组、产品资料、仓库主链和共享接缝
- 不擅自改主导航、辅助装配、草稿组、仓库主链
- 回报格式尽量简单
---

View File

@ -1,7 +1,7 @@
# 同事本地 AGENTS 模板
使用方式:把下面内容复制到同事自己电脑上的“项目外层工作区” `AGENTS.md` 中。
不要直接覆盖仓库里已经提交的 `AGENTS.md`。
使用方式:把下面内容复制到你同事自己电脑上的“项目外层工作区” `AGENTS.md` 中。
不要直接覆盖仓库里已经提交的那份 `AGENTS.md`。
---
@ -13,101 +13,107 @@
## 1. 默认沟通方式
- 默认中文回复。
- 默认先说结论,再说必要解释。
- 默认面向非程序员表达。
- 当前真实代码、脚本和线上返回优先于旧文档、旧记忆和历史摘要。
- 默认面向非程序员表达,先说结论,再说必要解释。
- 默认只说“现在你要做什么、是否完成、还有没有风险”。
## 2. 开工先读
## 2. 先做什么
开始任务前,先看:
开始任务前,先看下面这些文件:
- `AGENTS.md`
- `docs/README.md`
- `docs/workflow/团队协作模式.md`
- `docs/workflow/模块边界说明.md`
- `docs/workflow/主线同步操作手册.md`
按任务补读:
如果任务和包装有关,再看:
- 前端/视觉:`docs/设计规范/白曜石工坊统一视觉标准.md`、`docs/开发指南/组件使用指南.md`
- 部署/线上:`docs/workflow/生产部署入口说明.md`
- 数据库/迁移:`docs/workflow/数据库迁移操作手册.md`、`docs/开发指南/数据库基础命令速查.md`
- 包装协作:`docs/开发指南/包装方案增强/` 下当前任务相关文档
- `docs/workflow/同事交接模板.md`
- `docs/开发指南/包装方案增强/新平台包装底座-同事AI对接说明-20260331.md`
## 3. 当前项目主链
## 3. 你的默认职责
主业务链路是:
你当前默认只负责包装相关模块,以及与包装对接的预留能力。
```text
资产中心 -> 辅助装配 -> 草稿组 -> 产品资料/正式入库 -> 仓库管理/轻仓库
```
优先关注:
仓库质检、AI 出图工作台、OA 看板、用户和后台管理是工作台入口,不等于主数据链路本身。
- `src/app/api/pre-skus/[id]/packaging`
- `src/app/api/products/[id]/packaging`
- `src/server/services/packaging.service.ts`
- `src/server/repositories/packaging.repository.ts`
- `docs/开发指南/包装方案增强`
## 4. 我的负责范围
## 4. 默认不要改的地方
把下面三项按实际任务改掉:
除非任务明确要求,否则不要主动改下面这些地方:
- 我当前负责:<例如 AI 白底图 / 包装 / 仓库质检 / 资产中心 / 产品资料>
- 我默认可以改:<目录或文件>
- 我默认不能改:<目录或文件>
如果不确定是否能改共享接缝,先问主负责人。
共享接缝包括:
- `prisma/schema.prisma`
- `prisma/migrations/`
- `src/app/layout.tsx`
- `src/app/intake/`
- `src/app/draft-groups/[id]/`
- `src/app/api/platform/schemas.ts`
- `src/app/asset-center`
- `src/app/assisted-assembly`
- `src/app/intake`
- `src/app/warehouse`
- `src/app/draft-groups/[id]`
- `src/server/services/draft-group.service.ts`
- `src/server/services/intake.service.ts`
- `src/server/repositories/draft-group.repository.ts`
- `src/server/services/intake.service.ts`
- `src/server/services/warehouse.service.ts`
- `prisma/schema.prisma`
- 旧 CAD 兼容链的大范围逻辑
## 5. 分支和同步
## 5. 开发前的固定动作
开工前、准备提交前、主负责人刚推重要改动后,都先同步状态:
- 开工前先同步最新 `origin/codex/xuxiake-mainline`
- 主负责人刚推了重要改动后,再同步一次
- 自己准备提交前,再同步一次
高频协作固定铁律见:
- `docs/workflow/高频协作五条铁律.md`
固定分支命名:
- 统一用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`
- 长期分支使用 `codex/<负责人>-<工作域>`
- 临时小任务使用 `codex/<负责人>-<工作域>-<任务简称>`
- 不要使用 `dev` 这种看不出职责的泛分支名
默认同步步骤:
```bash
git fetch origin
git status --short --branch
git rev-list --left-right --count HEAD...origin/codex/xuxiake-mainline
git checkout <你的长期共享分支>
git merge origin/codex/xuxiake-mainline
git push origin <你的长期共享分支>
```
长期共享分支用 `merge`。
只有个人临时任务分支才可以 `rebase`。
如果你临时开的只是个人任务分支,再改用:
分支命名:
```bash
git fetch origin
git checkout <你的分支名>
git rebase origin/codex/xuxiake-mainline
git push --force-with-lease origin <你的分支名>
```
- `codex/<负责人>-<工作域>`
- `codex/<负责人>-<工作域>-<任务简称>`
## 6. 开发规则
不要使用 `dev`、`feature/...`、`packaging/...`、`codex/xuxiake-dev` 这类泛分支名。
- 先修根因,不做表面补丁。
- 不顺手扩范围,不把自己的任务做成整个项目大改。
- Route / Page 不直接写 Prisma 业务规则。
- 先复用现有实现,不重复造新结构。
- 遇到冲突或边界不清,先停下来说明,不要硬改。
- 主导航、主流程骨架、公共数据结构,不要擅自大改。
## 6. 前端规则
## 7. 前端与文档
- 优先使用项目组件库、元组件和 `src/components/platform/`。
- 白曜石工坊是第一视觉标准。
- 如果有 Stitch 设计稿,按 Stitch 对齐密度、层级、控件、留白、字号和低噪音表达。
- 资产中心是项目内参考样板。
- 不堆说明,不堆统计,不做普通 SaaS 卡片墙。
- 前端改动按 1920x1080 做浏览器自查;受影响时补移动端自查。
- 涉及前端时,优先保持现有统一视觉,不要自己另起一套样式。
- 涉及文档时,优先写短、直白、可执行的内容。
- 不要在文档里写你自己电脑上的绝对路径。
## 7. 数据和安全
## 8. 完成后默认回报格式
- SPU、SKU、barcode、批次不能混用。
- 生产账号密码、访问密钥、数据库连接串、对象存储密钥不能写进 Git、共享文档或长期记忆。
- 生产、数据库、对象存储写操作必须先只读核查,再取得明确授权。
完成后默认按下面格式回报:
## 8. 完成后回报
默认按下面格式回报:
1. 做了什么
2. 改了哪些文件或模块
1. 现在你做了什么
2. 是否完成
3. 跑了什么验证
4. 还有什么风险
5. 规范审查结论:`passed` / `passed with warnings` / `rejected`
6. 说明:这是自审,不是独立复审
4. 还有没有风险或待确认项
```

View File

@ -1,220 +1,323 @@
# 团队协作模式
适用范围:`furniture-3d-system/`。
目标:让多人和多 AI 在同一套主线、模块边界和视觉标准下协作,避免旧分支、旧流程和旧页面口径继续污染当前项目。
适用范围:`furniture-3d-system` 仓库。
目标:把当前项目从“单人连续开发”切换成“多人并行协作,但主链不打架”的模式。
本文件只写仓库内可以共享的规则。账号密码、访问密钥、数据库连接串、对象存储密钥等敏感信息不能写进 Git 文档;生产域名、PM2 名称、部署目录这类非密钥运行入口可以写,但必须保持最新。
进入这个仓库协作时,固定先读这 3 份文档,再开始改代码:
1. `docs/workflow/团队协作模式.md`
2. `docs/workflow/模块边界说明.md`
3. `docs/workflow/主线同步操作手册.md`
这 3 份就是当前唯一的第一层协作入口,不再额外维护第二套平级入口文档。
---
## 1. 当前一句话
## 1. 先记住一句话
当前主业务链路是:
这个仓库现在默认按下面这条主链协作:
```text
资产中心 -> 辅助装配 -> 草稿组 -> 产品资料/正式入库 -> 仓库管理/轻仓库
```
`资产中心 -> 辅助装配 -> 草稿组 -> 正式入库 -> 轻仓库`
需要同时记住:
- 全局导航还包括首页、OA 新品流程看板、仓库质检、AI 出图工作台、用户和管理入口;这些是工作台入口,不等于主数据链路。
- 包装、组件、仓库、质检、AI 出图都必须挂回明确的产品、SKU、批次或草稿组上下文,不能另起一套数据口径。
- 旧 CAD 兼容链路仍存在,但它服务历史产品编辑,不是新平台主链。
- 历史文档里的固定包装分支、旧服务器别名、旧 `dev` 分支说法,只能当历史参考,不能当当前事实。
- 包装相关能力当前挂在 `DraftGroup / PreSku / Warehouse` 承接口里,不作为独立顶层主线入口。
- 旧的 `CAD -> 装配步骤` 链继续保留,但它是**历史兼容链**,不是新开品主链。
- 历史文档里如果还出现 `auto-combinations`、`自动组合`、`自动拼装`,统一按旧称呼理解,不再代表当前页面入口。
---
## 2. 开工固定入口
## 2. 当前协作口径
当任务发生在 `furniture-3d-system/`,开始改代码前先读:
### 先固定两件事
- `AGENTS.md`
- `docs/workflow/团队协作模式.md`
- `docs/workflow/模块边界说明.md`
- `docs/workflow/主线同步操作手册.md`
- `main` 是最终发布主线,由 `WuYongJie` 收口;日常开发默认同步 `origin/codex/xuxiake-mainline`。
- 这份文档现在只固定 Git 协作方式、主线收口规则和共享接缝处理原则,不再把当前模块职责长期写死在这里。
- 生产部署入口以 `docs/workflow/生产部署入口说明.md` 和 `scripts/deploy-yydd-prod-from-git.ps1` 为准;`192.168.58.20` 不是当前项目生产部署目标。
如果任务涉及前端、生产或数据库,再按需补读:
### 当前主线入口
- `docs/设计规范/白曜石工坊统一视觉标准.md`
- `docs/workflow/规范审查清单.md`
- `docs/workflow/生产部署入口说明.md`
- `docs/workflow/数据库迁移操作手册.md`
- `docs/开发指南/数据库基础命令速查.md`
- `src/app/asset-center`
- `src/app/assisted-assembly`
- `src/app/draft-groups`
- `src/app/intake`
- `src/app/warehouse`
- `src/app/api/asset-center`
- `src/app/api/assisted-assembly`
- `src/app/api/draft-groups`
- `src/app/api/intake`
- `src/app/api/pre-skus`
- `src/app/api/warehouse`
- `src/components/platform`
- `src/lib/open-product`
开工时先确认真实现场:
### 具体职责怎么定
```bash
git fetch origin
git status --short --branch
git rev-list --left-right --count HEAD...origin/codex/xuxiake-mainline
```
- 具体谁负责哪个模块,不再以这份文档里的旧分工为准。
- 当前任务边界以任务单、交接摘要、已确认协作信息和实际代码现状为准。
- 如果任务碰到共享接缝,再和主线收口人对齐后再动。
`rev-list` 输出里,左边是当前分支独有提交数,右边是 `origin/codex/xuxiake-mainline` 独有提交数。右边大于 0 时,说明当前分支落后共享开发主线。
### 哪些地方仍然算共享接缝
---
## 3. 当前权威分支
- `main` 是最终发布主线,由 `WuYongJie` 收口。
- `origin/codex/xuxiake-mainline` 是共享开发主线和默认同步基线。
- 新功能、优化和修复默认从最新 `origin/codex/xuxiake-mainline` 开任务分支。
- 常规分支命名使用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`。
- 不再使用 `feature/packaging`、`packaging/...`、`codex/xuxiake-dev` 这类泛分支名。
长期共享分支同步共享开发主线时固定用 `merge`。
临时个人任务分支只有确认无人共用时才可以用 `rebase`。
任何人回答“xuxiake 主线有没有新提交”之前,都必须先 `git fetch origin`,再看真实远端状态。
---
## 4. 当前主要目录
主入口和全局布局:
- `src/app/page.tsx`
- `src/components/layout/`
- `src/app/layout.tsx`
新平台主链:
- `src/app/asset-center/`
- `src/app/api/asset-center/`
- `src/app/assisted-assembly/`
- `src/app/api/assisted-assembly/`
- `src/app/draft-groups/`
- `src/app/api/draft-groups/`
- `src/app/intake/`
- `src/app/api/intake/`
- `src/app/warehouse/`
- `src/app/api/warehouse/`
- `src/app/warehouse-quality/`
- `src/app/api/warehouse-quality/`
运营、AI 和平台能力:
- `src/app/ai-render-workbench/`
- `src/app/api/planning/`
- `src/app/api/platform/`
- `src/components/platform/`
- `src/lib/open-product/`
- `src/lib/planning/`
旧 CAD 兼容链路:
- `src/app/products/`
- `src/app/api/products/`
- `src/app/api/skus/`
- `src/components/assembly/`
- `src/components/3d/`
---
## 5. 共享接缝
下面这些位置属于高冲突区,改动前必须多看一层调用方,提交时也要讲清楚影响范围:
- `prisma/schema.prisma`
- `prisma/migrations/`
- `src/app/layout.tsx`
- `src/app/intake/`
- `src/app/draft-groups/[id]/`
- `src/app/api/platform/schemas.ts`
- `src/server/services/draft-group.service.ts`
- `src/server/services/intake.service.ts`
- `src/server/repositories/draft-group.repository.ts`
共享接缝不是不能改,而是不能顺手改、不能带着局部假设改、不能不验证就合回主线。
- `src/server/services/intake.service.ts`
- `src/app/intake`
- `prisma/schema.prisma`
- `src/app/draft-groups/[id]`
---
## 6. 业务对象口径
## 3. 分支规则
当前新平台不能混用这些概念:
### 主分支
- `AssetFamily`:资产中心里的产品族或产品款基础资产。
- `AssetVariant`:资产族下的规格、材质、花色等变体。
- `DraftGroup`:草稿组,是辅助装配到产品资料之间的承接对象。
- `PreSku`:正式 SKU 前的预 SKU 或待确认 SKU。
- `Product / SKU`:正式入库后的产品和 SKU。
- `IntakeNonIntegratedProduct / IntakeNonIntegratedSkuItem`:非集成产品实拍、AI 白底图、素材库回传等链路使用的对象。
- `main` 继续保持受保护状态,只作为最终发布主线收口。
- 其他协作者默认不能直接 push `main`,只能走自己的功能分支。
- 日常开发基线是 `origin/codex/xuxiake-mainline`,不要把半成品直接推上最终发布主线。
SPU、SKU、barcode、批次必须分清:
### 开发分支
- SPU 是产品款。
- SKU 是 SPU 下的具体子项。
- barcode 是逐步收敛的唯一业务定位值,不能拿来代替 SPU 或 SKU。
- 批次只能属于当前产品资料链路的批次上下文。
- 基于某个 SKU 换色得到的新 SKU,仍属于同一个 SPU,并且只能归属当前批次。
统一使用:
---
- `codex/<负责人>-<工作域>`
- `codex/<负责人>-<工作域>-<任务简称>`
## 7. 前端和视觉协作
固定口径:
当前第一视觉标准是白曜石工坊,不是旧苹果风,也不是普通后台卡片墙。
- 负责人必须进分支名,不能只用 `feature/...`、`packaging/...`、`dev` 这类泛名字
- `WuYongJie` 主线相关分支统一放 `codex/wuyongjie-mainline...`
- 长期共享分支和临时任务分支都要把当前工作域写进分支名,不再把人长期绑定在单一旧分支名上
- 其他负责人也按同一模式命名,不再额外发明新前缀
- 历史分支名不强制重命名,但从现在开始新分支全部按这套规则建
前端任务必须同时遵守:
示例:
- 优先使用 `src/components/platform/` 和项目里的元组件、组件库。
- Stitch 设计稿是视觉规格和验收标准,但落地时要通过项目组件体系实现。
- 资产中心页面是当前项目内最重要的参考样板。
- 新平台页面保持浅色工作台、低噪音、图片和数据优先、层级清楚。
- 不堆说明块、不重复统计、不做营销式大卡片布局。
- `apple-*`、暗色 `ObsidianControlShell`、旧主题 CSS 只作为兼容历史页面存在,不能作为新页面方向。
- `codex/wuyongjie-mainline`
- `codex/wuyongjie-mainline-asset-center`
- `codex/xuxiake-costing`
- `codex/xuxiake-intake-clean`
- `codex/zhangsan-cad-compat`
涉及图片、3D、AI 白底图、素材卡片时,重点是图本身和操作路径,不要让说明文字和状态块抢走主要视觉注意力。
### 合并方式
---
- 主负责人负责把稳定内容从共享开发主线收口到 `main`。
- 其他协作者默认先推自己的功能分支。
- 同事的改动进入主线前,默认先由主负责人检查和收口。
- 协作者不要自己合并到 `main`,也不要自己改远端主线历史。
- 没有过验收清单,不合并,不并线。
## 8. 生产和部署协作
当前固定流程:
生产发布必须由用户明确授权。
发布代码默认以 `origin/main` 为来源,常用入口是仓库根目录下的 `scripts/deploy-yydd-prod-from-git.ps1`,底层打包脚本是 `scripts/create-yydd-prod-deploy.ps1`。
1. 协作者先从最新 `origin/codex/xuxiake-mainline` 同步到自己的长期分支或任务分支。
2. 协作者只在自己负责的工作域内开发。
3. 协作者推送自己的分支,并附上交接说明。
4. `WuYongJie` 负责检查范围、决定 merge / cherry-pick / 手工收口。
5. 只有进过主负责人收口的内容,才允许进入 `main`。
部署前至少核对:
### 主线收口检查单
`WuYongJie` 收同事分支前,固定先看这 6 项:
1. 这个分支是不是已经同步过最新 `origin/codex/xuxiake-mainline`
2. 这次改动是不是只落在任务约定范围内
3. 有没有碰共享接缝;如果碰了,交接里有没有写清原因
4. 有没有最小验证结果,而不是只说“应该可以”
5. 有没有交接说明,且写清楚“这次只改了什么、没改什么、风险是什么”
6. 这次改动会不会影响正式入库、轻仓库或主链真相
只要这 6 项里有明显缺项,就先不要急着收口。
### merge / cherry-pick / 手工整合 怎么选
固定口径:
- 直接 `merge`
- 适用:改动范围干净,只在同事负责域内,没有混入共享接缝或无关改动
- 前提:已经同步最新 `origin/codex/xuxiake-mainline`,并且附了最小验证和交接说明
- 优先 `cherry-pick`
- 适用:同一个分支里既有可收的目标提交,也混入了暂时不该进主线的提交
- 常见场景:同一个协作分支里夹带了共享接缝改动、调试提交、试错提交
- 手工整合
- 适用:目标功能要收,但提交历史太脏、冲突太多,或者改动虽然合理但不能整段直接拿
- 常见场景:同一个文件同时碰到主线重构区和同事任务区
- 暂不收口
- 适用:没同步最新 `origin/codex/xuxiake-mainline`、没交接说明、没最小验证、范围失控、直接改了敏感共享接缝但没写原因
- 处理方式:先退回补说明、补同步、补验证,再继续看
### 同事和主线怎么保持同步
当前项目默认采用这个口径:
- `WuYongJie` 负责维护共享开发主线,并把稳定内容收口到 `main`
- 协作者长期在自己的长期共享分支或当前任务分支开发
- 同事不能等做完了再一次性同步,而是要高频同步 `origin/codex/xuxiake-mainline`
默认同步时机:
- 每天开始开发前同步一次
- 主负责人刚推了重要改动后同步一次
- 自己准备提交前再同步一次
默认推荐方式:
- 长期协作分支固定优先使用 `merge origin/codex/xuxiake-mainline`
- 只有临时个人任务分支才优先使用 `rebase origin/codex/xuxiake-mainline`
- 但不要一边长期不同步,一边继续堆改动
白话规则:
- 长期共享分支保留真实协作历史,所以固定用 `merge origin/codex/xuxiake-mainline`
- 临时个人任务分支只服务当前任务,为了保持提交线清楚,才用 `rebase origin/codex/xuxiake-mainline`
- 如果你拿不准自己现在是不是长期共享分支,就先按长期共享分支处理,不要擅自改写共享历史
长期共享分支日常同步固定步骤:
```bash
git fetch origin
git ls-remote origin refs/heads/main
git rev-parse origin/main
git checkout <你的长期共享分支>
git merge origin/codex/xuxiake-mainline
git push origin <你的长期共享分支>
```
部署后不能只看公网 `/api/health` 就说完成,还要按生产部署手册核对发布 commit、进程、端口、日志和关键页面。
如果你临时开了个人任务分支,再用:
仓库文档里不要写生产账号密码、访问密钥、数据库连接串或对象存储密钥。非密钥的生产入口信息可以写,但必须先核对当前脚本和线上现场。
```bash
git fetch origin
git checkout <你的临时任务分支>
git rebase origin/codex/xuxiake-mainline
git push --force-with-lease origin <你的临时任务分支>
```
怎么选:
- 长期共享分支,用 `merge`
- 临时个人任务分支,用 `rebase`
详细操作说明见:`docs/workflow/主线同步操作手册.md`
高频协作总规则见:`docs/workflow/高频协作五条铁律.md`
---
## 9. 本地文件和清理
## 4. 每个任务都按这个流程走
本仓库经常有本地产物、排错证据和临时交接文件,例如 `.next*`、审计目录、下载素材、截图、日志、Excel、JSON 摘要等。
1. 先建一张任务单。
2. 写清楚本次只做什么、不做什么、验收标准是什么。
3. 明确归属模块,不跨模块顺手乱改。
4. 开发前先读对应模块边界说明。
5. 改完后同步必要文档。
6. 跑最小验证。
7. 做一次规范自审,再推分支。
清理前先判断它属于哪一类:
额外固定动作:
- 正式源码和协作文档:不能随手删。
- 本地产物:确认不影响验证后可以清理。
- 排错证据:任务没收口前不要清。
- 敏感交接材料:不能提交 Git,必要时拆成本地版和仓库版。
- 用户或其他 agent 的未跟踪文件:默认不要动。
- 任务涉及共享接缝时,先在任务单里写清“为什么必须碰共享层”。
- 协作者推分支后,要附一段交接说明,明确本次只改了什么、没改什么、风险是什么。
- `WuYongJie` 收口同事改动时,优先按小任务合并,不一次性吞并长期积压改动。
- 如果任务只是要把局部工作区做完整,默认不要直接在 `src/app/draft-groups/[id]`、`src/app/intake` 这类共享接缝里堆实现;先确认是否该在当前工作域内独立收口。
- 如果这次改动会影响 `intake / warehouse / Product / SKU` 承接链,收口时额外确认:有没有把局部协作逻辑误带进正式入库主链,或者把旧兼容链重新当成主链真相。
---
## 10. 每次任务的交接口径
## 5. 人和 AI 都要遵守的几条硬规则
交接时讲清楚:
### 先修根因,不打补丁
- 本次解决什么问题。
- 改了哪些模块和共享接缝。
- 是否同步过 `origin/codex/xuxiake-mainline`。
- 跑了哪些验证,失败了什么。
- 是否部署,部署来源 commit 是什么。
- 规范审查结论是什么。
- 影响 3D 拼装、命名、包装、BOM、安装步骤的数据,优先在数据源修。
- 不允许只在页面上压报错、绕校验。
规范审查固定只查三件事:
### 不跨层
- 架构有没有被破坏。
- 视觉有没有跑偏。
- 这次是不是根因修复。
- Page / Route 不直接写 Prisma 业务逻辑。
- Route 负责收参、鉴权、返回。
- Service 负责业务规则。
- Repository 负责数据访问。
结论只能是 `passed`、`passed with warnings` 或 `rejected`。
如果这次只是自审,必须明确写“这是自审,不是独立复审”。
### 不混主链和兼容链
- 新平台主链的数据真相在 `AssetFamily / AssetVariant / DraftGroup / PreSku`。
- 旧 `Product / SKU / Panel / Hole / ProductHardware` 继续服务兼容链。
- 包装本期要能挂接 `PreSku`,但不直接跳过草稿组去造正式产品。
### 不破坏统一视觉
- 新平台页面优先复用 `src/components/platform`。
- 旧装配编辑器继续走现有视觉体系,不另起一套杂样式。
---
## 6. 本地目录管理规则
### 需要长期保留的
- 源码
- 迁移
- 测试
- 正式文档
- 确认要进仓库的设计稿或说明
### 默认视为本地产物的
- `.dev-*.log`
- `.qa-*.log`
- `.start-*.log`
- `.next-start-*.log`
- `dev-server.log`
- `.codex-logs/`
- `_logs/`
- `output/`
- `temp/`
- `public/uploads/`
### 推荐习惯
- 提交前先运行:`npm run clean:local`
- 然后再看:`git status --short`
---
## 7. 提交前最少要交代什么
- 这次改的是哪个模块
- 本次只做了什么
- 哪些地方明确没做
- 跑了哪些验证
- 有没有风险或依赖别人继续接
详细清单见:`docs/workflow/提测与验收清单.md`
---
## 8. 给 Codex 的一句话协作口径
如果要把任务转给 AI,建议在任务里直接写明:
- 当前目标
- 所属模块
- 禁止改动的目录
- 验收标准
- 需要同步更新的文档
模板见:`docs/workflow/任务单模板.md` 和 `docs/workflow/同事交接模板.md`
---
## 9. 团队共享 AGENTS 和个人 AGENTS 怎么分
当前默认规则:
- 仓库里的 `AGENTS.md` 是团队共享规则
- 个人额外规则不要直接改仓库根目录那份
- 谁要给自己的 Codex 加私有约束,就在自己电脑上的项目外层工作区再放一份本地 `AGENTS.md`
推荐说明:
- 共享规则:继续放仓库里,大家共用
- 个人规则:放各自机器上的外层工作区,不进仓库
对应说明和模板见:
- `docs/workflow/同事本地AGENTS使用说明.md`
- `docs/workflow/同事本地AGENTS模板.md`
- `docs/workflow/高频协作五条铁律.md`

View File

@ -21,16 +21,13 @@ SELECT *
FROM external.product_catalog_wide_v1;
```
这张视图是一条码一行。正式 `Product / ProductSKU` 已存在时输出正式条码商品行;正式商品尚未落库但 non-integrated 已进入 ready 状态时,补输出可消费的开发阶段条码行。外部系统必须按 `barcode_business_key` 做主同步、去重和幂等更新;同一 SKU 下多个条码会输出多行。
这张视图是一 SKU 一行。正式 `Product / ProductSKU` 已存在时输出正式商品行;正式商品尚未落库但 non-integrated 已进入 ready 状态时,补输出可消费的开发阶段 SKU 行。外部系统按 `barcode_business_key` 做主同步。
补充口径:
- non-integrated ready 行会带上主图、开发阶段限价和基础商品字段。
- non-integrated ready 行会带上产品开发明细页的负责人字段:外观设计师、结构设计师、SPU 负责人、商品企划、空间设计师、采购负责人;正式商品行没有开发阶段上下文时这些列保持空值。
- non-integrated ready 行尚未正式化时,正式 BOM、包装库存、仓库库位和 SKU 质检摘要字段会保持 `0 / NULL / []`。
- 需要核对开发阶段头信息或 SKU 明细真值时,继续读取 `external.intake_products_ready_v1 / external.intake_sku_items_ready_v1`。
- 图片、模型、贴图等 URL 字段在外部视图中输出为完整系统入口,例如 `https://cp.jiansun.vip/api/storage/object?key=...`。这些入口仍由应用侧鉴权和对象存储跳转控制,不把私有桶短期签名 URL 固化进数据库。
- 非生产环境如需使用其他站点域名,可由数据库管理员设置 `ALTER DATABASE <DB_NAME> SET app.external_asset_base_url = '<APP_ORIGIN>';`;未设置时默认使用正式站点 `https://cp.jiansun.vip`。
限价口径固定如下:
@ -42,15 +39,14 @@ FROM external.product_catalog_wide_v1;
| 方向 | 推荐视图 | 用途 |
| --- | --- | --- |
| 产品资料 | `external.product_catalog_wide_v1` | 一条码一行的商品目录宽表,覆盖正式条码与可消费的 non-integrated ready 条码 |
| 产品资料 | `external.product_catalog_wide_v1` | 一 SKU 一行的商品目录宽表,覆盖正式 SKU 与可消费的 non-integrated ready SKU |
| 产品限价 | `external.intake_product_limits_v1` | 非集成产品限价来源明细 |
| 产品资料明细 | `external.product_assets_v1` / `external.product_bom_v1` | SKU 素材和 BOM 明细 |
| 仓库管理 | `external.warehouse_locations_v1` | 库位资料 |
| 仓库管理 | `external.sku_warehouse_stock_v1` | 正式 SKU 库存和库位 |
| 仓库管理 | `external.packaged_items_v1` | 已关联正式 SKU 的包装库存项 |
| 仓库质检 | `external.sku_quality_inspections_v1` | SKU 质检记录 |
补充说明:`external.product_assets_v1` / `external.product_bom_v1` 是外部契约巡检覆盖的明细视图,但不在当前 `external_core_reader` 核心授权包内;外部系统确实需要逐条素材或 BOM 时,必须单独评审并追加白名单授权。
暂不开放:
- `public` schema 原表。
@ -149,60 +145,7 @@ psql "postgresql://external_core_reader:<REDACTED_SECRET>@<DB_HOST>:5432/<DB_NAM
psql "$DATABASE_URL" -f scripts/sql/check-external-readonly-contract.sql
```
## 7. 同步到 JSReportReplica.app 中间库
如果外部系统不直接连本项目 PostgreSQL,而是统一从 `JSReportReplica` 中间库读取,本项目维护一份 `external` 核心视图到 `app` schema 的项目专属前缀表同步。
`JSReportReplica.app` 是多个项目共用的应用 schema,本项目同步目标表固定使用 `furniture3d_` 前缀,避免和其他项目的对外数据混用。源库视图仍是 `external.*`,只有中间库目标表加前缀。
默认同步表:
- `app.furniture3d_product_catalog_wide_v1`
- `app.furniture3d_intake_product_limits_v1`
- `app.furniture3d_warehouse_locations_v1`
- `app.furniture3d_sku_warehouse_stock_v1`
- `app.furniture3d_packaged_items_v1`
- `app.furniture3d_sku_quality_inspections_v1`
- `app.furniture3d_external_readonly_sync_runs`:同步运行日志。
主宽表仍然是:
```sql
SELECT *
FROM app.furniture3d_product_catalog_wide_v1;
```
同步入口:
```bash
npm run sync:external-jsreport
npm run sync:external-jsreport:apply
```
第一个命令只演练并输出源视图行数;第二个命令才会写入 `JSReportReplica.app`。脚本会先写临时表,再替换正式表,避免同步中途留下半截数据。
生产定时器安装入口:
```bash
bash scripts/install-external-jsreport-sync-timer.sh
```
安装脚本只会在生产服务器本地生成 `/opt/furniture-3d-system-prod/shared/external-jsreport-sync.env` 模板。真实连接信息只允许写在这个本地 env 文件里,并保持 `600` 权限,不要写入 Git、文档或群公告。
必要环境变量:
- `DATABASE_URL` 或 `EXTERNAL_JSREPORT_SOURCE_DATABASE_URL`:源 PostgreSQL 连接。
- `JSREPORT_REPLICA_HOST`
- `JSREPORT_REPLICA_PORT`
- `JSREPORT_REPLICA_DATABASE`
- `JSREPORT_REPLICA_USERNAME`
- `JSREPORT_REPLICA_PASSWORD`
- `JSREPORT_REPLICA_SCHEMA`
- `EXTERNAL_JSREPORT_TARGET_TABLE_PREFIX`:中间库目标表前缀,默认 `furniture3d_`。
默认定时频率是每 45 分钟同步一次。安装后 5 分钟触发首次同步,后续按上次同步完成时间间隔 45 分钟再次执行;可通过 `EXTERNAL_JSREPORT_SYNC_ON_BOOT_SEC` 调整首次触发延迟,通过 `EXTERNAL_JSREPORT_SYNC_INTERVAL` 调整同步间隔。
## 8. 规范审查
## 7. 规范审查
结论:`passed`

View File

@ -8,8 +8,8 @@
- [ ] 任务范围没有失控,没有顺手改无关模块
- [ ] 关键文档已同步
- [ ] `git status --short --branch` 已看过,知道哪些是本次改动、哪些是已有现场
- [ ] `git status --short` 里没有准备误提交的日志、缓存、临时文件
- [ ] 先跑过 `npm run clean:local`
- [ ] `git status --short` 里没有不该提交的日志、缓存、临时文件
- [ ] 改动文件和任务单一致
- [ ] 自己能用一句话说清根因和修复方式
@ -45,8 +45,6 @@ npm run build
- [ ] 关键按钮/表单/保存动作能走通
- [ ] 关键接口返回正常
- [ ] 没有把别的入口改坏
- [ ] 前端视觉改动按 1920x1080 做过浏览器自查;移动端受影响时也补移动端自查
- [ ] 图片、AI、3D 或素材类页面确认主视觉不被说明块、统计块或弹层遮挡
---
@ -64,7 +62,6 @@ npm run build
- `docs/workflow/团队协作模式.md`
- `docs/workflow/模块边界说明.md`
- `docs/workflow/主线同步操作手册.md`
- 对应模块的专项说明文档
---

View File

@ -1,297 +1,272 @@
# 模块边界说明
适用范围:`furniture-3d-system/`。
目标:让前端、后端、数据库、AI 和包装协作时都知道自己该接在哪条链路上,避免把历史模块、新平台主链和运营入口混在一起。
目标:让人和 AI 在开工前先知道“哪里是新平台主链,哪里是旧兼容链,哪里不能乱碰”。
配套入口:
- 先读 `docs/workflow/团队协作模式.md`,确认你属于哪个工作域、该用什么分支名
- 再读 `docs/workflow/主线同步操作手册.md`,确认你当前分支该用 `merge origin/codex/xuxiake-mainline` 还是 `rebase origin/codex/xuxiake-mainline`
- 这份文档只负责讲边界,不额外创造新的角色体系或新的协作入口
- 这份文档只固定系统边界,不固定当前负责人;具体任务归属以当前任务单、交接摘要和已确认协作信息为准
---
## 1. 页面入口边界
## 1. 页面与入口边界
### 主业务链路
### 新平台主链入口
主业务链路是:
- `/`:新平台首页导航
- `/asset-center`:资产中心
- `/assisted-assembly`:辅助装配(手动拼装工作台入口)
- `/draft-groups`:草稿组列表
- `/draft-groups/[id]`:草稿组详情
- `/intake`:正式入库
- `/warehouse`:轻仓库
```text
资产中心 -> 辅助装配 -> 草稿组 -> 产品资料/正式入库 -> 仓库管理/轻仓库
```
### 旧兼容链入口
对应入口:
- `/products`
- `/products/[id]`
- `/products/[id]/assembly`
- `/products/[id]/skus/[skuId]`
- `/products/[id]/skus/[skuId]/assembly`
- `src/app/asset-center/`
- `src/app/assisted-assembly/`
- `src/app/draft-groups/`
- `src/app/intake/`
- `src/app/warehouse/`
### 不要混的原则
### 运营和工作台入口
- 新平台入口负责“资产准备、辅助装配、草稿组承接、入库前整理”。
- 旧兼容链负责“历史产品、CAD 导入、板件/孔位/五金/装配步骤”。
- 不要把旧产品详情页重新改成新平台首页。
这些入口服务工作台、看板、质检或 AI 生产,不等于主数据链路本身:
---
## 2. 新平台主链边界
### 前端目录
- `src/app/page.tsx`
- `src/app/home/`
- `src/app/intake/oa-board/`
- `src/app/warehouse-quality/`
- `src/app/ai-render-workbench/`
- `src/app/profile/`
- `src/app/users/`
- `src/app/admin/`
- `src/app/trash/`
- `src/app/asset-center`
- `src/app/assisted-assembly`
- `src/app/draft-groups`
- `src/app/intake`
- `src/app/warehouse`
- `src/components/platform`
### 旧 CAD 兼容入口
### API 目录
旧 CAD 链路继续保留,但它不是新平台主链:
- `src/app/api/asset-center`
- `src/app/api/assisted-assembly`
- `src/app/api/draft-groups`
- `src/app/api/intake`
- `src/app/api/planning`
- `src/app/api/pre-skus`
- `src/app/api/series-templates`
- `src/app/api/warehouse`
- `src/app/products/`
- `src/app/products/[id]/`
- `src/app/products/[id]/assembly/`
- `src/app/products/[id]/skus/[skuId]/assembly/`
- `src/components/assembly/`
- `src/components/3d/`
### 服务与仓库
旧链路可以修 bug,但不能把旧对象模型反向扩散到新平台主链。
- `src/server/services/asset-center.service.ts`
- `src/server/services/draft-group.service.ts`
- `src/server/services/intake.service.ts`
- `src/server/services/planning.service.ts`
- `src/server/services/series-template.service.ts`
- `src/server/services/warehouse.service.ts`
- `src/server/repositories/asset-center.repository.ts`
- `src/server/repositories/draft-group.repository.ts`
- `src/server/repositories/planning.repository.ts`
- `src/server/repositories/series-template.repository.ts`
- `src/server/repositories/warehouse.repository.ts`
### 纯规则与类型
- `src/lib/open-product`
- `src/lib/planning`
### 这里的业务真相是什么
- 资产族:`AssetFamily`
- 规格款:`AssetVariant`
- 草稿组:`DraftGroup`
- 准 SKU:`PreSku`
- 入库结果:正式 `Product / SKU`
### 不要做的事
- 不直接从辅助装配跳过草稿组生成正式产品。
- 不把历史 `auto-combinations` 旧链重新带回当前主线。
- 不把旧 `Product / SKU` 当作新平台组合阶段的数据真相。
- 不把包装做成绕过草稿组的独立系统。
---
## 2. 新平台主链模块
## 3. 包装与仓库协作边界
### 资产中心
### 当前包装与仓库主要入口分四条
职责:
- 轻仓库页面:`src/app/warehouse`
- 轻仓库接口:`src/app/api/warehouse`
- 新平台承接口:`src/app/api/pre-skus/[id]/packaging/route.ts`
- 旧正式产品口:`src/app/api/products/[id]/packaging`
- 管理产品族、规格、材质、花色、元组件和基础资产。
- 为辅助装配、草稿组、产品资料和 AI 链路提供上游事实。
### 主要服务与仓库
主要位置:
- `src/server/services/warehouse.service.ts`
- `src/server/repositories/warehouse.repository.ts`
- `src/server/services/packaging.service.ts`
- `src/server/repositories/packaging.repository.ts`
- `src/app/asset-center/`
- `src/app/api/asset-center/`
- `src/components/platform/`
### 当前约定
不要在资产中心里直接完成正式入库、仓库流转或批次质检。
- 包装结果最终按单个 `PreSku` 保存。
- 正式产品包装链保留,作为兼容与发布后使用。
- 轻仓库工作区可以独立协作推进,但轻仓库承接的正式 `Product / SKU` 仍然属于主线真相,不在仓库侧重复造数。
- 工艺图组合本期只预留接口,不在主线里单独展开。
- 草稿组详情页里的包装区只负责主线最小联通版占位,不是最终包装工作区。
- 轻仓库可以增强包装成品、库存、库位和展示交互,但不能绕过正式入库单独制造主链结果。
### 辅助装配
### 包装/仓库线可读但不默认直改的共享接缝
职责:
- 基于资产和元组件生成或维护装配方案。
- 输出可进入草稿组的中间结果。
主要位置:
- `src/app/assisted-assembly/`
- `src/app/api/assisted-assembly/`
- `src/lib/planning/`
- `src/app/api/planning/`
辅助装配不能绕过草稿组直接写正式产品。
### 草稿组
职责:
- 承接辅助装配结果。
- 管理进入产品资料前的草稿、预 SKU、批次和确认状态。
主要位置:
- `src/app/draft-groups/`
- `src/app/api/draft-groups/`
- `src/server/services/draft-group.service.ts`
- `src/server/repositories/draft-group.repository.ts`
草稿组是高共享接缝,改动时必须检查 `intake`、`pre-skus`、包装和仓库调用方。
### 产品资料和正式入库
职责:
- 管理正式入库前后的产品资料。
- 承接非集成实拍图、AI 白底图、素材库回传、SKU 和批次上下文。
主要位置:
- `src/app/intake/`
- `src/app/api/intake/`
- `src/server/services/intake.service.ts`
- `src/lib/open-product/`
- `src/app/intake`
- `prisma/schema.prisma`
- `src/app/draft-groups/[id]`
非集成产品链路使用 `IntakeNonIntegratedProduct / IntakeNonIntegratedSkuItem`。这些对象不能和正式 `Product / SKU`、`AssetVariant` 或 `PreSku` 随便互换。
这些文件不是包装/仓库线的默认自由修改区。
如果任务必须改这里,先在任务单里写清原因、影响范围,再由主线负责人确认后再动。
### 仓库管理和仓库质检
白话理解:
职责:
- 可以看,可以评估,可以提出接缝需求
- 不能在没有对齐的情况下直接开改
- 特别是 `draft-group`、`intake`、`schema.prisma` 这几处,本阶段只允许做边界说明,不允许顺手把实现也改掉
- 如果只是为了补包装 UI、材料选择器、最低成本方案,或补轻仓库展示与交互,不应默认落在这些共享接缝里完成
- 主线收口人检查同事分支时,如果这些共享接缝被改了,默认不要直接整支 `merge`;优先确认是否该 `cherry-pick` 或手工整合
- `warehouse` 处理仓库管理和轻仓库流转。
- `warehouse-quality` 处理仓库质检工作台和质检任务。
### 包装与仓库负责人不要乱碰的地方
主要位置:
- `src/app/warehouse/`
- `src/app/api/warehouse/`
- `src/app/warehouse-quality/`
- `src/app/api/warehouse-quality/`
仓库质检可以读取产品和 SKU 上下文,但不能在没有业务确认的情况下替代产品资料链路创建正式 SKU。
- 辅助装配主链规则(含手动拼装工作台保存口径)
- 草稿组转正式 SKU 的规则
- 正式入库的核心落库规则
- 旧 CAD 的板件/孔位/五金推导链
---
## 3. SPU、SKU、barcode 和批次
## 4. 旧 CAD 兼容链边界
这四个概念是当前产品资料和素材库对接的硬边界:
### 页面目录
- SPU 是产品款。
- SKU 是 SPU 下的具体子项。
- barcode 是逐步收敛的唯一业务定位值。
- 批次是当前产品资料链路的批次上下文。
- `src/app/products/[id]`
- `src/app/products/[id]/assembly`
- `src/app/products/[id]/skus/[skuId]`
- `src/app/products/[id]/skus/[skuId]/assembly`
- `src/components/assembly`
- `src/components/3d`
规则:
### API 目录
- 不能把 SPU、SKU、barcode 当成同一个字段混用。
- 基于某个 SKU 换色得到的新 SKU,只能属于同一个 SPU。
- 换色得到的新 SKU 只能归属当前批次,不能漂到其他批次。
- 如果外部素材库要求 barcode,必须传真实产品条码,不能拿 SKU 编号或 SPU 编号代替。
- 如果一个功能还只是草稿或预览,命名里要写清楚 `draft`、`preview` 或 `pending`,不要伪装成正式 SKU。
- `src/app/api/products/[id]/assembly`
- `src/app/api/products/[id]/panels`
- `src/app/api/products/[id]/holes`
- `src/app/api/products/[id]/import-hardware`
- `src/app/api/holes`
- `src/app/api/hardware`
- `src/app/api/product-hardware`
### 服务与库
- `src/server/services/assembly.service.ts`
- `src/server/services/panel.service.ts`
- `src/server/services/hole.service.ts`
- `src/server/services/hardware.service.ts`
- `src/server/services/sku-cad-import.helper.ts`
- `src/server/repositories/panel.repository.ts`
- `src/server/repositories/hole.repository.ts`
- `src/server/repositories/hardware.repository.ts`
- `src/server/repositories/product-hardware.repository.ts`
### 纯规则与共享链路
- `src/lib/assembly-generator.ts`
- `src/lib/panel-assembly-semantic.ts`
- `src/lib/sku-cad-data.ts`
- `src/lib/3d/engine.ts`
### 当前约定
- 装配语义是旧链增强能力,不是新平台主数据源。
- 旧链可以继续生成安装步骤和预览,但不要反向主导新平台资产模型。
---
## 4. AI、图片和素材库边界
## 5. 共享层边界
AI 白底图、AI 出图工作台、素材库回传和对象存储相关功能必须遵守:
- 图片上传后,数据库写入失败时要回滚刚上传的对象。
- 软删除、回收站记录仍然算引用,不能同步删除文件。
- 七牛或其他对象存储清理只能通过审计确认无引用后执行。
- 图片类页面以图片为第一视觉重点,不堆重复说明和统计块。
- 查看原图、缩放、拖拽、生成、上传素材库这些能力应就近服务当前产品、SKU、批次上下文。
主要位置:
- `src/app/ai-render-workbench/`
- `src/app/api/intake/`
- `src/app/api/storage/`
- `src/app/api/external/`
- `src/components/platform/`
素材库对接时必须严格区分 SPU、SKU、barcode 和产品分类。
---
## 5. 旧 CAD 兼容链路
旧链路主要围绕:
- `Product`
- `SKU`
- `Panel`
- `Hole`
- `ProductHardware`
- 旧 3D 组装和板件孔位编辑
主要位置:
- `src/app/products/`
- `src/app/api/products/`
- `src/app/api/skus/`
- `src/app/api/panels/`
- `src/app/api/holes/`
- `src/app/api/product-hardware/`
- `src/components/assembly/`
- `src/components/3d/`
旧链路可以和新平台共享基础数据,但不能把新平台的草稿组、资产中心、非集成产品资料逻辑硬塞进旧 CAD 编辑器。
---
## 6. 共享基础层
下面位置属于全项目共享基础层:
### 所有人都可能会碰到,但不要随手改
- `prisma/schema.prisma`
- `prisma/migrations/`
- `prisma/migrations`
- `src/server/services/index.ts`
- `src/server/repositories/index.ts`
- `src/app/layout.tsx`
- `src/components/layout/`
- `src/components/platform/`
- `src/app/api/platform/schemas.ts`
- `src/app/api/storage/`
- `src/lib/prisma.ts`
- `src/server/repositories/`
- `src/server/services/`
要求:
共享层边界的意思是:
- 前端页面优先使用平台组件和元组件,不绕开组件库单独造一套视觉。
- API 保持 `Route -> Service -> Repository` 分层,不在 route 里堆业务逻辑。
- Prisma schema 变化必须配套 migration,不能在生产或预生产直接 `prisma db push`。
- 共享服务改动后必须看调用方,不能只跑当前页面。
- 这些地方一改,通常会同时影响主链、包装线或兼容链
- 所以默认先对齐影响面,再决定谁来改,不是谁先看到谁就顺手动
### 共享层规则
- 任何 Prisma 模型调整,都必须带迁移。
- 任何新接口,都要有明确输入输出 DTO。
- 任何跨模块字段变更,都要先同步文档,再改代码。
- 没有明确任务授权时,不要顺手扩写到 `draft-group`、`intake`、`schema.prisma` 这些敏感接缝实现。
---
## 7. 视觉边界
## 6. 看到这些情况要先停一下
当前视觉标准:
- 第一标准:白曜石工坊统一视觉。
- 实现载体:项目组件库、元组件和 `src/components/platform/`。
- 外部设计稿:Stitch 是规格和验收标准,不替代项目组件体系。
- 项目内参考:资产中心页面优先级最高。
不要做:
- 普通 SaaS 卡片墙。
- 营销式大 hero。
- 旧苹果风玻璃大面积正文。
- 默认黑底后台。
- 一堆重复说明块、重复统计块和无效状态块。
表格、表单、Inspector、列表和长文本必须优先保证可读性。
- 一个任务同时改新平台主链和旧兼容链,但任务单里没写原因。
- 包装任务直接改共享接缝文件,但没先和主线负责人对齐。
- Route 里开始直接写 Prisma 查询。
- 包装开始自己造一套 `Product / SKU`。
- 页面样式开始绕开 `src/components/platform` 和既有视觉标准。
- 改动影响命名、BOM、包装、安装步骤,但没说明数据来源。
- 分支改动会影响 `intake / warehouse / Product / SKU` 承接链,但交接里没说明对正式入库或轻仓库的影响。
---
## 8. 数据库和迁移边界
## 7. 开工前最少要看什么
数据库相关任务先看:
### 做新平台主链
- `docs/workflow/数据库迁移操作手册.md`
- `docs/开发指南/数据库基础命令速查.md`
- `prisma/schema.prisma`
- `prisma/migrations/`
先看:
规则:
- `docs/workflow/团队协作模式.md`
- `src/components/platform/platform-navigation.ts`
- `src/lib/open-product/types.ts`
- `src/lib/open-product/assembly-workspace.ts`
- `src/lib/open-product/manual-assembly-launch.ts`
- `src/server/services/draft-group.service.ts`
- 结构变化走 migration。
- 数据修复先只读审计,再写修复脚本。
- 生产数据操作必须有用户明确授权。
- 不把一次性修复脚本伪装成长期业务逻辑。
### 做包装或仓库协作
---
先看:
## 9. 改动前最小检查
- `docs/workflow/团队协作模式.md`
- `docs/开发指南/包装方案增强/包装方案增强-同事交接说明.md`
- `src/app/warehouse`
- `src/app/api/warehouse`
- `src/app/api/pre-skus/[id]/packaging/route.ts`
- `src/server/services/packaging.service.ts`
- `src/server/services/draft-group.service.ts`
不知道该从哪里开始时,先看这些文件:
### 做旧 CAD 兼容链
- `AGENTS.md`
- `src/app/page.tsx`
- `src/components/layout/`
- `src/components/platform/`
- `src/app/asset-center/`
- `src/app/intake/`
- `src/app/draft-groups/`
- `src/app/api/platform/schemas.ts`
- `prisma/schema.prisma`
先看:
如果任务涉及生产、部署、数据库或对象存储,先只读核查现场,再决定是否写入。
---
## 10. 判断是否越界
出现下面情况,先停下来重新确认:
- 为了修一个页面,改了 schema、全局 layout 或共享 service。
- 为了一个 SKU,把 SPU、barcode、批次一起改了。
- 为了临时过 UI,把旧 CAD 模型塞进新平台主链。
- 为了清理文件,准备删除软删除或回收站仍引用的对象。
- 为了追视觉,把页面绕开组件库重写成另一套体系。
- 为了赶部署,跳过主线同步、构建、健康检查或规范审查。
越界不一定代表不能做,但必须说明原因、影响范围、验证方式和回滚路径。
- `docs/solutions/workflow-issues/CAD插件装配语义导出改造说明-20260325.md`
- `src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
- `src/components/assembly/AssemblyEditorClient.tsx`
- `src/server/services/assembly.service.ts`
- `src/lib/assembly-generator.ts`

View File

@ -15,57 +15,12 @@
## 本仓库部署脚本
- 首选入口:仓库根目录 `scripts/deploy-yydd-prod-from-git.ps1`
- 底层打包脚本:仓库根目录 `scripts/create-yydd-prod-deploy.ps1`
- 外层仓库脚本:`../scripts/create-yydd-prod-deploy.ps1`
执行部署或生产核查前,优先读取上面两个脚本的当前默认参数,并在远端实时确认当前 PM2 进程、`current` 软链接和健康检查结果。部署入口默认从 `origin/main` 取代码,发布前必须核对远端 `refs/heads/main` 和本地 `origin/main` 是否一致。
底层发布流程会执行依赖安装、`prisma generate`、迁移、构建、PM2 重启和健康检查。因此生产发布前必须先确认迁移目录、迁移历史和主数据 strict 巡检口径没有分叉。
执行部署或生产核查前,优先读取上面脚本的默认参数,并在远端实时确认当前 PM2 进程、`current` 软链接和健康检查结果。该脚本生成的远端发布脚本会执行 `npx prisma migrate deploy`,因此生产发布前必须先确认迁移目录、迁移历史和主数据 strict 巡检口径没有分叉。
只做生产/目标库地基核查时,优先在远端应用目录执行 `npm run db:foundation:audit:production-readonly`。该命令不事务回放治理迁移 SQL,只通过只读迁移状态和质量查询生成 `.master-data-audits/foundation-production-readonly-current/manifest.json`,适合把脱敏 summary 或 manifest 交回主线收口。拿到 manifest 后,用 `npm run db:foundation:verify-production-readonly -- --manifest <manifest.json>` 做机器验收;只有 `accepted: true` 才能把这份证据计入生产级地基收口。
## 聚水潭 RPA 登录凭据
- 本地可在 `furniture-3d-system/.env.jst-rpa-auth` 保存
`JST_RPA_LOGIN_ID` 和 `JST_RPA_LOGIN_PASSWORD` 两项。该文件被 `.env*`
忽略规则排除,禁止提交 Git。
- 正式部署脚本会自动发现该文件,上传到目标机 `incoming` 临时目录,再安全合并进
`/opt/furniture-3d-system-prod/shared/.env`;正式 `.env` 和临时文件均保持
`600`,临时文件在部署结束后删除。
- 使用其他安全位置时,可给部署入口显式传
`-JstRpaAuthFile <凭据文件绝对路径>`。未提供文件时会保留正式机已有值,不会被空值覆盖。
- 部署完成后可执行
`node_modules/.bin/tsx scripts/jst-shop-goods-rpa.ts --action=login`
验证登录态,不执行链接导出。
## 限价每日快照
- 生产安装入口:`furniture-3d-system/scripts/install-limit-price-snapshot-systemd-timer.sh`。
- systemd 名称:`furniture-limit-price-snapshot.service` / `furniture-limit-price-snapshot.timer`。
- 默认每天中国标准时间 `09:30` 执行,可在安装时通过 `LIMIT_PRICE_SNAPSHOT_CALENDAR` 覆盖。
- 快照持久目录默认 `/opt/furniture-3d-system-prod/shared/limit-price-snapshots`,安装器会把该路径写入生产 `.env` 的 `SUPPLY_CHAIN_LIMIT_SNAPSHOT_DIR`,避免部署切换版本后丢失。
- 任务先更新产品档案、限价、物流规则并完整对账正式产品全量档案,全部成功后才原子切换 `latest.json`。
- 安装器会检查数据库和聚水潭报表库环境变量;生产 `.env` 更新后需要重启正式应用进程。
- 安装、启用定时器和首次生产快照属于生产操作,需要单独授权。
## 模块化加工单每日生成
- 生产目标为 Linux,使用 systemd,不使用 macOS launchd。
- 安装入口:`furniture-3d-system/scripts/install-modular-processing-daily-snapshot-timer.sh`。
- systemd 名称:`furniture-modular-processing-daily-snapshot.service` / `furniture-modular-processing-daily-snapshot.timer`。
- 默认每天中国标准时间 `09:30` 执行,可通过 `MODULAR_PROCESSING_SNAPSHOT_CALENDAR` 覆盖。
- 安装器默认在启用 timer 后立即补跑当天快照,避免上线当天仍显示“未生成”;只需安装、不立即执行时设置 `MODULAR_PROCESSING_SNAPSHOT_RUN_NOW=0`。
- 安装命令:
```bash
sudo MODULAR_PROCESSING_SNAPSHOT_RUN_NOW=1 \
bash /opt/furniture-3d-system-prod/current/furniture-3d-system/scripts/install-modular-processing-daily-snapshot-timer.sh
```
- 运行状态:`systemctl list-timers --all furniture-modular-processing-daily-snapshot.timer --no-pager`
- 最近日志:`journalctl -u furniture-modular-processing-daily-snapshot.service -n 100 --no-pager`
- 手动补跑:`systemctl start furniture-modular-processing-daily-snapshot.service`
## 花名册同步
- 花名册同步属于当前 `furniture-3d-system` 自有能力,不依赖 `shujuku2` 或旧入口机。
@ -76,57 +31,6 @@
- 定时频率:每天 `03:00` 和 `15:00`。
- 源库连接信息只允许放在生产本地 `/opt/furniture-3d-system-prod/shared/employee-directory-sync.env`,不能写入 Git 或共享文档。
- 生产应用内部接口密钥继续从 `/opt/furniture-3d-system-prod/shared/.env` 的 `HR_SYNC_SECRET` 读取。
- 人员信息表的钉钉组织同步使用 `furniture-3d-system/scripts/install-dingtalk-directory-sync-timer.sh`,生产 systemd 名称为 `furniture-dingtalk-directory-sync.service` / `furniture-dingtalk-directory-sync.timer`。
- 钉钉同步每天 `03:00` 执行一次,读取生产共享 `.env` 中的 `DINGTALK_API_CLIENT_ID` 和 `DINGTALK_API_CLIENT_SECRET`,并自动更新人员信息;正式页面仍保持只读。钉钉网页登录使用独立的 `DINGTALK_LOGIN_CLIENT_ID`、`DINGTALK_LOGIN_CLIENT_SECRET`,两组凭据不能混用。
- 人员信息的每日更新口径:钉钉新人自动新增一行;在职人员按钉钉更新部门/负责人;人工覆盖(manualOverride)的行保持不变;**离职人员不做删除,只在页面把「在职状态」变为离职**(方案 B:先比对上一轮在册且本轮抓取范围内消失的人,再逐个调 `user/get` 确认 60121 用户不存在才标记;抓取人数明显缩水时整轮跳过并写入告警)。
- 首次部署后需在正式服务器执行该安装脚本,并手动执行一次 `npm run sync:dingtalk-directory:apply` 完成首次填充;后续由 systemd 每日执行。
## BOM 成本参考镜像
- 用户核算请求只读取本地 PostgreSQL 镜像,不在请求中连接上游 `rpt.pm_bomBaseInfo`。
- 低压只读预检:`npm run sync:bom-cost-reference -- --dry-run --max-pages=2`;默认单连接、每页 100 行、完整页之间等待 250ms。
- 真实同步必须显式传 `--apply`、固定确认词、目标 PostgreSQL 身份和来源 SQL Server 身份;上游登录必须是 `report_reader`,`app_writer` 会被程序拒绝。
- 生产定时器安装脚本:`furniture-3d-system/scripts/install-bom-cost-reference-sync-timer.sh`。
- 生产 systemd 名称:`furniture-bom-cost-reference-sync.service` / `furniture-bom-cost-reference-sync.timer`。
- 默认调度:每天中国标准时间 `02:00`,附带最多 10 分钟随机延迟;安装时可通过非敏感环境变量调整。
- 上游连接继续只使用生产共享 `.env` 中现有的 `JST_PLANNING_SQLSERVER_* / report_reader`;不在第二个文件中重复保存密码。
- 首次运行安装脚本只会创建权限为 `600` 的 `/opt/furniture-3d-system-prod/shared/bom-cost-reference-sync.env` 门禁与限速模板;目标/来源身份未填齐或现有只读配置不完整时拒绝安装定时器。
- 本地参考行只保留最新成功批次,避免每日约 76 万行无限增长;历史场景在自己的明细快照中保留来源行、指纹、单价、规则和原始计算依据,不依赖旧镜像行回查。
- 镜像新鲜度按最新成功批次的完成时间判断。上游当前可见的创建时间只作审计展示,不能用来判断当天同步是否过期。
- 准确度回测:`npm run backtest:bom-cost-reference -- --sample-size=30`,只读最新成功镜像,不访问上游;样本限制 20 至 50 个,任一可比较 SKU 超过固定 10% 误差门槛即失败。
- 迁移、真实同步、安装/启用定时器和生产回测都必须另行获得生产操作授权;部署代码本身不代表这些动作已经执行。
## 商品 SABC 月度核算
- 核算命令:`npm run run:product-sabc -- --year=2026 --month=8 --mode=APPLY --confirm=run-product-sabc`;试算(只算不写库)用 `--mode=PREVIEW`,末尾加 `--skip-if-exists` 可跳过已生成月份。
- 8 月人工表对账命令:`npm run compare:product-sabc-xlsx`,默认读取本机下载的人工工作簿;生产执行前应先完成产品全量档案日销售数据同步。
- 生产定时器安装脚本:`furniture-3d-system/scripts/install-product-sabc-timer.sh`。
- 生产 systemd 名称:`furniture-product-sabc-monthly.service` / `furniture-product-sabc-monthly.timer`。
- 默认调度:每月 3 日中国标准时间 `03:00`,附带最多 10 分钟随机延迟;服务生成上一个自然月的数据,已生成过则跳过(不覆盖管理员的重算结果)。
- 生成即冻结:每次生成会把整行明细写入 `SupplyChainProductArchiveSabcResult.snapshot`,页面、筛选下拉、导出和经营总览都只读取该快照。生成之后档案的销售状态、人员字段等变化都不会再改变已生成月份的数据。
- 重新生成(页面「执行核算」)仅限管理员(`ADMIN` / `SUPER_ADMIN`);其他账号只能查看冻结结果。本地一键刷新 `node scripts/local-sync-archive.mjs` 在每月 3 日前会跳过 SABC 步骤,需要立刻重算时用 `--force-sabc`。
- SABC 的销售收款总金额、真实业绩、发货件数、商品毛利额和售后金额统一读取 `SupplyChainProductArchiveSalesPerformanceDaily`,与产品全量档案页面保持同一月度口径。
- 首次安装生成权限为 `600` 的 `/opt/furniture-3d-system-prod/shared/product-sabc-timer.env`,默认 `PRODUCT_SABC_TIMER_MODE=APPLY`;如需先观察可临时改为 `PREVIEW`(只试算不写库、不冻结)。
- SABC 自动任务只读取现有 `DATABASE_URL`;上游日销售同步任务继续使用 `JST_REPORT_DB_*`,凭据不写入定时器配置。安装、迁移、首次 APPLY 和生产核算都需要单独授权。
## 操作日志保留策略
- 清理脚本:`npm run db:cleanup-operation-logs` 默认只做 dry-run;只有显式传入 `--apply --confirm=cleanup-operation-logs` 才会删除数据。
- 自动清理范围严格限定为 `OperationLog` 中 `SYSTEM + SUPPLY_CHAIN_PRODUCT_ARCHIVE + details.kind=UPDATED + SUCCESS` 且创建时间早于 30 天的字段变更。
- 用户操作、商品档案导入、失败/重试日志及其他业务范围日志至少保留 365 天;本次策略不自动删除超过 365 天的审计日志。
- 生产安装入口:`furniture-3d-system/scripts/install-operation-log-retention-timer.sh`。
- 生产 systemd 名称:`furniture-operation-log-retention.service` / `furniture-operation-log-retention.timer`,默认每天中国标准时间 `04:30` 执行,附带最多 10 分钟随机延迟。
- 安装脚本会在 `/opt/furniture-3d-system-prod/shared/operation-log-retention.env` 保存权限为 `600` 的数据库身份门禁和非敏感参数;身份未填写或保留天数不是 30 天时拒绝启用。
- 首次启用前必须先在生产执行 dry-run,核对候选数量、数据库身份和 systemd 落点;生产安装、首次 `--apply` 和后续代码部署均需另行获得授权。
## 停产需求确认超时收口
- 收口脚本:`furniture-3d-system/scripts/finalize-production-stop-confirmations.ts`,把超过确认截止时间仍未反馈的停产需求按「默认同意停产」落库,并给未反馈的确认人记 `TIMEOUT` 票。
- 生产安装入口:`furniture-3d-system/scripts/install-production-stop-confirmation-finalize-timer.sh`。
- 生产 systemd 名称:`furniture-production-stop-confirmation-finalize.service` / `furniture-production-stop-confirmation-finalize.timer`,默认每 10 分钟检查一次(北京时间),附带最多 1 分钟随机延迟;可用 `PRODUCTION_STOP_FINALIZE_CALENDAR` 覆盖调度。
- 收口逻辑幂等:只处理「已过截止时间且仍待确认」的需求,重复执行不会重复收口;失败由 systemd 在 5 分钟后自动重试。
- 运行日志:`journalctl -u furniture-production-stop-confirmation-finalize -n 100 --no-pager`,每次运行输出本次收口的需求数量。
- 首次安装与启用:在项目根执行 `bash scripts/install-production-stop-confirmation-finalize-timer.sh`(需 root,脚本写入 `/etc/systemd/system/` 并 `enable --now`);安装/启用定时器属于生产操作,需要另行获得授权。
## 明确排除
@ -142,18 +46,6 @@
2. 远端存在 `/opt/furniture-3d-system-prod`。
3. PM2 中存在 `furniture-3d-system-prod`。
4. 应用监听端口是 `3001`。
5. 部署来源是 `origin/main`,且远端 `main` 和本地 `origin/main` 一致。
6. 生产只读地基核查优先使用 `npm run db:foundation:audit:production-readonly`。
7. 生产只读 manifest 回传后,使用 `npm run db:foundation:verify-production-readonly -- --manifest <manifest.json>` 验收,不能人工挑几个字段就宣称生产通过。
8. 不把密码、密钥或连接串写入 Git、文档或长期记忆。
## 部署后核查
部署后不能只看公网 `/api/health`。至少补核:
- 远端 `REVISION` 或当前发布目录 commit。
- `pm2 describe furniture-3d-system-prod`。
- 应用端口 `3001` 是否监听。
- 最近错误日志是否有启动失败、数据库失败、对象存储失败。
- `https://cp.jiansun.vip/api/health`。
- 关键页面:首页、产品资料、仓库质检、非集成详情或本次改动涉及页面。
5. 生产只读地基核查优先使用 `npm run db:foundation:audit:production-readonly`。
6. 生产只读 manifest 回传后,使用 `npm run db:foundation:verify-production-readonly -- --manifest <manifest.json>` 验收,不能人工挑几个字段就宣称生产通过。
7. 不把密码、密钥或连接串写入 Git、文档或长期记忆。

View File

@ -14,8 +14,6 @@
- 有没有明显偏离当前统一视觉标准
- 新平台主链页面、后台管理页面是否优先符合 `白曜石工坊统一视觉标准.md`
- 是否仍把旧苹果风格规范当作当前视觉依据;新平台验收只按白曜石工坊口径判断
- 是否优先使用 `src/components/platform`、元组件和组件库,而不是另起一套页面皮肤
- 如果有 Stitch 设计稿,是否在组件库体系内对齐它的密度、层级、控件、留白、字号和低噪音表达
- 有没有临时拼凑感很强的新界面
- 新页面和现有页面的层级、间距、交互有没有失控
@ -40,6 +38,5 @@
- 明显破坏模块边界
- 明显破坏统一视觉
- 绕开组件库另造一套新平台视觉
- 明显只是补丁式修复
- 没做最小验证就宣称完成

View File

@ -1,23 +0,0 @@
# 铺货与业绩快照定时更新
本机 3001 的 Windows 计划任务 `Furniture-DistributionSnapshots-3001` 每天北京时间 09:00 顺序更新铺货明细、真实业绩。任务使用 `+08:00` 开始边界;错过时间后在机器可运行时补执行,重复触发不并发。电脑需开机、用户登录且公司报表库与本地 PostgreSQL 可连接。此任务未配置到正式站。
本机入口在 `D:\ChanPing\.local-services\scripts\run-distribution-snapshots.vbs`,依次调用隐藏 PowerShell、统一 Node 无窗口入口和个人凭据注入器。凭据仅运行时读取 `LOCAL_SECRETS.md` 的公司数据库节,不进入源码或日志。所有公司库操作均为 SELECT。
仓库入口 `scripts/sync-distribution-snapshots.ts` 验证来源和目标身份、持有任务锁,串行执行两个同步 CLI。日志及每次前后计数、含索引物理字节数、业绩统计区间报告写到 `D:\ChanPing\.local-services\logs\distribution-snapshots`。失败返回非零退出码,可从计划任务 LastTaskResult 和阶段日志定位;不自动报告成功。
铺货先读取目标管理、人员、店铺映射,字段一次补齐后写入连接专属临时表。自然键去重后核对实际条数、三类来源,较上一版减少超过 20% 时拒绝发布,等待人工核查。通过后在一个事务中替换正式表,失败保留上一版。最终替换阶段持有表锁,页面查询可能等待;临时表和会话锁会在进程退出后自动释放。没有新增长期全量副本,没有反复 UPDATE 数百万行。
组合补齐:`rpt.jst_comboProductInfo` 与 `rpt.jst_comboProductItem` 只提供完整套装编码、组成条码和数量。同步从三张 skumap 中同时匹配档案条码及包含档案条码的组合编码,每个原始套装规格仅保存一次。轻量组成关系写入 `SupplyChainDistributionComboItem`,与铺货规格在同一事务发布;读取视图 `SupplyChainDistributionListingResolved` 将套装规格关联到组成条码。页面按条码及平台、店铺ID、铺货ID去重,直接铺货与多个套装共享同一链接时计一次,组件数量不乘链接数。链接状态保持 skumap/目标管理原口径,不使用组合头 enabled 替代。关系缺失、组成为空或发布校验失败时保留上一批完整快照。
页面支持搜索组合编码/名称,并在链接明细的条码列查看“直接铺货”“组合套装”或两者共有及具体套装数量。Excel 铺货页保留原始货号和规格,新增独立“组合关系”工作表,避免重复保存/导出整份宽数据。业绩匹配可识别通过套装覆盖的子条码链接;只使用业绩源已经提供的子条码金额,不将套装金额复制或按数量自动分摊。
首次补齐可在两个同步 CLI 上显式增加 `--reuse-direct-snapshot`:先在本地数据库中将已发布的档案直接规格复制到临时表,再从三张源表读取组合规格,整体校验后统一替换。直接规格原有同步时间与内容保留,便于精确比较组合净增量。此选项不用于每日任务;定时任务默认完整刷新直接与组合规格。
业绩沿用独立版本发布:当月截至昨天以内源库最新可用日,按条码、店铺、铺货 ID 汇总;成功后保留当前和上一成功版本。每月 1 日当月无完整统计日时保留前一版并报出失败,页头继续显示实际统计周期。铺货与业绩不能作为一个跨库事务提交:铺货成功但业绩失败时,铺货保留新版本,业绩保留上一成功版,任务整体显示失败。
手动运行和定时执行使用相同入口,不并发补数。此文档不授权部署。
正式站经独立授权后,先核实目标库、来源库身份和磁盘余量,再通过统一部署入口应用三个迁移。服务器从公司库只读生成自己的快照,不复制整个本地数据库。个人凭据沿用 shared/marketing-sales-personal.env,目标和来源身份门禁放在权限为 600 的 shared/distribution-snapshots.env,分别为 DISTRIBUTION_EXPECTED_DATABASE 和 DISTRIBUTION_EXPECTED_SOURCE(host:port/database 格式)。来源连接的其他 JST_REPORT_DB_* 配置沿用 shared/.env。
生产安装入口 scripts/install-distribution-snapshots-timer.sh 校验身份及依赖后,启用 furniture-distribution-snapshots.timer,每天 Asia/Shanghai 09:00 执行;错过的调度补执行。对应 service 通过 scripts/run-distribution-snapshots.sh 运行同一串行同步入口,日志在 journalctl -u furniture-distribution-snapshots,前后条数与空间报告在 shared/logs/distribution-snapshots。手动首次执行使用 systemctl start --no-block furniture-distribution-snapshots.service;任务锁防止并发,失败状态需核查报告和日志。本机任务与生产任务独立,安装生产任务不会停用本机任务。

View File

@ -24,19 +24,10 @@
- 每天开始开发前,先同步一次最新 `origin/codex/xuxiake-mainline`
- 主负责人刚推了重要改动后,再同步一次
- 自己准备提交前,再同步一次
- 每次回答“主线有没有新提交”前,先 `git fetch origin`
白话理解:
不要拿旧主线继续往下改。
推荐固定检查:
```bash
git fetch origin
git status --short --branch
git rev-list --left-right --count HEAD...origin/codex/xuxiake-mainline
```
### 铁律 2:每次任务先写清边界
- 先说这次只改什么

View File

@ -1,33 +0,0 @@
# DIY 柜门图案方向修正:请核对并替换旧图片
状态:待发布的更新日志文案。后续正式发布时录入系统“更新日志管理”并发布,本文档本身不会向用户推送通知。
## 更新日志标题
DIY 柜门图案方向已修正,请核对并替换旧图片
## 更新日志摘要
DIY 柜门图案曾出现左右镜像,现已修正。如果此前使用过 DIY 截图、AI 白底图或场景效果图,请对照柜门原图核对方向;发现镜像的图片请重新出图,并替换已使用的旧图片。
## 已修正的内容
DIY 柜门图案在 3D 画面中曾左右镜像,部分图案、文字和动物的朝向可能与原图相反。现已修正柜门贴图方向,横竖切换后也按原图方向显示。
## 用过旧图片的同事需要处理
1. 核对修复前生成、下载或发布的 DIY 截图、AI 白底图和场景效果图,重点检查文字及图案的左右方向。
2. 如发现镜像,请打开原保存方案,刷新页面后对照柜门原图确认方向,重新截图或生成 AI 图片。
3. 将店铺商品图、详情页、方案资料及其他已使用位置中的错误图片替换为核对后的新图片。
## 已保存的方案和旧图片
已保存方案可以直接打开并重新出图,无需重新搭建,也不需要为这次显示方向修正重新上传柜门原图。
旧截图及已生成的 AI 图片不会自动改正,已下载或发布的文件也不会自动替换。请核对重新生成的图片后再使用;原本方向正确的图片可继续保留。
## 后续发布登记
- 这条说明需要作为用户可见更新日志发布,仅提交本文档不能代替发布通知。
- 发布时使用系统现有“更新日志管理”,覆盖使用 DIY 的同事;如不能准确筛选这批人员,按全员可见发布。
- 发布后确认普通使用者可看到标题、摘要及上述替换说明。不要把“修正 3D 预览”写成“旧图片已自动更新”。

View File

@ -1,8 +1,6 @@
# 3D 装配动画使用指南
> 如何使用装配动画功能展示家具组装过程
>
> 当前状态:历史兼容指南。旧文中的 Apple 风格表述只代表当时实现口径;新平台 3D、装配、资产工作台视觉以白曜石工坊标准、平台组件和当前资产中心样板为准。
---
@ -12,7 +10,7 @@
- ✅ 流畅播放 3D 装配动画(60 FPS)
- ✅ 分步骤展示组装过程
- ✅ 支持播放控制(播放/暂停/跳转)
- ✅ 历史实现使用 Apple 风格 UI;新平台应按白曜石工坊和平台组件重整
- ✅ Apple 风格的精美 UI
- ✅ 响应式设计
---
@ -107,9 +105,7 @@ interface Panel {
## 🎨 UI 特性
### 历史 Apple 风格设计
当前新平台不再把 Apple 风格作为第一标准;这里仅用于理解旧实现。
### Apple 风格设计
- **流畅动画**:使用 GSAP 实现 60 FPS 动画
- **毛玻璃效果**:`backdrop-blur-md`
@ -244,7 +240,7 @@ GLTF 节点名称应该清晰易懂:
- [Three.js 文档](https://threejs.org/docs/)
- [GSAP 文档](https://greensock.com/docs/)
- [Apple HIG 2025](https://developer.apple.com/design/human-interface-guidelines/):历史参考,不作为当前新平台验收标准
- [Apple HIG 2025](https://developer.apple.com/design/human-interface-guidelines/)
---

View File

@ -1,7 +1,5 @@
# 产品上传 API 使用指南
> 当前状态:历史兼容指南。本文记录旧产品上传链路,里面出现的 Apple HIG、MinIO 等表述不能作为当前新平台视觉或生产存储真相。新前端视觉以 `docs/设计规范/白曜石工坊统一视觉标准.md` 和 `docs/开发指南/组件使用指南.md` 为准;对象存储以当前代码、`src/lib/storage`、对象存储接入文档和生产环境变量核对为准。
## 概述
产品上传功能已完成,支持批量上传 3D 模型文件并自动创建产品和 SKU。
@ -9,13 +7,13 @@
## 功能特性
✅ **已完成**:
- 历史实现曾使用 Apple HIG 2025 风格上传弹窗;新界面应改按白曜石工坊和平台组件口径
- Apple HIG 2025 风格的上传弹窗
- 拖拽上传和点击选择文件
- 批量上传多个文件
- 实时上传进度显示
- 文件验证(格式、大小)
- 产品信息表单
- 历史实现上传到 MinIO;当前对象存储真相需按对象存储接入文档和代码核对
- 文件上传到 MinIO
- 自动创建产品和 SKU 记录
- 上传成功后自动刷新列表
@ -86,7 +84,7 @@ GET /api/products
4. 拖拽或选择 3D 模型文件
5. 显示上传进度
6. 点击"确认上传"
7. 文件上传到历史 MinIO 存储;当前实现需按对象存储接入文档核对
7. 文件上传到 MinIO
8. 创建产品和 SKU 记录
9. 自动刷新产品列表
@ -102,7 +100,7 @@ GET /api/products
确保 `.env` 文件中配置了以下变量:
```env
# 历史 MinIO 配置
# MinIO 配置
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=furniture-models
@ -120,7 +118,7 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/furniture3d
npm run dev
```
2. 历史本地联调需确保 MinIO 和 PostgreSQL 正在运行;当前存储联调按对象存储接入文档核对
2. 确保 MinIO 和 PostgreSQL 正在运行
3. 访问 http://localhost:3000
@ -133,7 +131,7 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/furniture3d
## 故障排查
### 上传失败
- 历史 MinIO 联调检查:访问 http://localhost:9000;当前对象存储问题按现行存储配置排查
- 检查 MinIO 是否运行:访问 http://localhost:9000
- 检查数据库连接:运行 `npm run prisma:studio`
- 查看浏览器控制台错误信息
- 查看服务器日志

View File

@ -1,7 +1,5 @@
# 包装方案增强同事交接说明(新平台一期版)
当前状态:包装专项历史交接。本文适合理解包装增强任务背景,但当前分支、主线、视觉标准、共享接缝和路径判断必须以仓库根目录 `AGENTS.md`、`docs/workflow/团队协作模式.md`、`docs/workflow/模块边界说明.md` 和当前代码为准。
## 1. 先说结论
这份文档是给接手「包装方案增强」任务的同事看的。

View File

@ -1,7 +1,5 @@
# 包装方案增强操作手册(非程序员版,新平台一期)
当前状态:包装专项历史说明。本文可以帮助理解包装功能,但涉及分支、主线、视觉和代码路径时,必须以仓库根目录 `AGENTS.md`、workflow 文档和当前代码为准。
## 1. 先看结论
你这次要做的事情只有一条:

View File

@ -3,8 +3,6 @@
适用对象:接手「新平台包装子工作区」的 Codex。
目标:让你直接在现有后端底座上继续做包装 UI、算料和最低成本方案,不要再重定 DTO、数据表和入库规则。
当前状态:包装专项历史交接。本文仍可用于理解包装底座,但分支、主线、视觉、共享接缝和路径判断必须先看仓库根目录 `AGENTS.md`、`docs/workflow/团队协作模式.md`、`docs/workflow/模块边界说明.md`、`docs/workflow/主线同步操作手册.md`,再核对当前代码。不要把本文里的固定包装分支或旧协作口径当成当前唯一规则。
---
## 1. 先说结论

View File

@ -1,170 +1,653 @@
# 平台组件与元组件使用指南
# Apple 风格 UI 组件使用指南
适用范围:新平台页面、后台管理页、产品资料、AI 白底图、资产中心、仓库质检等前端改造。
> **⚠️ 重要提示**: 本文档提供代码示例,但设计标准以 `docs/设计规范/苹果风格视觉标准.md` 为准。在使用代码示例时,请确保符合最新的设计 Token(如颜色、字体等)。
当前项目不再以旧 Apple 风格组件作为新页面标准。旧 `src/components/ui` 里的组件和旧苹果风文档只作为历史兼容参考;新平台默认优先使用平台组件、元组件和白曜石工坊视觉口径。
本指南提供了 Apple 风格 UI 设计系统中所有组件的详细使用说明和最佳实践。
---
## 目录
## 1. 先记住一句话
1. [设计令牌](#设计令牌)
2. [基础组件](#基础组件)
3. [导航组件](#导航组件)
4. [数据展示组件](#数据展示组件)
5. [反馈组件](#反馈组件)
6. [媒体组件](#媒体组件)
7. [布局组件](#布局组件)
8. [动画组件](#动画组件)
9. [可访问性工具](#可访问性工具)
10. [性能优化](#性能优化)
新平台前端优先顺序是:
## 设计令牌
```text
业务组件和元组件 -> src/components/platform -> 已有页面样板 -> 基础 UI 组件 -> 临时自写样式
### 字体系统
```typescript
import { LargeTitle, Title1, Body, Caption1 } from '@/components/ui/Typography'
function MyComponent() {
return (
<div>
<LargeTitle>大标题 (34pt)</LargeTitle>
<Title1>标题 1 (28pt)</Title1>
<Body>正文内容 (17pt)</Body>
<Caption1>说明文字 (12pt)</Caption1>
</div>
)
}
```
不要为了一个页面单独造一套皮肤。
如果 Stitch 设计稿和当前组件默认皮肤不一致,应该在组件库体系内做变体、重写皮肤或局部重构,而不是绕开组件库硬写静态稿。
**最佳实践:**
- 使用语义化的字体组件而不是直接的 CSS 类
- 遵循苹果的字体层级,不要跳级使用
- 中英文混排时使用 `fontType="chinese"` 属性
---
### 颜色系统
## 2. 当前第一视觉标准
```typescript
// 使用 Tailwind 类名
<div className="bg-apple-blue-light text-white">
蓝色背景
</div>
- 第一视觉标准:`docs/设计规范/白曜石工坊统一视觉标准.md`
- 规范审查:`docs/workflow/规范审查清单.md`
- 页面样板:资产中心页面
- 核心组件目录:`src/components/platform/`
页面应保持:
- 浅色工作台。
- 低噪音。
- 主体内容优先。
- 图片、3D、数据和操作路径优先。
- 表格、Inspector、表单和长文本可读性优先。
不要回到:
- 旧苹果风大玻璃组件。
- 普通 SaaS 卡片墙。
- 默认黑底后台。
- 营销式大 hero。
- 一堆解释性卡片和重复统计块。
---
## 3. 平台组件怎么选
优先看 `src/components/platform/`:
- `PlatformPageFrame`:页面骨架和工作台外框。
- `PlatformSplitLayout`:左右分栏,适合主画布 + Inspector。
- `PlatformSection`:工作台内的低噪音区块。
- `PlatformFlowNav`:平台流程导航。
- `PlatformHeaderTools`:顶部工具区。
- `PlatformMetricStrip`:少量关键指标条,不能堆成统计墙。
- `ViewportStageShell`:视口、画布、图片或 3D 工作台容器。
- `AssemblyWorkbench*`:装配和资产工作台相关承载。
- `AssetVariantThumbnailRenderer`、`AssemblyMaterialImage`:资产图和材质图展示。
使用前先搜现有调用:
```bash
rg -n "PlatformSplitLayout|PlatformPageFrame|ViewportStageShell|PlatformSection" src
// 使用设计令牌
import { appleColorTokens } from '@/lib/design-tokens'
const blueColor = appleColorTokens.system.blue.light
```
---
**最佳实践:**
- 优先使用语义化颜色(如 `apple-bg-primary`)
- 当前产品固定浅色主题,不再提供深浅色切换
- 使用系统颜色保持一致性
## 4. 元组件表达
## 基础组件
板材、五金、组件、包装、花色、贴图、资产规格款都不是普通图片卡片。它们应按 `Meta Component(元组件)` 表达:
### Button 按钮
- 有稳定业务身份。
- 有规格、材质、状态、来源。
- 能打开 Inspector 检视。
- 能进入装配、草稿、入库、仓库或质检链路。
- 能被 AI、素材库或对象存储链路追踪。
```typescript
import Button from '@/components/ui/Button'
如果只是展示图片,也要保留业务身份和状态,不要退化成“图片 + 标题 + 说明”的营销卡片。
---
## 5. 图片和 AI 页面
AI 白底图、素材库、质检九视图、原图预览这类页面要把图片放在第一优先级:
- 图片网格要稳定,不因状态文字改变尺寸。
- 缺图要用明确空状态,不显示坏图图标。
- 原图查看要避免被页面内其他卡片盖住。
- 缩放、拖拽、重新生成、上传素材库等操作应贴近图片工作流。
- 不要堆重复说明、重复计数和无效提示。
目标花色、SPU、SKU、barcode、批次等字段必须从业务上下文传递,不要靠页面文案兜底。
---
## 6. Inspector 和表单
Inspector 是长期阅读和编辑区,不使用大面积毛玻璃。
推荐:
- 实体白底。
- 清楚边界。
- 紧凑但不拥挤。
- 字段分组少而明确。
- 关键操作按钮固定在可预期位置。
避免:
- 把说明文字堆进 Inspector。
- 用半透明背景承载正文。
- 用多个统计块挤压主图或主表单。
- 表单下拉展开后改变整体页面宽高或遮挡关键按钮。
---
## 7. Stitch 设计稿落地
当用户指定 Stitch 设计稿时:
- Stitch 是当前功能的视觉规格和验收标准。
- 项目组件库是实现载体。
- 资产中心是项目内参考样板。
- 低噪音、抓重点、图片优先是默认方向。
落地时重点对齐:
- 密度。
- 控件大小。
- 留白。
- 字号。
- 圆角。
- 阴影。
- 列表和表单层级。
- hover、focus、disabled、loading、empty、error 状态。
不要自行补设计稿里没有的说明区、统计区或装饰区,除非用户明确要求。
---
## 8. 基础 UI 组件使用边界
`src/components/ui/` 可以继续服务历史页面和基础控件,但新平台不能只看这个目录就定视觉方向。
使用原则:
- 能用平台组件就先用平台组件。
- 需要按钮、输入框、下拉、弹层等基础控件时,可以复用现有 UI 组件。
- 如果基础控件默认样式和白曜石/Stitch 冲突,应通过组件变体或局部样式修正。
- 不新增 Apple 命名的新组件,不新增深浅色主题切换入口。
---
## 9. 验证要求
前端改动至少自查:
- 1920x1080 下页面不拥挤、不重叠、不遮挡主内容。
- 关键移动端宽度没有明显炸版。
- 图片、3D、表格、Inspector、下拉、弹层状态都能正常显示。
- 文案不溢出按钮、标签、卡片或表格单元格。
- 没有引入一套和平台组件明显割裂的新皮肤。
收口时必须给规范审查结论:
```text
规范审查:passed / passed with warnings / rejected
这是自审,不是独立复审。
function ButtonExample() {
return (
<div className="space-x-4">
{/* 四种变体 */}
<Button variant="filled">Filled</Button>
<Button variant="tinted">Tinted</Button>
<Button variant="gray">Gray</Button>
<Button variant="plain">Plain</Button>
{/* 三种尺寸 */}
<Button size="small">Small (32px)</Button>
<Button size="regular">Regular (44px)</Button>
<Button size="large">Large (50px)</Button>
{/* 状态 */}
<Button loading>Loading</Button>
<Button disabled>Disabled</Button>
</div>
)
}
```
**最佳实践:**
- 主要操作使用 `filled` 变体
- 次要操作使用 `tinted` 或 `gray` 变体
- 确保按钮最小尺寸为 44px(苹果触摸目标标准)
- 使用 `loading` 状态而不是禁用按钮
### Input 输入框
```typescript
import Input from '@/components/ui/Input'
function InputExample() {
return (
<div className="space-y-4">
{/* 基础输入框 */}
<Input
label="用户名"
placeholder="请输入用户名"
helperText="用户名必须唯一"
required
/>
{/* 状态变体 */}
<Input
label="邮箱"
type="email"
state="success"
helperText="邮箱格式正确"
/>
<Input
label="密码"
type="password"
errorText="密码长度至少 8 位"
/>
{/* 带图标 */}
<Input
label="搜索"
leftIcon="🔍"
rightIcon="✕"
onRightIconClick={() => console.log('清除')}
/>
</div>
)
}
```
**最佳实践:**
- 始终提供 `label` 属性以支持可访问性
- 使用 `helperText` 提供额外说明
- 错误状态使用 `errorText` 而不是 `helperText`
- 合理使用图标增强用户体验
### Card 卡片
```typescript
import Card, { CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/Card'
function CardExample() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 简单卡片 */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-2">卡片标题</h3>
<p className="text-gray-600">卡片内容</p>
</Card>
{/* 组合卡片 */}
<Card variant="elevated">
<CardHeader>
<CardTitle>高级卡片</CardTitle>
</CardHeader>
<CardContent>
<p>使用组合组件构建的卡片</p>
</CardContent>
<CardFooter>
<Button size="small">操作</Button>
</CardFooter>
</Card>
{/* 交互式卡片 */}
<Card
interactive
onClick={() => console.log('卡片点击')}
className="cursor-pointer"
>
<div className="p-6">
<h3>可点击卡片</h3>
<p>支持 hover 和 active 动画</p>
</div>
</Card>
</div>
)
}
```
**最佳实践:**
- 使用 `variant="elevated"` 创建层次感
- 交互式卡片添加 `interactive` 属性
- 保持卡片内容简洁,避免过度嵌套
## 导航组件
### NavigationBar 导航栏
```typescript
import NavigationBar, { BackButton, MenuButton } from '@/components/ui/NavigationBar'
function NavigationExample() {
return (
<div className="space-y-4">
{/* 大标题模式 */}
<NavigationBar
mode="large"
title="大标题导航"
subtitle="副标题说明"
leftContent={<BackButton onBack={() => history.back()} />}
rightContent={<MenuButton onMenu={() => console.log('菜单')} />}
/>
{/* 标准模式 */}
<NavigationBar
mode="standard"
title="标准导航"
leftContent={<BackButton />}
rightContent={
<div className="flex gap-2">
<Button size="small" variant="plain">编辑</Button>
<Button size="small" variant="tinted">保存</Button>
</div>
}
/>
</div>
)
}
```
### TabBar 标签栏
```typescript
import TabBar, { useTabBar } from '@/components/ui/TabBar'
function TabBarExample() {
const tabBar = useTabBar('home')
const tabs = [
{ id: 'home', label: '首页', icon: '🏠' },
{ id: 'search', label: '搜索', icon: '🔍' },
{ id: 'favorites', label: '收藏', icon: '❤️', badge: 3 },
{ id: 'profile', label: '我的', icon: '👤' },
]
return (
<TabBar
items={tabs}
activeId={tabBar.activeTab}
onTabChange={tabBar.switchTab}
/>
)
}
```
**最佳实践:**
- 标签栏最多显示 5 个标签
- 使用 `useTabBar` Hook 管理状态
- 为重要标签添加徽章提示
## 数据展示组件
### Badge 徽章
```typescript
import Badge, { Tag, NumberBadge, StatusDot } from '@/components/ui/Badge'
function BadgeExample() {
return (
<div className="space-y-4">
{/* 基础徽章 */}
<div className="flex gap-2">
<Badge>Default</Badge>
<Badge type="success">Success</Badge>
<Badge type="error">Error</Badge>
<Badge type="warning">Warning</Badge>
<Badge type="info">Info</Badge>
</div>
{/* 数字徽章 */}
<div className="flex gap-2">
<NumberBadge count={5} />
<NumberBadge count={99} max={99} />
<NumberBadge count={999} max={99} />
</div>
{/* 标签 */}
<div className="flex gap-2">
<Tag closable onClose={() => console.log('关闭')}>
可关闭标签
</Tag>
<Tag icon="🔥" type="success">
带图标标签
</Tag>
</div>
{/* 状态点 */}
<div className="flex gap-2">
<StatusDot status="online" />
<StatusDot status="busy" />
<StatusDot status="away" />
<StatusDot status="offline" />
</div>
</div>
)
}
```
### ProgressBar 进度条
```typescript
import ProgressBar, { StepProgress } from '@/components/ui/ProgressBar'
function ProgressExample() {
const [progress, setProgress] = useState(45)
return (
<div className="space-y-6">
{/* 线性进度条 */}
<ProgressBar
value={progress}
showLabel
showPercentage
label="下载进度"
animated
/>
{/* 环形进度条 */}
<ProgressBar
value={progress}
variant="circular"
size="large"
type="success"
showPercentage
/>
{/* 步骤进度 */}
<StepProgress
current={2}
total={4}
showLabels
labels={['开始', '配置', '确认', '完成']}
/>
</div>
)
}
```
## 反馈组件
### Modal 模态框
```typescript
import Modal, { ConfirmModal } from '@/components/ui/Modal'
function ModalExample() {
const [showModal, setShowModal] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
return (
<div>
<Button onClick={() => setShowModal(true)}>
打开模态框
</Button>
<Modal
open={showModal}
onClose={() => setShowModal(false)}
title="模态框标题"
description="模态框描述"
size="medium"
>
<div className="space-y-4">
<p>模态框内容</p>
<div className="flex gap-3">
<Button variant="filled">确认</Button>
<Button variant="gray" onClick={() => setShowModal(false)}>
取消
</Button>
</div>
</div>
</Modal>
<ConfirmModal
open={showConfirm}
onClose={() => setShowConfirm(false)}
title="确认删除"
description="此操作无法撤销"
onConfirm={() => console.log('确认删除')}
/>
</div>
)
}
```
### Toast 提示
```typescript
import { useToast, ToastContainer } from '@/components/ui/Toast'
function ToastExample() {
const toast = useToast()
return (
<div>
<div className="space-x-2">
<Button onClick={() => toast.success('操作成功')}>
成功提示
</Button>
<Button onClick={() => toast.error('操作失败')}>
错误提示
</Button>
<Button onClick={() => toast.info('新消息', {
title: '系统通知',
action: {
label: '查看',
onClick: () => console.log('查看消息')
}
})}>
信息提示
</Button>
</div>
<ToastContainer
toasts={toast.toasts}
onClose={toast.removeToast}
/>
</div>
)
}
```
## 动画组件
### AnimatedEntry 进入动画
```typescript
import AnimatedEntry, { SlideInUp, StaggeredAnimation } from '@/components/ui/AnimatedEntry'
function AnimationExample() {
return (
<div className="space-y-8">
{/* 基础进入动画 */}
<AnimatedEntry direction="up" duration="normal">
<Card className="p-6">
<h3>从下方滑入的卡片</h3>
</Card>
</AnimatedEntry>
{/* 预配置动画 */}
<SlideInUp delay={200}>
<div>延迟 200ms 的滑入动画</div>
</SlideInUp>
{/* 交错动画 */}
<StaggeredAnimation staggerDelay={100}>
{[1, 2, 3, 4].map(i => (
<Card key={i} className="p-4">
<p>卡片 {i}</p>
</Card>
))}
</StaggeredAnimation>
</div>
)
}
```
**最佳实践:**
- 使用 `trigger="visible"` 实现滚动触发动画
- 交错动画的延迟时间建议 50-150ms
- 响应用户的减少动画偏好设置
## 可访问性工具
### 用户偏好设置
```typescript
import { PreferencesProvider, AccessibilityPanel, useUserPreferences } from '@/lib/preferences'
function App() {
return (
<PreferencesProvider>
<div className="app">
<AccessibilityPanel />
<MyContent />
</div>
</PreferencesProvider>
)
}
function MyContent() {
const { preferences } = useUserPreferences()
return (
<div>
<p>当前字体大小: {preferences.fontSize}%</p>
<p>减少动画: {preferences.prefersReducedMotion ? '是' : '否'}</p>
</div>
)
}
```
### 包容性设计
```typescript
import { createInclusiveStatusIndicator } from '@/lib/accessibility'
function StatusExample() {
const successIndicator = createInclusiveStatusIndicator('success', {
showIcon: true,
showShape: true,
size: 'medium'
})
return (
<div
className="status-indicator success shape-circle"
style={successIndicator.style}
aria-label={successIndicator.ariaLabel}
>
{successIndicator.icon}
</div>
)
}
```
## 性能优化
### 虚拟滚动
```typescript
import VirtualList from '@/components/ui/VirtualList'
function VirtualListExample() {
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `项目 ${i + 1}`,
description: `描述 ${i + 1}`
}))
return (
<VirtualList
items={items}
itemHeight={60}
height={400}
renderItem={(item, index) => (
<div className="flex items-center p-4 border-b">
<div className="flex-1">
<h4>{item.name}</h4>
<p className="text-sm text-gray-500">{item.description}</p>
</div>
</div>
)}
/>
)
}
```
### 性能监控
```typescript
import { usePerformanceMonitor, useMemoryMonitor } from '@/lib/performance'
function PerformanceExample() {
usePerformanceMonitor('MyComponent')
const memoryInfo = useMemoryMonitor()
return (
<div>
{memoryInfo && (
<p>内存使用: {Math.round(memoryInfo.usedJSHeapSize / 1024 / 1024)}MB</p>
)}
</div>
)
}
```
## 迁移指南
### 从传统 CSS 迁移
```typescript
// 旧方式
<div className="bg-blue-500 text-white p-4 rounded-lg">
内容
</div>
// 新方式
<Card variant="filled" className="bg-apple-blue-light text-white">
<div className="p-apple-4">
内容
</div>
</Card>
```
### 从其他 UI 库迁移
```typescript
// Material-UI Button
<Button variant="contained" color="primary">
按钮
</Button>
// Apple UI Button
<Button variant="filled">
按钮
</Button>
```
## 常见问题
### Q: 如何自定义主题颜色?
A: 修改 `tailwind.config.ts` 中的颜色令牌,或在 CSS 中覆盖 CSS 变量。
### Q: 动画不生效怎么办?
A: 检查用户是否启用了减少动画偏好,或者组件是否设置了 `disabled` 属性。
### Q: 如何确保可访问性?
A: 使用语义化 HTML,提供 ARIA 属性,确保键盘导航,测试对比度。
### Q: 性能优化建议?
A: 使用 React.memo,避免不必要的重渲染,大列表使用虚拟滚动。
## Slider 滑动条(统一元组件)
`Slider` 元组件位于 `src/components/ui/Slider.tsx`,用于统一替代原生 `input[type="range"]`。
```tsx
import Slider from '@/components/ui/Slider'
function DemoSlider() {
return (
<Slider
min={0}
max={1}
step={0.01}
value={0.45}
onValueChange={(value) => console.log(value)}
aria-label="爆炸程度"
className="w-40"
/>
)
}
```
最佳实践:
- 必须使用 `onValueChange` 接收数值,避免在业务层反复做字符串转数字。
- 必须补齐 `aria-label`,保证键盘与读屏可访问性。
- 禁止新增独立的 range 样式分叉,统一使用 `apple-slider` 视觉规范。
## 总结
Apple 风格 UI 设计系统提供了完整的组件库和工具集,遵循苹果 Human Interface Guidelines 2025 标准。通过合理使用这些组件和工具,可以构建出美观、易用、高性能的用户界面。
记住始终考虑可访问性、性能和用户体验,让每个用户都能享受到优质的交互体验。

View File

@ -1,46 +0,0 @@
# 2026-10-28 限价运费计算阻塞网页线程
## 故障与证据
生产版本 `83a3138fe9704d61f13de9f665779e783931c57d` 的网页进程长时间占用一个 CPU 核心,端口 3001 的监听队列达到 512,本机健康检查无法响应,公网返回 502。穿透进程持续正常,内存和磁盘有余量,故障时没有检出矿工。
重启前的 CPU 采样主要落在 Unicode 格式转换、字符串替换和去空格。入口访问日志显示,部署后 `/api/supply-chain/limit-management/products` 出现长耗时;17:13 至 19:29 之间有 338 次限价计算请求,其中一次成功请求耗时 397.882 秒。请求时间包含等待与其他请求争用的时间,不能当作单次函数的纯计算耗时。
故障链路:
`限价商品列表 / 限价导出 -> calculateRegionFreightsForArchiveRows -> buildFreightCalculation -> 每个快递的每个省份调用 selectLatestApplicableRate -> 重扫并规范化整批费率`
10 月 28 日新增的区域和全国运费计算,把原来用于单个商品的全表扫描放大到整页商品。算法的重复扫描量随「商品数 × 快递数 × 29 个省份 × 全部费率行数」增长,而且原整批循环没有让出网页线程。并发打开或刷新列表会进一步积压请求;入口 502 和数据库事务超时是下游症状。
## 隔离复现
只读取得费率、物流规则和商品字段快照,在本机离线运行,不向生产网页接口重放旧重计算。上游费率视图有 75,630 行;复现的 200 行限价页中有 159 行能进行运费计算,共需 4,465 行费率。
同一快照、同一计算时间、相同输入与输出顺序,本机 Node.js 24.22.1 的一次带规范化调用计数的对比(计数会影响耗时):
| 项目 | 修复前 | 修复后 |
| --- | ---: | ---: |
| 计算耗时 | 16,056 ms | 124 ms |
| 字符串规范化调用 | 64,631,978 | 16,030 |
| 完整结果 SHA-256 | `a34b18ccfd50ae6053b26d7ca2513fa101368cadfdbee3befaf7f752c360efc6` | 相同 |
这是离线计算对比,不代表生产端到端请求耗时,也不包含数据库和网络时间。原始商业数据与 CPU 采样只保存在私有排错目录,不进入 Git。
另用本机独立 HTTP 子进程调用实际批量服务,数据访问以同一只读快照代替,不插入字符串计数。旧版计算用时 5,012 ms,期间并发健康请求最长等待 5,019 ms;修复版计算用时 143 ms,8 次健康请求均为 200,最长等待 29 ms。两次完整服务返回值摘要均为 `ef8a73cec474260611eb74c073d22b32c721b1881644db7ceff22cc83e41d489`。
## 修复边界
- 纯计算层按规范化后的快递和省份建立费率索引,并按原来的生效时间和规则 ID 排序。保留有效期、重量/体积/最长边、计泡、取整、失效提示和财务计算规则。
- `createFreightCalculator` 在同一批次复用费率索引与发货占比;新的批次重新使用自己的费率快照和计算时间,不增加跨请求的陈旧缓存。
- 批量服务超过 8 ms 计算时间片后用 `setImmediate` 让出线程,让健康检查和其他请求有执行机会。
- 新增复杂度、报价一致性、有效期和批量让出线程的回归测试。本次生产修复保留现有运费计算公式及基线期望,不带入开发线上尚未发布的运费功能和公式变化。
不修改数据库结构或业务数据、页面视觉、SSH 密钥、密码、共享账号、全局 Node.js、穿透配置或其他项目服务。
## 发布核查
2026-10-29 的真实页面验证发现额外的运行时兼容问题:单商品运费能返回,大页触发让出线程时抛出 `RangeError: Maximum call stack size exceeded`。在生产私有 Node.js 22.23.4 的独立子进程中,仅加载当前 Next.js 16.1.6 的 `fast-set-immediate.external.js` 并调用 `node:timers/promises.setImmediate()` 即可复现;调用栈反复进入框架的 `patchedSetImmediatePromise`,与数据库记录无关。
统一的 `yieldToEventLoop` 改用 `node:timers` 的回调接口包装 Promise。它仍然把执行机会交还事件循环,不修改框架、全局计时器或其他项目;新增子进程测试加载实际 Next.js 补丁,覆盖生产运行环境,避免只用模拟计时器漏掉这类问题。
发布需按当前生产部署入口说明执行,保留上一版本,核对目标主机、生产提交、PM2、端口和日志。重点验证真实的限价列表和限价导出,同时并行请求健康检查;不能只验证健康检查或本地测试就认定生产修复完成。其他项目的进程 PID 和重启次数应保持不变。

View File

@ -1,35 +0,0 @@
# 2026-10-29 限价排序与 DNS 延迟
## 两种独立的等待
生产版本 `66db2a26` 已修复运费重复扫描和 Next.js 计时器兼容问题,继续检查时仍出现两类延迟,不能混为同一个业务故障。
- 本机网页进程:限价排序函数在价格、时间相同的商品之间,反复调用带 `zh-CN` 和 `numeric: true` 参数的 `String.localeCompare`。每次比较都重新准备排序规则,整页取数时需要给五万多条候选商品排序,阻塞网页线程约 3 秒。
- 公网检查:分阶段记录 DNS、TCP、TLS 和首字节耗时,慢请求中 DNS 本身耗时约 5.01 秒;同次请求后续连接和网页响应只占约 0.09 秒。保持域名、TLS 验证与目标服务不变,仅诊断时用 `curl --resolve` 指定当前地址后,30 次请求均返回 200,最长约 0.234 秒。该 DNS 等待发生在检查机器上,不能据此认为每位用户的浏览器都有相同延迟。
当前主机的两个上游 DNS 直接查询都观察到少量超时,本地 DNS stub 的 AAAA 查询也有超时。证据不足以仅凭这些现象断定双宽带负载均衡是唯一原因。本次不改共享主机的 DNS、路由器、网卡、FRP 或其他项目配置。
## 排序对比与修复范围
在生产私有 Node.js 22.23.4 的独立进程中,只读取得 54,381 条销售商品候选和 13,537 条货号限价,使用同一批内存数据对比:
| 指标 | 原比较方式 | 复用 Intl.Collator |
| --- | ---: | ---: |
| 排序耗时 | 3,318 ms | 212 ms |
| 完整有序商品 ID SHA-256 | `8d83f84e3252953e0729d320da3a43ede88fca7cffce6e2f906c9f857a62ff10` | 相同 |
这是隔离计算耗时,不是整页网络时间。取数约 700 ms,不计入上表。
打开真实限价页面的同时采样 100 次本机健康请求,全部返回 200,但最长等待 3,076 ms,其中 4 次超过 1 秒。这与隔离复现的同步排序耗时吻合;无用户操作时的 30 次本机检查最长约 9 ms。
产品档案 Repository 内复用同一个 `Intl.Collator('zh-CN', { numeric: true })`,用于限价列表及筛选候选值的中文数字比较。价格优先、时间优先、条码数字顺序、相同比较值的稳定顺序、空值位置、筛选范围及分页契约保持原样。不增加跨请求业务数据缓存,不修改 SQL、数据库结构、业务数据或页面视觉。
回归验证覆盖中文与数字/前导零/大小写/全半角的顺序一致性、大批次不重复初始化排序规则、筛选候选值去重与分页。发布后还需在真实页面翻页/刷新并并发检查本机健康响应,核对其他项目进程和凭据指纹。
## 继续定位查询参数准备
第一轮上线后,连续翻页和打开筛选仍出现过约 2.1 秒的等待。仅对本项目网页进程短时启用函数采样,发现主线程热点包含 Prisma 的查询模板参数展开函数 `td / Hn / ca / od`,并伴随对象分配和垃圾回收。采样完成后已恢复原 Node.js 启动参数。
隔离只读查询使用 54,395 条销售商品中的 54,291 个货号,对比 ORM 的逐项 `IN` 参数与 PostgreSQL 的单个 `text[]` 参数:原查询约 958 ms,数组参数约 202 ms;两者均返回 9,552 条,完整货号与限价结果摘要均为 `801e325b6d702880dc7d386f0903b1be073655407bfc43b08a7cdbbf7713df1e`。CPU 时间约为 1,657 ms / 285 ms,包含独立测试进程的各线程。
限价列表及负责人限价候选值改用参数化 `= ANY($1::text[])`,仍只查询已有筛选候选对应的货号;保留空集合短路、未定价商品、非空价格条件与原始小数精度。没有拼接货号进入 SQL,不扩大读取范围,不修改数据库结构或记录,不新增缓存。新增超过 PostgreSQL 标量参数上限的批次、数据范围、SQL 参数绑定及空价格候选值回归测试。

View File

@ -1,39 +0,0 @@
# SSD 写入来源与库存快照差异发布
## 现场证据
2026-11-02 对当前生产机进行只读检查,未调整共享服务、数据库参数、账号或凭据。
- 最近 8 个完整日整盘写入约 163–270 GB/日;累计通电时长推算的 459 GB/日并不代表当前长期速率。
- 本次开机约 14.34 天,cgroup 的物理盘写入计数:MariaDB 约 1132.7 GB,PostgreSQL 约 1045.7 GB,Cronicle 约 163.9 GB,journald 约 32.3 GB,全部 PM2 约 17.5 GB。cgroup 有父子包含关系,不能再与子进程计数相加。
- MariaDB 的 `planning_database` 被多个项目共用;`shujuku3_system` 也存在活动连接。未启用 Performance Schema,不能把共享数据库的全部写入归给单个项目,未修改其配置。
- PostgreSQL 本项目库累计临时文件写入约 1.04 TB,库存快照约 1.116 亿次插入和删除;这些是不同统计口径,不能直接相加当作 SSD 实际写入。
- 库存快照每 15 分钟全量取数。最近 24 小时成功 95 次,读取约 796 万行,每轮约 8.4 万行。旧仓储无条件删除整个快照再插入,重复写入库存未变化的记录及索引。
- 120 秒空闲窗口整盘写入约 29 MB,网页进程直接写入约 12 KB;PM2 日志增长很小。后台批处理与持久化是重点,不应以空闲短采样替代完整日统计。
## 修改边界与契约
只修改商品库存快照仓储及其同步时间读取;不更改上游查询、库存公式、云仓扣减、每 15 分钟同步频率、表结构、其他项目或共享数据库参数。
全量来源读取、唯一键暂存、数量校验、独占 advisory lock 保持原样。在同一个发布事务内:
1. 对 `(barcode, wmsCoId)` 和全部持久化业务字段进行比较;相同行在 INSERT 前跳过,避免仅因冲突检查而锁定、记录 WAL。
2. 插入新增行,只更新业务字段变化的行,保留已有行 ID。
3. 只删除完整新快照中确实消失的键;空来源、重复键、暂存数量错误均拒绝发布。
4. 验证发布后的总行数,再将已有 `ProductInventorySnapshotRun` 标为成功;任一步失败都整体回滚。
`rowsWritten` 保留“本轮发布总行数”的旧返回契约;运行日志额外输出 `rowsChanged / rowsDeleted / rowsUnchanged`,用于识别实际明细写入。
明细 `ProductInventorySnapshot.syncedAt` 现在表示该行最近一次写入时间。商品库存页和前置仓页面的整批同步时间统一读最新成功的 `ProductInventorySnapshotRun.syncedAt`,与明细在同一条 SQL、同一个 MVCC 快照内读取。无任何行变化时,页面仍能显示新的成功同步时间。当前未发现依赖此表的数据库视图;技术只读账号若直接读原表,判断整批新鲜度时也应读运行记录。
## 验证与限制
- 6 组相关单元测试共 25 项通过,覆盖差异发布、完整性拒绝、发布新鲜度和现有库存页面/前置仓规则。
- 独立的本地 PostgreSQL 16 容器验证了未变化行及 ID 不变、整批同步时间推进、新增/修改/删除、相同条码不同仓库、云仓数量,以及在发布提交前注入失败后的数据和时间整体回滚。
- 1 万行不变数据:新实现 WAL 约 41,920 字节,旧全删全插约 6,058,184 字节,下降 99.31%。这是隔离环境的数据库日志对比,不能当作整盘或实际生产每日总写入降幅。
- 生产首次验证 83,809 行均未变化,全部保留原 ID 和行时间戳,明细写入 0 行,整批成功时间照常推进。前置仓实测发现 PrismaPg 对非 UTC 会话的 `timestamptz` 原生查询结果多偏移 8 小时,已保持原来的 `timestamp` 返回类型;直接 pg 读取则保留 `AT TIME ZONE 'UTC'`。两条读取链路分别验证,不能以 pg 驱动的测试代替 PrismaPg。
- 使用实际 PrismaPg 适配器、Asia/Shanghai 数据库会话,在隔离 PostgreSQL 中调用前置仓仓储,验证发布时间与 UTC 期望完全一致、公有可用数不变。
- 共享开发基线存在权限类型和 MySQL 参数类型错误,未扩大范围修复;生产发布应从最新 `main` 窄范围回流,并单独完成类型检查与构建。
- 尚未归因的共享 MariaDB 和 Cronicle 写入交由对应项目继续核查,不停用其任务、备份或日志。
规范自审:`passed with warnings`。这是自审,不是独立复审。剩余警告是共享写入尚不能精确分配到各调用项目,及真实长期降幅需要发布后跨完整任务周期观察。

View File

@ -1,163 +0,0 @@
# 类目配置取数与筛选逻辑
> 用途:给需要接入类目配置数据的开发人员使用。本文描述当前代码和数据库的真实口径(2026-09-24 更新:合并为单表展示结构)。
>
> 重要:本文不包含数据库连接串、账号、密码。连接信息统一见《README.md》。
## 1. 数据源与责任边界
- **权威数据源**:本系统 `DATABASE_URL` 指向的 PostgreSQL,schema `public`,共 2 张表(见第 2 节)。数据通过类目配置页面人工维护 + Excel 导入,**没有外部同步来源**。
- **业务作用**:
1. 维护「二级类目 → 一级类目」的唯一归属(产品全量档案类目列的规范值来源);
2. 维护每个类目的 4 档规模阈值(爆款/热卖款/动销款/低动销款 × 业绩区间、日销区间)与「分类」归属。
- **人工维护原则**:直接写表会绕过唯一键校验和页面权限,接入方一律只读。
### 1.1 代码依据
- Prisma 模型:`prisma/schema.prisma` 中 `SupplyChainProductArchiveCategoryConfig`、`SupplyChainProductArchiveCategoryScaleRow`。
- 页面:`src/app/supply-chain/database/category-config/`(单表展示,数据源即规模行表)。
- 服务:`supply-chain-product-archive-category-scale-row.service.ts`(页面主数据源,含类目清单同步)、`supply-chain-product-archive-category-config.service.ts`(类目主表维护)。
## 2. 物理表清单与分工
| 表名 | 角色 | 当前行数量级 |
| --- | --- | --- |
| `"SupplyChainProductArchiveCategoryScaleRow"` | **类目规模行(页面当前数据源)**:一行 = 二级类目 × 规模标签,自带一级类目、分类、阈值 | 类目数 × 4 |
| `"SupplyChainProductArchiveCategoryConfig"` | **类目主表**:一行一个二级类目的身份记录(唯一性、启停状态),供产品档案同步、类目下拉等消费方使用 | 80+ |
两表关系:
```text
ScaleRow(页面表:一级/二级/分类/阈值/标签,行=类目×档位,唯一键 categoryL2+scaleLabel)
│ 服务写入时自动同步(新增/改行会把一级类目、分类落到主表)
▼
Config(类目身份表:categoryL2 全表唯一 + status 启停,供外部消费方取类目清单)
```
> 历史说明:旧三表结构中的「分类档位模板表」(`SupplyChainProductArchiveCategoryGroupTier`)已于 2026-09-24 迁移(20260924160000)删除,其数据已按类目展开并入规模行表;旧接口 `/api/supply-chain/category-config/group-tiers/*` 已下线。
## 3. 表字段字典
### 3.1 `"SupplyChainProductArchiveCategoryScaleRow"`(类目规模行,页面数据源)
| 字段 | 类型/约束 | 业务含义 | 取值规则 |
| --- | --- | --- | --- |
| `id` | `TEXT` 主键 | 行 ID | 系统生成 |
| `categoryL1` | `TEXT NOT NULL DEFAULT ''` | 一级类目(≤160 字符) | 页面/导入必填 |
| `categoryL2` | `TEXT NOT NULL` | 二级类目名(≤160 字符) | 与 `scaleLabel` 联合唯一 |
| `categoryGroup` | `TEXT NOT NULL DEFAULT ''` | 分类(如 电脑桌/书桌、其他沙发类) | 空 = 未分类 |
| `scaleLabel` | `TEXT NOT NULL` | 规模标签 | 只能是 爆款/热卖款/动销款/低动销款 |
| `performanceStart` | `DECIMAL(14,2)` NOT NULL | 业绩起点(元) | |
| `performanceEnd` | `DECIMAL(14,2)` 可空 | 业绩终点(元) | **NULL = 不设上限** |
| `dailySalesStart` | `DECIMAL(12,2)` NOT NULL | 日销起点(件/天) | 支持小数 |
| `dailySalesEnd` | `DECIMAL(12,2)` 可空 | 日销终点(件/天) | NULL = 不设上限 |
| `sortOrder` | `INTEGER` | 档位顺序:爆款1 → 热卖款2 → 动销款3 → 低动销款4 | 判定从高档往低档取第一个命中 |
| `createdAt` / `updatedAt` | `TIMESTAMP(3)` | 系统字段 | |
约束:`UNIQUE(categoryL2, scaleLabel)`——一个类目每个标签只有一行;索引:`categoryL2`、`categoryGroup`。
### 3.2 `"SupplyChainProductArchiveCategoryConfig"`(类目主表)
| 字段 | 类型/约束 | 业务含义 | 取值规则 |
| --- | --- | --- | --- |
| `id` | `TEXT` 主键 | 配置记录 ID | 系统生成 |
| `categoryL2` | `TEXT` 唯一 | 二级类目名(≤160 字符) | 全表唯一 |
| `categoryL1` | `TEXT NOT NULL` | 一级类目(≤160 字符) | |
| `categoryGroup` | `TEXT NOT NULL DEFAULT ''` | 分类 | 空 = 未分类 |
| `scaleLabel` | `TEXT NOT NULL DEFAULT ''` | 【预留未使用】 | 阈值数据在 ScaleRow 表 |
| `performanceStart` / `performanceEnd` | `DECIMAL(14,2)` 可空 | 【预留未使用】 | 同上 |
| `dailySalesStart` / `dailySalesEnd` | `DECIMAL(12,2)` 可空 | 【预留未使用】 | 同上 |
| `status` | `TEXT`,`ACTIVE` / `INACTIVE` | 启用/停用;停用的不进类目选项 | 默认 `ACTIVE` |
| `createdAt` / `updatedAt` | `TIMESTAMP(3)` | 系统字段 | |
索引:`categoryL1`、`categoryGroup`、`status`;`categoryL2` 唯一。
## 4. 其他线程取数方式
### 4.1 只读 SQL(直连数据库)
```sql
-- 4.1.1 页面同款数据:全部规模行(一级类目、二级类目、分类、4 档阈值)
SELECT "categoryL1", "categoryL2", "categoryGroup", "scaleLabel",
"performanceStart", "performanceEnd", "dailySalesStart", "dailySalesEnd"
FROM "SupplyChainProductArchiveCategoryScaleRow"
ORDER BY "categoryL2" ASC, "sortOrder" ASC;
-- 4.1.2 某个类目的 4 档阈值
SELECT "scaleLabel", "performanceStart", "performanceEnd", "dailySalesStart", "dailySalesEnd"
FROM "SupplyChainProductArchiveCategoryScaleRow"
WHERE "categoryL2" = '住宅家具 - 书桌'
ORDER BY "sortOrder" ASC;
-- 4.1.3 类目身份清单(供下拉/同步用,只取启用的)
SELECT "categoryL1", "categoryL2", "categoryGroup"
FROM "SupplyChainProductArchiveCategoryConfig"
WHERE "status" = 'ACTIVE'
ORDER BY "categoryL1" ASC, "categoryL2" ASC;
-- 4.1.4 某分类下的全部类目及其阈值
SELECT "categoryL2", "scaleLabel", "performanceStart", "performanceEnd"
FROM "SupplyChainProductArchiveCategoryScaleRow"
WHERE "categoryGroup" = '电脑桌/书桌'
ORDER BY "categoryL2" ASC, "sortOrder" ASC;
```
注意事项:
- 表名/字段名**必须带双引号**保持大小写。
- 终点列为 `NULL` 表示不设上限,不要写成 0。
- 停用(`INACTIVE`)类目历史仍在 Config 表里,统计按 `status` 过滤。
- 商品/档案行上的类目与分类是写入时的快照,改配置不回刷历史数据。
### 4.2 代码内取数(同仓库服务)
```ts
// 规模行(页面同款数据:一行=类目×档位,含一级类目与分类)
import { supplyChainProductArchiveCategoryScaleRowService } from
'@/server/services/supply-chain-product-archive-category-scale-row.service'
await supplyChainProductArchiveCategoryScaleRowService.list({ page: 1, pageSize: 100 })
await supplyChainProductArchiveCategoryScaleRowService.listAll({ categoryGroup: '电脑桌/书桌' })
// 类目身份清单(产品全量档案同步、类目下拉在用)
import { supplyChainProductArchiveCategoryConfigService } from
'@/server/services/supply-chain-product-archive-category-config.service'
await supplyChainProductArchiveCategoryConfigService.list({ page: 1, pageSize: 100, status: 'ACTIVE' })
import { supplyChainProductArchiveCategoryConfigRepository } from
'@/server/repositories/supply-chain-product-archive-category-config.repository'
await supplyChainProductArchiveCategoryConfigRepository.findActiveByCategoryL2('住宅家具 - 书桌')
```
### 4.3 走 API(需要登录 + 供应链权限)
| 接口 | 方法 | 说明 |
| --- | --- | --- |
| `/api/supply-chain/category-config/scale-rows` | GET / POST | 规模行列表 / 新增(写入时自动同步类目主表) |
| `/api/supply-chain/category-config/scale-rows/{id}` | PATCH / DELETE | 规模行编辑 / 删除 |
| `/api/supply-chain/category-config/scale-rows/export` · `/import` | GET / POST | 导出(筛选跟随当前搜索/分类/标签) / 流式导入(表头:一级类目、二级类目、分类、规模标签、业绩起终点、日销起终点) |
| `/api/supply-chain/category-config/scale-rows/groups` | GET | 去重分类列表(筛选项) |
| `/api/supply-chain/category-config` · `/{id}` | GET/POST/PATCH | 类目身份维护(供应商新品推荐下拉也走这里) |
| `/api/supply-chain/category-config/export` · `/import` | GET / POST | 类目身份导出 / 导入 |
权限:查询走类目节点「查看」,导出走「下载」,写入走「编辑」;角色 `SUPER_ADMIN/ADMIN/SUPERVISOR` 或业务角色 `MANAGEMENT`。
## 5. 现有消费方(改动前必读)
| 消费方 | 取数点 | 依赖 |
| --- | --- | --- |
| 产品全量档案(DF/聚水潭同步) | `jst-sku-product-archive-sync.service.ts`、`jst-sku-product-archive-full-sync.service.ts` | `findActiveByCategoryL2`:仅启用类目作为规范值 |
| 供应商新品推荐类目下拉 | `/api/supply-chain/supplier-recommended-products/category-options` | Config `service.list`,仅 `ACTIVE` |
| 类目配置页面 | 第 4.3 节接口 | — |
改 `status`、`categoryL2` 名称、删除类目前先确认不影响以上消费方。
## 6. 接入前检查清单
- [ ] 表名/字段名带双引号保持大小写。
- [ ] 方向:`categoryL2`(二级)→ `categoryL1`(一级);`categoryGroup`(分类)是独立维度,不是一级类目,空 = 未分类。
- [ ] 终点列 `NULL` = 不设上限,不要当 0 处理。
- [ ] 档位判定顺序:爆款 → 热卖款 → 动销款 → 低动销款,从高到低取第一个命中区间。
- [ ] 阈值数据取 `ScaleRow` 表;Config 表上的 `scaleLabel`/阈值列为预留未使用。
- [ ] 停用(`INACTIVE`)配置历史仍在 Config 表里,统计按 `status` 过滤。
- [ ] 商品/档案行上的类目、分类是写入时快照,改配置不回刷历史数据。
- [ ] 页面写操作会双写两表(规模行 + 类目主表同步),外部只读不写就不会产生不一致。

View File

@ -1,99 +0,0 @@
# 内容视频流程看板取数与筛选逻辑
> 用途:给需要接入「新品-视频进度(内容部)」(钉钉内容视频流程)数据的开发人员使用。本文描述当前代码和数据库的真实口径(2026-10-08 首版)。
>
> 重要:本文不包含数据库连接串、账号、密码。该页面数据**不存在本系统 PostgreSQL**,全部来自企划 MySQL 的同步快照。
## 1. 数据源与责任边界
- **页面直接数据源**:企划 MySQL(连接变量 `JST_PLANNING_DB_*`,生产为生产服务器本机实例)的 `planning_database` 库,快照表 `oa_video_flow_board_snapshot`。
- **原始数据源**:钉钉 OA 审批镜像库的 `ding_Workflow` / `ding_WorkflowScheme` / `ding_WorkflowTask` 三张表,按流程编码过滤:
- 内容视频流程 `PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673`(登记在 `oa-board.constants.ts` 的 `OA_BOARD_PROCESS_CODES.video`)。
- **同步方式**:`scripts/sync-oa-video-flow-board.ts --apply`(即 `npm run sync:oa-video-flow:apply`,服务 `oa-video-flow-board.service.ts`)全量拉取该流程全部实例与表单字段,按"一条流程实例 = 一行"写入快照表,并用 `sync_version` 清理已消失的实例。生产由 systemd 定时器(`install-oa-board-sync-timer.sh` 安装的 `furniture-oa-board-sync.timer`)每 15 分钟在主看板、品牌策划看板之后顺序执行。
- **责任边界**:镜像库与钉钉侧的数据质量不由本系统负责;本系统保证"镜像库 → 快照 → 页面"这一段。下游一律**只读快照表**,不要直连 ding_ 三张源表(源表结构随钉钉变化,无稳定性承诺)。
- **与新品主链的关系**:内容视频流程**不参与** OA 新品看板主链的项目聚合、总状态与时效矩阵(其表单无开款编号/关联单号,仅有 SPU)。本表是独立流程维度的进度数据。
### 1.1 代码依据
- 同步与快照读写的唯一实现:`src/server/services/oa-video-flow-board.service.ts`(建表 DDL 在 `ensureSnapshotTable`,自动建表)。
- 源库连接复用:`oa-board-sync.service.ts` 导出的 `openOaBoardSourceMysqlConnection` / `openOaBoardSourceSqlServer`(跟随 `OA_BOARD_SOURCE_DRIVER`)。
- 页面接口:`GET /api/intake/oa-board/video-flow`(参数 `keyword` / `demandType` / `brand` / `status` / `page` / `pageSize`,需登录态)。
- 页面组件:`src/app/intake/oa-board/OaBoardVideoFlowPanel.tsx`(Tab 参数 `tab=video-flow`)。
## 2. 物理表(MySQL · planning_database)
| 物理表 | 用途 | 键与索引 |
| --- | --- | --- |
| `oa_video_flow_board_snapshot` | 最新快照,一行 = 一条内容视频流程实例 | `row_key` 唯一;`spu` / `status` / `stage_label` / `demand_type` / `brand` / `created_date` 有索引 |
同步任务自动建表(`CREATE TABLE IF NOT EXISTS`),引擎 InnoDB,字符集 utf8mb4。
## 3. 字段字典
| 字段 | 类型 | 含义 | 取值规则 |
| --- | --- | --- | --- |
| `row_key` | `VARCHAR(191)` 唯一 | 钉钉流程实例 ID(processInstanceId),幂等拉取键 | 源表主键,稳定不变 |
| `business_id` | `VARCHAR(128)` | 流程发起编号 | 形如 `202610071758000088167` |
| `title` | `VARCHAR(255)` | 流程标题 | `XX提交的内容视频和图片申请流程` |
| `submitter_name` | `VARCHAR(255)` | 提交人姓名 | 从标题提取;`originator_user_id` 存钉钉用户 ID 兜底 |
| `status` | `VARCHAR(32)` | 流程状态 | `RUNNING` / `COMPLETED` / `TERMINATED` |
| `demand_department` | `VARCHAR(255)` | 需求部门 | 表单字段「需求部门」 |
| `demand_type` | `VARCHAR(255)` | 需求类型 | 如 淘系-逛逛视频 / 抖音-千川素材视频 / 安装视频 / AI视频 等 13 种 |
| `task_type` | `VARCHAR(255)` | 任务类型 | 如 布景拍摄 / 二次剪辑 |
| `install_video_category` | `VARCHAR(255)` | 安装视频分类 | 仅安装视频类需求有值(如 B档) |
| `brand` | `VARCHAR(255)` | 品牌 | 表单字段「品牌」 |
| `scene_category` | `VARCHAR(255)` | 场景类别 | 如 卧室 / 客厅 / 玄关 |
| `spu` | `VARCHAR(64)` | SPU | 大写规范化;约 3% 行无值(账号维护等非产品需求) |
| `product_id` | `VARCHAR(128)` | 商品ID | 表单字段「商品ID」,可能为"暂无" |
| `sample_barcodes` | `TEXT` | 样品条码 | 表单「样品明细」表格中商品编码列,多值分号分隔 |
| `content_owner` | `VARCHAR(255)` | 内容部负责人 | 表单字段 |
| `planning_supervisor` | `VARCHAR(255)` | 品牌策划主管 | 表单字段 |
| `script_director` | `VARCHAR(255)` | 编导 | 流程推进到该环节时才填,未填为 NULL |
| `cameraman` | `VARCHAR(255)` | 摄像 | 同上 |
| `editor` | `VARCHAR(255)` | 剪辑 | 同上 |
| `retoucher` | `VARCHAR(255)` | 修图 | 可选环节 |
| `cover` | `VARCHAR(255)` | 封面 | 同上 |
| `stage_label` | `VARCHAR(64)` | 当前阶段 | 推断规则见第 4 节 |
| `acceptance_result` | `VARCHAR(255)` | 验收结果 | 表单「运营/策划/客服验收结果」 |
| `score` | `VARCHAR(64)` | 评分 | 表单「评分」 |
| `created_date` | `DATETIME` | 流程发起时间(北京时间) | 源表 createAt |
| `finished_date` | `DATETIME` | 流程完成时间 | RUNNING 为 NULL |
| `duration_days` | `DECIMAL(10,2)` | 总时效(天) | 发起 → 完成;未完成计至同步时刻 |
| `last_source_updated_at` | `DATETIME` | 源表最近更新时间 | 增量拉取判据 |
| `sync_version` | `VARCHAR(64)` | 本次同步批次标识 | 全表同批;用于清理已消失实例 |
| `synced_at` | `TIMESTAMP` | 本行最近同步时间 | 数据新鲜度判据(`MAX(synced_at)`) |
## 4. 阶段(stage_label)推断规则
源表只有 activityId 没有节点名,首版按表单字段的推进顺序推断(钉钉流程设计中角色字段按环节顺序逐步填写):
```text
status = COMPLETED → 已完结
status = TERMINATED → 已终止
验收结果或评分已填 → 验收/评分
否则取已填角色的最后一个:
编导 → 编导制作 → 摄像 → 摄影拍摄 → 剪辑 → 剪辑制作
→ 修图/封面 → 封面/修图制作
一个角色都没填 → 需求发起
```
若后续拿到钉钉流程节点名清单,可升级为 activityId 级精确映射(预留位:`oa-board.constants.ts` 的 `OA_BOARD_ACTIVITY_MAPPING_SEED.video` 当前为空数组)。
## 5. 接入示例(一律只读)
```sql
-- 按 SPU 拉该产品的全部视频流程
SELECT * FROM oa_video_flow_board_snapshot WHERE spu = 'SJ0159';
-- 只看进行中的
SELECT * FROM oa_video_flow_board_snapshot WHERE status = 'RUNNING';
-- 增量拉取(幂等键 row_key)
SELECT * FROM oa_video_flow_board_snapshot
WHERE last_source_updated_at > :上次拉取时间;
-- 取数前先看新鲜度(生产最多滞后 15 分钟)
SELECT MAX(synced_at) FROM oa_video_flow_board_snapshot;
```
关联口径:与产品数据对齐用 `spu` 或 `sample_barcodes` 中的条码;本表**没有开款编号**,不要与主看板的 `OPENING:` 维度直接关联。

View File

@ -1,188 +0,0 @@
# BOM 成本预估 v2(agent 方案)设计文档
> 日期:2026-09-16
> 状态:设计稿,待评审
> 前置阅读:`docs/项目总览.md`、`src/server/services/bom-costing/agent/`(已建成的引擎)
> 关联会话结论:1688 以图搜图锚定为过渡方案(双重失败已实测证伪),终局为 agent 估算 + 公司数据
## 1. 一句话定位
**粘贴竞品链接 → 几分钟后得到一张诚实的成本预估卡。**
原始需求从未变过。v2 的一切设计决策都回到这句话:数字 + 构成故事 + 诚实的区间,工序全部退到幕后。
## 2. v1 的教训(为什么 v2 长这样)
| v1 的问题 | 根因 | v2 的对策 |
|---|---|---|
| 工序暴露给用户(逐图泳道、五阶段时间线、重算模式、复核流) | 工程美德当成了产品 | 一根进度条 + 一张卡;审计链存在但默认隐藏 |
| 27 个 SKU 全量核算(用户等十几分钟才见结果) | 首结果时间没有被设计 | 首批 3 个精算先出结果,其余按需点算,结果缓存 |
| 十几分钟等待 | 按 SKU 串行/并发全量跑 | case 级一次运行,2-3 分钟 |
| 复杂度随精度承诺膨胀 | 用流程堆精度 | 用数据+校准逼近精度(±10% 是"尽量"的校准目标) |
| 1688 锚定顶替估算 | 上线压力下的权宜 | 1688 永远是后台交叉验证信号 |
**必须保留的教训**:用户会锚定数字。区间 + 置信度 + 不确定点是答案的一部分,不是脚注——这是"给一个数"和"给一个诚实的数"的差别。
## 3. 设计总纲(五条,裁决一切分歧)
1. **预估,不是核算**:输出是带区间与置信度的假设生成器,供决策筛选,永不承诺报价级精度。
2. **agent 主导分析,代码承载确定性**:看什么图、怎么拆结构、查什么价格由 agent 决定;一切数值计算(汇总、区间合成、损耗)由确定性代码完成。模型永不算数、永不编价。
3. **价格只认公司数据**:历史 BOM 镜像是唯一定价依据(检索→分类中位→cohort→损耗四级回退);1688 行情仅作后台交叉验证,永不成为展示价。
4. **前端来源中性(不可触碰)**:界面永不出现 1688 字样/报价/链接;一切序列化出口经过 `src/lib/bom-costing/public-estimate-text.ts`。
5. **实测闸门**:agent 流程未经回测与真实案例对比验证(偏差分布达标)前,不替换任何线上行为。
## 4. 用户形态
### 4.1 默认入口:预估卡 + 全量 SKU 按需核算
**覆盖率是需求,等待时间是体验问题**——所有 SKU 都要能出结果,但采用"先出一批、其余按需点算"的渐进交付:
```
粘贴链接 → [进度条:抓取中 → 分析中 → 出预估](2-3 分钟)→
┌────────────────────────────────────────┐
│ 源氏木语 白蜡木实木衣柜 │
│ 预计出厂成本 ¥260 – 380(首批 3 个 SKU)│
│ 置信度:中等 │
│ 板材 ¥180± · 五金 ¥25± · 加工+人工 ¥60± · 包材 ¥15± │
│ 依据:8 张商品图 + 公司历史 BOM 12 条 │
│ 不确定:板厚未确认;铰链数量按两门推定 │
└────────────────────────────────────────┘
SKU 列表(27 个):
[¥260.29 已核算] (3) 原木色 0.9米两门 ← 首批自动精算
[¥375.48 已核算] (7) 原木色 1.0米 ← 首批自动精算
[¥406.77 已核算] (18) 原木色 1.96米 ← 首批自动精算
[点击核算] (4) 原木色 0.9米/高2.2米 ← 用户点击后单独运行
[点击核算] ... ← 已算结果缓存,不重跑
────────────────────────────────────────
商品图片(保留现有形式:主图/SKU图/详情图 分类浏览 + 大图预览)
```
- **首批自动精算**:默认 3 个代表性 SKU(跨度原则:最小/最大/中间,或按材质/结构差异选),2-3 分钟出卡——用户立刻有结果。
- **其余 SKU 按需点算**:未核算的 SKU 显示占位按钮,用户点击触发单 SKU agent 运行(复用现有 per-SKU runner,1-2 分钟/个);**结果持久缓存**,重复点击不重跑。
- 进度状态只有三个:抓取中 / 分析中 / 出预估(含失败态与重试按钮)。
- **渐进展开**:点构成 → BOM 行明细;点行 → 证据(哪张图、哪条镜像参考行)。审计链存在,默认折叠。
- **商品图片保留现有展示形式**(主图/SKU 图/详情图分类 + 逐图状态 + 大图预览):用户需要直接查看竞品链接内容,图片是判断依据,不是工程噪声。
### 4.2 深度模式(保留现有工作台)
需要全量 27 SKU 拆解、人工核对、证据溯源的用户,从预估卡一键进入现有工作台。v2 验证前工作台不动;验证后它降级为二级入口,不再是默认。
## 5. 架构:case 级 agent 运行
### 5.1 运行编排(新,核心增量)
交付模型:**首批自动精算 + 其余按需点算**。现有 per-SKU runner 是按需点算的执行体,case 级编排负责首批选择与结果汇总。
```
阶段一:runBomCostingAgentEstimateForCase(caseId)(首批)
1. 取案例简报(标题/渠道/已解析品类 skill)+ 归档素材 + SKU 清单(含尺寸)
2. 代码侧选首批 SKU(默认 3 个:最小/最大/中间,或按材质/结构差异;
单 SKU 商品即为它自己)
3. 逐个运行现有 per-SKU agent 循环(结构行 + 查价)
4. 代码侧汇总出 EstimateCard:卡面五行/区间/置信度 + SKU 列表状态
(已核算 3 / 待核算 24)
阶段二:按需点算(用户驱动)
用户点击未核算 SKU → 单 SKU agent 运行(现有 runner)→
结果持久缓存 → 卡面/SKU 列表实时更新
```
- 首批预算:≤24 轮 / ≤32 次模型调用 / ≤8 分钟(per-SKU 预算 × 3 + 编排开销)。
- **结果缓存**:per-SKU 结果按 (caseId, skuId, 引擎版本) 持久化;点击已核算 SKU 直接展示缓存,不重跑。SKU 重算 = 显式刷新动作(换引擎版本或用户主动重跑)。
- 失败语义:首批中单个 SKU 失败 → 该 SKU 标记失败可单独重试,不拖垮整卡;结构成功但价格饥饿 → 出结果但区间放宽 + 不确定点说明(PARTIAL 是合法产物)。
- 并发上限与幂等:同一 SKU 的点算请求可去重合并(进行中重复点击返回同一运行)。
### 5.2 输出契约:EstimateCard(case 级)
```ts
interface BomEstimateCard {
caseId: string
title: string
skus: Array<{ // 全量 SKU 清单 + 状态
externalSkuId: string
name: string
status: 'ESTIMATED' | 'PENDING' | 'FAILED' | 'RUNNING'
estimate?: BomCostingAgentEstimate // 已核算时:行/区间/置信度/evidence
cachedAt?: string
}>
firstBatchSkuIds: string[] // 首批自动精算的 SKU
breakdown: Array<{ // 卡面五行(由已核算 SKU 的行汇总;随点算增长)
category: 'BOARD'|'STEEL'|'ACCESSORY'|'PACKAGING'|'PROCESSING'|'LABOR'
total: number | null // null = 该组无价,卡面显示"待补"
}>
totalRange: { min: number; max: number } // 当前已核算 SKU 的区间包络
confidence: number // 已核算 SKU 的最小置信度
openQuestions: string[] // 卡面"不确定"(已核算 SKU 的并集)
usage: { rounds; modelCalls; elapsedMs }
}
```
原则:明细(行级)保留在各 SKU 的 estimate 里供展开;卡面只消费 breakdown/totalRange/confidence/openQuestions,并随按需点算增量更新。
### 5.3 价格供给(核心依赖,最大缺口)
首测"定价饥饿"的根治项——把 v1 场景服务的回退链包装成 agent 工具:
| 优先级 | 工具 | 包装自 |
|---|---|---|
| P0 | 尺寸感知镜像检索(分类/材质词/尺寸/参考 SKU 多级过滤) | `findReferenceCandidates` |
| P0 | 分类画像中位价 | `fillCategoryProfileFallbacks` |
| P1 | 同类 cohort 报价 | `resolveCohortQuote` |
| P1 | 历史损耗率 | `applyHistoricalLossFactorFallbacks` |
| P2 | AI 补估(置信度上限 0.75 + 标记复核) | 现有 estimator |
工具输出纪律:摘要级(无供应商/店铺/链接),带 sourceRowId 供 evidence 引用,单位明确。**1688 工具维持现状**(只透出状态/参考价/区间/可比数四个中性字段)。
### 5.4 数据与知识资产接入(全部保留,形态不变)
| 资产 | v2 中的角色 | 接入形态 |
|---|---|---|
| 历史 BOM 镜像(每日 MSSQL 同步) | 唯一价格依据 | 工具(5.3) |
| 品类 Skill(5 个)+ 结构先验 | agent 脑中的专家清单:必备组件、证据纪律、校验规则 | 提示词注入(`renderBomCostingAgentSkillKnowledge`,已建成) |
| 材质目录 | 材质身份唯一权威 | 工具(已接线) |
| 商品抓取/归档/抠图 | 看图取证的素材 | 工具 + mission 组装(已接线;抠图模型待切 remove-background) |
| 1688 行情 | 后台交叉验证信号 | 工具(已接线,中性摘要) |
### 5.5 校准闭环(±10% "尽量"的实现方式)
- **回测**:历史 BOM 镜像抽 20-50 个真实 SKU,同一规则/agent 重算 vs 真实成本,产出误差分布(复用 `backtest:bom-cost-reference` 基建)。
- **shadow 对比**:agent 结果与现行引擎结果对同一案例并排落库(工程域),持续记录偏差(`report:bom-cost-shadow-divergence` 同款模式)。
- **阈值语义**:±10% 是回测循环追的校准目标(按品类分段看分布),不是单案例门禁。偏差大 → 调工具/知识/提示词 → 再回测。
- 未过门槛 → agent 流程保持旁路,线上仍是现行方案。
## 6. 前端形态
- **新入口**:`/bom-costing` 默认视图替换为预估卡流(粘贴框 → 卡 → SKU 列表 → 图片浏览)。
- **商品图片**:保留现有展示形式(主图/SKU 图/详情图分类 tab + 大图预览 + 逐图状态),用户需要直接查看竞品内容;它从"处理进度件"回归"内容主体"。
- **SKU 列表**:已核算显示价格,未核算显示"点击核算"按钮(运行中显示进度态);点击结果写缓存并即时更新卡面。
- **组件**:新 `BomEstimateCard`(平台组件体系 + 白曜石规范),状态只有 加载/成功/失败(可重试)。
- **现有工作台**:不动,作为深度模式从卡进入。
- **移动端**:卡、SKU 列表与图片浏览响应式。
- 红线复检:卡面文案经过 `publicEstimateWarningText` 清洗;evidence 展开层同样不得出现来源敏感词。
## 7. 落地顺序(每步可独立验证)
| 步骤 | 内容 | 验收 |
|---|---|---|
| ① | 价格回退工具(P0 两个) | 对已知案例重跑,未定价行显著下降 |
| ② | 首批编排 + EstimateCard 契约 + 按需点算与结果缓存 | 单案例 2-8 分钟出首批卡;点击未核算 SKU 增量出结果且缓存生效 |
| ③ | 预估卡前端(含 SKU 列表与现有图片浏览)+ 三态进度 | 真人走通 粘贴→出卡→点算其他 SKU→看图核对 全流程 |
| ④ | 回测 + shadow 接入,按品类出基线报告 | 误差分布报告(对照 ±10% 目标) |
| ⑤ | 实测样本扩到 5-10 案例,按品类定门槛 | 达标 → 翻转默认入口;未达标 → 继续迭代 |
## 8. 红线(重申)
1. 前端永不出现 1688 相关内容;一切文本出口过脱敏契约。
2. agent 引擎在过实测闸门前保持旁路(不替换线上行为)。
3. 区间 + 置信度 + 不确定点是答案的一部分,任何展示形态不得省略。
4. 单价只来自工具结果;总额只由代码汇总。
5. 现有工作台在 v2 过门槛前不删不改语义。
## 9. 当前资产清单(截至本文日期)
已建成:agent 契约/工具层/循环/网关适配/skill 知识渲染/per-SKU runner(27 测试)、镜像与材质只读仓储、1688 中性摘要工具、板材开料工具、shadow 报表、trial 试跑脚本、生产真实案例(源氏木语 27 SKU)对照数据。
待建:价格回退工具(5.3 表)、case 级编排、EstimateCard 契约与前端、回测接入。
外部依赖:1688 新 key(当前过期,影响搜图与 A/B 实测,不影响 ①② 开发)。

View File

@ -1,43 +0,0 @@
# BOM 成本预估产品口径
本文是 BOM 成本功能的产品口径裁决入口。代码、界面、测试或历史文档与本文冲突时,应先按本文区分“成本预估”与旧链路内部一致性检查,再决定是否修改实现。
## 产品承诺
- 本功能提供带区间、置信度和待确认项的成本预估,不提供报价级精确核算。
- 预估结果允许与实际采购价、旧规则结果或其他参考信号存在明显差异;差异本身不构成失败。
- 缺少依据的单价可以为空,未解决行应进入待确认项,不得为了提高覆盖率编造价格。
- Agent 决定查看什么资料、调用什么工具以及引用什么证据;总额、损耗、开料、分位数和行小计由确定性代码计算。
- 正式 Agent 引擎以公司历史 BOM 镜像、材质目录和损耗经验为主要价格依据;临时交付阶段按下节的整件九折方案展示单一预估结果。
## 临时整件九折方案
- 正式 Agent 引擎完善前,相似度合格的实时同款报价取中位数并固定乘以 `0.90`,作为前端唯一整件预估价。
- 没有可用同款、图片不可用或服务失败时,沿用结构预估,页面仍只显示一套结果。
- 原始报价、候选商品、九折过程和原结构价只保留在后台审计字段;普通接口和前端不得出现来源名称、商品链接、供应商或采购系数。
- 临时整件价生效时,前端不得同时显示结构分类金额、行小计、贡献占比或旧规则价格;BOM 部件、材质、尺寸和数量仍可在详情中查看。
- 正式 Agent 引擎通过产品验收后,在保持同一前端单结果契约的前提下替换临时价格来源。
## 验收口径
Agent 旁路试验主要验证:
1. 能否稳定完成工具调用并返回符合契约的结果。
2. 区间、置信度、证据和待确认项是否完整且容易理解。
3. 单价是否来自工具结果,总额是否由代码汇总。
4. 未定价行是否明确保留,而不是被虚构金额掩盖。
5. 前端是否保持来源中性,不泄露供应商、店铺、商品链接或实时市场来源。
Agent 与旧引擎、实际采购价或市场信号的差异仅作为观察数据,不设全局固定百分比通过门槛。按品类统计差异可以帮助理解结果分布,但不能自动判定预估失败。
## 旧 10% 门槛的边界
`backtest:bom-cost-reference` 中的 10% 只用于验证同一历史参考镜像内“已存总额”和“逐行确定性重建”是否一致,属于数据与重建逻辑检查。它不评价页面成本预估或 Agent 预估准确度,不得显示为产品目标,也不得复用于 Agent 灰度验收。
数据库中的 `targetErrorPercent` 暂时保留用于旧数据和接口兼容;新界面不得展示为成本预估承诺。后续清理旧模型时再通过正式 migration 移除。
## 术语
- 用户可见名称统一使用“BOM 成本预估”“预估结果”“重新预估”。
- `bom-costing` 等技术目录、路由和历史模型名暂时保留,避免为改名引入无关迁移。
- “核算”仅可用于描述已经确认的历史采购/生产成本,不能描述本功能输出。

View File

@ -1,310 +0,0 @@
# OA 看板钉钉待办接入方案
## 结论
正式系统**不能只靠新增环境变量**就直接发送钉钉待办。
原因:
1. 仓库当前只实现了钉钉登录与用户识别,没有“创建 / 更新 / 删除钉钉待办”的服务代码。
2. 当前没有“业务流程记录 <-> 钉钉待办 ID”的持久化表。
3. 当前没有发送幂等、失败重试、关闭待办、升级提醒等基础能力。
但从现有代码基础看,**补一层服务实现后,再由正式环境配置环境变量即可上线**。
## 现有基础
### 已具备
1. 钉钉 OAuth 登录能力
- 文件:`src/server/services/dingtalk-auth.service.ts`
- 已能完成:
- 构建授权地址
- 获取用户 access token
- 获取 unionId
- 获取 app access token
- 用 unionId 换取 userId
- 获取用户详情
2. 用户钉钉身份字段
- 系统已保存并使用:
- `dingtalkUserId`
- `dingtalkUnionId`
3. OA 看板责任人映射
- OA 看板已经能把不同阶段映射到默认责任角色 / 人员字段。
- 例如首单下单待发起时可兜底为 `采购/跟单`。
4. 责任与跟进界面基础
- 页面已有“责任与跟进”区域,适合作为钉钉待办的业务入口。
### 尚缺失
1. 钉钉待办发送 service
2. 待办投递日志表
3. 待办幂等规则
4. 待办关闭 / 更新规则
5. 定时升级提醒任务
6. 正式环境待办配置项
## 官方能力判断
根据钉钉开放平台官方文档,待办能力当前支持:
1. 创建工作待办
2. 更新待办
3. 删除待办
4. 查询用户待办
与本项目相关的关键点:
1. 创建待办时需要应用身份和用户身份,不能只知道姓名。
2. 待办通常需要可回跳的 `detailUrl`,跳回本系统详情页或看板页。
3. 如果要做闭环,流程状态变更后还要同步更新或关闭钉钉侧待办。
官方文档参考:
- [待办概述](https://open.dingtalk.com/document/development/dingtalk-todo-task-overview)
- [创建钉钉待办任务](https://open.dingtalk.com/document/development/add-dingtalk-to-do-task)
- [更新钉钉待办任务](https://open.dingtalk.com/document/development/updates-dingtalk-to-do-tasks)
- [删除钉钉待办任务](https://open.dingtalk.com/document/development/delete-dingtalk-to-do-tasks)
- [更新钉钉待办执行者状态](https://open.dingtalk.com/document/development/update-dingtalk-to-do-status)
## 正式环境建议新增的环境变量
下面这些变量建议作为**待办功能正式接入专用配置**。当前代码尚未消费它们。
```env
# 是否启用 OA 看板钉钉待办
DINGTALK_TODO_ENABLED=false
# 钉钉开放平台 API 应用配置(待办、审批等服务端调用)
DINGTALK_API_CLIENT_ID=
DINGTALK_API_CLIENT_SECRET=
# 钉钉网页登录使用独立 OAuth 应用配置,不与 API 应用混用
DINGTALK_LOGIN_CLIENT_ID=
DINGTALK_LOGIN_CLIENT_SECRET=
DINGTALK_CALLBACK_ORIGIN=
# 待办跳转回系统的基础地址
DINGTALK_TODO_DETAIL_BASE_URL=
# 用于构造钉钉待办来源标识 / 业务空间
DINGTALK_TODO_SOURCE_APP=oa-board
# 是否开启超时升级提醒
DINGTALK_TODO_ESCALATION_ENABLED=false
# 超时升级阈值(天)
DINGTALK_TODO_ESCALATION_DAYS=3
```
如果最终接入方案要求额外的应用标识,还可能需要:
```env
DINGTALK_AGENT_ID=
```
是否必须取决于最终选用的钉钉待办接口版本与应用类型。
## 建议新增的数据表
建议新增一张专门的待办投递表,避免把钉钉待办状态散落在 JSON 里。
建议表名:
- `oa_board_dingtalk_todo_dispatch`
用途:
1. 记录某个业务流程 / 阶段是否已创建钉钉待办
2. 保存钉钉待办 ID / 执行人 / 状态
3. 支持去重、重试、关闭、审计
SQL 草案见:
- `scripts/sql/create-oa-board-dingtalk-todo-dispatch-table.sql`
## 建议的发送时机
### 第一阶段:最小闭环
先只做一类待办:
1. 当流程进入新的“当前审批阶段”
2. 且解析出了唯一责任人
3. 且该阶段还没有有效待办
4. 则创建一条钉钉待办
建议首批接入阶段:
1. `产品设计-外观`
2. `产品设计-结构`
3. `产品生产-首单下单`
4. `限价-发起`
原因:
1. 这些阶段责任人相对清晰
2. 都已经有字段或规则可映射负责人
3. 业务价值高,适合先验证
### 第二阶段:关闭与更新
当以下任一情况发生时,同步钉钉待办:
1. 当前阶段变化
- 关闭旧待办
- 为新阶段责任人创建新待办
2. 流程完成 / 驳回 / 终止
- 关闭对应待办
3. 负责人变更
- 更新执行人,或关闭旧待办后重建
### 第三阶段:升级提醒
当流程满足以下条件时再发升级提醒:
1. 阶段超时
2. 原待办已创建
3. 距离上次提醒超过冷却时间
4. 升级接收人可解析
升级对象建议:
1. 当前负责人
2. 当前负责人上级
3. 商品企划 / 荆川 / 管理员可选兜底
## 负责人解析策略
待办发送目标不能只靠中文名,必须最终落到钉钉身份。
建议解析顺序:
1. 当前运行任务的 `userId`
- 直接映射 `dingtalkUserId`
2. 表单中责任人字段
- 如 `appearanceDesigner`、`structureDesigner`、`merchandiserOwner`、`pricingOwner`
- 用姓名匹配本地用户 / 员工目录,取 `dingtalkUserId`
3. 阶段默认角色兜底
- 例如 `采购/跟单`
- 通过角色到成员名单的映射表进一步解析
如果一个阶段只能解析到“角色名”而不能解析到唯一员工,建议:
1. 不直接发待办
2. 记录 `NEEDS_OWNER_MAPPING`
3. 在看板中提示“负责人映射未完成”
## 需要新增的服务模块
建议至少拆成这几个文件:
1. `src/server/services/dingtalk-todo.service.ts`
- 创建 / 更新 / 删除待办
- 管理 app token
2. `src/server/services/oa-board-dingtalk-todo-dispatch.service.ts`
- 负责业务规则
- 判断是否该发待办
- 生成 payload
- 落库记录
3. `src/server/services/oa-board-owner-resolution.service.ts`
- 负责将阶段负责人解析成钉钉用户
4. `scripts/sync-oa-board-todos.ts`
- 用于定时补发、关闭、升级
## 需要新增的 API / 任务
### 后台任务
建议新增定时任务:
1. `sync:oa-board:todos`
- 扫描当前有效流程
- 创建缺失待办
- 关闭失效待办
- 触发升级提醒
### 管理接口
如有需要,可新增:
1. `POST /api/intake/oa-board/todo-dispatch/retry`
- 手工重试失败待办
2. `GET /api/intake/oa-board/todo-dispatch`
- 查看待办发送日志
## 幂等与重试建议
待办系统必须做幂等。
建议唯一业务键:
```text
board_key + stage_key + receiver_dingtalk_user_id
```
规则:
1. 同一个业务键,只允许存在 1 条 `OPEN / PENDING / RETRYING` 记录
2. 接口超时后不立即重发,先查日志状态
3. 网络失败按指数退避重试
## 正式环境能否“只配置就开”
最终判断:
1. **现在不能**
- 因为缺少待办发送代码和日志表
2. **补完代码后可以**
- 正式系统只需配置环境变量
- 再开启定时任务即可
## 推荐落地顺序
### P1
1. 新增待办投递表
2. 新增 `dingtalk-todo.service.ts`
3. 先打通“创建待办”
4. 先只接 `产品生产-首单下单`
### P2
1. 接入更多阶段
2. 做关闭 / 更新逻辑
3. 做负责人映射异常日志
### P3
1. 做超时升级提醒
2. 做看板内待办发送状态可视化
3. 做手工重试与运维面板
## 建议的产品口径
为了降低误发风险,建议待办文案统一包含:
1. 开款编号
2. 当前阶段
3. 目标动作
4. 超时状态
5. 跳转链接
示例:
```text
【OA 新品流程看板】XP2026070900000291 已进入“产品生产-首单下单”,请尽快处理。
```

View File

@ -1,121 +0,0 @@
# 供应链产品全量档案字段来源对照表
更新时间:2026-10-16
## 1. 合并与追踪规则
- 汇总表:`SupplyChainProductArchive`
- 唯一合并键:`barcode`
- 来源记录:`SupplyChainProductArchiveSource`
- 字段级来源:`SupplyChainProductArchive.fieldSourceMap`
- 产品全量档案页面使用的来源数据必须先写入 `SupplyChainProductArchive` 或图片缓存;页面列表不再临时联查来源库。
- 新品图片先用 `external.product_catalog_wide_v1.archive_image` 判断当前 SKU/条码行是否真的有图;确认有图后,保存同一行标准化的绝对 `image_url`,绝对地址为空时才保留 `archive_image`。当 `archive_image` 为空时禁止读取 `image_url`,因为该列此时可能回退到产品级图片。宽表当前行无图时,再按当前条码/SKU精确查询素材库;命中的链接和来源信息保存在 `SupplyChainProductArchiveImageCache`。图片二进制不写入 PostgreSQL,图片变化不进入商品修改日志。
- 首次建立商品档案只记录一条“商品档案导入”;后续来源值实际变化时,按条码、字段、旧值、新值和来源写入 `OperationLog`。
- 扩展字段通过 `sync:supply-chain-product-archive-enrichment` 分批同步;重复执行时只有值发生变化的字段才更新主表并写日志。
- 生产定时入口为 `scripts/install-supply-chain-product-archive-sync-timer.sh`,默认每 15 分钟先同步 `rpt.jst_sku` 基础档案,再同步供应商、仓库、生产周期、日销和价格等扩展字段。
- 聚水潭来源只读取 `JSReportReplica.rpt.jst_sku` 中 `item_type = '成品'` 的行。
- `jst_sku` 只更新本表明确列出的字段;空值不覆盖其他来源的已有值。
- 已由 `JST_SKU` 提供且有值的字段,不再被产品开发同步覆盖。
- 一级类目由 `SupplyChainProductArchiveCategoryConfig` 按 JST 二级类目匹配,未配置时保持空值。
- 创建时间优先级固定为:`云杉商品资料.xlsx` 的“创建时间” > `jst_sku.created` > 产品开发创建时间。
- 云杉文件只在部署或补数时通过导入脚本读取一次,不进入 Git,页面运行时不读取 Excel。
- 体积统一由同一来源记录的长、宽、高相乘计算;任一尺寸缺失、不是有效数字或不大于 0 时保持空值。
- 未列出的字段不猜测、不兜底,保持原来源或空值。
## 2. 当前字段对照
| 全量档案字段 | 页面表头 | JST_SKU 来源 | 产品开发来源 | 其他来源 | 转换规则 |
| --- | --- | --- | --- | --- | --- |
| `barcode` | 条码 | `rpt.jst_sku.sku_id` | `external.product_catalog_wide_v1.barcode_business_key` | | 去空格并转大写;档案唯一键 |
| `spu` | SPU | `rpt.jst_sku.i_id` | `archive_spu` | | 去空格并转大写 |
| `sku` | SKU | `rpt.jst_sku.other_7` | `archive_sku` | | 去空格并转大写;JST 空值不兜底 |
| `image` | 图片 | | `external.product_catalog_wide_v1.archive_image` + `image_url` | 素材库接口 + `SupplyChainProductArchiveImageCache.previewUrl` | 新品以当前 SKU/条码行 `archive_image` 非空为前提,保存同一行标准化 `image_url`;`archive_image` 为空时不得使用可能回退到产品级图片的 `image_url`;当前行无图时再按当前条码/SKU精确查询素材库,命中链接长期复用,未命中则留空 |
| `businessCreatedAt` | 创建时间 | `rpt.jst_sku.created` | `product_development_created_at`,空时取 `source_created_at` | 云杉文件“创建时间”按条码匹配 | 按固定优先级覆盖 |
| `categoryL1` | 一级类目 | | `sales_category_name` | `SupplyChainProductArchiveCategoryConfig.categoryL1` | JST 行按完整二级类目匹配配置表 |
| `categoryL2` | 二级类目 | `rpt.jst_sku.category` | `archive_product_category_name` | | JST 原样保留完整类目文本 |
| `productName` | 商品名称 | `rpt.jst_sku.name` | `archive_product_name` | | 原样保留 |
| `designer` | 设计师 | 暂不接入 | `archive_designer` | | |
| `productStatus` | 商品状态 | `rpt.jst_sku.other_5` | `sku_sales_status` | | 只接受“销售、停产、退市” |
| `material` | 材质 | `rpt.jst_sku.other_10` | `archive_material` | | 原样保留 |
| `color` | 颜色 | `rpt.jst_sku.properties_value` | `archive_color` | | 原样保留 |
| `weight` | 重量 | `rpt.jst_sku.weight` | `sample_weight_kg` | | 转十进制数;空值不覆盖 |
| `length` | 长 | `rpt.jst_sku.l` | `package_length_cm` | | 转十进制数;空值不覆盖 |
| `width` | 宽 | `rpt.jst_sku.w` | `package_width_cm` | | 转十进制数;空值不覆盖 |
| `height` | 高 | `rpt.jst_sku.h` | `package_height_cm` | | 转十进制数;空值不覆盖 |
| `volume` | 体积 | `rpt.jst_sku.l * w * h` | `package_length_cm * package_width_cm * package_height_cm` | | 不读取来源体积列,统一按长宽高计算 |
| `packType` | 单双包 | `rpt.jst_sku.other_8` | `package_set_count` | | 1-9 转为非双包至九包;已是标准文字时原样保留 |
| `productTag` | 商品标签 | `rpt.jst_sku.labels` | `product_tag` | | 原样保留 |
| `installDocId` | 安装图纸/视频 ID | `rpt.jst_sku.other_9` | `drawing_id` | | 原样保留 |
| `spec` | 产品尺寸 | 从 `rpt.jst_sku.name` 提取 | `archive_spec` | | 提取三轴尺寸,统一 `/` 分隔,保留小数和范围 |
| `brand` | 品牌 | `rpt.jst_sku.brand` | `brand_name` | | 原样保留 |
| `orderType` | 叫货类型 | `rpt.jst_sku.other_price_2` | `purchase_order_method` | | 按现有枚举归一 |
| `storageType` | 入库/代发 | | | `SupplyChainProductArchiveWarehouseConfig.storageType` | 按出入库仓库名称匹配配置表 |
| `isSeasonal` | 是否季节性产品 | 暂无来源 | 暂无来源 | | 保持空值 |
| `replaceCount` | 替换款数量 | 暂无来源 | 暂无来源 | | 保持空值 |
| `region` | 区域 | | | `rpt.js_supplier.region` | 按供应商名称匹配后写主表 |
| `supplierName` | 供应商 | `rpt.jst_sku.supplier_name` | `archive_supplier_name` | | 原样保留 |
| `supplierType` | 供应商类型 | | | `rpt.js_supplier.scmtype` | 按供应商名称匹配后写主表 |
| `supplierStatus` | 供应商状态 | | | `rpt.js_supplier.coopersgn` | `1=合作`、`2=不合作` |
| `purchaseOwner` | 采购 | | `archive_purchase_owner` | `rpt.js_supplier.buyer` | 供应商采购非空时覆盖产品开发采购 |
| `settlementMethod` | 结算方式 | | | `rpt.js_supplier.paycyclesgn` | 按供应商名称匹配 |
| `boardProvider` | 板材提供方 | | | `rpt.js_supplier.undefined2` | 按供应商名称匹配 |
| `penaltyStatus` | 违约金赔付状态 | | | `rpt.js_supplier.undefined1` | 按供应商名称匹配 |
| `warehouseType` | 仓库属性 | | | `rpt.jst_warehouseInfo.remark2` | 按出入库仓库名称匹配 |
| `warehouseHead` | 仓库负责人 | | | `SupplyChainProductArchiveWarehouseConfig.warehouseHead` | 按出入库仓库名称匹配配置表 |
| `minimumOrderQuantity` | 产品起订量 | | `archive_minimum_order_quantity` | `rpt.pm_stockMonitor.minimum` | stock monitor 非空时覆盖产品开发起订量 |
| `firstLeadTime` | 首单生产周期 | | | `rpt.pm_stockMonitor.produceCycle` | 条码优先、SKU 唯一匹配兜底 |
| `palletSpec` | 托盘参数 | | | `rpt.pm_stockMonitor.trayParameter` | 条码优先、SKU 唯一匹配兜底 |
| `dailySales` | 采购日销 | | | `rpt.pm_stockMonitor.purchaseSale` | 按条码匹配最新记录 |
| `sales3d` | 3日日销 | | | `rpt.pm_stockMonitor.threeAvgSale` | 按条码匹配最新记录 |
| `sales7d` | 7日日销 | | | `rpt.pm_stockMonitor.sevenAvgSale` | 按条码匹配最新记录 |
| `sales15d` | 15日日销 | | | `rpt.pm_stockMonitor.fifteenAvgSale` | 按条码匹配最新记录 |
| `purchasePrice` | 采购价 | `rpt.jst_sku.purchase_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空 |
| `costPrice` | 成本价(聚水潭) | `rpt.jst_sku.cost_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空。DF 条码自 2026-10-16 起同样取本来源(原 `rpt.dws_sn_sales_full_info_di.goods_cost` 为金额口径,已停用) |
| `costPriceSecondary` | 成本价(新品) | | `external.product_catalog_wide_v1.cost_price` | | 按档案条码精确匹配 `barcode_business_key`,只读取 `catalog_source = INTAKE_READY` 的新品行;匹配不到或来源为空时留空 |
## 3. JST_SKU 第一批字段覆盖率
统计范围:`item_type = '成品'`,共 65,164 行。
| 来源字段 | 对应档案字段 | 非空行数 |
| --- | --- | ---: |
| `sku_id` | 条码 | 65,164 |
| `i_id` | SPU | 65,164 |
| `name` | 商品名称、规格解析 | 65,164 |
| `other_7` | SKU | 63,537 |
| `other_5` | 商品状态 | 63,771 |
| `other_9` | 安装图纸/视频 ID | 24,792 |
| `other_10` | 材质 | 31,394 |
| `properties_value` | 颜色 | 63,855 |
| `category` | 二级类目 | 63,539 |
| `weight` | 重量 | 35,754 |
| `l` | 长 | 38,224 |
| `w` | 宽 | 38,083 |
| `h` | 高 | 38,124 |
| `other_8` | 单双包 | 61,908 |
| `labels` | 商品标签 | 以本次来源库实时数据为准 |
| `brand` | 品牌 | 以本次来源库实时数据为准 |
| `supplier_name` | 供应商 | 以本次来源库实时数据为准 |
## 4. 来源代码
| 来源代码 | 来源系统 | Schema | 对象 |
| --- | --- | --- | --- |
| `JST_SKU` | 聚水潭 JSReportReplica | `rpt` | `jst_sku` |
| `PRODUCT_DEVELOPMENT` | 家具产品开发系统 | `external` | `product_catalog_wide_v1` |
| `CATEGORY_CONFIG` | 供应链中心类目配置 | `public` | `SupplyChainProductArchiveCategoryConfig` |
| `JST_SUPPLIER` | 聚水潭 JSReportReplica | `rpt` | `js_supplier` |
| `JST_STOCK_MONITOR` | 聚水潭 JSReportReplica | `rpt` | `pm_stockMonitor` |
| `JST_WAREHOUSE_INFO` | 聚水潭 JSReportReplica | `rpt` | `jst_warehouseInfo` |
| `JST_SKU_PRICE` | 聚水潭 JSReportReplica | `rpt` | `jst_sku` |
| `PRODUCT_CATALOG_COST_PRICE` | 家具产品开发系统 | `external` | `product_catalog_wide_v1` |
| `WAREHOUSE_CONFIG` | 供应链中心 | `public` | `SupplyChainProductArchiveWarehouseConfig` |
| `YUNSHAN_PRODUCT_EXPORT` | 云杉商品资料 | Excel 工作表名 | Excel 文件名 |
## 5. 生产同步入口
- 产品开发页面保存成功后,会按当前产品即时同步 `external.product_catalog_wide_v1` 的档案字段。
- 聚水潭及其关联表由 systemd timer 周期检查。同步过程可以重复执行,主表只写实际变化,商品修改日志只记录后续真实变化。
- 定时任务安装前必须在生产服务器的 `shared/supply-chain-product-archive-sync.env` 填写目标 PostgreSQL 和来源 SQL Server 的身份校验值;文件权限固定为 `600`,其中不保存数据库密码。
- 安装命令只在发布完成并获得生产操作授权后执行:`bash scripts/install-supply-chain-product-archive-sync-timer.sh`。
- 新品即时同步以宽表当前 SKU/条码行的 `archive_image` 是否存在为准,并把同一行标准化的绝对 `image_url` 写入档案和 `SupplyChainProductArchiveImageCache`。宽表当前行无图时,页面才按当前条码/SKU调用素材库接口并回写链接;存量图片规则不在本次新品图片调整范围内。

View File

@ -1,182 +0,0 @@
# 停产下架服务器运行与上游数据依赖
更新日期:2026-10-26
## 1. 服务器每日入口
服务器入口:
```bash
node scripts/run-supply-chain-discontinuation-daily.mjs
```
执行顺序:
1. 生成 Prisma Client。
2. 同步虚拟云仓库存快照。
3. 生成编码维度通知和库存预警。
4. 通过服务器 Chrome 从聚水潭“店铺商品管理”导出完整链接并生成链接维度快照。
5. 生成未下架监控与乐捐快照。
生产安装:
```bash
sudo bash scripts/install-supply-chain-discontinuation-linux-timer.sh
```
默认使用 systemd,每日北京时间 10:30 执行,`Persistent=true` 会在服务器恢复后补跑。
服务名默认为 `furniture-discontinuation-daily`。
RPA 使用的 Python 必须显式可执行:
- 正式机默认 `/usr/bin/python3`,安装器会检查可执行权限并写入
`JST_RPA_PYTHON=/usr/bin/python3`。
- 如果正式机使用虚拟环境或其他路径,安装前设置
`DISCONTINUATION_PYTHON_BIN=/明确路径/python3`。
- 每日同步在连接聚水潭页面、开始导出前先执行 `python3 --version` 预检;
路径不存在、不可执行或版本命令失败时立即终止,不会等到导出完成后才发现。
## 2. 数据源与生成责任
| 数据 | 服务器读取来源 | 应用侧生成方式 | 是否依赖个人电脑 |
| --- | --- | --- | --- |
| 商品状态、组合商品、库存 | `JianSun_Old.JianSun`、`JSReportReplica.rpt` | 服务端 SQL 只读,应用生成本地快照 | 否 |
| 虚拟云仓库存 | `JianSun_Old.JianSun.dbo.jst_virtualstockStocksInfo` | 服务端脚本读取,应用生成本地日快照 | 否 |
| 替换规则(PM) | `JSReportReplica.rpt.pm_replacementManage` | 服务端 SQL 只读 | 否 |
| 替换规则(聚水潭) | `JSReportReplica.rpt.jst_sku_replace_rule` | 读取最新 `snapshot_date` | 否 |
| 供应商剩余库存 | `JianSun_Old.JianSun.dbo.plm_productDiscontinuation` | 服务端 SQL 只读 | 否 |
| 链接完整快照 | 聚水潭“店铺商品管理”前台导出 | 服务器 Chrome + RPA,分批导出后落本地链接快照 | 否(需服务器浏览器登录态) |
| 链接数据库快照(诊断) | `JianSun_Old.JianSun.dbo.dm_jst_shop_goods_link_daily` | 只用于比对和排障,不作为每日全量写入源 | 否 |
| 订单与订单明细 | `JSReportReplica.rpt.jst_Orders`、`jst_orderitem`、`jst_qmcust_orderlist*` | 服务端 SQL 只读 | 否 |
| 产品档案、负责人、申诉审核状态 | 应用 PostgreSQL | 应用自身写入 | 否 |
## 3. 数据库链接快照仅用于诊断
`dm_jst_shop_goods_link_daily` 的真实字段包括:
- `run_date`
- `shop_i_id`
- `shop_sku_id`
- `sku_id`
- `online_status`
- `stock_sync_status`
- `imported_at`
该表缺少大量完整导出链接,不能作为链接维度最终数据源。2026-10-26 对比结果:
- 数据库快照:52,639 行,按条件过滤后匹配通知仅 1,868 条。
- 聚水潭完整导出:246,480 条,全部匹配通知。
- 数据库快照缺少 99% 以上完整链接,只能用于差异诊断。
诊断脚本带覆盖率保护:如果数据库快照结果不足已有链接的 50%,会拒绝覆盖。
可配置:
```text
JST_DISCONTINUATION_LINK_MAX_AGE_HOURS=26
```
手动核对:
```bash
node_modules/.bin/tsx scripts/sync-supply-chain-discontinuation-links-from-dm.ts \
--date=YYYY-MM-DD \
--dry-run
```
## 4. 上游任务归属
已确认此前完整链接由个人 Mac 的 launchd 每日 09:00 启动 RPA 生成,随后写入数据库。
数据库日快照不是完整当前链接。
新的服务器方案:
- Linux 安装器同时安装 `furniture-jst-rpa-browser.service`。
- 每日任务在 Linux 上会先重启专用 RPA 浏览器服务,避免长期运行后聚水潭 iframe
卡死但仍能通过 CDP 健康检查;服务名可用 `JST_RPA_BROWSER_SERVICE` 覆盖。
- Chrome 使用持久化 profile,并通过本机 CDP 端口供 RPA 使用。
- 每日入口从服务器 Chrome 导出完整聚水潭数据。
- 正常整单导出最多尝试 3 次,任一次成功立即结束;只有全部失败后才按每批
10,000 个编码降级导出,避免页面偶发失败时立即产生大量分批记录。
- 首次部署优先把已登录的聚水潭 profile 放到 `JST_RPA_PROFILE_DIR`,或在服务器图形/VNC 环境完成一次登录。
- 服务器私有环境文件可配置 `JST_RPA_LOGIN_ID` 和
`JST_RPA_LOGIN_PASSWORD`;持久登录态失效时 RPA 会自动填写账号密码登录页。
凭据只能保存在权限为 `600` 的服务器共享 `.env`,禁止提交到 Git。
- 自动登录遇到验证码、短信验证或密码错误时会明确失败并保留已有链接,不覆盖完整快照。
首次登录示例:
```bash
sudo systemctl stop furniture-jst-rpa-browser.service
sudo env JST_RPA_HEADLESS=false \
JST_RPA_PROFILE_DIR=/opt/furniture-3d-system-prod/shared/jst-rpa-profile \
node scripts/launch-jst-rpa-browser.mjs
```
在图形界面登录聚水潭后停止该进程,再启动 systemd 浏览器服务。
如需允许登录态失效后自动重新登录,在服务器私有环境文件
`/opt/furniture-3d-system-prod/shared/.env` 中配置以下两项后重启每日服务:
```text
JST_RPA_LOGIN_ID=<聚水潭登录账号>
JST_RPA_LOGIN_PASSWORD=<聚水潭登录密码>
```
不要把这些值写入仓库、聊天记录、普通日志或 systemd 命令行参数。
正式部署入口会自动读取本机被忽略的
`furniture-3d-system/.env.jst-rpa-auth`,并在发布时合并上述两项;也可通过
`-JstRpaAuthFile` 指定其他权限受限的凭据文件。部署结束后临时凭据会被删除。
配置后可只验证登录,不执行导出:
```bash
node_modules/.bin/tsx scripts/jst-shop-goods-rpa.ts --action=login
```
## 5. 重跑保护
通知:
- 按 `(barcode, statDate)` 原地 upsert,保持既有通知 ID 不变。
- 同日重跑成功后,当天通知集合必须与最新计算结果完全一致;不再命中的旧通知
会删除,其旧链接按外键级联删除。
- 通知、链接清理和库存预警写入位于同一数据库事务。计算或写入失败时整体
回滚,原有通知和链接保持不变。
- 当天停产/退市商品整体为 0 时也走同一清理事务,不保留上一轮通知。
乐捐:
- 同日重跑不再先删除全部当天记录。
- 已进入 `PENDING_REVIEW`、`APPROVED`、`REJECTED`、`RETURNED` 的记录保留。
- 仅清理不再命中当前结果且仍为 `PENDING_APPEAL` 或 `LEFT_EMPLOYEE` 的旧记录。
- 候选 ID 虽在事务外读取,最终 `DELETE` 会再次校验数据库中的最新状态;候选
读取后刚提交的申诉不会被误删。
- 清理和 upsert 位于同一数据库事务,失败时整体回滚。
链接导入:
- 旧链接删除、新链接解析和写入使用同一事务。
- 最终有效写入为 0 时默认回滚并保留旧链接快照。
- 只有业务明确确认当天正常零链接时,才可给导入命令追加
`--allow-zero-links`,允许清空当天链接。
## 6. 文件交付
- 每日入口:`scripts/run-supply-chain-discontinuation-daily.mjs`
- Linux 安装器:`scripts/install-supply-chain-discontinuation-linux-timer.sh`
- 完整链接 RPA:`scripts/jst-shop-goods-rpa.ts`
- 服务器浏览器:`scripts/launch-jst-rpa-browser.mjs`
- 链接数据库诊断:`scripts/sync-supply-chain-discontinuation-links-from-dm.ts`
- 通知重跑保护:`src/server/repositories/supply-chain-discontinuation-notice.repository.ts`
- 乐捐重跑保护:`src/server/services/supply-chain-discontinuation-penalty-sync.ts`
## 7. 本地验证结果
2026-10-26 使用完整聚水潭导出验证:
- 完整导出:246,480 条链接。
- 链接落库后共 246,480 条,涉及 304 个负责人。
- 同日重跑通知后链接数量和通知 ID 未变化。
- 未下架监控首次同步生成 516 条。
- 未下架监控第二次同步仍为 516 条,无重复。
- 自动化测试覆盖链接条件过滤、去重、新鲜度拒绝、通知/乐捐状态保护。

View File

@ -1,297 +0,0 @@
# 停产下架管理数据源
更新时间:2026-10-20
## 1. 当前状态
停产下架管理已按新口径重新接入数据源:
- 通知下架-编码维度已接入并生成每日快照。
- 通知下架-链接维度已接入并生成每日快照。
- 库存预警已接入,读取当日 `ProductInventorySnapshot`。
- 未下架监控与申诉已接入,读取聚水潭订单头及订单明细生成月度乐捐明细。
- 页面、页签、表头、筛选、分页和导出能力已保留。
- 四个页签均支持导出当前筛选结果:编码维度、链接维度、库存预警、未下架监控与申诉分别使用独立字段和文件名。
- 页面查询接口只读取本地快照,不再实时访问 SQL Server。
- 每日生成入口按北京时间生成当天快照;同日重跑只替换当天数据,历史日期不动。
## 2. 数据流边界
页面和查询接口只读本地落库结果,不直接访问外部库:
```text
外部权威数据源
-> 每日 10:30 同步/计算任务
-> 本地快照及业务表
-> 停产下架页面 API
-> 页面筛选、展示和导出
```
本地承接表:
| 数据类别 | 本地表 |
| --- | --- |
| 虚拟云仓库存 | `SupplyChainVirtualInventorySnapshot` |
| 库存预警(按商品编码 + 仓库) | `ProductInventorySnapshot` |
| 通知下架-编码维度 | `SupplyChainDiscontinuationNotice` |
| 通知下架-链接维度 | `SupplyChainDiscontinuationNoticeLink` |
| 未下架订单和乐捐 | `SupplyChainDiscontinuationPenalty` |
## 3. 权威数据源
### 3.1 简尚库存
来源:`JianSun.dbo.jst_inventoryInfo_view`
页面使用“公有可用数”口径:
```text
公有可用数
= 库存
- 虚拟云仓库存
- 订单锁定数(包含缺货仓订单占有数)
```
- 虚拟云仓库存来自 `JianSun.dbo.jst_virtualstockStocksInfo`,按商品编码汇总。
- 公有可用数小于 0 时按 0 处理。
- 仓库名称通过 `JianSun.dbo.jst_warehouseInfo` 映射。
- `三方-南昌樵舍仓` 作为京喜渠道仓单独汇总,不计入普通简尚库存。
- 渠道专用仓库存不计入简尚主库存,单独返回到“渠道专用仓库存”和“专用仓备注”。
- 库存预警的“库存所在仓库”只统计实际计入公有可用数且有正库存的普通仓。
- 同一商品在多个仓库有库存时拆成多条明细,不合并仓库名称。
- 虚拟云仓库存按各仓库存占比分摊到仓库明细,分摊后各行公有可用数合计与原商品汇总值一致。
- 虚拟云仓先落到本地 `SupplyChainVirtualInventorySnapshot`,日任务只读本地快照,避免链接服务器偶发超时。
- 日常通过 `scripts/sync-supply-chain-virtual-inventory-snapshot.ts` 从 `JianSun.dbo.jst_virtualstockStocksInfo` 同步;链接服务器不可用时,可用 `scripts/import-supply-chain-virtual-inventory-snapshot.ts` 导入页面导出文件作为兜底。
### 3.2 工厂库存
来源:`plm_productDiscontinuation`
- 使用 `remainingStock` 作为供应商剩余库存量。
- 按 `skucode` 去重,空条码忽略;同一商品编码存在多条记录时,以 `createTime DESC, Id DESC` 最新创建的一条为准。
### 3.3 商品状态
商品状态以聚水潭源表 `rpt.jst_sku.other_5` 为准,只纳入实时状态为停产、退市的编码。
- 本地产品全量档案只用于补充 SKU、成本、名称等资料。
- 本地档案同步较慢时,不能用档案中的旧状态决定当天停产/退市结果。
- 若源表有、档案暂时没有的新停产编码,使用源表条码、货号和成本兜底进入计算。
### 3.4 组合商品
来源:
- `rpt.jst_comboProductInfo`:组合商品主表。
- `rpt.jst_comboProductItem`:组合商品与子商品明细。
规则:
- 只检查 `enabled=1` 的组合商品。
- 子商品数量 `qty>0` 才参与判断。
- 组合中任一子商品为停产/退市,且该子商品的简尚库存 + 工厂库存 = 0,则组合商品进入通知下架。
- 支持组合嵌套:若子商品本身也是组合,继续向下递归检查,并对循环引用做保护。
- 组合商品自身库存仍按组合编码从简尚库存和工厂库存表读取,用于页面展示。
- 组合因停产/退市且零库存子商品下架时,若该子商品存在有效替换款,组合处理说明同步为
“全渠道替换”或“仅京喜替换”,可替换商品编码固定标记为“运营自行组合替换”。
### 3.5 可替换商品
来源:`rpt.pm_replacementManage` 与 `rpt.jst_sku_replace_rule`,两张表按同一套货号继承和条码展开规则合并。
- `pm_replacementManage` 只读取 `delete=0` 且替换货号非空的数据;聚水潭表按启用/删除字段可用性自动过滤无效行。
- 聚水潭替换表使用 `rpt.jst_sku_replace_rule`,只读取最新 `snapshot_date`;原编码取 `sku_id`,替换编码取 `data`,`data` 中逗号分隔的多个候选按顺序保留。
- 同一原编码同时存在两张表规则时优先使用聚水潭替换表;聚水潭表不可读取时记录告警,并继续使用 `pm_replacementManage`。
- 优先按完整商品编码匹配;完整编码没有规则时,按商品档案货号继承同货号其他条码的替换规则。
- 兼容反向录入:若当前停产条码或其货号等于 `replaceSku`,则反向使用对应 `sku` 作为替换来源。
- 同一原编码存在多条规则时,取 `createTime` 最新的一条。
- 替换目标能在商品档案映射时,必须展开为具体 barcode;同货号有多个条码时优先销售、有库存、最近更新的条码。
- 已映射的替换条码若实时状态为停产/退市,且简尚、工厂、渠道专用仓和京喜库存均为 0,则不作为有效替换款;存在其他可用候选时继续选择下一候选。
- 所有已映射候选都不可用且没有未映射替换编码时,可替换商品编码留空,处理说明改为“全渠道下架”或“仅京喜下架”。
- 替换目标完全无法映射时,先按实时状态和库存过滤停产/退市且零库存编码;仍可用的才展示
规则中的替换编码,是否换图、区别点和价格差允许为空。若无法映射的值是商品档案中的已知
货号/SKU,则不得直接写入“可替换商品编码”列;必须展开为具体条码,展开失败时留空。
- 是否换图取 `isSwapping`,1 为是。
- 区别点取 `differentPoints`。
- 价格差沿用现有页面口径:替换商品成本 - 原商品成本。
### 3.6 店铺链接
正式来源:聚水潭“店铺商品管理”完整前台导出(服务器 Chrome + RPA)。
- 每日编码维度生成后,服务器 Chrome 自动批量查询并导出完整链接。
- 默认先执行单次全量导出,连续尝试 3 次;第 1、2 次失败后重置页面再重试,
任一次成功立即结束;只有三次都失败后才切换为每批 10,000 个编码的分批导出。
- 只保留 `online_status='是'` 且 `stock_sync_status='开启同步'` 的导出记录。
- 编码维度命中专用仓时,链接维度按专用仓所属部门排除对应链接;例如
`简尚-分销部专用仓` 只排除利润中心为“分销部”的链接,其他部门继续通知。
同一备注包含多个专用仓时逐个部门排除。
- 全部批次成功后,在数据库事务中替换当天链接;失败时保留已有完整链接。
- 最终有效写入为 0 时默认回滚;确有业务依据的正常零链接必须显式使用
`--allow-zero-links`,避免异常空文件清空当天快照。
- 每个平台店铺商品编码独立展示,不合并多个平台店铺商品编码,不加总线上库存。
- 运营负责人取 `op_TargetManagement.ider`(ID负责人)。
- 部门负责人取 `op_TargetManagement.currCater`。
- 利润中心取 `op_TargetManagement.dept`。
- 同一商品ID在负责人表中存在多条时,按 `realTime DESC, Id DESC` 取最新一条。
- 最终结果落到本地 `SupplyChainDiscontinuationNoticeLink`,页面不直接读取 Excel 或大表。
链接维度页面字段顺序:
1. 统计时间
2. 平台
3. 店铺名称
4. 商品ID
5. 平台店铺商品编码
6. 线上商品编码
7. 线上库存
8. 运营负责人
9. 部门负责人
10. 利润中心
11. 处理说明
12. 可替换商品编码
13. 区别点
14. 是否换图
15. 价格差
16. 渠道专用仓库存
前 10 项来自聚水潭完整导出和负责人表;后 6 项关联同一系统商品编码的编码维度快照。
历史数据源差异留档:
- 聚水潭前台导出:234,656 行、234,641 个唯一链接。
- legacy 链接结果:779,412 行。
- 完全匹配:234,337 个链接。
- legacy 多余:545,075 个链接。
- 仅使用 `jst_skumap` 主表:447,558 行。
- 主表与前台精确匹配:182,276 个链接。
- 主表漏掉前台链接:52,380 个。
- 主表多出前台链接:265,282 个。
- `jst_skumap.enabled=1` 无法完整复刻前台“库存同步状态 + 店铺链接是否上架”的当前视图。
`dm_jst_shop_goods_link_daily` 仅用于差异诊断:
- 2026-10-26 快照共 52,639 行,`imported_at` 为当天 10:18。
- 同日聚水潭完整导出为 246,480 条;数据库快照匹配通知后只有 1,868 条,缺少 99% 以上完整链接。
- 因此该表不能作为链接维度最终数据源。
数据库快照诊断命令:
```bash
pnpm exec tsx scripts/sync-supply-chain-discontinuation-links-from-dm.ts --date=YYYY-MM-DD
```
只做只读核对时加 `--dry-run`。脚本默认在结果不足已有链接的 50% 时拒绝覆盖。
Linux 每日任务(北京时间 10:30):
```bash
sudo bash scripts/install-supply-chain-discontinuation-linux-timer.sh
```
任务顺序为“虚拟云仓库存快照 → 编码维度通知快照与库存预警快照 →
聚水潭完整链接导出 → 未下架监控与乐捐快照”。
页面提供“重新生成”按钮,仅经管业务角色可使用,同时仍要求
“停产下架管理”的 `edit` 操作权限。点击后由
`POST /api/supply-chain/discontinuation/regenerate` 启动后台任务,按上述顺序
重新生成四类数据;页面通过 `GET` 同一地址轮询阶段,成功后自动刷新当前页签,
任务运行期间禁止重复触发。
每日任务启动时会先按当前 `prisma/schema.prisma` 重新生成 Prisma Client,
并输出各数据源阶段日志,避免加载到旧客户端或无法判断卡在某一张外部表。
系统不再依赖个人 Mac;服务器使用独立的 `furniture-jst-rpa-browser.service`
和持久化 Chrome 登录态。
RPA Python 路径由 `JST_RPA_PYTHON` 明确指定;Linux 安装器默认
`/usr/bin/python3`,可用 `DISCONTINUATION_PYTHON_BIN` 覆盖。同步动作会在连接
浏览器和开始导出前预检 Python,路径或执行权限有误时立即失败。
正常整单导出次数默认由 `JST_RPA_ATTEMPTS=3` 控制,分批兜底大小默认由
`JST_RPA_FALLBACK_BATCH_SIZE=10000` 控制。
聚水潭账号密码只从服务器私有 `.env` 的 `JST_RPA_LOGIN_ID` 和
`JST_RPA_LOGIN_PASSWORD` 读取;持久登录态有效时不会重复提交登录,
失效时才自动填写登录页。凭据不得写入仓库或日志。
只补库存预警、不重跑通知和链接时:
```bash
pnpm exec tsx scripts/generate-supply-chain-inventory-alert-snapshot.ts
```
服务与定时器日志通过 `journalctl -u furniture-discontinuation-daily` 查看。
> 预策不作为任何数据源或参考流程;其数据正确仅因为每天人工导入了聚水潭前台文件。
### 3.7 订单
- 其他平台订单头:`jst_Orders`
- 其他平台订单明细:`jst_orderitem`
- 淘系订单头:`jst_qmcust_orderlist`
- 淘系订单明细:`jst_qmcust_orderlist_item`
订单只用于“未下架监控与申诉”和乐捐计算,不参与通知编码或库存预警:
- 订单明细通过 `shop_sku_id` 与通知链接维度的平台店铺商品编码匹配。
- 仅统计当月有通知下架的链接。
- 排除订单状态 `Sent`(已发货)、`Delivering`(发货中)、`Split`(被拆分)、`Merged`(被合并)。
- 仅当订单付款日期达到该链接首次通知下架日期后的第 2 天(含),才进入未处理明细;不按具体时分判断。
- 页面默认统计日期为北京时间当天,可手动切换历史日期。
- 未下架监控按“任务生成日期”归档,并实时计算到当前时间;通知后第 2 天起的所有后续订单都计入,不只是第 2 天当天。
- 每日任务固定处理三天前的通知队列:23 日统计 20 日通知、22 日起流入的订单;24 日统计 21 日通知、23 日起流入的订单。
- 同一订单命中同一平台店铺商品编码只计一笔;同一订单命中两个链接计两笔。
- 乐捐唯一键为“内部订单号 + 平台店铺商品编码”,重复来源数据不会重复计费。
- 只有运营业务身份且拥有操作权限的人员可对自己负责的待申诉明细上传凭证并发起申诉;
只有经管业务身份且拥有操作权限的人员可审核通过、驳回或打回。
- 申诉原因固定为“商品编码有误”“活动中,无法下架”“其他”三项,接口和页面使用同一份枚举。
- 申诉和审核均支持按链接批量处理:同一平台店铺商品编码下符合条件的订单一次覆盖。
- 审核通过后原乐捐记录保留,但页面展示与月度汇总按 0 元计算,实现自动减免。
- 负责人离职或无法匹配到员工目录在职记录时,状态显示“人员已离职”,乐捐金额按 0 元计算且不能申诉。
- “人员已离职”可作为申诉状态筛选;筛选后可批量向部门负责人的钉钉个人工作通知推送处理提醒。
- 未下架监控页顶部显示本月有效乐捐合计(¥10/单),审核通过的明细不计入合计。
- 未下架监控支持个人列显示/拖拽排序、紧凑展示和筛选字段配置。
- 未下架监控列表每次加载 100 条轻量元数据,不返回凭证图片;打开单条申诉后再通过
`/api/supply-chain/discontinuation/penalties/:id` 按需加载该条凭证,避免列表载荷过大。
- “活动中,无法下架”申诉提交时自动识别凭证中的 SPU ID 和 SPU 报名时间:时间早于通知
下架时间且 SPU ID 与订单商品ID一致则自动通过;缺少有效报名时间自动打回
“凭证时间不符合要求”,SPU ID 缺失或不一致自动打回“凭证与订单信息不一致”。
按链接批量申诉时,系统按每条订单自己的通知时间和商品ID分别判定、逐条更新,并返回
通过与打回数量;识图服务超时或异常时保留原申诉状态并提示重试,不执行自动打回。
- 用户权限管理使用独立“停产下架管理”节点,可按账号或岗位配置查看、
导出和操作权限;个人配置优先覆盖。
- 供应商账号不开放停产下架管理页面、编码维度查看和导出权限。
- 当前数据口径为所有在职内部账号查看编码维度、链接维度、库存预警和未下架监控的
全部数据;供应商账号和外部录入账号不开放。
- 系统保留用户数据范围配置能力:跟随人员信息、本组全部、部门全部、利润中心全部、
全部数据。当前所有在职内部账号统一配置为“全部数据”,数据范围与操作权限解耦。
- 具体用户还可额外选择一组店铺,使用 `platform + shopId` 唯一识别;附加店铺范围与人员
默认范围取并集,适用于同时负责运营中心和分销等跨组织店铺的人员。
- 各页签只展示当前维度实际支持的筛选字段;商品编码、店铺、商品ID、利润中心、线上商品编码、
平台店铺商品编码、内部/交易单号、平台和运营负责人统一使用可搜索下拉,选项来自
`/api/supply-chain/discontinuation/filter-options` 并按统计日期收敛。
- 所有字段筛选支持多选批量查询,也可粘贴换行、逗号、分号或顿号分隔的多个值后回车添加;
列表、分页统计和“导出当前筛选”使用同一组重复查询参数和多值条件。
- “操作”权限控制申诉、审核、取消驳回和离职负责人批量推送;申诉校验运营业务身份及本人
负责人匹配,审核类操作校验经管业务身份。旧 `discontinuationPenalties` 节点继续兼容审核操作,
已有授权不会失效。
## 4. 业务口径
- 通知下架:商品状态为停产或退市,且简尚库存 + 工厂库存 = 0。
- 京喜渠道判断:普通简尚库存、工厂库存和渠道专用仓库存均为 0,仅 `三方-南昌樵舍仓` 有库存时,处理说明为“仅京喜下架”;存在有效替换款时为“仅京喜替换”。
- 南昌樵舍仓无库存或其他仓库仍有库存时,是否通知及替换判断继续沿用原逻辑;需要进入通知时,处理说明显示“全渠道下架”或“全渠道替换”。
- 组合通知:组合商品任一子编码为停产/退市且合计库存为 0,整个组合进入通知下架。
- 库存预警:商品状态为停产或退市,且公有可用数 + 工厂库存为 1 至 5;0 库存只进入通知下架。
- 多仓商品先按商品编码判断是否进入预警,再拆成仓库明细;工厂库存没有仓库来源时单独展示为仓库为空的一条明细。
- 通知后订单监控:付款日期达到该链接首次通知下架日期后的第 2 天(含)。例如 20 日通知,22 日全天的订单都计入。
- 排除订单状态:已发货、发货中、被合并、被拆分。
- 乐捐:按“订单 + 链接”组合计算,每组 10 元;同一订单命中两个链接计两笔。
- 乐捐按月累计,次月 1 日开启新账期,历史明细保留。
- 运营人员只能查看自己的订单和申诉数据。
## 5. 接入约束
- 新同步任务负责写入本地快照,页面 API 不承担跨库实时计算。
- 编码维度、链接维度和库存预警必须使用同一统计日的库存口径。
- 历史通知数据一经生成不得覆盖,每日任务只新增当日结果。
- 同一统计日允许重跑并替换当天快照,其他日期保持不变。
- 统计日期统一使用北京时间。
- 页面查询不得直接访问任一外部数据源。

View File

@ -47,9 +47,8 @@ npx prisma db execute --file scripts/sql/check-external-readonly-contract.sql
- `missing_external_column`
- `external_contract_private_column_risk`
- `external_view_json_source_dependency_risk`
- `external_readonly_role_public_schema_grant_risk`
- `external_readonly_role_non_select_grant_risk`
- `external_asset_url_unstable_or_internal_risk`
- `external_reader_public_schema_grant_risk`
- `external_reader_non_select_grant_risk`
- `product_assets_data_url_risk`
- `product_sku_business_key_duplicate_risk`
- `intake_product_business_key_duplicate_risk`

View File

@ -168,10 +168,6 @@
| `visual_planning` | 空间/视觉负责人 |
| `structure_designer` | 结构设计负责人 |
| `purchase_owner` | 采购负责人结构化字段 |
| `appearance_designer` | 外观设计师明确字段;来自非集成产品基础列 |
| `spu_owner` | SPU 负责人明确字段;优先来自结构化 profile,旧运营负责人只作兜底 |
| `planning_owner` | 商品企划明确字段;来自结构化 profile |
| `space_designer` | 空间设计师明确字段;优先来自结构化 profile 的空间/视觉负责人 |
| `product_development_kind` | 开发类型 |
| `goods_type` | 商品类型 |
| `material_type` | 材料类型 |
@ -181,7 +177,7 @@
- 该视图过滤已软删除记录。
- 产品名称、图片、品类、年份、状态、开发类型、货品类型、材质类型、生产类型和结构化负责人字段来自 `IntakeNonIntegratedProductProfile`,不再直接读取 `payload`。
- `designer / operator / purchaser` 是兼容列,优先输出 profile 中的结构设计、商品企划、采购负责人;旧产品行列只作为兼容兜底。新接入优先读取 `appearance_designer / structure_designer / spu_owner / planning_owner / space_designer / purchase_owner` 这些明确字段。
- `designer / operator / purchaser` 是兼容列,优先输出 profile 中的结构设计、商品企划、采购负责人;旧产品行列只作为兼容兜底。
- `intake_product_id` 只用于内部追踪和问题定位;外部系统同步、去重和幂等更新必须优先使用 `product_business_key`。
- 不开放 `visibility`、`departmentId`、`teamId`、审批状态、内部确认状态。
- `external.intake_products_ready_v1` 使用同一字段结构,但只保留产品名称、品类、开发类型和 `product_business_key` 都已补齐的记录。外部系统做稳定同步时优先读取 ready 视图;`intake_products_v1` 用于治理对账和人工补齐。
@ -215,41 +211,6 @@
| `size_json` | 尺寸 JSON |
| `color` | 颜色/花色 |
| `material` | 材质 |
| `appearance_designer` | 产品开发明细的外观设计师;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 |
| `structure_designer` | 产品开发明细的结构设计师;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 |
| `spu_owner` | 产品开发明细的 SPU 负责人;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 |
| `planning_owner` | 产品开发明细的商品企划;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 |
| `space_designer` | 产品开发明细的空间设计师;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 |
| `purchase_owner` | 产品开发明细的采购负责人;开发阶段 ready 行来自产品资料负责人字段,正式商品行暂为空 |
| `supplier_name` | 产品定厂供应商名称;开发阶段 ready 行来自结构化 SKU 定厂资料,正式商品行暂为空 |
| `supplier_code` | 产品定厂供应商编码;开发阶段 ready 行来自结构化 SKU 定厂资料,正式商品行暂为空 |
| `factory_warehouse` | 产品定厂发货仓;与库存库位 `warehouse_location_name` 是两个不同业务字段 |
| `shipping_method` | 产品定厂发货方式 |
| `minimum_order_quantity` | 产品定厂最低起订量 |
| `tax_included_unit_price` | 产品定厂含税单价 |
| `lead_time` | 产品定厂交期 |
| `install_method` | 产品定厂安装方式 |
| `jst_product_pushed_at` | 商品资料成功推送聚水潭的时间;未成功推送时为空 |
| `is_external_intake` | 是否由外部录入人员创建 |
| `supply_chain_archive_eligible` | 是否满足供应链产品全量档案当前入档规则 |
| `source_pricing_row_id` | 产品开发核价记录 ID,用于追踪原始业务记录 |
| `source_product_id` | 产品开发产品 ID,用于定向增量同步和排查 |
| `source_sku_item_id` | 产品开发结构化 SKU 行 ID |
| `product_development_created_at` | 产品首次录入产品开发系统的时间,作为档案创建时间口径 |
| `product_development_updated_at` | 产品开发系统最近更新时间 |
| `archive_spu / archive_sku` | 产品全量档案使用的 SPU、SKU 口径 |
| `archive_product_name` | 产品全量档案商品名称,来自条码对应规格名称 |
| `archive_image` | 产品开发条码/SKU 图片原值 |
| `sales_category_name` | 一级类目,例如桌类、柜类 |
| `archive_product_category_name` | 二级类目原值,档案同步时补住宅家具/商业办公/其他前缀 |
| `sku_sales_status` | 销售状态原值;档案只接受销售、停产、退市 |
| `brand_name` | 产品开发品牌 |
| `sample_weight_kg` | 样包测量重量(千克) |
| `package_length_cm / package_width_cm / package_height_cm` | 样包长宽高(厘米) |
| `package_volume_cm3` | 样包体积(立方厘米) |
| `package_set_count` | 套件量;档案转换为非双包、双包至九包 |
| `purchase_order_method` | 叫货/采购方式 |
| `archive_shipping_method` | 产品全量档案的入仓/代发口径 |
| `warehouse_location_code` | 仓库库位编码 |
| `warehouse_zone / warehouse_aisle / warehouse_shelf / warehouse_bin` | 库位物理位置 |
| `warehouse_location_status` | 库位状态:`ACTIVE` / `INACTIVE` |
@ -288,16 +249,9 @@
注意:
- 产品全量档案只读取本宽表中 `supply_chain_archive_eligible = true` 的记录,不再由同步程序直接关联产品开发业务表或解析 `payload`。
- 当前入档规则为:产品开发系统创建、商品资料已成功推送聚水潭、创建人不是外部录入人员。
- 同一条码即使最终目录行优先采用正式商品,产品开发的入档字段仍按条码附加在该行末尾,避免来源标记被正式行覆盖。
- 该视图保持一条码一行,适合外部系统做商品目录同步;同一 SKU 下多个条码会输出多行。
- 该视图保持一 SKU 一行,适合外部系统做商品目录同步。
- 如果外部系统只想读一张表,优先使用该宽表;它已经覆盖正式 SKU 与可消费的 non-integrated ready SKU,以及产品资料、库存库位摘要、包装库存摘要和最新质检摘要。
- 需要逐条素材或逐条 BOM 时,仍读取 `external.product_assets_v1 / external.product_bom_v1`;这两张明细视图是否对外授权,以当前 `external_core_reader` 白名单为准。
- 所有 URL 字段都应是完整系统入口;相对的 `/api/...` 或 `/materials/...` 路径会在外部视图层按 `app.external_asset_base_url` 补全,未设置时默认补到 `https://cp.jiansun.vip`。
- `appearance_designer / structure_designer / spu_owner / planning_owner / space_designer / purchase_owner` 是产品开发明细页红框负责人字段的外部同步口径;它们只对 non-integrated ready 行有明确来源,正式商品行没有开发阶段上下文时保持空值。
- `supplier_name / supplier_code / factory_warehouse / shipping_method / minimum_order_quantity / tax_included_unit_price / lead_time / install_method` 来自结构化非集成 SKU 定厂资料;外部视图不读取 `payload.factoryRows`。其中 `factory_warehouse` 是定厂发货仓,不得与库存库位字段混用。
- 需要逐条素材或逐条 BOM 时,仍读取 `external.product_assets_v1 / external.product_bom_v1`。
- 需要逐条包装库存或质检明细时,读取 `external.packaged_items_v1 / external.sku_quality_inspections_v1`。
- 限价口径:`存量商品明细` 的产品一般没有限价,宽表会输出 `limit_price_status = NOT_APPLICABLE`;`产品开发明细`、`模块化产品明细` 到 `产品限价` 节点确认后,限价视为正式口径并输出为 `CONFIRMED`。
- `product_id / sku_id` 只用于内部追踪;正式行会输出正式产品/SKU ID,non-integrated ready 行会输出 `intake_product_id / intake_sku_item_id`;外部主同步、去重和幂等更新必须使用 `barcode_business_key`。

View File

@ -1,363 +0,0 @@
# 店铺账号管理取数与筛选逻辑
> 用途:给需要接入店铺账号管理数据的开发人员使用。本文描述当前代码和数据库的真实口径。
>
> 重要:本文不包含数据库连接串、账号、密码、加密密钥或任何业务明文数据。当前数据由本系统人工维护,不直接从外部数据库同步。
## 1. 数据源与责任边界
- **权威数据源**:本系统使用 `DATABASE_URL` 指向的 PostgreSQL 数据库,数据通过本系统店铺账号管理页面/API 人工新增、编辑、导入和维护。
- **不属于数据源的内容**:聚水潭、京东、京喜等外部系统目前不是本模块的自动同步来源;不能假设外部系统中存在同一份实时数据。
- **推荐接入方式**:优先调用本系统鉴权 API。若确实需要数据库直读,应由数据库管理员单独创建只读账号或只读视图,并按本文的字段白名单开放;不要复用应用账号,也不要把应用数据库密码或加密密钥交给外部系统。
- **人工维护原则**:不要直接写入业务表绕过 Service。直接写入会跳过页面权限、字段权限、密文加密、唯一键校验和操作日志。
### 1.1 代码与迁移依据
当前数据契约以以下文件为准:
- Prisma 模型:`prisma/schema.prisma` 中 `StoreAccount`、`StoreAccountStoreProfile`、`StoreAccountChangeLog`、`StoreAccountFieldPermission`、`SupplyChainPermissionOverride`。
- 建表迁移:`prisma/migrations/20260826120000_add_store_account_management/migration.sql`。
- 店铺主体和银行卡字段、平台资料表迁移:`prisma/migrations/20260828100000_extend_store_account_profiles/migration.sql`。
- 列表筛选规则:`src/server/repositories/store-account-management.repository.ts`。
- 业务校验、脱敏和日志:`src/server/services/store-account-management.service.ts`。
- 导出字段:`src/server/services/store-account-management-export.service.ts`。
## 2. 物理表清单与关联关系
PostgreSQL 物理表名使用双引号保留大小写,取数时应写成下表中的名称。
| 物理表 | 用途 | 主要关联键 | 是否主业务数据 |
| --- | --- | --- | --- |
| `"StoreAccount"` | 店铺账号主记录,一行代表一个店铺下的一种账号 | `id`;业务唯一键为 `storeName + accountType + account` | 是 |
| `"StoreAccountStoreProfile"` | 京东/京喜店铺资料,一行代表一个店铺的一个平台资料 | 逻辑关联 `storeName`,平台由 `platform` 区分 | 是(扩展资料) |
| `"StoreAccountChangeLog"` | 新增、编辑、导入、查看敏感字段、资料维护的审计日志 | `storeAccountId` 可为空;资料日志用 `storeName` | 否(审计) |
| `"StoreAccountFieldPermission"` | 店铺账号字段对业务类型/个人的可见、只读、可编辑配置 | `fieldKey + subjectKey` 唯一 | 否(权限配置) |
| `"SupplyChainPermissionOverride"` | 页面级的查看、下载、编辑权限 | `targetKey + subjectKey + action` 唯一;本模块 `targetKey = 'storeAccountManagement'` | 否(权限配置) |
`StoreAccountStoreProfile` 与 `StoreAccount` 没有数据库外键,应用层按 `storeName` 关联。因此外部查询时要注意:资料表可能没有对应账号行,也可能一个店铺有多个账号行;不要用 `id` 去连接资料表。
## 3. `"StoreAccount"` 字段字典
| 字段 | PostgreSQL 类型/约束 | 业务含义 | 空值与敏感性 |
| --- | --- | --- | --- |
| `id` | `TEXT`,主键 | 账号记录 ID | 系统生成,不为空 |
| `storeName` | `TEXT NOT NULL` | 店铺名称,也是资料表的逻辑关联键 | 不应为空;支持多选筛选 |
| `operationOwner` | `TEXT NOT NULL DEFAULT ''` | 店铺运营负责人 | 空字符串表示未维护 |
| `operationDepartmentHead` | `TEXT NOT NULL DEFAULT ''` | 店铺运营部门负责人 | 空字符串表示未维护 |
| `operationDirector` | `TEXT NOT NULL DEFAULT ''` | 店铺运营总监 | 空字符串表示未维护 |
| `profitCenter` | `TEXT NOT NULL DEFAULT ''` | 店铺所属利润中心 | 空字符串或:`运营中心A`、`运营中心B`、`运营中心C`、`运营中心G` |
| `subject` | `TEXT NOT NULL DEFAULT ''` | 店铺主体 | 空字符串表示未维护 |
| `accountType` | `TEXT NOT NULL` | 账号类型 | 正常值 `MAIN`(主账号)、`SUB`(子账号);不完整导入允许空字符串或 `/` |
| `account` | `TEXT NOT NULL` | 登录账号 | 新增时必填 |
| `passwordCiphertext` | `TEXT NOT NULL DEFAULT ''` | 密码密文 | 绝不作为普通取数字段返回;空字符串表示未设置 |
| `boundBankAccountNumberCiphertext` | `TEXT NOT NULL DEFAULT ''` | 店铺绑定银行卡号密文 | 与密码同等级敏感;空字符串表示未设置 |
| `boundPhone` | `TEXT NOT NULL DEFAULT ''` | 绑定手机号 | 空字符串表示未维护 |
| `verificationContact` | `TEXT NOT NULL DEFAULT ''` | 手机号验证码对接人 | 空字符串表示未维护 |
| `jstOrderService` | `TEXT NOT NULL DEFAULT ''` | 聚水潭订单服务号 | 空字符串表示未维护 |
| `accountGroupName` | `TEXT NOT NULL DEFAULT ''` | 账号分组名称 | 空字符串表示未维护 |
| `createdById` | `TEXT` 可空 | 首次创建人 ID | 系统审计字段 |
| `updatedById` | `TEXT` 可空 | 最近修改人 ID | 系统审计字段 |
| `createdAt` | `TIMESTAMP(3) NOT NULL` | 账号记录创建时间 | 系统生成;列表按此字段倒序 |
| `updatedAt` | `TIMESTAMP(3) NOT NULL` | 账号记录最近修改时间 | 系统自动更新时间 |
约束和索引:
- 唯一约束:`("storeName", "accountType", "account")`,导入按这三个字段执行 upsert。
- 索引:`storeName`、`account`、`accountType`、`updatedAt`。
- 本表没有软删除字段;空白字段是空字符串,不使用“系统默认值”兜底。
## 4. `"StoreAccountStoreProfile"` 字段字典
该表用于“京东/京喜店铺资料”次页面的后续维护,不要求在账号主表导入时同时填写。
| 字段 | PostgreSQL 类型/约束 | 业务含义 | 取值规则 |
| --- | --- | --- | --- |
| `id` | `TEXT`,主键 | 店铺平台资料 ID | 系统生成 |
| `storeName` | `TEXT NOT NULL` | 店铺名称 | 与 `StoreAccount.storeName` 逻辑关联 |
| `platform` | `TEXT NOT NULL` | 平台代码 | `JD`=京东,`JX`=京喜 |
| `merchantNumber` | `TEXT NOT NULL DEFAULT ''` | 商户号(钱包商户号) | 空字符串表示未维护 |
| `secondaryMerchantNumber` | `TEXT NOT NULL DEFAULT ''` | 二级商户号 | 空字符串表示未维护 |
| `merchantStoreId` | `TEXT NOT NULL DEFAULT ''` | 商家 ID(钱包店铺号) | 空字符串表示未维护 |
| `companyId` | `TEXT NOT NULL DEFAULT ''` | 公司 ID(京麦店铺 ID) | 空字符串表示未维护 |
| `createdById` | `TEXT` 可空 | 首次创建人 ID | 系统审计字段 |
| `updatedById` | `TEXT` 可空 | 最近修改人 ID | 系统审计字段 |
| `createdAt` | `TIMESTAMP(3) NOT NULL` | 资料创建时间 | 系统生成 |
| `updatedAt` | `TIMESTAMP(3) NOT NULL` | 资料最近修改时间 | 系统自动更新时间 |
约束和平台规则:
- 唯一约束:`("storeName", "platform")`,同一店铺同一平台只有一条资料。
- 店铺名以 `京东-` 开头时只能使用 `JD`;以 `京喜-` 开头时只能使用 `JX`。
- 非 `京东-` 或 `京喜-` 开头的店铺不支持店铺资料页面/API,服务端会拒绝读取或保存。
- 资料字段通过 `storeName` 关联账号主表,导出时会按平台展开为京东四列和京喜四列。
## 5. `"StoreAccountChangeLog"` 字段与日志格式
| 字段 | PostgreSQL 类型/约束 | 含义 |
| --- | --- | --- |
| `id` | `TEXT`,主键 | 日志 ID |
| `storeAccountId` | `TEXT` 可空 | 账号记录 ID;店铺资料变更时为空 |
| `storeName` | `TEXT NOT NULL` | 店铺名称 |
| `operation` | `TEXT NOT NULL` | 操作类型 |
| `actorId` | `TEXT` 可空 | 操作人 ID |
| `actorName` | `TEXT NOT NULL` | 操作人名称 |
| `changes` | `JSONB NOT NULL` | 字段变更明细数组 |
| `createdAt` | `TIMESTAMP(3) NOT NULL` | 日志时间 |
当前 `operation` 代码:
| operation | 含义 |
| --- | --- |
| `CREATE` | 新增账号 |
| `UPDATE` | 编辑账号 |
| `IMPORT_CREATE` | 导入新增 |
| `IMPORT_UPDATE` | 导入更新 |
| `BATCH_UPDATE` | 批量修改 |
| `VIEW_PASSWORD` | 查看密码 |
| `VIEW_BANK_ACCOUNT` | 查看银行卡号 |
| `PROFILE_UPDATE` | 修改京东/京喜店铺资料 |
`changes` 的标准元素为:
```json
{
"field": "operationOwner",
"previousValue": "原值",
"nextValue": "新值"
}
```
特殊字段和资料字段:
- `password`、`boundBankAccountNumber` 只记录“已设置/未设置”,不写入明文或密文。
- 查看敏感字段时记录 `nextValue: "已查看"`。
- 平台资料字段使用 `JD.merchantNumber`、`JX.companyId` 这样的字段名。
- API 返回日志前还会根据当前调用人的字段权限过滤隐藏字段。
## 6. `"StoreAccountFieldPermission"` 字段字典
| 字段 | PostgreSQL 类型/约束 | 含义 |
| --- | --- | --- |
| `id` | `TEXT`,主键 | 权限记录 ID |
| `fieldKey` | `TEXT NOT NULL` | 店铺账号字段 key,如 `account`、`password`、`boundBankAccountNumber` |
| `subjectKey` | `TEXT NOT NULL` | 业务类型或个人主体 |
| `visibility` | `TEXT NOT NULL DEFAULT 'READONLY'` | `HIDDEN`、`READONLY` 或 `EDITABLE` |
| `updatedById` | `TEXT` 可空 | 最后配置人 ID |
| `createdAt` / `updatedAt` | `TIMESTAMP(3)` | 权限记录时间 |
当前可配置的业务类型 `subjectKey`:
`design`(设计)、`planning`(企划)、`brandCenter`(策划)、`purchase`(采购)、`merchandiser`(采购-跟单)、`pricing`(采购-核价)、`quality`(采购-质检)、`operation`(运营)、`warehouse`(仓库)、`customerService`(客服)、`management`(经管)、`viewer`(普通用户)。
个人例外使用 `user@<用户ID>`。唯一约束为 `("fieldKey", "subjectKey")`。`createdAt`、`updatedAt` 是系统字段,不能通过字段权限配置为可编辑。
## 7. 页面级权限表
`"SupplyChainPermissionOverride"` 的 `targetKey` 固定为 `storeAccountManagement`,`action` 为 `view`、`download`、`edit`。
| 场景 | 当前规则 |
| --- | --- |
| 未登录 | 所有店铺账号接口返回 401 |
| 页面查看 | 需要工作台可见且 `storeAccountManagement:view` 生效 |
| 新增、编辑、导入、批量修改 | 需要页面 `edit` |
| 导出全部或已选 | 需要页面可见,且调用人是管理员或经管 |
| 配置字段权限 | 需要页面可见,且调用人是管理员或经管 |
| 密码/银行卡查看 | 还要通过对应字段权限;服务端单独记录查看日志 |
字段可见性解析顺序:
1. 页面没有 `view` 时,所有字段均为 `HIDDEN`。
2. 先取页面编辑能力形成基础值:系统时间字段只读,密码和银行卡默认隐藏,普通字段按页面编辑能力为可编辑或只读。
3. 同一字段的业务类型配置按 `EDITABLE > READONLY > HIDDEN` 合并。
4. `user@<用户ID>` 的个人配置覆盖业务类型配置。
5. 没有页面编辑权时,即使字段配置为 `EDITABLE` 也会降为只读;系统时间字段永远不会变成可编辑。
列表接口对隐藏字段返回空字符串;密码和银行卡只返回 `passwordConfigured`、`boundBankAccountNumberConfigured` 布尔值,不返回密文。
## 8. 列表取数与筛选逻辑
### 8.1 API 参数
`GET /api/supply-chain/store-account-management`
| 参数 | 默认/限制 | 逻辑 |
| --- | --- | --- |
| `page` | 默认 `1`,最小 `1` | 页码 |
| `pageSize` | 默认 `50`,范围 `1..200` | 每页行数;页面常用 50/100/200 |
| `search` | 最多 120 字符 | 关键词,可用空格、逗号、中文逗号、分号分隔多个 token |
| `storeName` | 最多 120 字符 | 店铺名称多选值,同样支持逗号、中文逗号、分号分隔 |
| `accountType` | 空、`MAIN`/`SUB` 或显示名 | 包含匹配;页面显示主账号/子账号 |
| `operationOwner` | 空或文本 | 店铺运营负责人包含匹配 |
| `accountGroupName` | 空或文本 | 账号分组名称包含匹配 |
| `createdAtGranularity` | `year`、`month`、`day` | 创建时间筛选粒度 |
| `createdAtValue` | 年 `YYYY`;月 `YYYY-MM`;日 `YYYY-MM-DD` | 与粒度配套的值 |
### 8.2 条件组合
- 不同筛选项之间是 **AND**。
- `search` 内部每个 token 会对以下字段做 **OR**:`storeName`、`account`、`operationOwner`、`operationDepartmentHead`、`operationDirector`、`profitCenter`、`subject`、`boundPhone`、`verificationContact`、`jstOrderService`、`accountGroupName`。
- `search` 不搜索密码、银行卡、创建时间、修改时间和京东/京喜资料字段。
- `storeName` 会拆分并去重,使用精确 `IN` 匹配;最多取 500 个店铺名。
- `accountType`、`operationOwner`、`accountGroupName` 使用不区分大小写的 `contains` 匹配。
- 创建时间使用中国时区 `+08:00` 的半开区间:`[开始时间, 结束时间)`。例如 `2026-08` 是 `2026-08-01 00:00:00+08:00`(含)到 `2026-09-01 00:00:00+08:00`(不含)。
### 8.3 排序与分页
固定排序:
```sql
ORDER BY "createdAt" DESC, "id" ASC
```
因此最新创建的记录永远在前面;`id ASC` 只用于创建时间相同时稳定排序。分页使用 `OFFSET (page - 1) * pageSize LIMIT pageSize`,同时查询总数。
成功响应核心结构:
```json
{
"success": true,
"data": {
"rows": [],
"storeNames": ["京东-示例店"],
"pageInfo": { "page": 1, "pageSize": 50, "total": 0, "totalPages": 1 },
"fieldAccess": {}
},
"meta": { "requestId": "...", "timestamp": "..." }
}
```
`storeNames` 是全表去重后按名称升序返回的店铺列表,用于前端多选;它不受当前页面筛选条件限制。
## 9. 只读 SQL 示例
以下 SQL 仅用于说明字段和筛选口径,执行账号必须是单独的只读账号。示例不包含密码密文列。
### 9.1 最新账号列表
```sql
SELECT
"id", "storeName", "operationOwner", "operationDepartmentHead",
"operationDirector", "profitCenter", "subject", "accountType",
"account", "boundPhone", "verificationContact", "jstOrderService",
"accountGroupName", "createdAt", "updatedAt"
FROM "StoreAccount"
ORDER BY "createdAt" DESC, "id" ASC
LIMIT 50 OFFSET 0;
```
### 9.2 店铺 + 账号类型 + 月份筛选
```sql
SELECT
"id", "storeName", "accountType", "account", "createdAt", "updatedAt"
FROM "StoreAccount"
WHERE "storeName" IN ('京东-示例店', '京喜-示例店')
AND "accountType" = 'MAIN'
AND "createdAt" >= TIMESTAMPTZ '2026-08-01 00:00:00+08:00'
AND "createdAt" < TIMESTAMPTZ '2026-09-01 00:00:00+08:00'
ORDER BY "createdAt" DESC, "id" ASC;
```
### 9.3 关键词筛选的 SQL 等价写法
```sql
WHERE (
"storeName" ILIKE '%' || $1 || '%'
OR "account" ILIKE '%' || $1 || '%'
OR "operationOwner" ILIKE '%' || $1 || '%'
OR "operationDepartmentHead" ILIKE '%' || $1 || '%'
OR "operationDirector" ILIKE '%' || $1 || '%'
OR "profitCenter" ILIKE '%' || $1 || '%'
OR "subject" ILIKE '%' || $1 || '%'
OR "boundPhone" ILIKE '%' || $1 || '%'
OR "verificationContact" ILIKE '%' || $1 || '%'
OR "jstOrderService" ILIKE '%' || $1 || '%'
OR "accountGroupName" ILIKE '%' || $1 || '%'
)
```
多个 token 时,把每个 token 的上述括号用 `AND` 连接。
### 9.4 查询平台资料
```sql
SELECT
"storeName", "platform", "merchantNumber", "secondaryMerchantNumber",
"merchantStoreId", "companyId", "createdAt", "updatedAt"
FROM "StoreAccountStoreProfile"
WHERE "storeName" = $1
AND "platform" IN ('JD', 'JX')
ORDER BY "platform" ASC;
```
资料缺失时不要补默认值;没有行或字段为空都表示尚未维护。
### 9.5 查询审计日志
```sql
SELECT
"id", "storeAccountId", "storeName", "operation",
"actorId", "actorName", "changes", "createdAt"
FROM "StoreAccountChangeLog"
WHERE "storeName" = $1
ORDER BY "createdAt" DESC
LIMIT 200;
```
## 10. 当前 API 接口清单
所有接口都要求登录,并返回统一的 `{ success, data/error, meta }` 包装。接口权限还会检查工作台权限和 `storeAccountManagement` 页面权限。
| 方法 | 路径 | 用途 | 主要限制 |
| --- | --- | --- | --- |
| `GET` | `/api/supply-chain/store-account-management` | 分页列表、筛选、店铺名称选项 | 页面 `view` |
| `POST` | `/api/supply-chain/store-account-management` | 新增账号 | 页面 `edit`;店铺名、账号类型、账号必填 |
| `GET` | `/api/supply-chain/store-account-management/{id}` | 单条账号 | 页面 `view` |
| `PATCH` | `/api/supply-chain/store-account-management/{id}` | 编辑账号 | 页面 `edit`;只更新有权限的字段 |
| `POST` | `/api/supply-chain/store-account-management/import` | Excel/CSV 导入账号 | 页面 `edit`;10 MB、5000 行;NDJSON 进度 |
| `POST` | `/api/supply-chain/store-account-management/batch` | 批量修改前预览 | 页面 `edit` + 目标字段 `EDITABLE`;最多 500 条 |
| `PATCH` | `/api/supply-chain/store-account-management/batch` | 批量修改 | 同上;密码、银行卡和系统时间不可批量修改 |
| `GET` | `/api/supply-chain/store-account-management/export` | 导出全部账号 | 仅管理员或经管;包含明文密码/银行卡,必须严格控制调用方 |
| `GET` | `/api/supply-chain/store-account-management/export?scope=selected&ids=id1,id2` | 导出已选账号 | 仅管理员或经管;最多 200 个 ID |
| `GET` | `/api/supply-chain/store-account-management/logs?id=...` | 查询单账号日志 | 页面 `view`;返回内容按字段权限过滤 |
| `GET` | `/api/supply-chain/store-account-management/logs?storeName=...` | 查询店铺日志 | 页面 `view`;最多返回 200 条 |
| `GET` | `/api/supply-chain/store-account-management/{id}/password` | 查看密码明文 | 字段可见;响应不缓存,并写查看日志 |
| `GET` | `/api/supply-chain/store-account-management/{id}/bank-account` | 查看银行卡号明文 | 字段可见,并写查看日志 |
| `GET` | `/api/supply-chain/store-account-management/permissions` | 读取字段权限配置 | 仅管理员或经管 |
| `PUT` | `/api/supply-chain/store-account-management/permissions` | 保存字段权限配置 | 仅管理员或经管;最多 500 条更新 |
| `GET` | `/api/supply-chain/store-account-management/profiles?storeName=...` | 读取京东/京喜店铺资料 | 页面 `view`;店铺名前缀必须匹配 |
| `PATCH` | `/api/supply-chain/store-account-management/profiles` | 保存平台资料 | 页面 `edit`;平台必须与店铺名前缀一致 |
| `GET` | `/api/supply-chain/store-account-management/profiles/template?platform=JD\|JX` | 下载平台资料模板 | 页面 `edit` |
| `POST` | `/api/supply-chain/store-account-management/profiles/import` | 导入平台资料 | 页面 `edit`;表单字段 `file` + `platform`,10 MB、5000 行 |
导出文件为 XLSX,包含账号主表字段、解密后的密码和绑定银行卡号,以及按 `JD`/`JX` 展开的八个店铺资料字段。外部系统若不需要敏感字段,不应调用导出接口,应该调用列表接口或使用只读白名单视图。
## 11. 导入、更新和空白值规则
- 主表导入支持中文表头和内部英文 key;未知列会被忽略。
- 行数上限 5000,文件上限 10 MB;每一行按 `storeName + accountType + account` upsert,并写入 `IMPORT_CREATE` 或 `IMPORT_UPDATE` 日志。
- 普通文本字段导入空白会保存为空字符串,表示“未维护”;不会套用系统默认值。
- 密码和绑定银行卡号为空时不会覆盖已有密文;要清除敏感值需由具备相应字段编辑权限的业务流程明确处理,不能通过普通空白导入误清除。
- 新增账号时 `storeName`、`accountType`、`account` 必填,`accountType` 正常应为 `MAIN` 或 `SUB`。
- 批量修改会先返回所选记录当前值预览,再统一更新;每条实际发生变化的记录写一条 `BATCH_UPDATE` 日志。
- 平台资料导入只识别“店铺名称”和四个资料字段,按店铺名和平台 upsert;空白资料仍然允许保存。
## 12. 敏感字段与外部接入注意事项
1. `passwordCiphertext` 和 `boundBankAccountNumberCiphertext` 使用 AES-256-GCM 存储,密钥来自服务端 `STORE_ACCOUNT_ENCRYPTION_KEY`(生产必须配置至少 32 个字符)。
2. 外部系统不能自行解密,也不能索要或复制该密钥;密文列不应进入普通数据同步任务、日志或缓存。
3. 普通列表接口只返回“是否已设置”布尔值;明文只通过受权限保护的查看接口或管理员/经管导出得到,并且会留下审计日志。
4. 字段权限和页面权限不是同一层:先满足页面 `view/edit`,再应用业务类型和个人字段权限。
5. 直连数据库时建议只开放不敏感白名单字段,至少排除两个 `*Ciphertext` 字段、权限表和日志中的敏感上下文;如需日志,应单独授予审计读取权限。
6. 当前没有外部只读视图或公开 Token 契约。外部接入前应由系统负责人确认鉴权方式、限流、字段白名单、数据刷新频率和脱敏要求。
## 13. 接入前检查清单
- [ ] 确认读取的是当前 `DATABASE_URL` 对应的 PostgreSQL,而不是历史数据库或外部平台库。
- [ ] 使用本文中的双引号物理表名和字段名,确认迁移已执行。
- [ ] 明确只读范围:主表、平台资料、日志、权限配置是否都需要。
- [ ] 明确是否需要京东/京喜资料,按 `storeName + platform` 读取,不能按账号 `id` 连接。
- [ ] 明确筛选时区为中国 `+08:00`,创建时间使用半开区间。
- [ ] 按 `createdAt DESC, id ASC` 保持与页面相同的排序。
- [ ] 不读取或传播密码、银行卡密文;需要明文时走授权接口并保留审计记录。
- [ ] 不直接写表;新增、编辑、导入和权限变更必须走系统 API 或经负责人批准的维护流程。

View File

@ -134,7 +134,6 @@
- `payload.skuItems` 作为历史过程快照保留,用于同步差异巡检,不作为对外主数据来源。
- `IntakeNonIntegratedProductProfile` 承接产品名称、主图、品类、销售分类、年份、状态、开发类型、货品类型、材质类型、生产类型、主要负责人等产品头主数据。
- `IntakeNonIntegratedProductLimitRow` 承接产品限价行,来源是 `productLimitRows` 的结构化同步副本,不允许外部系统直接读取 `payload` 推断限价。
- 产品定厂的供应商、发货仓、发货方式、最低起订量、含税单价、交期和安装方式由 `IntakeNonIntegratedSkuItem` 结构化列承接;`payload.factoryRows` 只保留兼容快照,不作为外部主读来源。
- `external.intake_products_v1` 读取 `IntakeNonIntegratedProductProfile`,不再直接从 `payload` / `openingReference` 读取产品头主数据。
- `external.intake_product_limits_v1` 读取 `IntakeNonIntegratedProductLimitRow`;`存量商品明细` 默认限价不适用,`产品开发明细` 和 `模块化产品明细` 到 `产品限价` 节点确认后才视为正式限价。
- `external.intake_products_v1 / external.intake_sku_items_v1` 显式输出稳定业务键:`product_business_key = 品牌:SPU`,`sku_business_key = 品牌:SPU:SKU编码`;这些键用于产品归组和兼容关联。
@ -215,7 +214,7 @@ CREATE SCHEMA IF NOT EXISTS external;
| 视图 | 用途 |
| --- | --- |
| `external.product_skus_v1` | 正式产品 SKU 主数据 |
| `external.product_catalog_wide_v1` | 一条码一行的商品目录聚合宽表,覆盖正式条码与可消费的 non-integrated ready 条码,以及产品资料、开发阶段负责人、限价、库存库位摘要、包装库存摘要和最新质检摘要 |
| `external.product_catalog_wide_v1` | 商品目录聚合宽表,覆盖正式 SKU 与可消费的 non-integrated ready SKU,以及产品资料、限价、库存库位摘要、包装库存摘要和最新质检摘要 |
| `external.intake_product_limits_v1` | 非集成产品限价白名单 |
| `external.warehouse_locations_v1` | 仓库库位资料 |
| `external.sku_warehouse_stock_v1` | 正式 SKU 库存和库位 |
@ -233,17 +232,12 @@ CREATE SCHEMA IF NOT EXISTS external;
只读账号授权原则:
```sql
GRANT USAGE ON SCHEMA external TO external_core_reader;
GRANT SELECT ON external.product_catalog_wide_v1 TO external_core_reader;
GRANT SELECT ON external.intake_product_limits_v1 TO external_core_reader;
GRANT SELECT ON external.warehouse_locations_v1 TO external_core_reader;
GRANT SELECT ON external.sku_warehouse_stock_v1 TO external_core_reader;
GRANT SELECT ON external.packaged_items_v1 TO external_core_reader;
GRANT SELECT ON external.sku_quality_inspections_v1 TO external_core_reader;
GRANT USAGE ON SCHEMA external TO external_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA external TO external_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA external
GRANT SELECT ON TABLES TO external_reader;
```
不要给外部账号使用 `SELECT ON ALL TABLES IN SCHEMA external` 这类全量授权;新增外部视图必须单独评审后追加白名单。
禁止:
- 给外部账号授权 `public` schema 全表读取。

View File

@ -160,7 +160,6 @@
- 代码巡检已增加“未归类本地存储”硬护栏:非测试源码里的 `localStorage/sessionStorage` 直接读写和 `createJSONStorage` 封装持久化用途必须被归类为主数据风险、兼容缓存、旧缓存,或明确列入非主数据 UI/登录态缓存;未归类时命令失败,避免新的本地主数据真相源漏进系统。
- 代码巡检已增加“应用层本地 JSON 文件主数据源”硬护栏:`src` 下不得用同步或异步 `readFile/writeFile`、`require/import` 读写或导入本地 `.json` 文件作为产品、资料库、权限或外部同步真相;导入载荷、迁移计划、备份和测试夹具必须留在明确的工具/测试边界内。
- 代码巡检已增加非集成产品 `payload` 查询护栏:开发类型、负责人、待办和列表筛选不得直接把 `payload` JSON 当作并列真相源;历史兜底必须通过 `payloadJsonWhenProfileMissing(...)` 明确绑定到 profile 缺失场景。
- 非集成产品定厂字段已结构化到 `IntakeNonIntegratedSkuItem`:供应商、供应商编码、工厂仓库、发货方式、最低起订量、含税单价、交期和安装方式均以 SKU 明细列为真相;`payload.factoryRows` 只保留迁移兼容快照和流程型字段,运行时回显与 `external.product_catalog_wide_v1` 不得再从该 JSON 兜底取定厂字段。`factoryWarehouse` 表示供应商发货仓,不得和库存仓位 `warehouse_location_name` 混用。
- 代码巡检已增加关键地基表写入边界护栏:`src/app` 下的 Route/Page 不得直接写 `SystemConfig / ProviderConfig / ThemeConfig / IntakeNodePermissionOverride / AssistedAssemblyAiRender / Feedback / ActivityLog`,必须进入 service/repository 的规范化写入口;回收站产品/SKU 日志写入已改走统一 `logActivity`。
- 代码巡检已继续扩展到核心主数据写入边界:`src/app` 下的 Route/Page 不得直接写 `Product / ProductSKU / ProductSkuAssetProfile / DraftGroup / PreSku / PreSkuBasicInfo / AssetFamily / AssetVariant / IntakeNonIntegratedProduct / IntakeNonIntegratedProductProfile / IntakeNonIntegratedSkuItem / ProductPatentRecord / SpuNumberCounter / IntakeReferenceLibraryItem`,防止页面接口绕开服务层的编码规范化、快照同步、质量状态和冲突检测;`src/server` 写入口会同时识别 Prisma client 别名和 relation 嵌套写入,子模型结构化副本不能藏在父模型 `create/update` 里绕过分类;资料库条目身份也禁止恢复 `id` 兜底 `itemKey`、直接 `itemKey: item.id`、`code || item.id` 或 `` `item:${item.id}` `` 的写法,有稳定编码的资料库必须用 `code` 作为条目身份,缺显式编码的资料库必须使用业务规格派生的稳定 key,避免浏览器临时 id 或数据库主键变成业务身份;草稿组编号禁止恢复纯 `Date.now()` 生成,服务层必须使用可读前缀和足够熵,数据库唯一索引只做最后兜底。
- 文件系统输入也纳入代码巡检:`filesystem-master-data-source` 会识别读取 `public/uploads`、上传临时文件、ZIP 解包目录等文件来源后又写 `Product / ProductSKU / Panel / ProductHardware / Hardware` 的组合。只有登记过的旧上传/导入入口可保留,新增未登记入口会阻断,防止把素材目录或本地文件扫描当成第二主数据源。

View File

@ -1,223 +0,0 @@
# 清货管理(2026-10-20)
## 1. 页面与入口
- 页面:`/supply-chain/management/clearance`
- 菜单:供应链中心 > 经营管理 > 清货管理
- 五个页签:清货池、清货任务、清货分数、清货复盘、清货配置
## 2. 数据与边界
页面明细读取本地 PostgreSQL 快照/业务表;只有清货发货同步和进度计算会读取 SQL Server 出库单,普通列表加载直接使用本地持久化进度。
| 数据 | 本地表 | 说明 |
| --- | --- | --- |
| 清货池 | `SupplyChainClearancePoolSnapshot` | 产品全量档案的月度快照;包含 `periodKey`、`generatedAt`;只有经管重新生成时才会更新 |
| 清货池生成记录 | `SupplyChainClearancePoolGeneration` | 每个清货月份只有一条经管生成记录,保存生成时间、生成人和商品数 |
| 清货任务 | `SupplyChainClearanceTask` | 按完整月份保存任务量、折扣、原因、审核状态;同月调整记录独立保留 |
| 流转记录 | `SupplyChainClearanceTaskEvent` | 提交、通过、驳回、调整、运营确认移除 |
| 复盘快照 | `SupplyChainClearanceReviewSnapshot` | 月度整体/运营中心/部门负责人/优先级/折扣维度 |
| 清货发货进度 | `SupplyChainClearanceTaskShipmentDaily` | 按任务、出库日持久化实际出库数量;来源版本为 `outbound-confirmed-v2`,页面只读取该版本的汇总,旧销量快照和旧中心范围缓存不参与发货量 |
| 时间窗口 | `SupplyChainClearanceApplicationWindow` | 每个清货月份包含次月任务制定窗口和次月折扣调整窗口;清货周期始终为一个完整自然月 |
| 清货池库存 | 聚水潭库存、PLM 清货表 | 经管生成时实时读取源表,计算结果落到清货池月度快照;页面不直接查询源表 |
清货池月度生成:
- 只有 `clearanceReview.edit` 权限的经管可以在「清货配置 > 时间窗口」中同时选择是否生成清货池。
- 清货池页面标题说明会读取当前月份的生成记录,展示生成时间、生成人和生成方式;未通过配置生成或没有生成记录时不追加该说明。
- 经管选择 `YYYY-MM` 清货月份后,系统按当前产品全量档案补充商品资料,并从聚水潭库存和 PLM 清货表重新计算库存。
- 简尚总库存 = 大货仓实体库存 + 前置仓库存 + 专用仓库存;库存直接读取上游最新视图 `JianSun_Old.JianSun.dbo.jst_inventoryInfo_view`,四类仓库统一使用公有可用数 `qty - order_lock`。大货、前置、专用仓保留负数参与汇总,例如缺货仓的负库存会冲减大货仓;代发仓负数按 0。
- 大货仓:仓库类型为 `成品专用仓-真实库存` 或 `成品发货仓-真实库存`,剔除新外贸仓、张小飞代发仓、济南外贸仓、南昌樵舍仓和 `简尚-运营` 开头的专用仓。
- 前置仓:仓库名称包含 `南昌樵舍仓`。
- 专用仓:仓库名称以 `简尚-运营` 开头。
- 代发仓:仓库类型为 `成品代发仓-虚拟库存`,负数参与汇总,且结果小于 `77777`。
- 简尚总库存 = 大货仓 + 前置仓 + 专用仓 - 最新虚拟云仓库存;云仓库存完全抵扣后为 0 的商品不进入清货池。
- 供应商剩余库存读取 `rpt.plm_productDiscontinuation.remainingStock`,同一商品取 `state=2/3` 中 `updateTime, Id` 最新的一条。
- 工厂总库存 = 供应商剩余库存 + 代发仓;已移除贴单发货仓。
- 简尚 + 工厂总库存 = 简尚总库存 + 工厂总库存;清货池只写入和展示总库存大于 0 的商品。
- 两清协议按 `rpt.plm_productDiscontinuation` 判断:存在任意状态记录、但没有 `state=2/3` 的有效库存记录时显示「工厂库存未统计」;PLM 完全无记录或已有有效库存记录时留空。
- 清货池「日销」= 产品全量档案 `sales3d × 0.5 + sales7d × 0.3 + sales15d × 0.2`,结果保留两位小数,不取采购日销 `dailySales`;旧数据仅存在 7 日销时回退到 `sales7d`。
- 清货池「最近入库时间」在简尚总库存为 0 时显示「简尚无库存」;工厂仍有库存时总库存和工厂库存照常保留,入库天数仍按真实采购入库日期计算。
- 清货池生成排除二级分类为空和「其他 - 福利品」的商品,并单独排除编码 `GBGJ-MPWL01N126V01`。
- 优先级规则和固定任务规则的条件值从当前清货池读取候选并支持多选;数字字段可同时选择或直接输入任意数值,仅「包含 / 不包含」使用自由文本输入,「为空 / 不为空」无需填写值。
- 清货池优先级支持经管人工调整和勾选批量调整;人工值写入清货池快照并标记人工来源,后端仅接受 `clearanceReview.edit` 权限。
- 清货批次按月沿用上一月快照:销售转停产/退市进入上一月最大批次加一的新批次,停产/退市转销售恢复“库存积压”;销售且可用天数小于 30 天标记“正常销售”并关闭任务、折扣通道。人工优先级会随快照向后沿用,优先于系统规则。
- 人工清货任务按“统计月 + 条码 + 利润中心”区分,任务清货量作为期初任务量;人工清货价独立保存,批次和优先级从对应月清货池映射。
- 禁止申请折扣配置使用与优先级规则相同的条件编辑器,可选择任意清货池抬头字段、组合且/或条件并维护多条限制规则;旧商品编码/SPU 配置自动转换为等值条件。
- 清货池「停产时间」按商品编码关联 `rpt.plm_productDiscontinuationDetail_flow` 与 `rpt.plm_productDiscontinuation_flow`:仅取已完成的停产流程(`flowtype=14`、`flowStatus=1`)、有效停产明细(`prostatus=1`、`state=0`),以最新流程的 `applyTime` 填入年月日;销售状态为「销售」的商品无论是否存在历史停产流程都清空停产时间。
- 清货池「利润中心」按产品档案的运营负责人 `opsHead`,在人员信息表 `SupplyChainManagementOwnerConfig.operationOwner` 中精确匹配后取 `profitCenter`;未匹配时留空。
- 清货组合匹配只处理产品全量档案的单双包字段原本为「双包」或「三包」且有库存的商品;「非双包」和空值即使出现在组合明细中,也不改写包型,清货商品编码直接填当前商品编码。
- 组合读取启用的 `jst_comboProductInfo` 和 `jst_comboProductItem`,并严格按档案包型匹配:双包只取 2 个不同商品编码的组合,三包只取 3 个不同商品编码的组合;1 个、4 个及以上或其他包型组合不参与。
- 同一商品编码命中多个组合时只选择一个,且组合编码必须以 `ZH-` 开头并直接包含当前商品编码;`运营组合套装xxx`、`MKH-运营组合套装xxx` 等无法从编码识别组成的组合不使用。具体组合中优先选择所有组成商品数量均为 1 的组合,其次选择总数量更小、存在 A/B 对应件、无异常且编码稳定的组合。例如同时存在 `ZH-...A-2-...B-2` 和 `ZH-...A-1-...B-1` 时,必须选择后者。
- 原有单商品明细行不新增、不拆分,`清货商品编码` 只填写选中的完整具体组合编码;不是 `ZH-` 开头或找不到具体组合时,填当前商品编码。
- 对选中的组合递归检查组成商品:任一组件状态为停产或退市且总库存小于等于 0 时,组合状态标记「多包异常」,并记录首个异常原因;同月二次折扣调整不清空或重算这些组合字段。
- 组合清货总量取所有组成商品「简尚+工厂总库存」的最小值;非组合商品取自身简尚+工厂总库存。组合所有组成商品的简尚库存都大于 0 但数值不一致时标记「多包库存不等」。
- 可用天数按 `清货总量 / 日销` 向下取整;日销为 0 时显示「无销量」,日销为空时留空。
- 清货池页面和导出中的日销最多保留两位小数;整数不补小数位,可用天数仍使用原始日销精度计算。
- 「多包异常」商品禁止填写任务、申请折扣、推送草稿或由经管调整后通过;「多包库存不等」允许填写任务和申请折扣,但任务量上限仍使用组合最小清货总量。
- 常规生成完成后,向钉钉个人通知发送一条汇总提醒,只说明「多包异常」和「多包库存不等」已生成及各自数量,不推送商品编码或组合明细;收件人从在职员工目录按姓名匹配,默认「卷帘大将」,可通过 `SUPPLY_CHAIN_CLEARANCE_COMBO_NOTIFICATION_RECIPIENT` 调整。二次折扣调整不重新打标,也不重复发送该通知。
- 同一清货月份允许经管不限次数重新生成;再次生成时事务内覆盖该月生成记录和商品快照,未再次生成时数据保持不变。
- 生成方式分为「常规生成」和「二次折扣调整」。常规生成重算完整商品池;二次折扣调整必须基于已生成月份,只更新现有商品的大货仓、前置仓、专用仓、代发仓、供应商剩余库存、简尚库存、工厂库存、总库存、日销(7 日销)和可用天数,商品集合、状态、负责人、利润中心、两清协议及其他字段保持不变。
- 「清货配置」是紧邻「清货复盘」的独立页面,不再以清货池工具栏弹窗展示,且仅对具备 `clearanceReview.edit` 权限的经管显示;页面包含「时间窗口」「优先级规则」「固定任务规则」「折扣限制」四个子页签。优先级和固定任务规则由经管人工维护并持久化到 `SupplyChainClearanceConfig`,每条规则填写目标值、多个条件以及条件关系(同时满足 `ALL` / 满足任一 `ANY`);条件关系通过点击条件之间的「且 / 或」切换。同一商品命中多条规则时采用序号最小的一条。
- 「折扣限制」按商品编码或 SPU 人工维护禁止申请折扣清单,匹配不区分大小写,重复配置自动合并;命中后清货池折扣输入禁用,保存草稿、提交、审核、申请人确认及经管强制推送都会在服务端再次校验,不能通过接口绕过。
- 优先级字段是人工自定义的优先级名称,不使用固定的高/中/低字典;规则值和兜底值都由经管自由命名。
- 优先级规则可判断销售状态、商品状态、分类、品牌、供应商、单双包、合作状态、负责人、商品编码、货号、各类库存、日销、可用天数、成本价、清货商品编码、清货总量、多包标记、利润中心、两清协议、停产时间和入库天数;支持等于、不等于、包含、不包含、数值比较以及为空/不为空。
- 有预设值的文本字段支持勾选多个值;等于和包含在任一选中值命中时成立,不等于和不包含在避开全部选中值时成立。数字字段及自由文本字段保持单值输入。
- 优先级规则只在常规生成清货池时计算并写入 `extra.priority`,同时保留命中的规则 ID 和序号;二次折扣调整不重算优先级。未命中任何规则时优先级留空。
- 固定任务规则使用安全算术公式,可引用简尚库存、工厂库存、总库存、日销、可用天数、清货总量、成本价、入库天数等数值字段,支持数字、括号和加减乘除;只解析受控字段和运算符,不执行任意代码。公式结果向下取整,字段无数值或计算无效时固定任务量留空。
- 优先级和固定任务配置都支持选填兜底值:当所有规则均未命中时,使用人工填写的兜底优先级或兜底公式;留空时保持为空。
- 常规生成清货池时把首个命中公式的结果写入 `extra.fixedTaskQty`,同时保留公式、规则 ID 和序号;二次折扣调整不重算。
- 页面可通过「清货月份」切换已经生成的月度清货池。
历史导入和诊断脚本:
- 当前全量映射:`npm run import:clearance-pool-archive`
- 把产品全量档案全部行写入清货池,销售状态取 `productStatus`;
- 《10月清货池》38 个业务抬头覆盖到对应字段,其余抬头保存在 `extra` JSONB;
- 商品名称和品牌作为商品档案列保留,可在列设置中显示;
- 已有快照中的库存值优先保留,其余使用档案库存字段。
- 停产/退市手工诊断同步:`npm run sync:clearance-pool:discontinued`。该命令会重写指定日期的清货池快照,只用于单独核对,不纳入清货池库存刷新流程。
- 使用 `rpt.jst_sku` 的实时停产/退市状态;
- 简尚库存取 `jst_inventoryInfo_view`,按现有规则排除专用仓并扣减虚拟云仓库存;
- 工厂库存取 `plm_productDiscontinuation` 中同一商品编码最新创建记录的 `remainingStock`;
- 只写入总库存 > 0 的款式。
- 本机导入(已有合并表时):`npm run import:clearance-pool -- --file=<csv> --date=YYYY-MM-DD`
- 复盘快照导入:`npm run import:clearance-review`
- 「清货配置 > 时间窗口」默认选中当前正在查看的清货月份,并提供「不生成」「常规生成或重新生成」「二次折扣调整生成」三个选项。
- 当前正式生成只保留产品全量档案字段,以及重新计算的库存、7 日销、可用天数、利润中心和两清协议;清货商品编码、清货批次、优先级、固定任务量等清货专有字段继续留空,等待后续逻辑接入。
- 档案基线重建:`npm run reset:clearance-pool:archive -- --date=YYYY-MM-DD --apply`;只更新现有快照字段,不新增或删除商品。
- 已处理库存实验命令 `sync:clearance-stock:stock-monitor` 当前暂停,不作为清货池正式库存来源。
## 3. 任务流转
清货任务默认列按经管提供抬头展示:统计月、spu、清货商品编码、清货价、成本价、折扣预警、价格类型、运营负责人、部门负责人、利润中心、期初任务量、真实任务量、发货数量、任务清货进度、退款数量、简尚库存、工厂库存、总库存、日销、可用天数、全国运费、清货批次、优先级。任务页只展示经管已推送的 `APPROVED` 明细,因此不再展示任务审核状态;统计月固定为第一列。
任务计算口径:
- 清货商品编码映射当前任务月份清货池的 `extra.clearanceCode`。
- 成本价取产品全量档案 `costPrice`;如果清货商品编码是组合款,则递归汇总组合明细中所有组成商品的产品档案成本,并按组成数量计算。
- 清货价 = 成本价 × 申请折扣;折扣为空时直接展示成本价。
- 折扣预警仅在存在折扣时计算:任务清货进度小于 50% 标记「异常」,大于等于 50% 标记「正常」。
- 期初任务量取同一申请人该条任务最早一次草稿/提交记录中的任务量,不随审核调整覆盖。
- 任务清货进度 = 发货数量 / 真实任务量。
- 简尚库存、工厂库存、7 日销取每日更新的产品全量档案;总库存 = 简尚库存 + 工厂库存;可用天数 = 总库存 / 7 日销并向下取整,日销为 0 时显示「无销量」。
- 运营负责人、部门负责人、利润中心、清货批次和优先级映射当月清货池数据;退款数量仍取本地销售出库日数据;发货数量统一取聚水潭实际出库单。
- 运营在清货池填写并保存任务申请,可以查看清货池全部商品;只有清货经管可以审核申请,审核权限走 `clearanceReview.edit`。申请人确认和正式任务推送分别校验操作/审核权限。
- 同一商品、同一期次允许不同运营分别保存自己的任务申请;草稿按申请人隔离,互不覆盖。经管可查看该编码下全部申请;普通运营只能读取和看到本人申请、审核结果,不能看到其他人的申请内容、审核状态或状态计数。
- 审核状态流转为 `DRAFT/PENDING 待审核` → `READY 已审核` → `APPROVED 已确认`。经管审核通过后,申请人只看到自己的“已审核”结果;点击“确认无误”后自动写入清货任务明细并切换到清货任务页,无需经管再次推送。未确认的 `READY` 申请仅经管有权限强制推送。任务仍按申请人拆分,`spu负责人` 显示实际申请人。
- 清货任务页只做数据展示,不承载审核或调整操作。
- 清货池展示任务流程和「全部任务 / 待审核 / 已通过 / 按调整后通过 / 已驳回」审核结果快捷按钮;经管看到全部申请统计,普通运营仅看到本人申请统计。点击后筛选清货池明细,导出时沿用同一结果。清货任务页不展示利润中心汇总、状态分段按钮、重复搜索框或记录类型切换;任务周期、筛选项、刷新、导出和列设置合并为同一行。任务明细每页 50 条,表格下方使用紧凑分页控件展示总条数、本页条数和上一页/下一页。没有任务时仍保留完整表头,只在表体显示原有空态提示。
- 清货池任务流程和状态概览并入清货月份栏:任务流程压缩为单行步骤条,销售、退市、停产各自展示编码数量、简尚库存和总库存,长期滞销库存当前显示待配置;窄屏时状态卡片自动换行。指标按整个生成月份汇总,不随筛选条件变化,宽屏展示状态卡片后为筛选和明细释放纵向空间。
- 清货池明细最右侧为“审核结果”列。待审核、已通过、按调整后通过、已驳回分别标识;已驳回结果附带驳回原因。经管可点击结果查看申请详情;申请人端仅显示本人结果,并在审核通过时突出显示“确认审核结果”。申请人确认后自动跳转清货任务页;强制推送和自动确认都会刷新清货池、审核计数与任务明细。
- 审核弹窗顶部展示商品编码、清货商品编码、SPU、运营负责人、部门负责人、利润中心、清货批次、商品状态、优先级、简尚/工厂/总库存、清货总量、7 日销、可用天数、停产时间和入库天数,并展示同商品编码最近月份的任务量、折扣、完成量和完成率。每条申请可“通过原申请”“调整后通过”或“驳回”;调整后通过必须修改任务量或折扣,调整原因选填,驳回必须填写驳回原因。待审核弹窗不提供强制推送。
- 经管强制推送只处理 `READY`(已审核未确认)记录,入口保留在清货池明细下方,一次推送当前月份和任务期次的全部未确认记录;申请人已确认的记录会自动进入清货任务,不在强制推送范围内。
```text
经管选择清货月份并确认次月任务制定、次月折扣调整时间(起止时间)
-> 只有在对应时间窗口内,运营才能填写任务量 + 折扣 + 原因
-> 三个字段完整后离开输入框自动保存为 DRAFT 草稿
-> DRAFT / PENDING 待审核
-> 经管审核通过 -> READY 已审核
-> 申请本人确认无误 -> APPROVED 已确认,自动进入清货任务
-> 或由经管强制推送未确认的 READY 申请 -> APPROVED 已确认
-> 经管驳回 -> REJECTED(必须填写原因,仅原申请人可见)
-> 运营确认移除:REMOVED
-> 经管调整任务量/折扣后重新审核
```
同一月份、同一商品、同一种记录类型只允许一条非 REMOVED 任务。调整窗口开放后,即使已有月初确认任务,也可以为同款创建独立的调整记录,初始任务不覆盖、不删除。
分数和复盘按商品去重:同一商品存在初始任务和调整记录时,只取最新一期已确认记录参与任务量、完成量和维度汇总,历史记录继续保留用于追溯。
销售状态商品不允许申请折扣:清货池折扣和折扣原因都固定为空,只允许填写任务量;草稿保存、批量推送、经管调整接口均强制校验,不能通过接口绕过。
非销售状态商品申请折扣时必须先填写任务量,填写折扣时必须填写原因;没有折扣需求时折扣留空即可,可以只填写任务量。
草稿单元格在输入过程中只更新本地引用,失焦时立即提交保存,避免逐字重绘整张 50 行明细;保存接口并行校验时间窗口和库存快照,并并行查询本人草稿及有效任务。折扣输入保留 200ms 防抖,保存期间继续修改会在首次保存完成后自动补存最新内容。
时间窗口规则:
- 只有 `clearanceReview.edit` 可以配置和确认时间窗口(「清货配置」页签只对经管显示);选择生成时,窗口保存成功后再执行清货池生成。
- 未配置、未开始或已结束时,清货池进入只读,运营不能保存草稿,经管不能推送正式任务;后端会强制校验。经管审核和申请人确认审核结果不受该限制。
- 清货月份始终是完整自然月,任务和后续折扣调整都归属该清货月份。
- 次月任务制定:目标清货月前一个月的最后 7 天。
- 次月折扣调整:目标清货月第 8 天起的第一个工作日,自动跳过周末;法定节假日可由经管人工调整。
- 「清货配置」页面会按上述规则自动填入建议时间,经管可修改后确认。
执行进度:
- 每日档案与销售同步完成后,自动刷新当前清货池的库存拆项、简尚库存、工厂库存、总库存、日销和可用天数;优先级、批次、状态、人工覆盖及库存天数分数、日销分数、入库天数分数、总分数、建议折扣等清货池派生结果保持不变。
- 清货池派生评分仅在经管人工执行「二次折扣调整」时,根据当时的库存、日销和可用天数重新计算。
- 清货任务页读取每日刷新后的池库存、日销和可用天数,并据此重新计算真实任务量、清货进度、分数概览等派生结果。
- 发货数量只读取 `JianSun_Old.JianSun.dbo.jst_salesOutboundInfo + jst_salesOutboundItem` 和 `jst_qmcust_salesoutbound + jst_qmcust_salesoutbound_item`,仅统计状态为 `Confirmed` 的出库单,并按 `op_ShopConfig_view` 汇总运营中心 A、B、C、G、分销部五类店铺。运营中心 B 仅计入发货量,任务可选利润中心仍为 A、C、G、分销部。
- `jst_qmcust_salesoutbound` 与 `jst_salesOutboundInfo` 的同一 `io_id` 会重复时,以出库单头 `io_id` 去重,不按 SKU 去重;任一任务利润中心都汇总上述四类店铺的实际出库,不按任务利润中心排除其他店铺。
- 清货周期固定为完整自然月;每日截止到昨天,月初第一天尚无完整出库日时同步结果为未覆盖。当前月计算月初 1 日至当月月末,历史月计算完整自然月。
- 第一次同步按天补齐月初至昨天,之后每日只查询并回写昨天;若某天当时为 0、后续补入出库,次日只核对昨天这一日,不重放更早日期。
- 每日销售出库同步完成后会自动刷新清货池指标并回写当月任务进度;也可单独执行 `npm run sync:clearance-progress -- --period=YYYY-MM`,不传月份时同步当月。
- 回写内容为 `clearedQty`、`lastProgressAt`;销售源未覆盖目标月份时不覆盖上次进度。
- 出库源未覆盖目标月份时保留上次持久化进度;页面固定展示截止日,不用最后一条出库记录日期冒充覆盖日期。
草稿接口:
- `POST /api/supply-chain/clearance/tasks/draft`:表内自动保存(同一商品同一记录类型重复保存会更新同一条草稿)。
- `POST /api/supply-chain/clearance/tasks/push`:把勾选的 DRAFT 批量转为 PENDING,并写 SUBMIT 流转记录。
- `GET/POST /api/supply-chain/clearance/window`:读取指定清货月份的次月任务制定/折扣调整时间;经管保存起止时间并确认。
- `POST /api/supply-chain/clearance/tasks/:id/review`:直接通过或驳回。
- `POST /api/supply-chain/clearance/tasks/:id/approve-adjust`:待审批任务调整任务量/折扣后通过。
- `POST /api/supply-chain/clearance/tasks/:id/adjust`:经管调整已驳回任务后确认。
- `POST /api/supply-chain/clearance/generate`:经管常规生成或重新生成清货池;二次折扣调整模式仅刷新库存、日销和可用天数。
## 4. 三张明细表的通用能力
清货池、清货任务、清货分数均支持:
1. 右上角齿轮「显示设置」:与限价管理一致,统一提供紧凑展示、列设置、筛选设置三个入口。
2. 个人列设置:搜索、勾选要显示的字段,上下移动字段顺序;列宽直接在明细表头右侧拖动手柄调整,松手后按用户保存到浏览器本地。
3. 顶部筛选:在「显示设置 → 筛选设置」中选择要展示的筛选项后,字段以横向下拉框显示;候选值可搜索并直接点击多选,也可粘贴或输入空格、逗号、分号、顿号、换行分隔的批量值后回车应用。点击或批量输入均使用精确等于匹配,避免选择「双包」时误包含「非双包」。同一字段多值按 OR 组合,不同字段按 AND 组合,并以标签展示已选条件、逐项清除或清空全部。清货池筛选在服务端执行,覆盖普通字段、日期、数值和 `extra` JSONB 抬头;任务量、折扣、原因按当前清货月份的动态任务数据筛选,任意人工配置字段的候选值通过 `GET /api/supply-chain/clearance/options` 加载。
4. 导出 Excel:清货池提供「导出明细」和「导出明细(含图片)」两个入口,统一放在筛选行右侧。普通明细移除图片列并生成 `清货池-明细` 文件;含图片导出会强制加入图片列、抓取商品图片,并生成 `清货池-含图片` 文件。两者都按当前列顺序、列宽和筛选结果导出,并按每页 5,000 条自动循环读取全部匹配记录,不再截断为首页。
5. 横向滚动:明细底部固定显示可拖动的横向滚动条,位于分页操作区上方;同时保留触控板横向滚动。
清货池默认展示《10月清货池》38 个抬头,任务量、折扣、原因、任务状态固定放在列顺序末尾;销售状态单独作为业务字段展示。
清货池顶部不再重复提供商品搜索框,统一通过下方人工配置筛选项查询。
## 5. 权限
页面可见:`businessManagement.view`
| 动作 | 权限节点 |
| --- | --- |
| 清货池定任务、提交折扣申请、运营确认移除 | `clearanceOperation.edit` |
| 经管通过、驳回、调整任务 | `clearanceReview.edit` |
| 经管生成或重新生成清货池 | `clearanceReview.edit` |
| 查看清货池、任务、分数、复盘 | `businessManagement.view` |
默认角色:
- `operation` 角色默认获得清货运营操作权限;
- `management` 角色默认获得清货经管审核权限;
- 管理员默认拥有全部权限。
## 6. 当前版本边界
- 清货任务的 `clearedQty` 已切换为实际出库单口径;清货池指标和任务发货进度均由每日 09:30 数据同步链中的 `sync:clearance-progress` 持久化,旧 `SupplyChainProductArchiveSalesPerformanceDaily` 发货数仅供其他模块使用。
- 所有旧导入、重置和库存同步脚本仍会拒绝直接修改已生成月份;更新必须通过经管「清货配置 > 时间窗口」中的生成选项执行。
- 清货分数先使用示例权重:完成度 60 + 毛利率 25 + 货值回收 15;历史周期使用复盘快照计算,当月任务只有完成度可计算。
- 清货分数档位按完成率上下限区间匹配,完成率允许高于 100%;上下限均可配置为包含或不包含,上限留空表示无上限,区间不可重叠,旧“最低完成率”配置会自动转换为连续区间。
- 档位对应分数允许超过 100(最高 99999),用于配置超额完成奖励;完成率和三类任务占比仍限制在 0–100%。
- 清货复盘优先使用 `2026年4月清货复盘.xlsx` 导入的历史快照;没有快照的月份由最新一期已确认任务 + 本地销售出库进度自动生成。
- 当月自动复盘暂不计算毛利率、结算毛利率和货值回收率,字段保持为空,待结算口径接入后再补齐。
- 清货池导出单次最多 5,000 条,按当前筛选范围导出;超出时在「导出说明」中提示实际总数。
- 本机已导入 2026-10-20 全量清货池快照(79,959 个产品,销售 51,619 / 停产 21,010 / 退市 3,548 / 未标记 3,782)。
## 7. 验证命令
```bash
npm run type-check
npm test -- src/server/services/__tests__/supply-chain-clearance.service.test.ts
npm test -- src/server/services/__tests__/supply-chain-clearance-review.builder.test.ts
npm test -- src/lib/clearance-filters.test.ts
npm test -- src/lib/supply-chain-permission-settings.test.ts
```
全仓 `type-check` 当前存在与本次改动无关的历史错误(`node_modules.pnpm-mixed`、`src/hooks/usePerformance.ts` 等);本次新增文件无 TypeScript 报错,ESLint 无 error。

View File

@ -1,289 +0,0 @@
# 物流到货签收时长看板 · 订单明细每日快照
## 为什么要做
看板的全部数据来自远端只读报表视图 `rpt.jst_order_express_details`(外加 `jst_qmcust_orderlist_item` 条码明细和 `SupplyChainProductArchive` 产品档案富化)。
该视图是远端对象,一次扫描通常以分钟计,页面每次刷新、翻页、下钻、导出都要重新扫一遍,看板因此很难"高效运转"。
做法:每天把看板口径的订单明细物化到本地 Postgres,看板优先读本地快照,只有快照覆盖不到时才回落实时数据源。
## 数据与表
### 条码/供应商/采购的匹配来源(两套互补的订单表)
报表库把订单明细拆成了两套**互不重叠**的表,只看一套会漏掉近一半订单的条码:
| 表 | 覆盖平台 | 规模(实测) |
| --- | --- | --- |
| `rpt.jst_qmcust_orderlist` / `rpt.jst_qmcust_orderlist_item` | 淘宝天猫、阿里巴巴(1688)、淘工厂、天猫供销 | 408 万 / 576 万行 |
| `rpt.jst_Orders` / `rpt.jst_OrderItem` | 京东商城、京东厂家直送、拼多多、头条放心购(抖音)、得物、唯品会、线下、微信视频号、小红书 | 124 万 / 175 万行 |
看板取条码时会**同时查两张明细表并用 `INNER_ORDER_ID`(即 `o_id`)合并**(`listItemRows`,两表按订单号各发一批查询后合并结果)。修复前只查 `jst_qmcust_orderlist_item`,导致京东/拼多多/抖音/得物/唯品会的订单条码全空;修复后新生成的快照条码匹配率为 **100%**(此前整体 52.3%)。
> 注:`rpt.jst_salesOutboundInfo`(销售出库单头)可以读取,但只有 `o_id / so_id / l_id / shop_name / io_id / qty` 等出库单头字段,没有 SKU 列,不能单独提供条码。
纯新增两张表(迁移 `prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot`):
| 表 | 作用 |
| --- | --- |
| `LogisticsArrivalSigningDurationSnapshotRun` | 批次元数据:`snapshotDate`、`windowFrom`、`windowTo`、`status`(RUNNING/COMPLETED/FAILED)、`rowCount`、`message` |
| `LogisticsArrivalSigningDurationSnapshot` | 明细行:与看板 09 订单明细一一对应,含条码/供应商/采购/`productItems` 等快照时点已固化的富化结果 |
要点:
- 只有 `status = COMPLETED` 的批次对读路径可见,写入中途或失败的批次不会被读到。
- 行唯一键 `(snapshotDate, rowKey)`,`rowKey` 与下钻键一致(内部单号 + 线上订单号 + 物流单号 + 签收时间),重复执行用 `skipDuplicates` 幂等。
- `(snapshotDate, signTime)` 有索引,按签收日期区间取数走索引。
## 生成任务
```bash
# 默认:快照日期=当天(Asia/Shanghai),窗口=最近 62 天(覆盖本月与上月),保留最近 2 批
npm run sync:logistics-arrival-signing-duration-snapshot -- \
--apply --confirm=sync-logistics-arrival-signing-duration-snapshot \
--expected-database-identity="<本地库身份>" \
--expected-source-database-identity="<远端报表身份>"
```
可调参数:`--snapshot-date=YYYY-MM-DD`、`--window-days`、`--chunk-days`(默认 7,按天分批抓取以控制内存)、`--batch-size`(默认 2000)、`--retention`、`--force`(当天批次已完成时强制重建)。
编排逻辑在 `src/server/services/logistics-arrival-signing-duration-snapshot.service.ts`:
1. 当天批次已 `COMPLETED` 且未 `--force` → 直接跳过;
2. 标记 `RUNNING` 并清空当天旧行;
3. 按 `chunk-days` 分片读远端并写入本地,同时记下真正抓到的最新签收时间 `dataThrough`;
4. 全部成功才标记 `COMPLETED`(连同 `dataThrough`),随后按 `retention` 清理更早批次;任一分片失败则标记 `FAILED` 并保留错误信息,读路径会自动继续用上一批快照;
5. 收尾关闭远端连接池(一次性任务必须关闭,否则进程挂在事件循环上)。
生产定时(systemd timer,默认北京时间 03:00):
```bash
sudo bash scripts/install-logistics-arrival-signing-duration-snapshot-timer.sh
```
首次安装会生成 `$BASE/shared/logistics-arrival-signing-duration-snapshot.env` 模板,需要填两个身份校验值(缺省为空会拒绝写入)。可用环境变量覆盖:`LOGISTICS_SNAPSHOT_CALENDAR`、`LOGISTICS_SNAPSHOT_WINDOW_DAYS`、`LOGISTICS_SNAPSHOT_RETENTION`。
## 读路径规则
`src/server/repositories/logistics-arrival-signing-duration.repository.ts` 的每个读方法都先判断快照是否可用:
- 取最近一批 `COMPLETED` 快照;请求区间**完全落在快照的实际覆盖范围**内才读快照(否则回落实时视图,避免返回不完整结果)。覆盖上限 = `min(windowTo - 1, dataThrough 当天)`:
- `windowTo` 是跑批当天,当天还没签收完,所以只能信任到它前一天;
- 再用批次记录的 `dataThrough`(该批真实最大签收时间)兜底,避免数据源滞后时把不完整的一天当成完整。
- 例:一批 10-08 生成、数据只到 10-08 05:30 的快照,对 `10-01~10-08` 不再命中,看板/导出自动改用实时源,不会少算 10-08。
- 命中快照时返回值带 `source: 'snapshot'` 与 `snapshotDate`,页面头部显示"快照数据 · 日期";未命中为 `source: 'live'`,显示原来的"数据更新 · 时间";
- 快照表还没迁移或还没有任何批次时,`findLatestCompletedRun()` 返回 null(并吞掉异常),整条链路自动等价于改造前行为。
因此在以下情况仍会走实时数据源(较慢,但结果正确):请求区间超出窗口、任务当天尚未跑完或失败、本地还没生成过快照。
## 指标口径(整区间精确 vs 样本)
命中快照时,**整块看板都是整区间精确值**,与 2,000 条样本彻底解耦:
| 看板块 | 精确口径实现 |
| --- | --- |
| 经营总览(6 个 KPI) | `aggregateDashboard()` 一次扫描聚合 |
| 履约环节拆解(5 环节平均/中位/P90) | 同上,`percentile_cont` 直接给出分位数 |
| 发货→签收时效分布 | 同上,4 个区间计数 |
| 付款→签收 / 付款→发货 多维经营分析(01–08) | `aggregatePayToSignAnalysis()` / `aggregatePayToSendAnalysis()`,按签收日期 + 采购/供应商/条码(`jsonb_array_elements` 展开 `productItems`)+ 物流公司/发货仓/平台/店铺分组 |
| 发货仓 / 物流公司 超时排名 | `aggregateThresholdRankings()` |
| 09 订单明细下钻 | `listDrilldownRows()`:按维度条件下钻,**列出全量匹配订单**并给出真实总数与页数 |
口径一致性由三点保证:筛选条件统一由 `snapshotFilterConditions()` 派生(Prisma 与原始 SQL 共用);比率/取整统一走 lib 的 `buildLogisticsArrivalSigningDurationOverview`、`buildPayToSignMetrics`、`buildPayToSendMetrics`;排序统一走 `comparePayToSignGroups` / `comparePayToSendGroups` / `compareLogisticsArrivalSigningDurationThresholdRows`。每个聚合块都用"把整区间明细喂给样本路径同样函数"的方式做过逐字段比对(MATCH)。
### 看板顺序与联动链路
### 履约环节拆解:目标值与看板宽度
### 经营总览:平均到货时长卡片与异常下钻
- 签收订单后面新增 **付款→签收平均到货时长** 卡片:主值是该环节平均时长,附行给出 **本月目标**(上月平均 × 98%)与 **目标达成率**,与履约环节拆解同一套口径。
- **缺少关键时间 / 时间异常** 两张卡片可点击:点击后用 `{ kind: 'anomaly', value: 'missing' | 'invalid' }` 条件下钻,09 订单明细列出对应订单;再点一次取消。判定口径与总览一致——缺少关键时间 = 四个时间任一为空;时间异常 = 四个时间齐全但至少一个环节时长算不出来(时间顺序异常)。
- **自动滚动只保留给经营总览的异常卡片**(卡片上写着"点击看明细"):点它们会把 09 订单明细滚到视口顶部(`detailBoardRef` + `scrollIntoView`,按"下钻签名"去重,不会在翻页时反复滚动)。**经营对象维度(平台/店铺/发货仓/物流/采购/供应商/商品/签收日期)的点选不再自动滚动**——那些看板是连着看的,一滚到底就没法接着看其他维度、还得手动往上拉;下钻筛选本身照常生效,09 明细就在原位置更新。
- 为保证"卡片数字 = 点击后的明细条数",**命中快照时经营总览始终按整区间精确统计**(`aggregateDashboard(..., includeStages=false)`,只算总览不算分位数);超出精确上限的大区间里,只有履约环节拆解/时效分布/经营分析/超时排名退回样本,页面脚注会写明。
### 经营总览视觉层次与看板工具条
- 经营总览从"7 张卡一张网格"改为**两组网格**:5 张履约指标卡一排(`--sc-kpi-cols`,标准 5 / 紧凑 3 / 展开 2),2 张数据质量卡单独成排(`--sc-kpi-alert-cols`,标准 2 / 展开 1)。7 张卡在常见宽度下不再是 5+2、第二排右侧大片留白。
- 卡片排版统一:标签 12px 用 `--sc-muted`,数值 24px 且 `font-variant-numeric: tabular-nums`,用 `margin-top: auto` 贴底、副标题保留 2 行最小高度(`min-height: 2.9em`)。同一行内标签换行不影响数值对齐,卡片等高、数字列对齐。
- 绿色强调卡(付款→签收平均到货时长、付款→签收履约率)改为"浅绿底 + 左侧 2px 竖条"(`inset 2px 0 0`),不再整卡大面积着色;点击态的竖条与边框跟着状态色走。
- 缺关键时间 / 时间异常改为横向"告警条":左侧图标 + 名称/说明,数值贴右,用低饱和暖色表达"异常"语义;点击下钻、`is-active` 选中态、`aria-pressed` 与 `aria-label`(含条数)都保留。
- 修复看板工具条(导出 / 尺寸切换)样式不生效的根因:`BoardTools` 是本文件内的**独立组件**,styled-jsx 按组件注入作用域类,主组件里的 `.sc-logistics-icon-button` / `.sc-logistics-board-tools` / `.sc-logistics-board-size-label` 规则永远匹配不到它的 DOM,四个看板右上角一直是"裸图标"。现在样式放进 `BoardTools` 自己的 `<style jsx>`(深色排名表头用 `:global(.sc-logistics-ranking-board-header)` 适配),并删除主样式块里的死规则。
- 每个环节多出一列 **目标**:`本月目标 = 上月同环节平均时长 × 98%`(`LOGISTICS_ARRIVAL_SIGNING_DURATION_TARGET_IMPROVEMENT_RATIO`)。基准月取所选区间结束日期所在月的上一个月(跨年正确),基准值由 `aggregateStageAverages()` 用同一套筛选条件跑上个月区间得到;当前平均不高于目标时目标值标绿表示达标,鼠标悬停可看到"目标 = 上月平均 × 98%"的算式。
- 目标后面还有 **目标达成率**:该环节时长不超过目标的订单占比。精确口径由 `aggregateStageTargetHits()` 按目标值统计命中订单数,样本口径由 `buildLogisticsArrivalSigningDurationStageAchievement()` 在样本上计算;无目标或无可计算订单时显示 `-`,鼠标悬停显示"命中 / 可计算"明细。环节行的平均/中位/P90/目标/达成率都只显示数值,单位(小时)只在表头语义里体现。
- 环节行按 `128px / 弹性条 / 74 / 74 / 86 / 92` 收紧列宽,列表支持横向滚动,避免 P90、目标被截断。
- 履约环节拆解与发货→签收时效分布之间加了**可拖动的分隔条**(`role="separator"`,支持左右方向键微调),比例默认 62% 并记在本地,可随时左右拖动调整两个看板的宽度。
经营对象问题排名各看板按"上级 → 下级"顺序排列,并逐级联动:
```
01 签收日期维度 → 02 平台维度 → 03 店铺维度 → 04 发货仓维度 → 05 物流维度
→ 06 采购维度 → 07 供应商维度 → 08 商品维度 → 09 订单明细
```
点击任意一级对象,都会保留更上级条件、丢弃更下级条件,并把这条链路作为筛选条件重新取数:
- 整区间口径:链路以 `cascade`(JSON 数组,`LogisticsArrivalSigningDurationDrilldownFilter[]`)随 dashboard 请求下发,服务端在 `aggregatePayToSignAnalysis` / `aggregatePayToSendAnalysis` / `aggregateThresholdRankings` 的 WHERE 上追加 `cascadeSqlWhere`,因此下级看板与 09 明细都只展示对应数据;
- 样本口径(实时源或超出精确上限):前端用 `logisticsArrivalSigningDurationRowMatchesCascade` 收窄样本后重算;
- 店铺维度分组键是"平台 + 店铺",因此平台级联动能精确到该平台下的店铺;
- 联动刷新是原位刷新(只有页面筛选变化才显示整块加载态),排名看板标题会显示当前链路与"联动加载中…"提示;
- 经营总览、履约环节拆解、时效分布属于页级口径,不随联动链路变化。
### 经营对象的目标与目标达成率
02–08 经营对象表(平台 / 店铺 / 发货仓 / 物流 / 采购 / 供应商 / 商品)在原有指标后面追加 **目标** 与 **目标达成率** 两列;01 签收日期维度的「指标 × 日期」矩阵与总计不追加(按日算"上月同日目标"没有业务含义)。
- **目标 = 该对象上月同环节平均时长 × 98%**,达成率 = `100% − (平均 − 目标) / 目标 × 100%`,与履约环节拆解同一套口径,只是精确到每个经营对象——所以每个对象的目标值不同(例如 08 月各采购平均不同,9 月目标 52.7 / 56.5 / 60.5 …),达成的判断是"相对自己上个月是否改善 2%"。
- 数值口径与展示一致:平均、目标都按 1 位小数取值后再算达成率,便于和表里看到的数字对账;没有上月基线的对象(新采购、新店铺、当月新条码)显示 `-`。
- 取数:`aggregateObjectBaselines()`(快照仓库)在上月区间一次扫描算出 7 个维度 × 付款→签收 / 付款→发货 两个环节的平均时长,随 dashboard 载荷以 `objectBaselines` 下发;前端用 `withObjectAnalysisTargets()` 按分组键(`JSON.stringify(keyParts)`,与样本路径同一套键)合并,样本口径与整区间口径共用。
- 这条聚合约 2–4 秒,因此按「快照日期 + 基准月 + 关键词/排除词」在服务端缓存 10 分钟(这些平均值只在每日快照重建时变化),并与看板其余查询并行执行;查询失败只让这两列显示 `-`,不会把整块看板拖回实时源。
- 「经营对象问题排名」导出的 CSV 同步带上这两列。
### 看板分页
> 页面顶部的「经营管理 / 物流时效 + 看板标题 + 物流签收明细口径 + 明细条数/更新时间」页头已去掉(用户口径:无需展示,标签页标题已经说明是哪个看板);快照/缓存状态在数据源异常时的黄色提示条里仍会给出。
### 批量关键词查询
筛选区的「关键词」支持批量输入(与排除关键词同一套拆分规则):
- 按中文逗号 `,`、英文逗号 `,`、换行拆分,自动去空、去重,最多 100 个、单个最长 120 字符(原始输入上限 4000 字符);
- **多个关键词之间是"或"**:命中任一字段即算匹配。整区间(快照)口径覆盖:内部单号、线上订单号、物流单号、物流公司、店铺、平台、发货仓、收货地址,以及快照时点固化的**条码、商品名称、供应商、采购**(`KEYWORD_FIELDS`)——所以能直接按采购/供应商/条码检索;
- 走实时源时(区间超出快照窗口)只覆盖前 8 个报表字段,条码/采购/供应商这三类需要本地快照支持,这是两套口径的已知差异;
- 解析规则由 `splitSearchKeywords()` 统一提供,服务端缓存键按关键词集合(排序后)计算,输入顺序不同不会重复缓存。
- 输入框与其它筛选控件**等高**(34px 单行高度,多行时框内滚动),"已识别 N 个关键词"显示在标签行右侧并在空间不足时省略,不再把这一格撑成两行高。
筛选区的三个按钮各管一件事,点下去**立即按新条件查询**(互不重复):
| 按钮 | 签收日期 | 关键词 | 排除关键词 | 结果 |
| --- | --- | --- | --- | --- |
| **重置** | 清空(输入框留空) | 清空 | 清空 | 没有任何筛选条件;空日期由服务端兜底为本月 1 号~昨日(`dateRangeCleared` 让输入框保持空白,不被兜底值回填) |
| **恢复默认** | 回到本月 1 号~昨日(显式填回输入框) | 保留 | 恢复系统默认配置(全部勾选) | 默认口径 |
| **清空排除** | 回到本月 1 号~昨日(显式填回输入框) | 保留 | 置空 | 默认区间 + 不排除任何关键词 |
三者过去都只是"改输入、等用户点查询"(重置只清日期与关键词、另两个只改排除词),结果看起来一样容易混;现在各自的语义、界面状态与结果都区分开了,按钮 hover 有对应说明,筛选栏底部也有一行图例。空日期提交时服务端仍按默认区间查询(跨天且当日快照未生成时可能落实时源,较慢但结果正确)。
**默认签收日期区间是「本月 1 号 ~ 昨日」**(由 `normalizeLogisticsArrivalSigningDurationFilters()` 生成):当日签收数据仍在滚动,且每日快照只覆盖到昨日,用"到昨日"作默认值可以让首屏直接命中快照(整区间精确 + 秒级),不再因为区间含当天而回落到实时源。每月 1 号时会自动退化为"昨日当天"(本月 1 号还没到昨日)。
筛选控件(签收日期、关键词输入框,以及排除关键词选择器的摘要)都是**受控并与服务端下发的筛选条件同步**:从菜单重新进入(无参数 URL)、重置后查询、浏览器前进/后退时,控件会刷成当前真正生效的条件。这一点很重要——否则会出现"数据已经是默认区间,输入框还停在上次条件"的错觉。
### 排除关键词选择器
筛选区的「排除关键词」是**按字段分组**的勾选式选择器(`ExcludeKeywordPicker.tsx`)。关键词只能配置在它要匹配的那个字段上,**不跨字段混用**——这是这一版最重要的口径修正。
**字段与匹配规则**
| 字段(配置分组) | 数据列 | 匹配方式 |
| --- | --- | --- |
| 发货仓 | `warehouseName` | 该字段值包含关键词(不区分大小写),命中即排除 |
| 物流公司 | `logisticCompanyName` | 同上 |
| 订单标签 | `orderLabels`(JSON 数组文本) | 同上 |
| 买家留言 | `buyerMessage` | 同上 |
| 卖家备注 | `remark` | 同上 |
配置与筛选都以「字段 → 关键词」存储(`LogisticsArrivalSigningDurationExcludeKeywords`)。SQL 侧对每个关键词只生成一条 `NOT (该字段 ILIKE '%词%')`;旧版"命中 5 个字段里任意一个就排除"的口径已废弃——那正是「物流」会连带命中订单标签里的 `商家指定物流`、`物流探查` 和买家留言里"物流单号"的根因。
**选择器交互**
- **按字段分组**:发货仓 / 物流公司 / 订单标签 / 买家留言 / 卖家备注,每组标题显示"共 N 项 · 已勾选 M 项",右侧索引条按字段跳转(字段没有候选词时不显示该组);
- **默认全选**:候选词 = 该字段的默认排除词 ∪ 当前已排除词(旧链接里带的自定义词不会丢),进入看板时全部处于"排除"状态;取消勾选 = 本次不排除该项,确定后立即以当前表单条件重新查询(`requestSubmit()` 在受控值落到 DOM 后再触发);一个都不勾时以 `excludeKeywordsMode=clear` 提交,避免回落到默认排除词;
- 选择器内含:搜索、`全部 / 已选项(n)` 页签、`全选 / 反选 / 清空`(作用于当前可见列表,全选带半选态)、`紧凑模式`(四列更密);
- **新增候选词仅系统管理员可操作**:底部先选字段、再输入关键词(回车或点「添加」),新词并入**该字段**的默认排除词并立即落库(`PUT …/settings`,服务端 `assertCanManage` 同样校验管理员身份),提示"已保存到「物流公司」默认排除关键词:…";非管理员只看到"需要新增请联系系统管理员";
- 页面原先独立的「默认排除关键词」设置卡片已删除,维护入口统一收敛到选择器;`清空排除`(不排除任何词)与 `恢复默认`(回到全部默认排除词)两个按钮保留,只改输入、点查询生效。
**编码与兼容**
- 筛选栏摘要按字段给数量(如"已排除 14 项:发货仓 3 · 物流公司 5 · 订单标签 2 · 买家留言 2 · 卖家备注 2"),URL 用 `excludeKeywords=字段:词,词;字段:词` 的紧凑写法;
- **老链接的平铺写法按当前配置归位**:`excludeKeywords=词1,词2` 会被逐个对应到配置里它所属的字段(大小写不敏感,`bbff` 会归一到配置里的 `BBFF`),配置里已经没有的词说明它没有字段、直接忽略——**不会再把一个词摊到 5 个字段上**(那会让选择器每组都列出全部关键词)。只有当配置为空、任何词都对应不上时才退回旧口径,避免老链接突然变成"不排除任何词"。
- 历史配置(数组)在 `normalizeExcludeKeywords()` 里仍按旧口径挂到全部 5 个字段(那是当年真实的匹配语义);但配置一旦以新结构保存,后续解析都会走"按字段归位"。
**当前初设配置**(2026-09-30 落库):发货仓=`自动发货仓 / 常州简尚家居有限公司 / 京东云仓`;物流公司=`京喜 / 物流 / 安能 / 自提 / 中铁`;订单标签=`配件 / 1688一件代发`;买家留言=`BBFF / SSDD`;卖家备注=`BBFF / SSDD`。2026-09-01~09-02 实测:不排除 26,296 行 → 按字段排除后 17,506 行(旧口径同样 14 个词是 16,552 行,多出的 954 行正是"标签里带物流、物流公司里没有"的那些)。
经营对象问题排名里的每个看板都有独立翻页,不再用"显示更多"或无限滚动,也不会因为对象太多被截断:
- 01 签收日期维度:按日期列翻页(默认 10 列/页,可切 10/20/50/100);
- 02–08 采购/供应商/条码/物流公司/发货仓/平台/店铺:按对象行翻页(默认 20 行/页);
- 发货仓 / 物流公司 超时排名:按对象行翻页(默认 20 行/页)。
- 09 订单明细:翻页控件就在看板内部(不再挂在页面底部);未下钻时按 URL 的 `page/pageSize` 走服务端分页(每页 25/50/100/200,命中快照时显示真实总数"共 N 条筛选结果"),下钻时用本地分页翻全量匹配明细。
分页条统一由 `BoardPagination.tsx` 渲染(含"共 N 条 · 第 x / y 页"、上/下一页与每页条数),行集合变化(例如切换采购后重新筛选供应商)时自动回到第 1 页。
**每页条数切换的坑(已修)**:下拉框原先在 `onChange` 里同时调了 `onPageSizeChange(新值)` 和 `onPageChange(1)`。服务端分页(09 订单明细未下钻时)这两次都会触发 `router.push`,而后一次带着**旧的** `pageSize` 构造 URL 并覆盖前一次,表现为"订单明细每页条数选了没反应、又跳回 50"。现在 `BoardPagination` 只通知"每页条数变了",回到第 1 页由调用方负责:服务端分页用 `href(1, { ...filters, pageSize })`,本地分页(各经营对象表、发货仓/物流公司排名、下钻后的 09 明细)在 `setState` 里带 `page: 1`。
店铺维度表在"店铺"列前额外展示"平台"列:点击平台即把该表筛成该平台的店铺(标题变成"店铺维度 · 天猫",再次点击取消),点击店铺名仍走 09 明细下钻。店铺分组的业务键是"平台 + 店铺",避免同名店铺跨平台互相覆盖;平台列在样本路径(JS 归组)与整区间路径(SQL `GROUPING SETS ((platform, shop))`)都做了同样的处理,并逐字段比对过一致性。
### 规模上限与样本回落
SQL 聚合成本与区间行数近似线性(约 5 万行/秒)。当**区间原始行数超过 `LOGISTICS_ARRIVAL_SIGNING_DURATION_EXACT_SCOPE_MAX_ROWS`(11 万条,约 11 天)**时,`getDashboard` 会退回区间内均匀抽取的 2,000 条样本(`listDashboardSampleRows`,5 层并行、按签收时间分层,不再是"取最近一段"),并在底部注释写明原因与"缩小签收日期范围即可得到精确值"。
返回值上的 `dataScope`(`exact` / `sample`)与 `dataScopeReason`(`live-source` / `range-too-large`)驱动卡片副标题、底部注释与 CSV 导出的口径说明。
实测(663k 行快照,含 13 个默认排除关键词):
| 区间 | 原始行数 | 口径 | 首屏耗时 |
| --- | --- | --- | --- |
| 1 天 | 约 1.3 万 | 整区间精确 | 0.55s |
| 2 天 | 约 2.6 万 | 整区间精确 | 1.7s |
| 5 天 | 约 6.5 万 | 整区间精确 | 3.1s |
| 本月 | 约 30 万 | 样本(超上限) | 3.8–5.0s |
服务端 60s 内重复访问直接命中缓存。要把大区间也做成亚秒精确,方向是在快照写入时就把 4 个时间戳、5 个环节时长落成列,并把多维指标的计数改为位掩码 `GROUP BY`(一次扫描产出全部桶计数),现有文档结论不变、只替换实现。
### 查询性能优化(2026-10)
上线后"每次查询生成太慢"的根因有三个,都在快照读路径上:
1. **整表顺序扫描**:聚合查询原先 `SELECT` 全列(`rowKey`、`productItems`、十几个文本列),
Postgres 只能对 900MB 的堆表做 `Seq Scan`(28 天区间实测 `read=106409` 页、6.3 秒)。
现在按用途收窄列集合——`FILTERED_TIMELINE_SELECT`(只取 4 个时间戳)用于整区间总览/环节目标,
`FILTERED_DIMENSION_SELECT`(时间戳 + 4 个维度列)用于签收日期/平台/店铺/发货仓/物流/超时排名——
并新增覆盖索引 `LogisticsArrivalSigningDurationSnapshot_cover_idx`
(`(snapshotDate, signTime) INCLUDE (payTime, sendTime, fetchTime, warehouseName, platformName, shopName, logisticCompanyName)`),
扫描变成 **Index Only Scan**(同样的 28 天区间 26ms、`Heap Fetches: 0`)。
2. **有序集聚合把整行拖进排序**:`percentile_cont(0.5/0.9) WITHIN GROUP` 原先和十几个计数写在同一条语句里,
输入行 14 列宽,PG 18 会把它整行送进排序(两个分位数单独就占 4.3 秒,环节分位数叠在一起甚至到分钟级)。
现在分位数**按列拆成独立语句**、只投影一个浮点列,并与计数语句并行下发(`Promise.all`)。
3. **CTE 表达式被重复内联**:`measured`/`durations` 的 `CASE + EXTRACT` 会在每一处引用被重新计算,
同一条聚合里重复十几次。改为 `AS MATERIALIZED` 后只算一次(计数语句从 2.3s 降到 ~0.5s)。
另外把 **环节目标(上月平均 × 98%)** 也接入 10 分钟内存缓存(此前每次刷新都重跑上月聚合)。
优化前后(本机 Postgres 18、快照 648,689 行、冷启动、无缓存):
| 场景 | 优化前 | 优化后 |
| --- | --- | --- |
| 仓库层 `getDashboard`(28 天,样本口径) | 7.3s | **2.3s** |
| 仓库层 `getDashboard`(10 天) | 4.4s | **0.4s** |
| 仓库层 `getDashboard`(2 天,精确口径) | 1.7s | **0.3s** |
| 浏览器首屏出数(冷启动,含 5.6MB 载荷 + 渲染) | ~7–10s | **2.7s** |
正确性用 2 天区间做过逐项对账:SQL 聚合与"取全量明细在 JS 里现算"在订单数、平均/中位/P90、
异常计数、达成率、时效分布、5 个环节的 可计算单量/平均/中位/P90 上完全一致。
时间戳在 SQL 里用 `NULLIF(BTRIM(col),'')::timestamp` 直接转型:快照写入的就是远端 `CONVERT(varchar(19), …, 120)` 结果,格式固定;早期版本用正则逐行校验,整区间聚合约慢一个数量级。
## 运维排查
```sql
-- 批次状态
SELECT "snapshotDate", status, "windowFrom", "windowTo", "rowCount", message, "finishedAt"
FROM "LogisticsArrivalSigningDurationSnapshotRun" ORDER BY "snapshotDate" DESC;
-- 当天行数
SELECT count(*) FROM "LogisticsArrivalSigningDurationSnapshot" WHERE "snapshotDate" = CURRENT_DATE;
```
- 页面长时间显示"数据更新"而不是"快照数据" → 先看批次是不是 `FAILED`,或窗口是否覆盖了页面的签收日期区间。
- 批次 `RUNNING` 卡住(进程被杀)→ 该批次不会发布;直接重跑或加 `--force` 即可(重跑会先清空当天行)。
- 需要重建历史窗口 → `--window-days=` 调大 + `--force`。
## 部署步骤(生产)
1. 部署新版本代码;
2. 执行迁移 `prisma/migrations/20260928120000_add_logistics_arrival_signing_duration_snapshot`(需按生产迁移流程授权、先备份);
3. 跑一次手工同步生成第一批快照(`--apply`),确认批次为 `COMPLETED`、行数与窗口符合预期;
4. 安装 timer 并核对 `systemctl list-timers`;
5. 打开看板确认头部显示"快照数据 · 日期"。
注意:Prisma Client 重新生成过,运行中的 dev/prod 进程需要重启才能拿到新模型。

View File

@ -1,6 +1,6 @@
# White Obsidian Atelier(白曜石工坊)统一视觉标准
适用范围:资产中心、辅助装配、草稿组、产品资料/正式入库、仓库管理/轻仓库、仓库质检、AI 出图工作台,以及 `/admin`、`/users` 等新平台后台管理页面。
适用范围:资产中心、辅助装配、草稿组、正式入库、轻仓库等新平台主链页面。
目标:统一“白曜石工坊 / White Obsidian Atelier”视觉方向。默认基调是浅色工作台,不再做深浅色主题切换;同时避免把资产中心做成普通 SaaS 卡片列表,也避免把毛玻璃效果滥用成低可读性的装饰。
@ -12,7 +12,7 @@ White Obsidian Atelier 的关键词是:浅色精密、克制通透、资产工
- `White Obsidian(白曜石)`:整体基调偏浅、安静、低干扰,用冷白、浅灰、石墨灰、金属中性色建立专业感。
- `Atelier(工坊)`:页面不是营销展示页,而是给人长期处理资产、装配、审核、入库的工作空间。
- `Unified(统一)`:资产中心、辅助装配、草稿组、产品资料/正式入库、仓库管理、仓库质检和 AI 工作台要像同一套产品,不允许每个页面各自发明一套视觉语言。
- `Unified(统一)`:资产中心、辅助装配、草稿组、正式入库、轻仓库要像同一套产品,不允许每个页面各自发明一套视觉语言。
- `3D Workbench(3D 工作台)`:新平台的第一感知应是“可操作的资产工作台”,而不是“管理后台卡片墙”。
视觉上应保持:
@ -58,14 +58,6 @@ White Obsidian Atelier 的关键词是:浅色精密、克制通透、资产工
在新平台里,板材、五金、组件、包装等不应被当作普通卡片处理,而应被当作 `Meta Component(元组件)`。
落地时优先使用项目现有组件库和平台组件:
- `src/components/platform/`
- 资产中心现有工作台组件
- 已有元组件、Inspector、工作台、HUD 和平台导航结构
Stitch 设计稿是视觉规格和验收标准,但不是绕开组件库重写静态页面的理由。实现时应在项目组件体系内对齐 Stitch 的密度、层级、控件形态、留白、字号、圆角、阴影和低噪音表达。
元组件的含义是:它是可以被系统理解、检视、规格化、装配、审核、材质配置和后续入库承接的资产元件。
元组件至少应具备这些表达能力:
@ -164,9 +156,7 @@ className="bg-white divide-y divide-zinc-100"
- 毛玻璃是否只出现在顶栏、HUD、浮动工具条和轻量状态浮层。
- 板材、五金、组件、包装是否按元组件表达,而不是普通展示卡片。
- 3D 工作台、Inspector、数据列表之间的主次关系是否明确。
- 当前页面是否仍符合项目主链:资产中心 -> 辅助装配 -> 草稿组 -> 产品资料/正式入库 -> 仓库管理/轻仓库。
- 是否优先复用平台组件和元组件,而不是另起一套页面皮肤。
- 如果有 Stitch 设计稿,是否按设计稿控制密度、控件状态和低噪音表达。
- 当前页面是否仍符合项目主链:资产中心 -> 辅助装配 -> 草稿组 -> 正式入库 -> 轻仓库。
结论口径:

View File

@ -0,0 +1,207 @@
# 苹果风格视觉设计标准 (Apple-Style Visual Design Standards)
> **版本**: 2.0 (2026-01-29)
> **状态**: 补充参考标准;当前新平台第一视觉标准以 `白曜石工坊统一视觉标准.md` 为准
> **优先级**: 如本文件与白曜石工坊标准冲突,优先执行白曜石工坊标准
> **目标**: 打造优雅、统一、沉浸式的用户体验,完全去除"模板化"和"AI味",接近苹果官网的视觉水准。
---
## 目录
1. [核心设计哲学](#1-核心设计哲学)
2. [详细视觉规范](#2-详细视觉规范)
* [色彩系统](#21-色彩系统)
* [排版系统](#22-排版系统)
* [布局与间距](#23-布局与间距)
* [材质与光影](#24-材质与光影)
3. [交互与动画](#3-交互与动画)
4. [去AI味专项指南](#4-去ai味专项指南)
5. [技术架构与文件结构](#5-技术架构与文件结构)
6. [组件开发规范](#6-组件开发规范)
---
## 1. 核心设计哲学
### 1.1 内容至上 (Content is King)
* **原则**:界面不仅是容器,更是内容的舞台。UI 应该"退后",让 3D 模型、产品图片和关键数据"向前"。
* **执行**:
* 背景色主要使用白色或极淡的灰色;当前产品不再提供深浅色主题切换。
* **拒绝**无意义的装饰性边框、分割线和阴影。只有在区分层级必要时才使用。
* **拒绝**均等分割的网格布局("AI味"典型特征)。使用非对称布局(如:左侧 2/3 展示 3D 模型,右侧 1/3 展示控制面板)。
### 1.2 物理感与材质 (Materiality & Physics)
* **原则**:数字界面应模拟真实世界的物理属性。
* **执行**:
* **Liquid Glass (毛玻璃)**:仅在覆盖内容时使用(如导航栏、侧边栏、浮动面板)。标准:`backdrop-filter: blur(20px) saturate(180%)`。
* **Spring Animation (弹簧动画)**:交互元素(按钮按压、弹窗出现)必须使用弹簧物理模拟,而不是简单的线性过渡。
* **光影**:光源应统一来自正上方。阴影应柔和、弥散,模拟环境光遮蔽 (Ambient Occlusion)。
### 1.3 优雅的排版 (Elegant Typography)
* **原则**:排版是信息层级的核心。
* **执行**:
* **动态字间距 (Dynamic Tracking)**:大标题收紧 (-0.4px),正文标准,小字加宽 (+0.06px)。
* **层次分明**:使用字重 (Weight) 和颜色 (Color) 区分层级,而不是单纯靠字号。
---
## 2. 详细视觉规范
### 2.1 色彩系统
**设计令牌位置**: `src/lib/design-tokens/colors.ts`
不要使用纯灰 (`#808080`),总是混入微量的蓝色或暖色调。
| 用途 | Light Mode | Dark Mode | 说明 |
| :--- | :--- | :--- | :--- |
| **背景** | `#FFFFFF` | `#000000` | 纯净背景,最大化对比度 |
| **次级背景** | `#F5F5F7` | `#1C1C1E` | 用于分组区块,**比系统灰(#F2F2F7)更淡、更优雅** |
| **主文本** | `#1D1D1F` | `#F5F5F7` | 接近纯黑但不是纯黑 (Off-black) |
| **次文本** | `#86868b` | `#86868b` | 经典的苹果灰,用于说明文字 |
| **强调色** | `#008D74` | `#00B896` | 品牌绿,仅用于链接和主按钮 |
| **分割线** | `rgba(0,0,0,0.1)` | `rgba(255,255,255,0.1)` | 极细,极淡 |
### 2.2 排版系统
**设计令牌位置**: `src/lib/design-tokens/typography.ts`
我们使用**合法的开源/系统字体栈**,严格避免版权风险:
* **西文**: `-apple-system`, `BlinkMacSystemFont`, `Inter`, `Segoe UI`
* **中文**: `-apple-system`, `Noto Sans CJK SC`, `Source Han Sans SC` (思源黑体), `Microsoft YaHei`
* **代码**: `JetBrains Mono`, `Fira Code`
| 样式 | 字号 (px) | 行高 (px) | 字间距 (px) | 用途 |
| :--- | :--- | :--- | :--- | :--- |
| **Large Title** | 34 | 41 | -0.4 | 页面主标题 |
| **Title 1** | 28 | 34 | -0.36 | 章节标题 |
| **Title 2** | 22 | 28 | -0.26 | 子章节标题 |
| **Headline** | 17 | 22 | -0.41 | 强调文本 (Semibold) |
| **Body** | 17 | 25 | -0.41 | 正文内容 |
| **Caption 1** | 12 | 16 | 0 | 说明文字 |
### 2.3 布局与间距
**设计令牌位置**: `src/lib/design-tokens/spacing.ts`
* **基础网格**: 4px
* **大留白原则**:
* 模块间距至少 `48px` 或 `64px`。
* 卡片内部 Padding 至少 `24px`。
* **流体布局**:
* 容器宽度随视口变化,最大宽度限制 (Max-width: 1440px)。
* **全宽突破**: Hero Banner 等关键元素应占据全宽。
### 2.4 材质与光影
* **Liquid Glass**:
* Light: `rgba(255, 255, 255, 0.72)` + Blur 20px
* Dark: `rgba(22, 22, 23, 0.72)` + Blur 20px
* **阴影 (Shadows)**:
* Subtle: `0 2px 8px rgba(0, 0, 0, 0.04)` (静态元素)
* Standard: `0 4px 12px rgba(0, 0, 0, 0.08)` (悬浮/交互)
* Elevated: `0 8px 24px rgba(0, 0, 0, 0.12)` (模态框)
---
## 3. 交互与动画
**设计令牌位置**: `src/lib/design-tokens/animations.ts`
所有交互必须有物理反馈。
* **按钮按压**: Scale 0.98, Duration 100ms
* **微交互 (Hover)**: Scale 1.02, Duration 200ms
* **页面过渡**: Duration 500ms, Curve `cubic-bezier(0.16, 1, 0.3, 1)`
* **Spring 参数**: 使用 `spring(mass: 1, stiffness: 100, damping: 15)` 模拟真实回弹。
---
## 4. 去AI味专项指南
"AI味" = 死板、重复、缺乏人情味、过度依赖默认组件。
### 4.1 拒绝"Lorem Ipsum"
* ❌ "Product Name 1", "This is a description."
* ✅ "Eames Lounge Chair", "由模压胶合板和皮革制成,经典的设计符号。"
* **要求**:所有占位文本必须是**真实场景数据**。
### 4.2 拒绝"均等分布"
* ❌ 4个功能点,每个占 25% 宽度,图标+标题+描述,完全一样。
* ✅ 1个核心功能占 60% 宽度配大图,其余 3 个功能以列表形式在侧边展示。打破对称。
### 4.3 拒绝"默认图标"
* ❌ 使用实心块状图标。
* ✅ 优先使用文字。如果必须用图标,使用细线条风格 (Stroke width 1.5px-2px)。
### 4.4 拒绝"彩虹渐变"
* ❌ 高饱和度彩虹渐变背景。
* ✅ 纯色、微渐变或高质量实拍图/3D渲染图。
---
## 5. 技术架构与文件结构
本项目基于 **Next.js 16 + Tailwind CSS + React 19** 构建。
### 5.1 目录结构
```
src/
├── lib/
│ └── design-tokens/ # 核心视觉配置
│ ├── typography.ts # 字体定义
│ ├── colors.ts # 颜色定义
│ ├── spacing.ts # 间距定义
│ └── animations.ts # 动画定义
├── components/
│ └── ui/ # 基础原子组件
│ ├── Button.tsx # 遵循规范的按钮
│ ├── Card.tsx # 遵循规范的卡片
│ ├── Typography.tsx # 文本组件封装
│ └── ...
└── app/
└── globals.css # CSS 变量映射 (自动从 tokens 生成)
```
### 5.2 迁移指南
如果你在代码中看到旧的 CSS 类(如 `text-gray-500`),请替换为语义化的设计令牌类:
* ❌ `text-gray-500` -> ✅ `text-(--color-label-secondary)` (或封装好的 Tailwind 类)
* ❌ `bg-white` -> ✅ `bg-(--color-background-primary)`
* ❌ `shadow-lg` -> ✅ `shadow-apple-lg`
---
## 6. 组件开发规范
### 6.1 Button
* **形状**: 高度 36px/44px,Pill shape (999px) 或 Rounded (8px)。
* **质感**: Primary (纯色无边框), Secondary (浅灰背景)。禁止复杂渐变。
### 6.2 Card
* **圆角**: 统一 `20px` 或 `24px`。
* **边框**: Light Mode 下通常无边框或极淡 `1px` 边框。
* **内容**: 内部 Padding 至少 `24px`。
### 6.3 Input
* **样式**: 移除默认边框,使用浅灰背景 (`#F5F5F7`),聚焦时出现 Focus Ring。
* **高度**: 至少 `44px`。
### 6.4 Slider
* **组件来源**: 统一使用 `src/components/ui/Slider.tsx`,禁止直接使用原生 `input[type="range"]`。
* **轨道样式**: 高度 `6px`,圆角 `999px`,未填充色 `#D4DCE6`,填充色使用品牌色 `#008D74`(深色场景可切换 `#00B896`)。
* **滑块样式**: 尺寸 `16px`,白色描边 `2px`,投影 `0 1px 3px rgba(0, 0, 0, 0.25)`。
* **状态规范**: 禁用态降低不透明度并禁用交互光效;拖动态允许轻微放大(建议 `scale(1.08)`)。
* **可访问性**: 必须提供 `aria-label`,并保持键盘可调节。
### 6.5 响应式
* 使用 `useBreakpoint` 或 Tailwind 的响应式前缀 (`md:`, `lg:`)。
* 支持 iPhone 刘海屏安全区域 (`safe-area-inset`).
---
*遵循此标准,我们将构建出一个不仅好用,而且令人愉悦的家具展示系统。*

View File

@ -1,137 +0,0 @@
# 限价与破价判定规则(业务确认版)
> 适用范围:限价管理 · 破价订单审核(`/supply-chain/management/custom-table` → 破价订单审核)
> 本文件为业务口径的唯一来源;规则变更请改本文件并注明生效时间。
## 一、限价与判定口径
1. **判定基准**:以「商家实收」为准;`商家实收 < 限价 × 数量` 即判定破价。
2. **实付款与平台承担**:实付款 + 平台承担(红包 / 消费券 / 平台券)= 商家实收;凭证必须同时包含「实付款」和「数量」。
3. **出资方字典**
- 商家承担:店铺优惠(官方立减、单品直降、店铺券)、政府补贴
- 平台承担:红包、消费券、平台券、平台优惠、新品礼金
- 买家承担:淘金币
- 待定:首单礼金(等与运营确认后补录)
4. **限价维度**:按**货号**限价——同一货号下所有规格/条码共用同一套限价。
5. **限价时间版本**:按订单付款时点生效的限价判定(限价调整前后分别取当时版本),不做"用最新限价回算历史单"。
6. **成本变动规则(限价维护侧)**:成本价上涨 5% 以内不动限价;成本价下调不变限价。
7. **非负责人限价**:2026-10-07 00:00 前的 ID 价格不判定破价(此后按规则判定)。
8. **老链接分界**:上架时间早于 2026-10-07 00:00 的链接(老链接)破价申诉通过;之后的按限价流程结束日期判定(上架时间需早于流程结束日期),并核对店铺款式编码 / 商品编码。
9. **负责人触发规则**:非运营发起的流程,前两个月全公司运营同价(不归属个人);两个月后按销量触发负责人(销量高者为负责人)。
10. **数据快照**:破价单在生成时保存金额快照;退款/售后状态单独记录,退款不等于免责(仍需判断是否已调价)。
11. **离职人员**:订单负责人已离职 → 自动标记「人员已离职」,不判定、不计乐捐。
12. **是否有检测报告**:取素材平台「检测报告」接口(`/api/external/certificate-reports`,按 **SPU** 精确匹配,只认有报告文件的记录),进程内缓存 10 分钟;
接口未配置或读取失败时该列显示为空、筛选候选值为空。配置项:`MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL/API_KEY`,
不填则复用素材平台 `MATERIAL_LIBRARY_WHITE_BACKGROUND_API_*`。
13. **手工换货单**:聚水潭订单明细出现「被替换行(`item_status=Replaced`、数量归 0)」+「换入行(`item_ext_data` 含 `item_source=手工换货/手工添加`)」时按换货单处理:
- 判定按**换出行原编码**的限价执行(客户在平台上购买的那个编码);
- 成交数量与商家实收取**换入行**(换货不改变订单成交金额);换入行金额抓不到时回退订单主表商家实收 → 订单主表买家实付,都取不到才按 0(由运营申诉说明);
- 换货行配对成功后按原编码生成一条,不再按换入编码单独生成,避免同一订单重复生成两条破价单;
- 配对优先按 `sku_uuid` 精确匹配,剩余行仅在一对一时顺序配对;复杂结构(多对多)暂不强行配对,相关行按各自编码现状处理。
## 二、申诉与审核
1. **申诉类型**(4 组)
- 链接时间:老链接,上架时间早于限价流程(凭证须含链接 ID + 创建时间)/ 京喜换主体 · 老店转新店
- 优惠 / 活动:平台优惠 · 红包 · 券(非商家承担)/ 参与秒杀 · 百补 · 大促等平台活动(凭证须含活动开始与截止时间,且活动价未破价)/ 拼多多多件折扣(99折)
- 流程申请:已走新品限价及限价调整(必填流程号,系统到「新品限价及限价调整」流程校验,查不到不允许提交)
- 其他原因(须在备注中详细说明)
2. **申诉范围**:链接时间 / 流程申请类支持**链接级一次性申诉**(默认勾选,覆盖该链接下全部破价订单,后续新单自动继承);优惠 / 活动类按订单申诉。
3. **审核要点仅审核人可见**;申诉人只看到凭证要求与示例图。
4. **凭证校验**:必填校验 + 识别提示(识别结果仅提示,人工一键打回);后续增加图片查重(hash / 相似度)与关键字段(订单号、链接 ID)一致性校验。
5. **乐捐**:驳回计 ¥10/单(离职不计);配套审计链(数据快照 + 申诉材料 + 审核记录),扣款执行交财务确认,保留异议通道。
6. **举证期限**:待与运营确认后设置(避免事后无法追溯凭证)。
## 三、权限(由管理员统一管理)
路径:用户管理后台 → 权限配置 → 经营管理。限价管理相关的查看 / 导出 / 申诉都由管理员在这里开通。
| 能力 | 权限节点 | 动作 |
| --- | --- | --- |
| 页面与数据查看(限价明细、破价订单审核) | 限价管理 | 可见 |
| 导出(导出明细 / 导出明细(含图片)/ 破价订单导出 / 乐捐明细) | 限价管理 | 下载 |
| 申诉 · 审核 · 生成 · 导入 | 限价管理 | 操作 |
| 规则配置(页面可见 + 保存参数) | 限价管理 · 规则配置 | 可见 / 操作 |
- 没有「下载」权限时页面不展示导出入口,服务端导出接口同样鉴权(下载中心任务按「限价管理 · 下载」校验)。
- 没有「操作」权限时:不显示申诉/审核按钮,服务端也会拒绝申诉、审核、生成、导入。
- 申诉保留一条兜底:破价单的负责人本人,即使没有「操作」权限,也可以对本人负责的订单发起申诉(审核仍需「操作」)。
- 没有「规则配置」权限时,规则配置页签不显示、接口不可调用。
## 四、生成与运行
1. **按天增量**:限价逻辑上线后,破价订单**按天增量生成**(每天只生成最新一天的破价单并入库),**历史数据不再重复回算**;破价订单审核页每天展示最新破价单。
2. **数据来源**:聚水潭报表库(`jst_Orders` + `jst_OrderItem` / 天猫奇门 `jst_qmcust_orderlist` + `_item`),取 `seller_income_amount`、`buyer_paid_amount`、`qty`、`shop_i_id`、`shop_sku_id`、`pay_date`、退款状态,以及换货识别字段 `item_status`、`item_ext_data`(`item_source`、`sku_uuid`)。报表库查询较慢,逐日增量任务建议按天分片拉取。
3. **限价来源**:钉钉流程(仅取**已完结**),共 4 个模板,同步脚本 `scripts/sync-limit-workflows.mjs`:
- 「新品限价及限价调整」→ 货号取「货号(非商品编码)」,限价取「期望日常限价」;
其中的「调整限价明细表」子表(申请类型=调整原货号限价)只填「调整后限价」,该列同样作为限价取值
(2026-10-16 修复,此前这类调价会被漏掉);
- 「新品限价流程」→ 明细取系统产品限价页面(按流程链接 SPU 关联)。页面数据来自正式库
`external.product_catalog_wide_v1`(限价字段:`expected_daily_limit_price` / `non_spu_owner_limit_price`),
本地库该视图为空时退回本地 `IntakeNonIntegratedProduct(LimitRow)`;
- 「各中心特殊限价需求申请」→ 货号取「货号」,限价取「最低售价」;
- 「GB特殊限价需求申请」→ 货号取「货号」,限价取「期望日常限价」(2026-10-16 纳入)。
- 「产品限价页面」已确认(CONFIRMED)的限价全量纳入(2026-10-16):来源正式库
`external.product_catalog_wide_v1`,给没有限价流程的货号兜底(如 CY012801=268.6);
同货号与流程限价冲突时**以流程为准**,页面价只在流程没覆盖时生效。
- 「限价期初数据」(业务整理的存量限价 Excel,2026-10-16 纳入):放在期初数据目录
(`LIMIT_SYNC_INITIAL_DIR`,默认 `<app>/期初数据`;目录为空时用仓库自带
`scripts/data/limit-sync/期初数据/限价期初数据*.xlsx`),取目录里最新一份,
按「货号 + 负责人限价 + 更新日期」写入,作为**最低优先级**兜底(如 MKHDNZ0409260600468=279)。
优先级:**限价流程 > 产品限价页面(已确认)> 限价期初数据**;低优先级只在货号没有更高优先级限价时生效。
**例外(2026-10-16 确认)**:期初数据的「更新日期」**晚于**流程完成时间时,以期初数据为准(谁新用谁);
期初比流程旧则维持上面的优先级。
**非负责人限价一律按最新口径计算**(2026-10-16 确认):负责人限价 × 1.05,且不低于 +10 元;
期初数据表 / 产品限价页面里的历史「非负责人限价」(倍数 1%~15% 不统一)不采用,
限价明细页、导出文件、破价判定三处都用同一公式。
写入规则:同货号多流程取**审核通过时间最新**的一条;限价为 0 或空的明细剔除;「申请与SPU负责人同价 / 活动限价」不覆盖负责人限价,只作为价格授权;流程「货号」列若填的是商品编码,用商品编码匹配产品档案换算为货号;无货号时同样用商品编码匹配。页面限价的时间按**北京时间墙钟**存(与流程完成时间一致)。
4. **覆盖率**:暂不做未匹配清单与告警(后续优化)。
5. **正式站同步(服务器端)**:`scripts/sync-limit-workflows.mjs` 已改成服务器可运行,连接信息全部取
服务器 `shared/.env` 里的既有变量(`DATABASE_URL` / `JST_REPORT_DB_*` / `PROD_ARCHIVE_DB_*`),
不再依赖 Mac 本地路径:
```bash
# 手动跑一次(正式站目录下,root 或能读 shared/.env 的账号)
cd /opt/furniture-3d-system-prod/current/furniture-3d-system
sudo -E bash -lc 'set -a; . /opt/furniture-3d-system-prod/shared/.env; set +a; \
LIMIT_SYNC_OUT_DIR=/opt/furniture-3d-system-prod/shared/limit-workflow-sync/导出结果 \
LIMIT_SYNC_INITIAL_DIR=/opt/furniture-3d-system-prod/shared/limit-workflow-sync/期初数据 \
node scripts/run-limit-workflow-sync-production.cjs'
# 安装每日定时器(systemd,与营销日销同一套样板):每天北京时间 07:30
sudo bash scripts/install-limit-workflow-sync-timer.sh
systemctl list-timers --all 'furniture-limit-workflow-sync*' --no-pager
journalctl -u furniture-limit-workflow-sync.service -n 200 --no-pager
```
- 写库是**全量重建 + 单事务**:成功才提交;失败自动回滚,正式库保留上一版限价与授权。
- 输出目录 `LIMIT_SYNC_OUT_DIR`(默认 `<app>/导出结果`)里的 `限价流程_完整明细表.xlsx` 每日覆盖,
旧版本进 `历史版本/`,默认保留最近 20 份(`LIMIT_SYNC_HISTORY_KEEP`)。
- `--skip-excel` / `LIMIT_SYNC_SKIP_EXCEL=1` 可只写库不导 Excel。
- 期初数据更新:把新的 `限价期初数据*.xlsx` 放进 `shared/limit-workflow-sync/期初数据/` 即可,下次同步自动取最新。
- `SupplyChainBreachRuleConfig` 不需要灌数:接口读取时用代码内置默认值,首次在「规则配置」保存时落库。
### 每日聚水潭物流规则快照
- 正式站采用服务器端 systemd:`npm run install:jst-logistics-systemd`,默认每天 `09:30`(Asia/Shanghai)执行,避开 09:00 的数据库任务,并与 10:30 停产 RPA 任务错开。可通过 `JST_LOGISTICS_CALENDAR` 覆盖。
- 开发机示例使用 macOS launchd:`npm run install:jst-logistics-daily`,默认每天 `09:00`。
- 正式站快照目录固定为 `$BASE/shared/freight-config`,应用和定时任务共同读取 `FREIGHT_CONFIG_DATA_DIR`,发布切换 release 不会重置。
- 本地未配置 `FREIGHT_CONFIG_DATA_DIR` 时继续使用 `data/freight-config`。
- 每天最多成功更新一次;若快照的 `generatedAt` 已是北京时间当天,重跑会自动跳过。
- 当天确需人工覆盖时执行:在聚水潭 RPA 浏览器保持登录后运行 `npm run sync:jst-logistics-rules -- --force`。
- 同步失败时保留上一份可用快照,不写入半成品。
- 物流规则同步、停产下架同步和其他聚水潭 RPA 任务不得并发;正式站 unit 已与停产和条码同步任务建立冲突保护。
- 商品标签按 `仓库-xxx`、`简尚-xxx`、`三方-xxx` 三种前缀解析发货仓,三者使用同一套运费计算。
- 条码命中 `fys_barcodeFreightRate_view` 的有效记录时,优先使用表中绑定的快递和固定单价;固定报价不依赖重量、体积、省份发货占比或仓库通用快递名称过滤,也不参与新快递替换。
## 五、待办
- [ ] 限价流程数据接入 + 筛选逻辑(生效时间版本、货号维度)
- [ ] 非负责人限价 10-07 规则、负责人触发规则(前两个月同价 / 两个月后按销量)落到判定引擎
- [ ] 按天增量任务(cron / worker)与失败告警
- [ ] 凭证查重(hash / 相似度)、关键字段一致性校验
- [ ] 举证期限、乐捐财务对接流程
- [ ] 首单礼金 / 新品礼金出资方确认后补录出资方字典

View File

@ -53,46 +53,25 @@ const connectSources = [
'https://api.dingtalk.com',
'https://oapi.dingtalk.com',
...collectStorageConnectSources(),
'ws://127.0.0.1:8000',
'ws://127.0.0.1:18000',
'ws://localhost:8000',
'ws://localhost:18000',
'wss://localhost.lodop.net:8443',
...(!isProduction ? ['ws:', 'http://localhost:*', 'http://127.0.0.1:*'] : []),
]
const lodopScriptSources = [
'http://127.0.0.1:8000',
'http://127.0.0.1:18000',
'http://localhost:8000',
'http://localhost:18000',
'https://localhost.lodop.net:8443',
]
const contentSecurityPolicy = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
`frame-src 'self' ${lodopScriptSources.join(' ')}`,
"form-action 'self'",
"img-src 'self' data: blob: http: https:",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
`script-src 'self' 'unsafe-inline' ${lodopScriptSources.join(' ')}${isProduction ? '' : " 'unsafe-eval'"}`,
`script-src 'self' 'unsafe-inline'${isProduction ? '' : " 'unsafe-eval'"}`,
`connect-src ${connectSources.join(' ')}`,
"worker-src 'self' blob:",
"media-src 'self' blob:",
...(isProduction ? ["upgrade-insecure-requests"] : []),
].join('; ')
// 市场选款看板静态页(public/modules/selection-market/)由宿主页 /market-research/selection-market
// 以同源 iframe 嵌入:在基础 CSP 上仅放开 frame-ancestors 为同源,其余指令不变。
const boardContentSecurityPolicy = contentSecurityPolicy.replace(
"frame-ancestors 'none'",
"frame-ancestors 'self'"
)
const baseSecurityHeaders = [
{
key: 'X-Frame-Options',
@ -202,43 +181,7 @@ for (const candidate of [
}
}
function buildClientStorageEnv() {
return {
STORAGE_PROVIDER:
trimToNull(process.env.STORAGE_PROVIDER) ||
trimToNull(process.env.OBJECT_STORAGE_PROVIDER) ||
'',
OBJECT_STORAGE_PROVIDER:
trimToNull(process.env.OBJECT_STORAGE_PROVIDER) ||
trimToNull(process.env.STORAGE_PROVIDER) ||
'',
STORAGE_FILE_PATH:
trimToNull(process.env.STORAGE_FILE_PATH) ||
trimToNull(process.env.OBJECT_STORAGE_FILE_PATH) ||
'',
OBJECT_STORAGE_FILE_PATH:
trimToNull(process.env.OBJECT_STORAGE_FILE_PATH) ||
trimToNull(process.env.STORAGE_FILE_PATH) ||
'',
S3_FILE_PATH: trimToNull(process.env.S3_FILE_PATH) || '',
QINIU_FILE_PATH: trimToNull(process.env.QINIU_FILE_PATH) || '',
S3_PUBLIC_DOMAIN: trimToNull(process.env.S3_PUBLIC_DOMAIN) || '',
S3_PUBLIC_ENDPOINT: trimToNull(process.env.S3_PUBLIC_ENDPOINT) || '',
S3_PUBLIC_URL: trimToNull(process.env.S3_PUBLIC_URL) || '',
S3_ENDPOINT: trimToNull(process.env.S3_ENDPOINT) || '',
S3_BUCKET: trimToNull(process.env.S3_BUCKET) || '',
QINIU_PUBLIC_DOMAIN: trimToNull(process.env.QINIU_PUBLIC_DOMAIN) || '',
QINIU_PUBLIC_ENDPOINT: trimToNull(process.env.QINIU_PUBLIC_ENDPOINT) || '',
QINIU_PUBLIC_URL: trimToNull(process.env.QINIU_PUBLIC_URL) || '',
QINIU_USE_HTTPS: trimToNull(process.env.QINIU_USE_HTTPS) || '',
}
}
const clientStorageEnv = buildClientStorageEnv()
const nextConfig = {
devIndicators: false,
distDir: process.env.NEXT_DIST_DIR || '.next',
// 启用standalone输出,用于Docker部署
output: 'standalone',
@ -248,7 +191,7 @@ const nextConfig = {
},
// 服务器外部包配置(Next.js 16新配置)
serverExternalPackages: ['sharp', 'three', 'gsap', '@huggingface/transformers', 'onnxruntime-node', 'mssql', 'mysql2'],
serverExternalPackages: ['sharp', 'three', 'gsap'],
// Turbopack配置(Next.js 16默认启用)
turbopack: {},
@ -352,7 +295,6 @@ const nextConfig = {
// 环境变量配置
env: {
CUSTOM_KEY: process.env.CUSTOM_KEY,
...clientStorageEnv,
},
// 重定向配置
@ -391,21 +333,6 @@ const nextConfig = {
source: '/(.*)',
headers: baseSecurityHeaders,
},
{
// 市场选款看板静态页需要被本平台宿主页同源 iframe 嵌入:
// 覆盖基础安全头的 X-Frame-Options DENY / frame-ancestors 'none',仅此路径生效。
source: '/modules/selection-market/:path*',
headers: [
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'Content-Security-Policy',
value: boardContentSecurityPolicy
}
],
},
{
// Next 静态资源在生产环境可长期缓存;开发环境必须禁用缓存,否则浏览器会继续复用旧 bundle。
source: '/_next/static/:path*',

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"worker:ai-image": "node scripts/run-ai-image-worker.mjs",
"predev": "npm run prisma:check-client-sync",
"dev": "next dev --webpack",
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "eslint",
@ -17,39 +16,6 @@
"prisma:check-client-sync": "node scripts/check-prisma-client-sync.js",
"prisma:push": "node scripts/prisma-db-push-local.js",
"prisma:studio": "prisma studio",
"sync:supply-chain-product-archive": "tsx scripts/sync-supply-chain-product-archive.ts",
"sync:supply-chain-product-archive-daily": "tsx scripts/sync-supply-chain-product-archive-sales-performance-daily.ts",
"generate:modular-processing-daily-snapshot": "tsx scripts/generate-modular-processing-daily-snapshot.ts",
"sync:supply-chain-product-archive-enrichment": "tsx scripts/sync-supply-chain-product-archive-enrichment.ts",
"sync:distribution-listings": "tsx scripts/sync-distribution-listings.ts",
"backfill:product-archive-designer": "tsx scripts/backfill-product-archive-designer-from-jst-sku-info-manage.ts",
"backfill:product-archive-designer:apply": "tsx scripts/backfill-product-archive-designer-from-jst-sku-info-manage.ts --apply",
"backfill:product-archive-opshead": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts --mode=regular",
"backfill:product-archive-opshead:apply": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts --mode=regular --apply",
"backfill:product-archive-opshead:regular": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts --mode=regular",
"backfill:product-archive-opshead:regular:apply": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts --mode=regular --apply",
"backfill:product-archive-production-type": "tsx scripts/backfill-product-archive-production-type.ts",
"backfill:product-archive-production-type:apply": "tsx scripts/backfill-product-archive-production-type.ts --apply",
"sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts",
"run:product-sabc": "tsx scripts/run-product-sabc.ts",
"compare:product-sabc-xlsx": "tsx scripts/compare-product-sabc-xlsx.ts",
"seed:local-product-sabc-xlsx": "tsx scripts/seed-local-product-sabc-xlsx.ts",
"sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts",
"snapshot:limit-price": "tsx scripts/generate-limit-price-snapshot.ts",
"snapshot:limit-price:daily": "node scripts/run-limit-price-daily-snapshot.mjs",
"install:limit-price-snapshot-systemd": "bash scripts/install-limit-price-snapshot-systemd-timer.sh",
"import:province-shipment-share": "node scripts/import-province-shipment-share.mjs",
"sync:jst-logistics-rules": "tsx scripts/sync-jst-logistics-rules.ts",
"snapshot:jst-logistics:daily": "tsx scripts/sync-jst-logistics-rules.ts",
"install:jst-logistics-daily": "bash scripts/install-jst-logistics-daily-sync.sh",
"install:jst-logistics-systemd": "bash scripts/install-jst-logistics-systemd-timer.sh",
"sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts",
"backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts",
"report:bom-cost-shadow-divergence": "tsx scripts/report-bom-cost-shadow-divergence.ts",
"report:bom-agent-baseline": "tsx scripts/report-bom-agent-baseline.ts",
"trial:bom-costing-agent": "tsx scripts/run-bom-costing-agent-trial.ts",
"import:yunshan-created-at": "tsx scripts/import-yunshan-product-created-at.ts",
"import:product-archive-drawing-media": "tsx scripts/import-product-archive-drawing-media.ts",
"db:verify-master-data": "node scripts/verify-master-data-foundation.js",
"db:verify-master-data:audit": "node scripts/verify-master-data-foundation.js --output .master-data-audits/foundation-hard-ready-gate-current.json --summary-output .master-data-audits/foundation-hard-ready-gate-current.summary.json --summary-stdout",
"db:verify-master-data:strict": "node scripts/verify-master-data-foundation.js --require-applied",
@ -57,8 +23,6 @@
"db:verify-master-data:production-readonly": "node scripts/verify-master-data-foundation.js --read-only-quality --summary-output .master-data-audits/foundation-production-readonly-current.summary.json --summary-stdout",
"db:cleanup-master-data": "node scripts/apply-master-data-cleanup-plan.js",
"db:cleanup-master-data:validate": "node scripts/apply-master-data-cleanup-plan.js --validate-plan",
"db:cleanup-operation-logs": "tsx scripts/cleanup-operation-logs.ts",
"db:cleanup-operation-logs:apply": "tsx scripts/cleanup-operation-logs.ts --apply --confirm=cleanup-operation-logs",
"db:export-product-category-review": "node scripts/export-non-integrated-product-category-review.js",
"db:build-product-category-cleanup-plan": "node scripts/build-non-integrated-product-category-cleanup-plan.js",
"db:match-jst-product-category-cleanup-plan": "node scripts/match-jst-product-category-cleanup-plan.js",
@ -70,8 +34,6 @@
"storage:migrate-historical-assets": "tsx scripts/migrate-historical-assets-to-qiniu.ts",
"storage:migrate-historical-assets:gate": "tsx scripts/migrate-historical-assets-to-qiniu.ts --fail-on-missing-sources",
"storage:migrate-historical-assets:apply": "tsx scripts/migrate-historical-assets-to-qiniu.ts --apply",
"storage:gc:audit": "tsx scripts/storage-gc.ts",
"storage:gc:apply": "tsx scripts/storage-gc.ts --apply",
"storage:inventory:filesystem": "tsx scripts/migrate-historical-assets-to-qiniu.ts --skip-database-scan --include-filesystem-inventory",
"storage:inventory:filesystem:apply": "tsx scripts/migrate-historical-assets-to-qiniu.ts --skip-database-scan --include-filesystem-inventory --apply",
"demo:create": "node scripts/create-demo-data.js",
@ -85,37 +47,13 @@
"materials:sync-standard": "tsx scripts/sync-standard-finishes.ts",
"materials:sync-standard:apply": "tsx scripts/sync-standard-finishes.ts --apply",
"sync:jst-sku-stock": "tsx scripts/sync-jst-sku-stock-products.ts",
"sync:product-inventory-snapshot": "tsx scripts/sync-product-inventory-snapshot.ts",
"check:modular-processing-generation": "tsx scripts/check-modular-processing-generation-health.ts",
"image-search:model:prepare": "tsx scripts/prepare-product-archive-image-search-model.ts",
"image-search:index": "tsx scripts/index-product-archive-images.ts",
"repair:barcode-print:historical": "tsx scripts/repair-barcode-print-source-rows.ts",
"sync:oa-board": "tsx scripts/sync-oa-board.ts",
"sync:oa-board:apply": "tsx scripts/sync-oa-board.ts --apply",
"sync:oa-brand-planning": "tsx scripts/sync-oa-brand-planning-board.ts",
"sync:oa-brand-planning:apply": "tsx scripts/sync-oa-brand-planning-board.ts --apply",
"sync:oa-video-flow": "tsx scripts/sync-oa-video-flow-board.ts",
"sync:oa-video-flow:apply": "tsx scripts/sync-oa-video-flow-board.ts --apply",
"sync:oa-art-flow-raw": "tsx scripts/sync-oa-art-flow-raw.ts",
"sync:oa-art-flow-raw:apply": "tsx scripts/sync-oa-art-flow-raw.ts --apply",
"import:clearance-pool": "tsx scripts/import-clearance-pool-snapshot.ts",
"import:clearance-pool-archive": "tsx scripts/import-clearance-pool-archive.ts",
"backfill:clearance-task-audit": "tsx scripts/backfill-clearance-task-audit-snapshots.ts",
"import:clearance-price-push-manual": "tsx scripts/import-clearance-price-push-manual.ts",
"reset:clearance-pool:archive": "tsx scripts/reset-clearance-pool-to-archive.ts",
"sync:clearance-pool:discontinued": "tsx scripts/sync-clearance-pool-snapshot.ts",
"sync:clearance-stock:stock-monitor": "tsx scripts/sync-clearance-stock-from-stock-monitor.ts",
"sync:clearance-progress": "tsx scripts/sync-clearance-progress.ts",
"import:clearance-review": "tsx scripts/import-clearance-review-snapshots.ts",
"cache:oa-board:local": "tsx scripts/cache-oa-board-local-preview.ts",
"import:oa-board-activity-mappings": "tsx scripts/import-oa-board-activity-mappings.ts",
"import:oa-board-node-rules": "tsx scripts/import-oa-board-node-rules.ts",
"report:oa-board-activities": "tsx scripts/report-oa-board-activities.ts",
"sync:employee-directory": "tsx scripts/sync-employee-directory.ts",
"sync:employee-directory:apply": "tsx scripts/sync-employee-directory.ts --apply",
"sync:dingtalk-directory:apply": "tsx scripts/sync-dingtalk-directory.ts --apply",
"sync:external-jsreport": "tsx scripts/sync-external-readonly-to-jsreport.ts",
"sync:external-jsreport:apply": "tsx scripts/sync-external-readonly-to-jsreport.ts --apply",
"db:verify-master-data:truth": "node scripts/verify-master-data-truth-sources.js",
"db:verify-master-data:code": "node scripts/verify-master-data-code-boundaries.js",
"db:verify-master-data:all": "npm run db:verify-master-data:truth && npm run db:verify-master-data:code && npm run db:verify-master-data:migration-status && npm run db:verify-master-data",
@ -153,7 +91,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@huggingface/transformers": "3.8.1",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"@react-three/drei": "^10.7.7",
@ -161,18 +98,15 @@
"@tanstack/react-query": "^5.90.20",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/qrcode": "^1.5.6",
"autoprefixer": "^10.4.22",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
"dockerode": "^4.0.9",
"exceljs": "^4.4.0",
"framer-motion": "^12.29.2",
"gsap": "^3.12.5",
"html2canvas": "^1.4.1",
"ioredis": "^5.9.2",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
@ -183,23 +117,18 @@
"next": "^16.1.6",
"obj2gltf": "^3.2.0",
"pg": "^8.17.2",
"pinyin-pro": "^3.29.5",
"pinyin-pro": "^3.28.1",
"postcss": "^8.5.6",
"print-js": "^1.6.0",
"puppeteer": "^24.40.0",
"qiniu": "^7.15.2",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.54.2",
"server-only": "^0.0.1",
"sharp": "^0.34.5",
"ssh2": "^1.17.0",
"tailwind-merge": "^3.4.0",
"three": "^0.182.0",
"tsx": "^4.23.15",
"uuid": "^11.1.1",
"xlsx": "^0.18.5",
"zod": "^4.3.6",
"zustand": "^5.0.2"
@ -233,7 +162,6 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/crypto-js": "^4.2.2",
"@types/dockerode": "^4.0.1",
"@types/ioredis": "^4.28.10",
"@types/jest": "^30.0.0",
@ -254,21 +182,7 @@
"prisma": "^7.3.0",
"supertest": "^7.2.2",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "^5"
},
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"@prisma/engines",
"cpu-features",
"esbuild",
"onnxruntime-node",
"prisma",
"protobufjs",
"puppeteer",
"sharp",
"ssh2",
"unrs-resolver"
]
}
}

View File

@ -1,5 +0,0 @@
ALTER TABLE "User"
ADD COLUMN "supplierCode" TEXT,
ADD COLUMN "supplierName" TEXT;
CREATE INDEX "User_supplierCode_idx" ON "User"("supplierCode");

View File

@ -1,37 +0,0 @@
CREATE TABLE IF NOT EXISTS "BarcodePrintImportRecord" (
"id" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"skuCode" TEXT NOT NULL,
"normalizedSku" TEXT NOT NULL,
"supplierCode" TEXT NOT NULL DEFAULT '',
"supplierName" TEXT NOT NULL DEFAULT '',
"sourceFileName" TEXT NOT NULL DEFAULT '',
"rowNumber" INTEGER NOT NULL DEFAULT 0,
"importedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updates" JSONB NOT NULL,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BarcodePrintImportRecord_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "BarcodePrintImportRecord_kind_normalizedSku_supplierCode_key"
ON "BarcodePrintImportRecord"("kind", "normalizedSku", "supplierCode");
CREATE INDEX IF NOT EXISTS "BarcodePrintImportRecord_kind_idx" ON "BarcodePrintImportRecord"("kind");
CREATE INDEX IF NOT EXISTS "BarcodePrintImportRecord_normalizedSku_idx" ON "BarcodePrintImportRecord"("normalizedSku");
CREATE INDEX IF NOT EXISTS "BarcodePrintImportRecord_supplierCode_idx" ON "BarcodePrintImportRecord"("supplierCode");
CREATE INDEX IF NOT EXISTS "BarcodePrintImportRecord_createdBy_idx" ON "BarcodePrintImportRecord"("createdBy");
CREATE INDEX IF NOT EXISTS "BarcodePrintImportRecord_createdAt_idx" ON "BarcodePrintImportRecord"("createdAt");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'BarcodePrintImportRecord_createdBy_fkey'
) THEN
ALTER TABLE "BarcodePrintImportRecord"
ADD CONSTRAINT "BarcodePrintImportRecord_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;

View File

@ -1,67 +0,0 @@
CREATE TABLE "AiRenderWorkbenchSpu" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdBy" TEXT NOT NULL,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiRenderWorkbenchSpu_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "AiRenderWorkbenchSku" (
"id" TEXT NOT NULL,
"spuId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"modelUrl" TEXT NOT NULL DEFAULT '',
"thumbnailUrl" TEXT,
"note" TEXT,
"materialColor" TEXT NOT NULL DEFAULT '#d8c6ad',
"materialTextureUrl" TEXT,
"nodeMaterials" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdBy" TEXT NOT NULL,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiRenderWorkbenchSku_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "AssistedAssemblyAiRender"
ADD COLUMN "workbenchSpuId" TEXT,
ADD COLUMN "workbenchSkuId" TEXT;
CREATE INDEX "AiRenderWorkbenchSpu_createdBy_idx" ON "AiRenderWorkbenchSpu"("createdBy");
CREATE INDEX "AiRenderWorkbenchSpu_sortOrder_createdAt_idx" ON "AiRenderWorkbenchSpu"("sortOrder", "createdAt");
CREATE INDEX "AiRenderWorkbenchSku_spuId_sortOrder_createdAt_idx" ON "AiRenderWorkbenchSku"("spuId", "sortOrder", "createdAt");
CREATE INDEX "AiRenderWorkbenchSku_createdBy_idx" ON "AiRenderWorkbenchSku"("createdBy");
CREATE INDEX "AssistedAssemblyAiRender_workbenchSpuId_createdAt_idx" ON "AssistedAssemblyAiRender"("workbenchSpuId", "createdAt");
CREATE INDEX "AssistedAssemblyAiRender_workbenchSkuId_createdAt_idx" ON "AssistedAssemblyAiRender"("workbenchSkuId", "createdAt");
ALTER TABLE "AiRenderWorkbenchSpu"
ADD CONSTRAINT "AiRenderWorkbenchSpu_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AiRenderWorkbenchSku"
ADD CONSTRAINT "AiRenderWorkbenchSku_spuId_fkey"
FOREIGN KEY ("spuId") REFERENCES "AiRenderWorkbenchSpu"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AiRenderWorkbenchSku"
ADD CONSTRAINT "AiRenderWorkbenchSku_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_workbenchSpuId_fkey"
FOREIGN KEY ("workbenchSpuId") REFERENCES "AiRenderWorkbenchSpu"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_workbenchSkuId_fkey"
FOREIGN KEY ("workbenchSkuId") REFERENCES "AiRenderWorkbenchSku"("id")
ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -1,84 +0,0 @@
-- 20260613151000 temporarily restored AssetSkuGroup for legacy-data recovery.
-- The current schema deliberately keeps this grouping as JSON compatibility data,
-- so replaying the migration chain must retire the transient relational shape again.
DO $$
BEGIN
IF to_regclass('public."AssetSkuGroup"') IS NOT NULL
AND EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'AssetVariant'
AND column_name = 'skuGroupId'
)
THEN
EXECUTE $SQL$
UPDATE "AssetVariant" AS variant
SET "businessData" = jsonb_set(
CASE
WHEN variant."businessData" IS NULL OR jsonb_typeof(variant."businessData") <> 'object'
THEN '{}'::jsonb
ELSE variant."businessData"
END,
'{legacySkuGroup}',
jsonb_build_object(
'id', sku_group.id,
'code', sku_group.code,
'name', sku_group.name,
'description', sku_group.description,
'sortOrder', sku_group."sortOrder",
'metadata', sku_group.metadata,
'assetFamilyId', sku_group."assetFamilyId",
'source', 'AssetSkuGroup.replay-aligned'
),
true
)
FROM "AssetSkuGroup" AS sku_group
WHERE variant."skuGroupId" = sku_group.id
$SQL$;
EXECUTE $SQL$
UPDATE "AssetFamily" AS family
SET metadata = jsonb_set(
CASE
WHEN family.metadata IS NULL OR jsonb_typeof(family.metadata) <> 'object'
THEN '{}'::jsonb
ELSE family.metadata
END,
'{legacySkuGroups}',
grouped.groups,
true
)
FROM (
SELECT
"assetFamilyId",
jsonb_agg(
jsonb_build_object(
'id', id,
'code', code,
'name', name,
'description', description,
'sortOrder', "sortOrder",
'metadata', metadata,
'source', 'AssetSkuGroup.replay-aligned'
)
ORDER BY "sortOrder", code
) AS groups
FROM "AssetSkuGroup"
GROUP BY "assetFamilyId"
) AS grouped
WHERE family.id = grouped."assetFamilyId"
$SQL$;
END IF;
END $$;
ALTER TABLE "AssetVariant"
DROP CONSTRAINT IF EXISTS "AssetVariant_skuGroupId_fkey";
DROP INDEX IF EXISTS "AssetVariant_skuGroupId_idx";
ALTER TABLE "AssetVariant"
DROP COLUMN IF EXISTS "skuGroupId";
DROP TABLE IF EXISTS "AssetSkuGroup";

View File

@ -1,445 +0,0 @@
-- 把宽表 product_catalog_wide_v1 的口径从"一 SKU 一行"明确保护成"一条码一行"。
-- 原视图 FROM sku_source 的行粒度是 SKU,barcode 只是字段;当前 43261 行 barcode 全唯一是数据巧合,
-- 一旦出现"一条码多 SKU"(正式 ProductSKU 落库 + intake ready 同条码 / intake 内部同条码多行),
-- 宽表就会重复,污染外部系统的 barcode 主同步键。
-- 修复:复用项目已有的 DISTINCT ON(barcode_business_key) 模式(latest_quality / limit_by_barcode CTE),
-- 把原主查询整体包成 catalog_by_sku CTE,外层加 DISTINCT ON (barcode_business_key)。
-- 优先级:FORMAL(正式商品)> INTAKE_READY(开发阶段),同源内 updated_at 最新 > created_at 最新。
-- 用 CREATE OR REPLACE VIEW(不用 DROP),保留 external_reader 已有的 GRANT SELECT 权限。
-- Expand the external product catalog wide view so external systems can
-- consume ready non-integrated SKU rows before formal Product / ProductSKU
-- records are materialized.
CREATE OR REPLACE VIEW external.product_catalog_wide_v1 AS
WITH formal_sku_source AS (
SELECT
sku.product_id,
sku.product_model_number,
sku.product_name,
sku.product_description,
sku.product_category_id,
sku.product_category_name,
sku.product_thumbnail_url,
sku.product_model_url,
sku.sku_id,
sku.sku_code,
sku.sku_name,
sku.price,
sku.cost_price,
sku.stock,
sku.is_active,
sku.record_status,
sku.model_url,
sku.image_url,
sku.image_urls,
sku.source_pre_sku_id,
sku.source_asset_variant_id,
sku.source_asset_variant_ids,
sku.asset_family_code,
sku.asset_family_name,
sku.spec_code,
sku.spec_name,
sku.barcode,
sku.size_json,
sku.color,
sku.material,
sku.source_status,
sku.synced_at,
sku.warehouse_location_id,
sku.warehouse_location_code,
sku.warehouse_location_name,
sku.created_at,
sku.updated_at,
sku.product_business_key,
sku.sku_business_key,
sku.barcode_business_key,
'FORMAL'::text AS catalog_source
FROM external.product_skus_v1 sku
),
formal_identity_map AS (
SELECT
barcode_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku_code))
ELSE NULL
END AS normalized_sku_code
FROM formal_sku_source
),
intake_ready_sku_source AS (
SELECT
product.intake_product_id AS product_id,
NULLIF(btrim(COALESCE(product.spu, '')), '') AS product_model_number,
NULLIF(btrim(COALESCE(product.product_name, '')), '') AS product_name,
NULL::text AS product_description,
NULL::text AS product_category_id,
NULLIF(btrim(COALESCE(product.product_category, '')), '') AS product_category_name,
COALESCE(
NULLIF(btrim(COALESCE(product.product_image_url, '')), ''),
NULLIF(btrim(COALESCE(sku.image_url, '')), '')
) AS product_thumbnail_url,
NULL::text AS product_model_url,
sku.intake_sku_item_id AS sku_id,
NULLIF(btrim(COALESCE(sku.sku_code, '')), '') AS sku_code,
COALESCE(
NULLIF(btrim(COALESCE(sku.spec_name, '')), ''),
NULLIF(btrim(COALESCE(sku.sku_code, '')), '')
) AS sku_name,
NULL::numeric(10,2) AS price,
NULL::numeric(10,2) AS cost_price,
0::integer AS stock,
true AS is_active,
'ACTIVE'::text AS record_status,
NULLIF(btrim(COALESCE(sku.model_3d_url, '')), '') AS model_url,
COALESCE(
NULLIF(btrim(COALESCE(sku.image_url, '')), ''),
NULLIF(btrim(COALESCE(product.product_image_url, '')), '')
) AS image_url,
CASE
WHEN COALESCE(
NULLIF(btrim(COALESCE(sku.image_url, '')), ''),
NULLIF(btrim(COALESCE(product.product_image_url, '')), '')
) IS NOT NULL
THEN ARRAY[
COALESCE(
NULLIF(btrim(COALESCE(sku.image_url, '')), ''),
NULLIF(btrim(COALESCE(product.product_image_url, '')), '')
)
]
ELSE ARRAY[]::text[]
END AS image_urls,
NULL::text AS source_pre_sku_id,
NULL::text AS source_asset_variant_id,
ARRAY[]::text[] AS source_asset_variant_ids,
NULLIF(btrim(COALESCE(sku.model, '')), '') AS asset_family_code,
NULL::text AS asset_family_name,
NULLIF(btrim(COALESCE(sku.attribute_code, '')), '') AS spec_code,
NULLIF(btrim(COALESCE(sku.spec_name, '')), '') AS spec_name,
NULLIF(btrim(COALESCE(sku.barcode, '')), '') AS barcode,
CASE
WHEN NULLIF(btrim(COALESCE(sku.size, '')), '') IS NOT NULL
THEN jsonb_build_object('label', btrim(sku.size))
ELSE NULL::jsonb
END AS size_json,
NULLIF(btrim(COALESCE(sku.color, '')), '') AS color,
NULLIF(btrim(COALESCE(sku.material, '')), '') AS material,
NULL::text AS source_status,
NULL::text AS synced_at,
NULL::text AS warehouse_location_id,
NULL::text AS warehouse_location_code,
NULL::text AS warehouse_location_name,
product.created_at AS created_at,
product.updated_at AS updated_at,
CASE
WHEN NULLIF(btrim(COALESCE(product.spu, '')), '') IS NOT NULL
THEN upper(btrim(product.spu))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku.sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku.sku_code))
ELSE NULL
END AS sku_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NOT NULL
THEN upper(btrim(sku.barcode))
ELSE NULL
END AS barcode_business_key,
'INTAKE_READY'::text AS catalog_source
FROM external.intake_sku_items_ready_v1 sku
JOIN external.intake_products_ready_v1 product
ON product.intake_product_id = sku.intake_product_id
WHERE NOT EXISTS (
SELECT 1
FROM formal_identity_map formal
WHERE (
NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NOT NULL
AND formal.barcode_business_key = upper(btrim(sku.barcode))
)
OR (
NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NULL
AND NULLIF(btrim(COALESCE(sku.sku_code, '')), '') IS NOT NULL
AND formal.normalized_sku_code = upper(btrim(sku.sku_code))
)
)
),
sku_source AS (
SELECT * FROM formal_sku_source
UNION ALL
SELECT * FROM intake_ready_sku_source
),
asset_summary AS (
SELECT
product_business_key,
sku_business_key,
COUNT(*) FILTER (WHERE asset_type = 'SKU_IMAGE')::integer AS image_asset_count,
COUNT(*) FILTER (WHERE asset_type = 'SKU_MODEL')::integer AS model_asset_count,
COUNT(*) FILTER (WHERE asset_type = 'SKU_THUMBNAIL')::integer AS thumbnail_asset_count,
MIN(asset_url) FILTER (WHERE asset_type = 'SKU_MODEL') AS first_model_asset_url,
MIN(asset_url) FILTER (WHERE asset_type = 'SKU_THUMBNAIL') AS first_thumbnail_asset_url
FROM external.product_assets_v1
GROUP BY product_business_key, sku_business_key
),
packaged_summary AS (
SELECT
barcode_business_key,
COUNT(*)::integer AS packaged_item_count,
COALESCE(SUM(stock_quantity), 0)::integer AS packaged_stock_quantity
FROM external.packaged_items_v1
WHERE NULLIF(barcode_business_key, '') IS NOT NULL
GROUP BY barcode_business_key
),
quality_summary AS (
SELECT
barcode_business_key,
COUNT(*)::integer AS quality_record_count,
COUNT(*) FILTER (WHERE source_type = 'WAREHOUSE')::integer AS warehouse_quality_record_count,
COUNT(*) FILTER (WHERE source_type = 'FIRST_BATCH')::integer AS first_batch_quality_record_count
FROM external.sku_quality_inspections_v1
WHERE NULLIF(barcode_business_key, '') IS NOT NULL
GROUP BY barcode_business_key
),
latest_quality AS (
SELECT DISTINCT ON (barcode_business_key)
barcode_business_key,
record_key AS latest_quality_record_key,
source_type AS latest_quality_source_type,
batch_no AS latest_quality_batch_no,
inspected_at AS latest_quality_inspected_at,
conclusion AS latest_quality_conclusion,
status AS latest_quality_status
FROM external.sku_quality_inspections_v1
WHERE NULLIF(barcode_business_key, '') IS NOT NULL
ORDER BY barcode_business_key, COALESCE(inspected_at, updated_at, created_at) DESC, created_at DESC
),
limit_by_barcode AS (
SELECT DISTINCT ON (identity_value)
identity_value,
product_business_key AS limit_source_product_business_key,
sku_business_key AS limit_source_sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at
FROM (
SELECT
upper(btrim(barcode)) AS identity_value,
product_business_key,
sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at,
updated_at
FROM external.intake_product_limits_v1
WHERE NULLIF(btrim(COALESCE(barcode, '')), '') IS NOT NULL
) limit_rows
WHERE NULLIF(identity_value, '') IS NOT NULL
ORDER BY
identity_value,
limit_price_applicable DESC,
limit_price_confirmed DESC,
limit_confirmed_at DESC NULLS LAST,
updated_at DESC
),
limit_by_sku AS (
SELECT DISTINCT ON (identity_value)
identity_value,
product_business_key AS limit_source_product_business_key,
sku_business_key AS limit_source_sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at
FROM (
SELECT
upper(btrim(sku_code)) AS identity_value,
product_business_key,
sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at,
updated_at
FROM external.intake_product_limits_v1
WHERE NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
) limit_rows
WHERE NULLIF(identity_value, '') IS NOT NULL
ORDER BY
identity_value,
limit_price_applicable DESC,
limit_price_confirmed DESC,
limit_confirmed_at DESC NULLS LAST,
updated_at DESC
),
catalog_by_sku AS (
SELECT
sku.product_id,
sku.product_model_number,
sku.product_business_key,
sku.product_name,
sku.product_description,
sku.product_category_id,
sku.product_category_name,
sku.product_thumbnail_url,
sku.product_model_url,
sku.sku_id,
sku.sku_code,
sku.sku_business_key,
sku.sku_name,
sku.price,
sku.cost_price,
sku.stock,
sku.is_active,
sku.record_status,
sku.model_url,
sku.image_url,
sku.image_urls,
sku.source_pre_sku_id,
sku.source_asset_variant_id,
sku.source_asset_variant_ids,
sku.asset_family_code,
sku.asset_family_name,
sku.spec_code,
sku.spec_name,
sku.barcode,
sku.barcode_business_key,
sku.size_json,
sku.color,
sku.material,
sku.source_status,
sku.synced_at,
sku.warehouse_location_id,
sku.warehouse_location_code,
sku.warehouse_location_name,
stock.warehouse_zone,
stock.warehouse_aisle,
stock.warehouse_shelf,
stock.warehouse_bin,
stock.warehouse_location_is_active,
stock.warehouse_location_status,
COALESCE(limit_by_barcode.limit_price_applicable, limit_by_sku.limit_price_applicable, false) AS limit_price_applicable,
COALESCE(limit_by_barcode.limit_price_confirmed, limit_by_sku.limit_price_confirmed, false) AS limit_price_confirmed,
COALESCE(limit_by_barcode.limit_price_status, limit_by_sku.limit_price_status, 'NOT_APPLICABLE') AS limit_price_status,
COALESCE(limit_by_barcode.expected_daily_limit_price, limit_by_sku.expected_daily_limit_price) AS expected_daily_limit_price,
COALESCE(limit_by_barcode.expected_limit_gross_margin_rate, limit_by_sku.expected_limit_gross_margin_rate) AS expected_limit_gross_margin_rate,
COALESCE(limit_by_barcode.non_spu_owner_limit_price, limit_by_sku.non_spu_owner_limit_price) AS non_spu_owner_limit_price,
COALESCE(limit_by_barcode.limit_confirmed_at, limit_by_sku.limit_confirmed_at) AS limit_confirmed_at,
COALESCE(limit_by_barcode.limit_source_product_business_key, limit_by_sku.limit_source_product_business_key) AS limit_source_product_business_key,
COALESCE(limit_by_barcode.limit_source_sku_business_key, limit_by_sku.limit_source_sku_business_key) AS limit_source_sku_business_key,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN CASE WHEN NULLIF(btrim(COALESCE(sku.image_url, '')), '') IS NOT NULL THEN 1 ELSE 0 END
ELSE COALESCE(asset_summary.image_asset_count, 0)
END AS image_asset_count,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN CASE WHEN NULLIF(btrim(COALESCE(sku.model_url, '')), '') IS NOT NULL THEN 1 ELSE 0 END
ELSE COALESCE(asset_summary.model_asset_count, 0)
END AS model_asset_count,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN CASE
WHEN COALESCE(
NULLIF(btrim(COALESCE(sku.product_thumbnail_url, '')), ''),
NULLIF(btrim(COALESCE(sku.image_url, '')), '')
) IS NOT NULL
THEN 1
ELSE 0
END
ELSE COALESCE(asset_summary.thumbnail_asset_count, 0)
END AS thumbnail_asset_count,
COALESCE(
asset_summary.first_model_asset_url,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN NULLIF(btrim(COALESCE(sku.model_url, '')), '')
ELSE NULL
END
) AS first_model_asset_url,
COALESCE(
asset_summary.first_thumbnail_asset_url,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN COALESCE(
NULLIF(btrim(COALESCE(sku.product_thumbnail_url, '')), ''),
NULLIF(btrim(COALESCE(sku.image_url, '')), '')
)
ELSE NULL
END
) AS first_thumbnail_asset_url,
COALESCE(bom_summary.bom_item_count, 0) AS bom_item_count,
COALESCE(bom_summary.panel_item_count, 0) AS panel_item_count,
COALESCE(bom_summary.hardware_item_count, 0) AS hardware_item_count,
COALESCE(bom_summary.material_codes, ARRAY[]::text[]) AS material_codes,
COALESCE(bom_summary.material_names, ARRAY[]::text[]) AS material_names,
COALESCE(packaged_summary.packaged_item_count, 0) AS packaged_item_count,
COALESCE(packaged_summary.packaged_stock_quantity, 0) AS packaged_stock_quantity,
COALESCE(quality_summary.quality_record_count, 0) AS quality_record_count,
COALESCE(quality_summary.warehouse_quality_record_count, 0) AS warehouse_quality_record_count,
COALESCE(quality_summary.first_batch_quality_record_count, 0) AS first_batch_quality_record_count,
latest_quality.latest_quality_record_key,
latest_quality.latest_quality_source_type,
latest_quality.latest_quality_batch_no,
latest_quality.latest_quality_inspected_at,
latest_quality.latest_quality_conclusion,
latest_quality.latest_quality_status,
sku.created_at,
sku.updated_at,
sku.catalog_source
FROM sku_source sku
LEFT JOIN external.sku_warehouse_stock_v1 stock
ON stock.barcode_business_key = sku.barcode_business_key
LEFT JOIN limit_by_sku
ON limit_by_sku.identity_value = upper(btrim(COALESCE(sku.sku_code, '')))
LEFT JOIN limit_by_barcode
ON limit_by_barcode.identity_value = sku.barcode_business_key
LEFT JOIN asset_summary
ON asset_summary.product_business_key = sku.product_business_key
AND asset_summary.sku_business_key = sku.sku_business_key
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS bom_item_count,
COUNT(*) FILTER (WHERE item_type = 'PANEL')::integer AS panel_item_count,
COUNT(*) FILTER (WHERE item_type = 'HARDWARE')::integer AS hardware_item_count,
array_remove(array_agg(DISTINCT material_code ORDER BY material_code), NULL) AS material_codes,
array_remove(array_agg(DISTINCT material_name ORDER BY material_name), NULL) AS material_names
FROM external.product_bom_v1 bom
WHERE bom.product_business_key = sku.product_business_key
AND (
bom.sku_business_key = sku.sku_business_key
OR bom.sku_business_key IS NULL
)
) bom_summary ON true
LEFT JOIN packaged_summary
ON packaged_summary.barcode_business_key = sku.barcode_business_key
LEFT JOIN quality_summary
ON quality_summary.barcode_business_key = sku.barcode_business_key
LEFT JOIN latest_quality
ON latest_quality.barcode_business_key = sku.barcode_business_key
)
SELECT DISTINCT ON (catalog_by_sku.barcode_business_key)
catalog_by_sku.*
FROM catalog_by_sku
WHERE catalog_by_sku.barcode_business_key IS NOT NULL
AND btrim(catalog_by_sku.barcode_business_key) <> ''
ORDER BY
catalog_by_sku.barcode_business_key,
CASE WHEN catalog_by_sku.catalog_source IS NULL THEN 1
WHEN catalog_by_sku.catalog_source = 'FORMAL' THEN 0
ELSE 1 END,
catalog_by_sku.updated_at DESC NULLS LAST,
catalog_by_sku.created_at DESC;

View File

@ -1,390 +0,0 @@
-- Normalize asset URLs exposed by external read-only views.
--
-- Business tables still store stable in-app object routes such as
-- /api/storage/object?key=... . External database consumers need complete
-- URLs, so the external contract layer prefixes relative application routes
-- with the current production site origin. The routes remain application
-- controlled; this migration does not expose private bucket objects directly.
CREATE OR REPLACE FUNCTION external.to_external_asset_url(raw_value text)
RETURNS text
LANGUAGE sql
STABLE
AS $$
WITH normalized AS (
SELECT NULLIF(btrim(raw_value), '') AS value
),
base_url AS (
SELECT COALESCE(
NULLIF(btrim(current_setting('app.external_asset_base_url', true)), ''),
NULLIF(btrim(current_setting('app.public_base_url', true)), ''),
'https://cp.jiansun.vip'
) AS value
)
SELECT
CASE
WHEN normalized.value IS NULL THEN NULL
WHEN normalized.value ~* '^https?://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?/' THEN
regexp_replace(base_url.value, '/+$', '') ||
regexp_replace(
normalized.value,
'^https?://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?',
'',
'i'
)
WHEN normalized.value LIKE '/%' THEN regexp_replace(base_url.value, '/+$', '') || normalized.value
ELSE normalized.value
END
FROM normalized, base_url;
$$;
CREATE OR REPLACE VIEW external.materials_v1 AS
SELECT
m."id" AS material_id,
m."code" AS material_code,
m."name" AS material_name,
c."name" AS category_name,
external.to_external_asset_url(m."thumbnailUrl") AS thumbnail_url,
external.to_external_asset_url(m."textureUrl") AS texture_url,
external.to_external_asset_url(m."normalMapUrl") AS normal_map_url,
m."color" AS base_color,
m."metalness" AS metalness,
m."roughness" AS roughness,
m."opacity" AS opacity,
CASE WHEN m."isActive" THEN 'ACTIVE' ELSE 'INACTIVE' END AS record_status
FROM "Material" m
JOIN "MaterialCategory" c ON c."id" = m."categoryId";
CREATE OR REPLACE VIEW external.asset_variants_v1 AS
SELECT
v."id" AS asset_variant_id,
v."code" AS spec_code,
v."name" AS spec_name,
v."assetType"::text AS asset_type,
v."status"::text AS asset_status,
f."id" AS asset_family_id,
f."code" AS asset_family_code,
f."name" AS asset_family_name,
s."id" AS asset_series_id,
s."code" AS asset_series_code,
s."name" AS asset_series_name,
pc."id" AS product_category_id,
pc."name" AS product_category_name,
v."sizeLabel" AS size_label,
v."dimensions" AS dimensions,
external.to_external_asset_url(v."modelUrl") AS model_url,
external.to_external_asset_url(v."thumbnailUrl") AS thumbnail_url,
v."barcode" AS barcode,
v."supplierName" AS supplier_name,
v."supplierCode" AS supplier_code,
v."versionNo" AS version_no,
sub."id" AS substrate_id,
sub."name" AS substrate_name,
finish."id" AS finish_id,
finish."code" AS finish_code,
finish."name" AS finish_name,
CASE
WHEN sub."name" IS NOT NULL AND finish."name" IS NOT NULL THEN sub."name" || ' + ' || finish."name"
ELSE COALESCE(sub."name", finish."name")
END AS material_label,
v."weightKg" AS weight_kg,
v."costPrice" AS cost_price,
v."estimatedCostPrice" AS estimated_cost_price,
v."createdAt" AS created_at,
v."updatedAt" AS updated_at
FROM "AssetVariant" v
JOIN "AssetFamily" f ON f."id" = v."assetFamilyId"
LEFT JOIN "AssetSeries" s ON s."id" = f."assetSeriesId"
LEFT JOIN "ProductCategory" pc ON pc."id" = f."productCategoryId"
LEFT JOIN "Substrate" sub ON sub."id" = v."substrateId"
LEFT JOIN "Material" finish ON finish."id" = v."finishId"
WHERE v."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.product_skus_v1 AS
SELECT
p."id" AS product_id,
p."modelNumber" AS product_model_number,
p."name" AS product_name,
p."description" AS product_description,
pc."id" AS product_category_id,
pc."name" AS product_category_name,
external.to_external_asset_url(p."thumbnailUrl") AS product_thumbnail_url,
external.to_external_asset_url(p."modelUrl") AS product_model_url,
sku."id" AS sku_id,
sku."skuCode" AS sku_code,
COALESCE(
NULLIF(sku."name", ''),
NULLIF(profile."specName", ''),
sku."skuCode"
) AS sku_name,
sku."price" AS price,
sku."costPrice" AS cost_price,
sku."stock" AS stock,
sku."isActive" AS is_active,
CASE WHEN sku."isActive" THEN 'ACTIVE' ELSE 'INACTIVE' END AS record_status,
external.to_external_asset_url(
COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", ''))
) AS model_url,
external.to_external_asset_url(
COALESCE(
NULLIF(profile."imageUrl", ''),
sku."images"[1],
NULLIF(p."thumbnailUrl", '')
)
) AS image_url,
ARRAY(
SELECT external.to_external_asset_url(image_item.image_url)
FROM unnest(sku."images") AS image_item(image_url)
WHERE NULLIF(btrim(image_item.image_url), '') IS NOT NULL
) AS image_urls,
sku."sourcePreSkuId" AS source_pre_sku_id,
sku."sourceAssetVariantId" AS source_asset_variant_id,
sku."sourceAssetVariantIds" AS source_asset_variant_ids,
profile."model" AS asset_family_code,
profile."modelName" AS asset_family_name,
profile."specCode" AS spec_code,
profile."specName" AS spec_name,
profile."barcode" AS barcode,
profile."size" AS size_json,
profile."color" AS color,
profile."material" AS material,
profile."sourceStatus"::text AS source_status,
profile."syncedAt"::text AS synced_at,
wh."id" AS warehouse_location_id,
wh."code" AS warehouse_location_code,
wh."name" AS warehouse_location_name,
sku."createdAt" AS created_at,
sku."updatedAt" AS updated_at,
CASE
WHEN NULLIF(btrim(COALESCE(p."modelNumber", '')), '') IS NOT NULL
THEN upper(btrim(p."modelNumber"))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL
THEN upper(btrim(sku."skuCode"))
ELSE NULL
END AS sku_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(profile."barcode", '')), '') IS NOT NULL
THEN upper(btrim(profile."barcode"))
ELSE NULL
END AS barcode_business_key
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
LEFT JOIN "ProductCategory" pc ON pc."id" = p."categoryId"
LEFT JOIN "WarehouseLocation" wh ON wh."id" = sku."warehouseLocationId"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.product_assets_v1 AS
WITH image_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
p."modelNumber" AS product_model_number,
sku."skuCode" AS sku_code,
profile."barcode" AS barcode,
image_item.image_url,
image_item.sort_order
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
CROSS JOIN LATERAL unnest(sku."images") WITH ORDINALITY AS image_item(image_url, sort_order)
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL
),
model_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
p."modelNumber" AS product_model_number,
sku."skuCode" AS sku_code,
profile."barcode" AS barcode,
COALESCE(NULLIF(sku."modelUrl", ''), NULLIF(p."modelUrl", '')) AS model_url
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL
),
thumbnail_assets AS (
SELECT
sku."id" AS sku_id,
sku."productId" AS product_id,
p."modelNumber" AS product_model_number,
sku."skuCode" AS sku_code,
profile."barcode" AS barcode,
COALESCE(
NULLIF(profile."imageUrl", ''),
NULLIF(p."thumbnailUrl", '')
) AS thumbnail_url
FROM "ProductSKU" sku
JOIN "Product" p ON p."id" = sku."productId"
LEFT JOIN "ProductSkuAssetProfile" profile ON profile."skuId" = sku."id"
WHERE p."deletedAt" IS NULL
AND sku."deletedAt" IS NULL
)
SELECT
'SKU_IMAGE' AS asset_type,
product_id,
sku_id,
external.to_external_asset_url(image_url) AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
sort_order::integer AS sort_order,
CASE
WHEN NULLIF(btrim(COALESCE(product_model_number, '')), '') IS NOT NULL
THEN upper(btrim(product_model_number))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku_code))
ELSE NULL
END AS sku_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(barcode, '')), '') IS NOT NULL
THEN upper(btrim(barcode))
ELSE NULL
END AS barcode_business_key
FROM image_assets
WHERE NULLIF(image_url, '') IS NOT NULL
UNION ALL
SELECT
'SKU_MODEL' AS asset_type,
product_id,
sku_id,
external.to_external_asset_url(model_url) AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
0 AS sort_order,
CASE
WHEN NULLIF(btrim(COALESCE(product_model_number, '')), '') IS NOT NULL
THEN upper(btrim(product_model_number))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku_code))
ELSE NULL
END AS sku_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(barcode, '')), '') IS NOT NULL
THEN upper(btrim(barcode))
ELSE NULL
END AS barcode_business_key
FROM model_assets
WHERE NULLIF(model_url, '') IS NOT NULL
UNION ALL
SELECT
'SKU_THUMBNAIL' AS asset_type,
product_id,
sku_id,
external.to_external_asset_url(thumbnail_url) AS asset_url,
NULL::text AS file_name,
NULL::text AS mime_type,
0 AS sort_order,
CASE
WHEN NULLIF(btrim(COALESCE(product_model_number, '')), '') IS NOT NULL
THEN upper(btrim(product_model_number))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku_code))
ELSE NULL
END AS sku_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(barcode, '')), '') IS NOT NULL
THEN upper(btrim(barcode))
ELSE NULL
END AS barcode_business_key
FROM thumbnail_assets
WHERE NULLIF(thumbnail_url, '') IS NOT NULL;
CREATE OR REPLACE VIEW external.intake_products_v1 AS
SELECT
product."id" AS intake_product_id,
product."spu" AS spu,
product."spuBase" AS spu_base,
product."brand" AS brand,
profile."productName" AS product_name,
external.to_external_asset_url(profile."productImageUrl") AS product_image_url,
profile."productCategory" AS product_category,
profile."salesCategory" AS sales_category,
profile."productYear" AS product_year,
profile."productStatus" AS product_status,
COALESCE(NULLIF(profile."structureDesigner", ''), NULLIF(product."designer", '')) AS designer,
COALESCE(NULLIF(profile."productPlanning", ''), NULLIF(product."operator", '')) AS operator,
COALESCE(NULLIF(profile."purchaseOwner", ''), NULLIF(product."purchaser", '')) AS purchaser,
profile."productDevelopmentKind" AS product_development_kind,
profile."goodsType" AS goods_type,
profile."materialType" AS material_type,
profile."productionType" AS production_type,
product."createdAt" AS created_at,
product."updatedAt" AS updated_at,
profile."productPlanning" AS product_planning,
profile."visualPlanning" AS visual_planning,
profile."structureDesigner" AS structure_designer,
profile."purchaseOwner" AS purchase_owner,
CASE
WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL
THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu"))
ELSE NULL
END AS product_business_key
FROM "IntakeNonIntegratedProduct" product
LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.intake_sku_items_v1 AS
SELECT
product."id" AS intake_product_id,
product."spu" AS spu,
product."brand" AS brand,
sku."skuItemId" AS intake_sku_item_id,
sku."skuSequence" AS sku_sequence,
sku."batchNo" AS batch_no,
external.to_external_asset_url(sku."image") AS image_url,
sku."imageName" AS image_name,
sku."model" AS model,
sku."skuCode" AS sku_code,
sku."specName" AS spec_name,
sku."barcode" AS barcode,
sku."size" AS size,
sku."color" AS color,
sku."material" AS material,
sku."versionNo" AS version_no,
sku."attributeCode" AS attribute_code,
sku."drawingId" AS drawing_id,
sku."installMethod" AS install_method,
sku."executeStandard" AS execute_standard,
sku."manufacturer" AS manufacturer,
sku."manufacturerCode" AS manufacturer_code,
sku."salesStatus" AS sales_status,
external.to_external_asset_url(sku."model3dDataUrl") AS model_3d_url,
sku."source" AS source,
sku."sourceTable" AS source_table,
sku."sourceSkuId" AS source_sku_id,
sku."missingRequiredFields" AS missing_required_fields,
sku."dataQualityStatus"::text AS data_quality_status,
sku."contentLocked" AS content_locked,
sku."sortOrder" AS sort_order,
CASE
WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL
THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu"))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL
THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu")) || ':' || upper(btrim(sku."skuCode"))
ELSE NULL
END AS sku_business_key
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL;

View File

@ -1,510 +0,0 @@
-- Add explicit development-owner columns to the external catalog wide view.
--
-- The external main entry remains external.product_catalog_wide_v1. The new
-- columns mirror the product-development list people fields and are appended to
-- the existing wide view so current consumers keep their column order.
CREATE OR REPLACE VIEW external.intake_products_v1 AS
SELECT
product."id" AS intake_product_id,
product."spu" AS spu,
product."spuBase" AS spu_base,
product."brand" AS brand,
profile."productName" AS product_name,
external.to_external_asset_url(profile."productImageUrl") AS product_image_url,
profile."productCategory" AS product_category,
profile."salesCategory" AS sales_category,
profile."productYear" AS product_year,
profile."productStatus" AS product_status,
COALESCE(NULLIF(profile."structureDesigner", ''), NULLIF(product."designer", '')) AS designer,
COALESCE(NULLIF(profile."productPlanning", ''), NULLIF(product."operator", '')) AS operator,
COALESCE(NULLIF(profile."purchaseOwner", ''), NULLIF(product."purchaser", '')) AS purchaser,
profile."productDevelopmentKind" AS product_development_kind,
profile."goodsType" AS goods_type,
profile."materialType" AS material_type,
profile."productionType" AS production_type,
product."createdAt" AS created_at,
product."updatedAt" AS updated_at,
profile."productPlanning" AS product_planning,
profile."visualPlanning" AS visual_planning,
profile."structureDesigner" AS structure_designer,
profile."purchaseOwner" AS purchase_owner,
CASE
WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL
THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu"))
ELSE NULL
END AS product_business_key,
NULLIF(btrim(COALESCE(product."designer", '')), '') AS appearance_designer,
COALESCE(
NULLIF(btrim(COALESCE(profile."spuOwner", '')), ''),
NULLIF(btrim(COALESCE(product."operator", '')), ''),
NULLIF(btrim(COALESCE(profile."operationsOwner", '')), '')
) AS spu_owner,
NULLIF(btrim(COALESCE(profile."productPlanning", '')), '') AS planning_owner,
COALESCE(
NULLIF(btrim(COALESCE(profile."visualPlanning", '')), ''),
NULLIF(btrim(COALESCE(profile."visualDirector", '')), '')
) AS space_designer
FROM "IntakeNonIntegratedProduct" product
LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.intake_products_ready_v1 AS
SELECT *
FROM external.intake_products_v1
WHERE NULLIF(btrim(COALESCE(product_name, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_category, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_development_kind, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NOT NULL;
CREATE OR REPLACE VIEW external.product_catalog_wide_v1 AS
WITH formal_sku_source AS (
SELECT
sku.product_id,
sku.product_model_number,
sku.product_name,
sku.product_description,
sku.product_category_id,
sku.product_category_name,
sku.product_thumbnail_url,
sku.product_model_url,
sku.sku_id,
sku.sku_code,
sku.sku_name,
sku.price,
sku.cost_price,
sku.stock,
sku.is_active,
sku.record_status,
sku.model_url,
sku.image_url,
sku.image_urls,
sku.source_pre_sku_id,
sku.source_asset_variant_id,
sku.source_asset_variant_ids,
sku.asset_family_code,
sku.asset_family_name,
sku.spec_code,
sku.spec_name,
sku.barcode,
sku.size_json,
sku.color,
sku.material,
sku.source_status,
sku.synced_at,
sku.warehouse_location_id,
sku.warehouse_location_code,
sku.warehouse_location_name,
sku.created_at,
sku.updated_at,
sku.product_business_key,
sku.sku_business_key,
sku.barcode_business_key,
'FORMAL'::text AS catalog_source,
NULL::text AS appearance_designer,
NULL::text AS structure_designer,
NULL::text AS spu_owner,
NULL::text AS planning_owner,
NULL::text AS space_designer,
NULL::text AS purchase_owner
FROM external.product_skus_v1 sku
),
formal_identity_map AS (
SELECT
barcode_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku_code))
ELSE NULL
END AS normalized_sku_code
FROM formal_sku_source
),
intake_ready_sku_source AS (
SELECT
product.intake_product_id AS product_id,
NULLIF(btrim(COALESCE(product.spu, '')), '') AS product_model_number,
NULLIF(btrim(COALESCE(product.product_name, '')), '') AS product_name,
NULL::text AS product_description,
NULL::text AS product_category_id,
NULLIF(btrim(COALESCE(product.product_category, '')), '') AS product_category_name,
COALESCE(
NULLIF(btrim(COALESCE(product.product_image_url, '')), ''),
NULLIF(btrim(COALESCE(sku.image_url, '')), '')
) AS product_thumbnail_url,
NULL::text AS product_model_url,
sku.intake_sku_item_id AS sku_id,
NULLIF(btrim(COALESCE(sku.sku_code, '')), '') AS sku_code,
COALESCE(
NULLIF(btrim(COALESCE(sku.spec_name, '')), ''),
NULLIF(btrim(COALESCE(sku.sku_code, '')), '')
) AS sku_name,
NULL::numeric(10,2) AS price,
NULL::numeric(10,2) AS cost_price,
0::integer AS stock,
true AS is_active,
'ACTIVE'::text AS record_status,
NULLIF(btrim(COALESCE(sku.model_3d_url, '')), '') AS model_url,
COALESCE(
NULLIF(btrim(COALESCE(sku.image_url, '')), ''),
NULLIF(btrim(COALESCE(product.product_image_url, '')), '')
) AS image_url,
CASE
WHEN COALESCE(
NULLIF(btrim(COALESCE(sku.image_url, '')), ''),
NULLIF(btrim(COALESCE(product.product_image_url, '')), '')
) IS NOT NULL
THEN ARRAY[
COALESCE(
NULLIF(btrim(COALESCE(sku.image_url, '')), ''),
NULLIF(btrim(COALESCE(product.product_image_url, '')), '')
)
]
ELSE ARRAY[]::text[]
END AS image_urls,
NULL::text AS source_pre_sku_id,
NULL::text AS source_asset_variant_id,
ARRAY[]::text[] AS source_asset_variant_ids,
NULLIF(btrim(COALESCE(sku.model, '')), '') AS asset_family_code,
NULL::text AS asset_family_name,
NULLIF(btrim(COALESCE(sku.attribute_code, '')), '') AS spec_code,
NULLIF(btrim(COALESCE(sku.spec_name, '')), '') AS spec_name,
NULLIF(btrim(COALESCE(sku.barcode, '')), '') AS barcode,
CASE
WHEN NULLIF(btrim(COALESCE(sku.size, '')), '') IS NOT NULL
THEN jsonb_build_object('label', btrim(sku.size))
ELSE NULL::jsonb
END AS size_json,
NULLIF(btrim(COALESCE(sku.color, '')), '') AS color,
NULLIF(btrim(COALESCE(sku.material, '')), '') AS material,
NULL::text AS source_status,
NULL::text AS synced_at,
NULL::text AS warehouse_location_id,
NULL::text AS warehouse_location_code,
NULL::text AS warehouse_location_name,
product.created_at AS created_at,
product.updated_at AS updated_at,
CASE
WHEN NULLIF(btrim(COALESCE(product.spu, '')), '') IS NOT NULL
THEN upper(btrim(product.spu))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku.sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku.sku_code))
ELSE NULL
END AS sku_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NOT NULL
THEN upper(btrim(sku.barcode))
ELSE NULL
END AS barcode_business_key,
'INTAKE_READY'::text AS catalog_source,
product.appearance_designer,
product.structure_designer,
product.spu_owner,
product.planning_owner,
product.space_designer,
product.purchase_owner
FROM external.intake_sku_items_ready_v1 sku
JOIN external.intake_products_ready_v1 product
ON product.intake_product_id = sku.intake_product_id
WHERE NOT EXISTS (
SELECT 1
FROM formal_identity_map formal
WHERE (
NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NOT NULL
AND formal.barcode_business_key = upper(btrim(sku.barcode))
)
OR (
NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NULL
AND NULLIF(btrim(COALESCE(sku.sku_code, '')), '') IS NOT NULL
AND formal.normalized_sku_code = upper(btrim(sku.sku_code))
)
)
),
sku_source AS (
SELECT * FROM formal_sku_source
UNION ALL
SELECT * FROM intake_ready_sku_source
),
asset_summary AS (
SELECT
product_business_key,
sku_business_key,
COUNT(*) FILTER (WHERE asset_type = 'SKU_IMAGE')::integer AS image_asset_count,
COUNT(*) FILTER (WHERE asset_type = 'SKU_MODEL')::integer AS model_asset_count,
COUNT(*) FILTER (WHERE asset_type = 'SKU_THUMBNAIL')::integer AS thumbnail_asset_count,
MIN(asset_url) FILTER (WHERE asset_type = 'SKU_MODEL') AS first_model_asset_url,
MIN(asset_url) FILTER (WHERE asset_type = 'SKU_THUMBNAIL') AS first_thumbnail_asset_url
FROM external.product_assets_v1
GROUP BY product_business_key, sku_business_key
),
packaged_summary AS (
SELECT
barcode_business_key,
COUNT(*)::integer AS packaged_item_count,
COALESCE(SUM(stock_quantity), 0)::integer AS packaged_stock_quantity
FROM external.packaged_items_v1
WHERE NULLIF(barcode_business_key, '') IS NOT NULL
GROUP BY barcode_business_key
),
quality_summary AS (
SELECT
barcode_business_key,
COUNT(*)::integer AS quality_record_count,
COUNT(*) FILTER (WHERE source_type = 'WAREHOUSE')::integer AS warehouse_quality_record_count,
COUNT(*) FILTER (WHERE source_type = 'FIRST_BATCH')::integer AS first_batch_quality_record_count
FROM external.sku_quality_inspections_v1
WHERE NULLIF(barcode_business_key, '') IS NOT NULL
GROUP BY barcode_business_key
),
latest_quality AS (
SELECT DISTINCT ON (barcode_business_key)
barcode_business_key,
record_key AS latest_quality_record_key,
source_type AS latest_quality_source_type,
batch_no AS latest_quality_batch_no,
inspected_at AS latest_quality_inspected_at,
conclusion AS latest_quality_conclusion,
status AS latest_quality_status
FROM external.sku_quality_inspections_v1
WHERE NULLIF(barcode_business_key, '') IS NOT NULL
ORDER BY barcode_business_key, COALESCE(inspected_at, updated_at, created_at) DESC, created_at DESC
),
limit_by_barcode AS (
SELECT DISTINCT ON (identity_value)
identity_value,
product_business_key AS limit_source_product_business_key,
sku_business_key AS limit_source_sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at
FROM (
SELECT
upper(btrim(barcode)) AS identity_value,
product_business_key,
sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at,
updated_at
FROM external.intake_product_limits_v1
WHERE NULLIF(btrim(COALESCE(barcode, '')), '') IS NOT NULL
) limit_rows
WHERE NULLIF(identity_value, '') IS NOT NULL
ORDER BY
identity_value,
limit_price_applicable DESC,
limit_price_confirmed DESC,
limit_confirmed_at DESC NULLS LAST,
updated_at DESC
),
limit_by_sku AS (
SELECT DISTINCT ON (identity_value)
identity_value,
product_business_key AS limit_source_product_business_key,
sku_business_key AS limit_source_sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at
FROM (
SELECT
upper(btrim(sku_code)) AS identity_value,
product_business_key,
sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at,
updated_at
FROM external.intake_product_limits_v1
WHERE NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
) limit_rows
WHERE NULLIF(identity_value, '') IS NOT NULL
ORDER BY
identity_value,
limit_price_applicable DESC,
limit_price_confirmed DESC,
limit_confirmed_at DESC NULLS LAST,
updated_at DESC
),
catalog_by_sku AS (
SELECT
sku.product_id,
sku.product_model_number,
sku.product_business_key,
sku.product_name,
sku.product_description,
sku.product_category_id,
sku.product_category_name,
sku.product_thumbnail_url,
sku.product_model_url,
sku.sku_id,
sku.sku_code,
sku.sku_business_key,
sku.sku_name,
sku.price,
sku.cost_price,
sku.stock,
sku.is_active,
sku.record_status,
sku.model_url,
sku.image_url,
sku.image_urls,
sku.source_pre_sku_id,
sku.source_asset_variant_id,
sku.source_asset_variant_ids,
sku.asset_family_code,
sku.asset_family_name,
sku.spec_code,
sku.spec_name,
sku.barcode,
sku.barcode_business_key,
sku.size_json,
sku.color,
sku.material,
sku.source_status,
sku.synced_at,
sku.warehouse_location_id,
sku.warehouse_location_code,
sku.warehouse_location_name,
stock.warehouse_zone,
stock.warehouse_aisle,
stock.warehouse_shelf,
stock.warehouse_bin,
stock.warehouse_location_is_active,
stock.warehouse_location_status,
COALESCE(limit_by_barcode.limit_price_applicable, limit_by_sku.limit_price_applicable, false) AS limit_price_applicable,
COALESCE(limit_by_barcode.limit_price_confirmed, limit_by_sku.limit_price_confirmed, false) AS limit_price_confirmed,
COALESCE(limit_by_barcode.limit_price_status, limit_by_sku.limit_price_status, 'NOT_APPLICABLE') AS limit_price_status,
COALESCE(limit_by_barcode.expected_daily_limit_price, limit_by_sku.expected_daily_limit_price) AS expected_daily_limit_price,
COALESCE(limit_by_barcode.expected_limit_gross_margin_rate, limit_by_sku.expected_limit_gross_margin_rate) AS expected_limit_gross_margin_rate,
COALESCE(limit_by_barcode.non_spu_owner_limit_price, limit_by_sku.non_spu_owner_limit_price) AS non_spu_owner_limit_price,
COALESCE(limit_by_barcode.limit_confirmed_at, limit_by_sku.limit_confirmed_at) AS limit_confirmed_at,
COALESCE(limit_by_barcode.limit_source_product_business_key, limit_by_sku.limit_source_product_business_key) AS limit_source_product_business_key,
COALESCE(limit_by_barcode.limit_source_sku_business_key, limit_by_sku.limit_source_sku_business_key) AS limit_source_sku_business_key,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN CASE WHEN NULLIF(btrim(COALESCE(sku.image_url, '')), '') IS NOT NULL THEN 1 ELSE 0 END
ELSE COALESCE(asset_summary.image_asset_count, 0)
END AS image_asset_count,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN CASE WHEN NULLIF(btrim(COALESCE(sku.model_url, '')), '') IS NOT NULL THEN 1 ELSE 0 END
ELSE COALESCE(asset_summary.model_asset_count, 0)
END AS model_asset_count,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN CASE
WHEN COALESCE(
NULLIF(btrim(COALESCE(sku.product_thumbnail_url, '')), ''),
NULLIF(btrim(COALESCE(sku.image_url, '')), '')
) IS NOT NULL
THEN 1
ELSE 0
END
ELSE COALESCE(asset_summary.thumbnail_asset_count, 0)
END AS thumbnail_asset_count,
COALESCE(
asset_summary.first_model_asset_url,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN NULLIF(btrim(COALESCE(sku.model_url, '')), '')
ELSE NULL
END
) AS first_model_asset_url,
COALESCE(
asset_summary.first_thumbnail_asset_url,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN COALESCE(
NULLIF(btrim(COALESCE(sku.product_thumbnail_url, '')), ''),
NULLIF(btrim(COALESCE(sku.image_url, '')), '')
)
ELSE NULL
END
) AS first_thumbnail_asset_url,
COALESCE(bom_summary.bom_item_count, 0) AS bom_item_count,
COALESCE(bom_summary.panel_item_count, 0) AS panel_item_count,
COALESCE(bom_summary.hardware_item_count, 0) AS hardware_item_count,
COALESCE(bom_summary.material_codes, ARRAY[]::text[]) AS material_codes,
COALESCE(bom_summary.material_names, ARRAY[]::text[]) AS material_names,
COALESCE(packaged_summary.packaged_item_count, 0) AS packaged_item_count,
COALESCE(packaged_summary.packaged_stock_quantity, 0) AS packaged_stock_quantity,
COALESCE(quality_summary.quality_record_count, 0) AS quality_record_count,
COALESCE(quality_summary.warehouse_quality_record_count, 0) AS warehouse_quality_record_count,
COALESCE(quality_summary.first_batch_quality_record_count, 0) AS first_batch_quality_record_count,
latest_quality.latest_quality_record_key,
latest_quality.latest_quality_source_type,
latest_quality.latest_quality_batch_no,
latest_quality.latest_quality_inspected_at,
latest_quality.latest_quality_conclusion,
latest_quality.latest_quality_status,
sku.created_at,
sku.updated_at,
sku.catalog_source,
sku.appearance_designer,
sku.structure_designer,
sku.spu_owner,
sku.planning_owner,
sku.space_designer,
sku.purchase_owner
FROM sku_source sku
LEFT JOIN external.sku_warehouse_stock_v1 stock
ON stock.barcode_business_key = sku.barcode_business_key
LEFT JOIN limit_by_sku
ON limit_by_sku.identity_value = upper(btrim(COALESCE(sku.sku_code, '')))
LEFT JOIN limit_by_barcode
ON limit_by_barcode.identity_value = sku.barcode_business_key
LEFT JOIN asset_summary
ON asset_summary.product_business_key = sku.product_business_key
AND asset_summary.sku_business_key = sku.sku_business_key
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS bom_item_count,
COUNT(*) FILTER (WHERE item_type = 'PANEL')::integer AS panel_item_count,
COUNT(*) FILTER (WHERE item_type = 'HARDWARE')::integer AS hardware_item_count,
array_remove(array_agg(DISTINCT material_code ORDER BY material_code), NULL) AS material_codes,
array_remove(array_agg(DISTINCT material_name ORDER BY material_name), NULL) AS material_names
FROM external.product_bom_v1 bom
WHERE bom.product_business_key = sku.product_business_key
AND (
bom.sku_business_key = sku.sku_business_key
OR bom.sku_business_key IS NULL
)
) bom_summary ON true
LEFT JOIN packaged_summary
ON packaged_summary.barcode_business_key = sku.barcode_business_key
LEFT JOIN quality_summary
ON quality_summary.barcode_business_key = sku.barcode_business_key
LEFT JOIN latest_quality
ON latest_quality.barcode_business_key = sku.barcode_business_key
)
SELECT DISTINCT ON (catalog_by_sku.barcode_business_key)
catalog_by_sku.*
FROM catalog_by_sku
WHERE catalog_by_sku.barcode_business_key IS NOT NULL
AND btrim(catalog_by_sku.barcode_business_key) <> ''
ORDER BY
catalog_by_sku.barcode_business_key,
CASE WHEN catalog_by_sku.catalog_source IS NULL THEN 1
WHEN catalog_by_sku.catalog_source = 'FORMAL' THEN 0
ELSE 1 END,
catalog_by_sku.updated_at DESC NULLS LAST,
catalog_by_sku.created_at DESC;

View File

@ -1,16 +0,0 @@
CREATE TABLE "InstallationDemoConfig" (
"id" TEXT NOT NULL,
"drawingId" TEXT NOT NULL,
"parts" JSONB NOT NULL,
"steps" JSONB NOT NULL,
"numbering" JSONB,
"uploadedModel" JSONB,
"explodeTemplate" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InstallationDemoConfig_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "InstallationDemoConfig_drawingId_key"
ON "InstallationDemoConfig"("drawingId");

View File

@ -1,26 +0,0 @@
ALTER TABLE "IntakeNonIntegratedSkuItem"
ADD COLUMN "demandFlowNo" TEXT NOT NULL DEFAULT '',
ADD COLUMN "purchaseOwner" TEXT NOT NULL DEFAULT '';
UPDATE "IntakeNonIntegratedSkuItem" AS sku
SET
"demandFlowNo" = COALESCE(
NULLIF(BTRIM(product."payload" -> 'skuBatchFlowNos' ->> sku."batchNo"::text), ''),
NULLIF(BTRIM(profile."demandFlowNo"), ''),
''
),
"purchaseOwner" = COALESCE(
NULLIF(BTRIM(profile."purchaseOwner"), ''),
NULLIF(BTRIM(product."purchaser"), ''),
''
)
FROM "IntakeNonIntegratedProduct" AS product
LEFT JOIN "IntakeNonIntegratedProductProfile" AS profile
ON profile."productId" = product."id"
WHERE sku."productId" = product."id";
CREATE INDEX "IntakeNonIntegratedSkuItem_productId_purchaseOwner_idx"
ON "IntakeNonIntegratedSkuItem"("productId", "purchaseOwner");
CREATE INDEX "IntakeNonIntegratedSkuItem_demandFlowNo_idx"
ON "IntakeNonIntegratedSkuItem"("demandFlowNo");

View File

@ -1,12 +0,0 @@
-- Prisma @updatedAt columns are maintained by application writes, not by a
-- database default. Align fresh migration replay with the current schema and
-- the existing local database.
ALTER TABLE "AiRenderWorkbenchSku"
ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "AiRenderWorkbenchSpu"
ALTER COLUMN "updatedAt" DROP DEFAULT;
ALTER TABLE "BarcodePrintImportRecord"
ALTER COLUMN "updatedAt" DROP DEFAULT;

View File

@ -1 +0,0 @@
ALTER TYPE "BusinessAccountType" ADD VALUE IF NOT EXISTS 'OPERATIONS';

View File

@ -1,6 +0,0 @@
UPDATE "User" AS u
SET "businessRoles" = ARRAY['OPERATIONS'::"BusinessAccountType"]
FROM "Department" AS d
WHERE u."departmentId" = d."id"
AND cardinality(u."businessRoles") = 0
AND regexp_replace(d."fullPath", '^\s*[^>]+>\s*', '') LIKE '运营中心%';

View File

@ -1,9 +0,0 @@
UPDATE "User" AS u
SET "businessRoles" = CASE
WHEN cardinality(u."businessRoles") = 0 THEN ARRAY['OPERATIONS'::"BusinessAccountType"]
ELSE array_append(u."businessRoles", 'OPERATIONS'::"BusinessAccountType")
END
FROM "Department" AS d
WHERE u."departmentId" = d."id"
AND split_part(d."fullPath", ' > ', 2) LIKE '运营中心%'
AND NOT ('OPERATIONS'::"BusinessAccountType" = ANY(u."businessRoles"));

View File

@ -1,2 +0,0 @@
ALTER TYPE "BusinessAccountType" ADD VALUE IF NOT EXISTS 'GENERAL';
ALTER TYPE "BusinessAccountType" ADD VALUE IF NOT EXISTS 'STRATEGY';

View File

@ -1,30 +0,0 @@
UPDATE "User" AS u
SET "businessRoles" = CASE
WHEN cardinality(array_remove(u."businessRoles", 'GENERAL'::"BusinessAccountType")) = 0 THEN ARRAY['PLANNING'::"BusinessAccountType"]
ELSE array_append(array_remove(u."businessRoles", 'GENERAL'::"BusinessAccountType"), 'PLANNING'::"BusinessAccountType")
END
FROM "Department" AS d
WHERE u."departmentId" = d."id"
AND split_part(d."fullPath", ' > ', 2) = '品牌中心'
AND (
d."fullPath" LIKE '% > 空间视觉部%'
OR d."fullPath" LIKE '% > 商品企划部%'
)
AND NOT ('PLANNING'::"BusinessAccountType" = ANY(u."businessRoles"));
UPDATE "User" AS u
SET "businessRoles" = CASE
WHEN cardinality(array_remove(u."businessRoles", 'GENERAL'::"BusinessAccountType")) = 0 THEN ARRAY['STRATEGY'::"BusinessAccountType"]
ELSE array_append(array_remove(u."businessRoles", 'GENERAL'::"BusinessAccountType"), 'STRATEGY'::"BusinessAccountType")
END
FROM "Department" AS d
WHERE u."departmentId" = d."id"
AND split_part(d."fullPath", ' > ', 2) = '品牌中心'
AND d."fullPath" NOT LIKE '% > 空间视觉部%'
AND d."fullPath" NOT LIKE '% > 商品企划部%'
AND NOT ('STRATEGY'::"BusinessAccountType" = ANY(u."businessRoles"));
UPDATE "User"
SET "businessRoles" = ARRAY['GENERAL'::"BusinessAccountType"]
WHERE cardinality("businessRoles") = 0
AND (NULLIF(BTRIM(COALESCE("supplierCode", '')), '') IS NULL);

View File

@ -1 +0,0 @@
ALTER TYPE "BusinessAccountType" ADD VALUE IF NOT EXISTS 'CUSTOMER_SERVICE';

View File

@ -1,20 +0,0 @@
WITH customer_service_department AS (
SELECT U&'\5BA2\670D\4E2D\5FC3'::text AS name
)
UPDATE "User" AS u
SET "businessRoles" = CASE
WHEN cardinality(array_remove(COALESCE(u."businessRoles", ARRAY[]::"BusinessAccountType"[]), 'GENERAL'::"BusinessAccountType")) = 0
THEN ARRAY['CUSTOMER_SERVICE'::"BusinessAccountType"]
ELSE array_append(
array_remove(COALESCE(u."businessRoles", ARRAY[]::"BusinessAccountType"[]), 'GENERAL'::"BusinessAccountType"),
'CUSTOMER_SERVICE'::"BusinessAccountType"
)
END
FROM "Department" AS d, customer_service_department AS cs
WHERE u."departmentId" = d."id"
AND (
d."fullPath" = cs.name
OR split_part(d."fullPath", ' > ', 2) = cs.name
OR d."fullPath" LIKE ('% > ' || cs.name || '%')
)
AND NOT ('CUSTOMER_SERVICE'::"BusinessAccountType" = ANY(COALESCE(u."businessRoles", ARRAY[]::"BusinessAccountType"[])));

View File

@ -1,13 +0,0 @@
ALTER TABLE "IntakeNonIntegratedPricingRow"
ADD COLUMN "jstVirtualInventoryRetryCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "jstVirtualInventoryLastAttemptAt" TIMESTAMP(3);
ALTER TABLE "IntakeNonIntegratedPricingRow"
ADD CONSTRAINT "IntakeNonIntegratedPricingRow_virtual_inventory_retry_count_valid"
CHECK ("jstVirtualInventoryRetryCount" >= 0);
CREATE INDEX "IntakeNonIntegratedPricingRow_virtual_inventory_due_idx"
ON "IntakeNonIntegratedPricingRow" (
"jstVirtualInventoryStatus",
"jstVirtualInventoryScheduledAt"
);

View File

@ -1,38 +0,0 @@
CREATE TYPE "OperationLogAction" AS ENUM (
'INTAKE_PRODUCT_WRITE',
'JUSHUITAN_PRODUCT_PUSH',
'JUSHUITAN_VIRTUAL_INVENTORY_PUSH'
);
CREATE TYPE "OperationLogStatus" AS ENUM ('SUCCESS', 'FAILED', 'RETRY');
CREATE TYPE "OperationLogSource" AS ENUM ('USER', 'SYSTEM');
CREATE TABLE "OperationLog" (
"id" TEXT NOT NULL,
"action" "OperationLogAction" NOT NULL,
"status" "OperationLogStatus" NOT NULL,
"source" "OperationLogSource" NOT NULL DEFAULT 'USER',
"actorId" TEXT,
"actorName" TEXT NOT NULL,
"productId" TEXT,
"productSpu" TEXT,
"skuId" TEXT,
"skuCode" TEXT,
"workspaceCategory" TEXT,
"errorCode" TEXT,
"message" TEXT NOT NULL,
"requestId" TEXT,
"details" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OperationLog_pkey" PRIMARY KEY ("id"),
CONSTRAINT "OperationLog_actorId_fkey"
FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "OperationLog_status_createdAt_idx" ON "OperationLog"("status", "createdAt");
CREATE INDEX "OperationLog_action_createdAt_idx" ON "OperationLog"("action", "createdAt");
CREATE INDEX "OperationLog_actorId_createdAt_idx" ON "OperationLog"("actorId", "createdAt");
CREATE INDEX "OperationLog_productSpu_createdAt_idx" ON "OperationLog"("productSpu", "createdAt");
CREATE INDEX "OperationLog_productId_createdAt_idx" ON "OperationLog"("productId", "createdAt");

View File

@ -1,659 +0,0 @@
-- Move product-factory fields out of the compatibility payload and into the
-- structured non-integrated SKU truth. The payload is used only for this
-- one-time backfill; runtime reads and the external contract use columns.
ALTER TABLE "IntakeNonIntegratedSkuItem"
ADD COLUMN IF NOT EXISTS "factoryWarehouse" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "shippingMethod" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "minimumOrderQuantity" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "taxIncludedUnitPrice" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "leadTime" TEXT NOT NULL DEFAULT '';
WITH factory_rows AS (
SELECT DISTINCT ON (product."id", factory_row.item ->> 'skuId')
product."id" AS product_id,
factory_row.item ->> 'skuId' AS sku_item_id,
factory_row.item
FROM "IntakeNonIntegratedProduct" product
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(product."payload" -> 'factoryRows') = 'array'
THEN product."payload" -> 'factoryRows'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS factory_row(item, ordinal_position)
WHERE NULLIF(btrim(COALESCE(factory_row.item ->> 'skuId', '')), '') IS NOT NULL
ORDER BY product."id", factory_row.item ->> 'skuId', factory_row.ordinal_position DESC
)
UPDATE "IntakeNonIntegratedSkuItem" sku
SET
"manufacturer" = COALESCE(
NULLIF(btrim(sku."manufacturer"), ''),
btrim(COALESCE(factory.item ->> 'supplierName', ''))
),
"manufacturerCode" = COALESCE(
NULLIF(btrim(sku."manufacturerCode"), ''),
upper(btrim(COALESCE(factory.item ->> 'supplierCode', '')))
),
"installMethod" = COALESCE(
NULLIF(btrim(sku."installMethod"), ''),
btrim(COALESCE(factory.item ->> 'installMethod', ''))
),
"factoryWarehouse" = COALESCE(
NULLIF(btrim(sku."factoryWarehouse"), ''),
btrim(COALESCE(factory.item ->> 'warehouse', ''))
),
"shippingMethod" = COALESCE(
NULLIF(btrim(sku."shippingMethod"), ''),
btrim(COALESCE(factory.item ->> 'shippingMethod', ''))
),
"minimumOrderQuantity" = COALESCE(
NULLIF(btrim(sku."minimumOrderQuantity"), ''),
btrim(COALESCE(factory.item ->> 'minimumOrderQuantity', ''))
),
"taxIncludedUnitPrice" = COALESCE(
NULLIF(btrim(sku."taxIncludedUnitPrice"), ''),
btrim(COALESCE(factory.item ->> 'taxIncludedUnitPrice', ''))
),
"leadTime" = COALESCE(
NULLIF(btrim(sku."leadTime"), ''),
btrim(COALESCE(factory.item ->> 'leadTime', ''))
)
FROM factory_rows factory
WHERE sku."productId" = factory.product_id
AND sku."skuItemId" = factory.sku_item_id;
COMMENT ON COLUMN "IntakeNonIntegratedSkuItem"."factoryWarehouse" IS
'Product-factory shipping warehouse; not the inventory warehouse location.';
CREATE OR REPLACE VIEW external.intake_sku_items_v1 AS
SELECT
product."id" AS intake_product_id,
product."spu" AS spu,
product."brand" AS brand,
sku."skuItemId" AS intake_sku_item_id,
sku."skuSequence" AS sku_sequence,
sku."batchNo" AS batch_no,
external.to_external_asset_url(sku."image") AS image_url,
sku."imageName" AS image_name,
sku."model" AS model,
sku."skuCode" AS sku_code,
sku."specName" AS spec_name,
sku."barcode" AS barcode,
sku."size" AS size,
sku."color" AS color,
sku."material" AS material,
sku."versionNo" AS version_no,
sku."attributeCode" AS attribute_code,
sku."drawingId" AS drawing_id,
sku."installMethod" AS install_method,
sku."executeStandard" AS execute_standard,
sku."manufacturer" AS manufacturer,
sku."manufacturerCode" AS manufacturer_code,
sku."salesStatus" AS sales_status,
external.to_external_asset_url(sku."model3dDataUrl") AS model_3d_url,
sku."source" AS source,
sku."sourceTable" AS source_table,
sku."sourceSkuId" AS source_sku_id,
sku."missingRequiredFields" AS missing_required_fields,
sku."dataQualityStatus"::text AS data_quality_status,
sku."contentLocked" AS content_locked,
sku."sortOrder" AS sort_order,
CASE
WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL
THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu"))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(sku."skuCode", '')), '') IS NOT NULL
THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu")) || ':' || upper(btrim(sku."skuCode"))
ELSE NULL
END AS sku_business_key,
sku."factoryWarehouse" AS factory_warehouse,
sku."shippingMethod" AS shipping_method,
sku."minimumOrderQuantity" AS minimum_order_quantity,
sku."taxIncludedUnitPrice" AS tax_included_unit_price,
sku."leadTime" AS lead_time
FROM "IntakeNonIntegratedSkuItem" sku
JOIN "IntakeNonIntegratedProduct" product ON product."id" = sku."productId"
WHERE product."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.intake_sku_items_ready_v1 AS
SELECT sku.*
FROM external.intake_sku_items_v1 sku
JOIN external.intake_products_ready_v1 product
ON product.intake_product_id = sku.intake_product_id
WHERE sku.data_quality_status = 'READY'
AND NULLIF(btrim(COALESCE(sku.sku_business_key, '')), '') IS NOT NULL;
CREATE OR REPLACE VIEW external.intake_products_v1 AS
SELECT
product."id" AS intake_product_id,
product."spu" AS spu,
product."spuBase" AS spu_base,
product."brand" AS brand,
profile."productName" AS product_name,
external.to_external_asset_url(profile."productImageUrl") AS product_image_url,
profile."productCategory" AS product_category,
profile."salesCategory" AS sales_category,
profile."productYear" AS product_year,
profile."productStatus" AS product_status,
COALESCE(NULLIF(profile."structureDesigner", ''), NULLIF(product."designer", '')) AS designer,
COALESCE(NULLIF(profile."productPlanning", ''), NULLIF(product."operator", '')) AS operator,
COALESCE(NULLIF(profile."purchaseOwner", ''), NULLIF(product."purchaser", '')) AS purchaser,
profile."productDevelopmentKind" AS product_development_kind,
profile."goodsType" AS goods_type,
profile."materialType" AS material_type,
profile."productionType" AS production_type,
product."createdAt" AS created_at,
product."updatedAt" AS updated_at,
profile."productPlanning" AS product_planning,
profile."visualPlanning" AS visual_planning,
profile."structureDesigner" AS structure_designer,
profile."purchaseOwner" AS purchase_owner,
CASE
WHEN NULLIF(btrim(COALESCE(product."brand", '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product."spu", '')), '') IS NOT NULL
THEN upper(btrim(product."brand")) || ':' || upper(btrim(product."spu"))
ELSE NULL
END AS product_business_key,
NULLIF(btrim(COALESCE(product."designer", '')), '') AS appearance_designer,
COALESCE(
NULLIF(btrim(COALESCE(profile."spuOwner", '')), ''),
NULLIF(btrim(COALESCE(product."operator", '')), ''),
NULLIF(btrim(COALESCE(profile."operationsOwner", '')), '')
) AS spu_owner,
NULLIF(btrim(COALESCE(profile."productPlanning", '')), '') AS planning_owner,
COALESCE(
NULLIF(btrim(COALESCE(profile."visualPlanning", '')), ''),
NULLIF(btrim(COALESCE(profile."visualDirector", '')), '')
) AS space_designer
FROM "IntakeNonIntegratedProduct" product
LEFT JOIN "IntakeNonIntegratedProductProfile" profile ON profile."productId" = product."id"
WHERE product."deletedAt" IS NULL;
CREATE OR REPLACE VIEW external.intake_products_ready_v1 AS
SELECT *
FROM external.intake_products_v1
WHERE NULLIF(btrim(COALESCE(product_name, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_category, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_development_kind, '')), '') IS NOT NULL
AND NULLIF(btrim(COALESCE(product_business_key, '')), '') IS NOT NULL;
CREATE OR REPLACE VIEW external.product_catalog_wide_v1 AS
WITH formal_sku_source AS (
SELECT
sku.product_id,
sku.product_model_number,
sku.product_name,
sku.product_description,
sku.product_category_id,
sku.product_category_name,
sku.product_thumbnail_url,
sku.product_model_url,
sku.sku_id,
sku.sku_code,
sku.sku_name,
sku.price,
sku.cost_price,
sku.stock,
sku.is_active,
sku.record_status,
sku.model_url,
sku.image_url,
sku.image_urls,
sku.source_pre_sku_id,
sku.source_asset_variant_id,
sku.source_asset_variant_ids,
sku.asset_family_code,
sku.asset_family_name,
sku.spec_code,
sku.spec_name,
sku.barcode,
sku.size_json,
sku.color,
sku.material,
sku.source_status,
sku.synced_at,
sku.warehouse_location_id,
sku.warehouse_location_code,
sku.warehouse_location_name,
sku.created_at,
sku.updated_at,
sku.product_business_key,
sku.sku_business_key,
sku.barcode_business_key,
'FORMAL'::text AS catalog_source,
NULL::text AS appearance_designer,
NULL::text AS structure_designer,
NULL::text AS spu_owner,
NULL::text AS planning_owner,
NULL::text AS space_designer,
NULL::text AS purchase_owner,
NULL::text AS supplier_name,
NULL::text AS supplier_code,
NULL::text AS factory_warehouse,
NULL::text AS shipping_method,
NULL::text AS minimum_order_quantity,
NULL::text AS tax_included_unit_price,
NULL::text AS lead_time,
NULL::text AS install_method
FROM external.product_skus_v1 sku
),
formal_identity_map AS (
SELECT
barcode_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku_code))
ELSE NULL
END AS normalized_sku_code
FROM formal_sku_source
),
intake_ready_sku_source AS (
SELECT
product.intake_product_id AS product_id,
NULLIF(btrim(COALESCE(product.spu, '')), '') AS product_model_number,
NULLIF(btrim(COALESCE(product.product_name, '')), '') AS product_name,
NULL::text AS product_description,
NULL::text AS product_category_id,
NULLIF(btrim(COALESCE(product.product_category, '')), '') AS product_category_name,
COALESCE(
NULLIF(btrim(COALESCE(product.product_image_url, '')), ''),
NULLIF(btrim(COALESCE(sku.image_url, '')), '')
) AS product_thumbnail_url,
NULL::text AS product_model_url,
sku.intake_sku_item_id AS sku_id,
NULLIF(btrim(COALESCE(sku.sku_code, '')), '') AS sku_code,
COALESCE(
NULLIF(btrim(COALESCE(sku.spec_name, '')), ''),
NULLIF(btrim(COALESCE(sku.sku_code, '')), '')
) AS sku_name,
NULL::numeric(10,2) AS price,
NULL::numeric(10,2) AS cost_price,
0::integer AS stock,
true AS is_active,
'ACTIVE'::text AS record_status,
NULLIF(btrim(COALESCE(sku.model_3d_url, '')), '') AS model_url,
COALESCE(
NULLIF(btrim(COALESCE(sku.image_url, '')), ''),
NULLIF(btrim(COALESCE(product.product_image_url, '')), '')
) AS image_url,
CASE
WHEN COALESCE(
NULLIF(btrim(COALESCE(sku.image_url, '')), ''),
NULLIF(btrim(COALESCE(product.product_image_url, '')), '')
) IS NOT NULL
THEN ARRAY[
COALESCE(
NULLIF(btrim(COALESCE(sku.image_url, '')), ''),
NULLIF(btrim(COALESCE(product.product_image_url, '')), '')
)
]
ELSE ARRAY[]::text[]
END AS image_urls,
NULL::text AS source_pre_sku_id,
NULL::text AS source_asset_variant_id,
ARRAY[]::text[] AS source_asset_variant_ids,
NULLIF(btrim(COALESCE(sku.model, '')), '') AS asset_family_code,
NULL::text AS asset_family_name,
NULLIF(btrim(COALESCE(sku.attribute_code, '')), '') AS spec_code,
NULLIF(btrim(COALESCE(sku.spec_name, '')), '') AS spec_name,
NULLIF(btrim(COALESCE(sku.barcode, '')), '') AS barcode,
CASE
WHEN NULLIF(btrim(COALESCE(sku.size, '')), '') IS NOT NULL
THEN jsonb_build_object('label', btrim(sku.size))
ELSE NULL::jsonb
END AS size_json,
NULLIF(btrim(COALESCE(sku.color, '')), '') AS color,
NULLIF(btrim(COALESCE(sku.material, '')), '') AS material,
NULL::text AS source_status,
NULL::text AS synced_at,
NULL::text AS warehouse_location_id,
NULL::text AS warehouse_location_code,
NULL::text AS warehouse_location_name,
product.created_at AS created_at,
product.updated_at AS updated_at,
CASE
WHEN NULLIF(btrim(COALESCE(product.spu, '')), '') IS NOT NULL
THEN upper(btrim(product.spu))
ELSE NULL
END AS product_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku.sku_code, '')), '') IS NOT NULL
THEN upper(btrim(sku.sku_code))
ELSE NULL
END AS sku_business_key,
CASE
WHEN NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NOT NULL
THEN upper(btrim(sku.barcode))
ELSE NULL
END AS barcode_business_key,
'INTAKE_READY'::text AS catalog_source,
product.appearance_designer,
product.structure_designer,
product.spu_owner,
product.planning_owner,
product.space_designer,
product.purchase_owner,
NULLIF(btrim(COALESCE(sku.manufacturer, '')), '') AS supplier_name,
NULLIF(btrim(COALESCE(sku.manufacturer_code, '')), '') AS supplier_code,
NULLIF(btrim(COALESCE(sku.factory_warehouse, '')), '') AS factory_warehouse,
NULLIF(btrim(COALESCE(sku.shipping_method, '')), '') AS shipping_method,
NULLIF(btrim(COALESCE(sku.minimum_order_quantity, '')), '') AS minimum_order_quantity,
NULLIF(btrim(COALESCE(sku.tax_included_unit_price, '')), '') AS tax_included_unit_price,
NULLIF(btrim(COALESCE(sku.lead_time, '')), '') AS lead_time,
NULLIF(btrim(COALESCE(sku.install_method, '')), '') AS install_method
FROM external.intake_sku_items_ready_v1 sku
JOIN external.intake_products_ready_v1 product
ON product.intake_product_id = sku.intake_product_id
WHERE NOT EXISTS (
SELECT 1
FROM formal_identity_map formal
WHERE (
NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NOT NULL
AND formal.barcode_business_key = upper(btrim(sku.barcode))
)
OR (
NULLIF(btrim(COALESCE(sku.barcode, '')), '') IS NULL
AND NULLIF(btrim(COALESCE(sku.sku_code, '')), '') IS NOT NULL
AND formal.normalized_sku_code = upper(btrim(sku.sku_code))
)
)
),
sku_source AS (
SELECT * FROM formal_sku_source
UNION ALL
SELECT * FROM intake_ready_sku_source
),
asset_summary AS (
SELECT
product_business_key,
sku_business_key,
COUNT(*) FILTER (WHERE asset_type = 'SKU_IMAGE')::integer AS image_asset_count,
COUNT(*) FILTER (WHERE asset_type = 'SKU_MODEL')::integer AS model_asset_count,
COUNT(*) FILTER (WHERE asset_type = 'SKU_THUMBNAIL')::integer AS thumbnail_asset_count,
MIN(asset_url) FILTER (WHERE asset_type = 'SKU_MODEL') AS first_model_asset_url,
MIN(asset_url) FILTER (WHERE asset_type = 'SKU_THUMBNAIL') AS first_thumbnail_asset_url
FROM external.product_assets_v1
GROUP BY product_business_key, sku_business_key
),
packaged_summary AS (
SELECT
barcode_business_key,
COUNT(*)::integer AS packaged_item_count,
COALESCE(SUM(stock_quantity), 0)::integer AS packaged_stock_quantity
FROM external.packaged_items_v1
WHERE NULLIF(barcode_business_key, '') IS NOT NULL
GROUP BY barcode_business_key
),
quality_summary AS (
SELECT
barcode_business_key,
COUNT(*)::integer AS quality_record_count,
COUNT(*) FILTER (WHERE source_type = 'WAREHOUSE')::integer AS warehouse_quality_record_count,
COUNT(*) FILTER (WHERE source_type = 'FIRST_BATCH')::integer AS first_batch_quality_record_count
FROM external.sku_quality_inspections_v1
WHERE NULLIF(barcode_business_key, '') IS NOT NULL
GROUP BY barcode_business_key
),
latest_quality AS (
SELECT DISTINCT ON (barcode_business_key)
barcode_business_key,
record_key AS latest_quality_record_key,
source_type AS latest_quality_source_type,
batch_no AS latest_quality_batch_no,
inspected_at AS latest_quality_inspected_at,
conclusion AS latest_quality_conclusion,
status AS latest_quality_status
FROM external.sku_quality_inspections_v1
WHERE NULLIF(barcode_business_key, '') IS NOT NULL
ORDER BY barcode_business_key, COALESCE(inspected_at, updated_at, created_at) DESC, created_at DESC
),
limit_by_barcode AS (
SELECT DISTINCT ON (identity_value)
identity_value,
product_business_key AS limit_source_product_business_key,
sku_business_key AS limit_source_sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at
FROM (
SELECT
upper(btrim(barcode)) AS identity_value,
product_business_key,
sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at,
updated_at
FROM external.intake_product_limits_v1
WHERE NULLIF(btrim(COALESCE(barcode, '')), '') IS NOT NULL
) limit_rows
WHERE NULLIF(identity_value, '') IS NOT NULL
ORDER BY
identity_value,
limit_price_applicable DESC,
limit_price_confirmed DESC,
limit_confirmed_at DESC NULLS LAST,
updated_at DESC
),
limit_by_sku AS (
SELECT DISTINCT ON (identity_value)
identity_value,
product_business_key AS limit_source_product_business_key,
sku_business_key AS limit_source_sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at
FROM (
SELECT
upper(btrim(sku_code)) AS identity_value,
product_business_key,
sku_business_key,
limit_price_applicable,
limit_price_confirmed,
limit_price_status,
expected_daily_limit_price,
expected_limit_gross_margin_rate,
non_spu_owner_limit_price,
limit_confirmed_at,
updated_at
FROM external.intake_product_limits_v1
WHERE NULLIF(btrim(COALESCE(sku_code, '')), '') IS NOT NULL
) limit_rows
WHERE NULLIF(identity_value, '') IS NOT NULL
ORDER BY
identity_value,
limit_price_applicable DESC,
limit_price_confirmed DESC,
limit_confirmed_at DESC NULLS LAST,
updated_at DESC
),
catalog_by_sku AS (
SELECT
sku.product_id,
sku.product_model_number,
sku.product_business_key,
sku.product_name,
sku.product_description,
sku.product_category_id,
sku.product_category_name,
sku.product_thumbnail_url,
sku.product_model_url,
sku.sku_id,
sku.sku_code,
sku.sku_business_key,
sku.sku_name,
sku.price,
sku.cost_price,
sku.stock,
sku.is_active,
sku.record_status,
sku.model_url,
sku.image_url,
sku.image_urls,
sku.source_pre_sku_id,
sku.source_asset_variant_id,
sku.source_asset_variant_ids,
sku.asset_family_code,
sku.asset_family_name,
sku.spec_code,
sku.spec_name,
sku.barcode,
sku.barcode_business_key,
sku.size_json,
sku.color,
sku.material,
sku.source_status,
sku.synced_at,
sku.warehouse_location_id,
sku.warehouse_location_code,
sku.warehouse_location_name,
stock.warehouse_zone,
stock.warehouse_aisle,
stock.warehouse_shelf,
stock.warehouse_bin,
stock.warehouse_location_is_active,
stock.warehouse_location_status,
COALESCE(limit_by_barcode.limit_price_applicable, limit_by_sku.limit_price_applicable, false) AS limit_price_applicable,
COALESCE(limit_by_barcode.limit_price_confirmed, limit_by_sku.limit_price_confirmed, false) AS limit_price_confirmed,
COALESCE(limit_by_barcode.limit_price_status, limit_by_sku.limit_price_status, 'NOT_APPLICABLE') AS limit_price_status,
COALESCE(limit_by_barcode.expected_daily_limit_price, limit_by_sku.expected_daily_limit_price) AS expected_daily_limit_price,
COALESCE(limit_by_barcode.expected_limit_gross_margin_rate, limit_by_sku.expected_limit_gross_margin_rate) AS expected_limit_gross_margin_rate,
COALESCE(limit_by_barcode.non_spu_owner_limit_price, limit_by_sku.non_spu_owner_limit_price) AS non_spu_owner_limit_price,
COALESCE(limit_by_barcode.limit_confirmed_at, limit_by_sku.limit_confirmed_at) AS limit_confirmed_at,
COALESCE(limit_by_barcode.limit_source_product_business_key, limit_by_sku.limit_source_product_business_key) AS limit_source_product_business_key,
COALESCE(limit_by_barcode.limit_source_sku_business_key, limit_by_sku.limit_source_sku_business_key) AS limit_source_sku_business_key,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN CASE WHEN NULLIF(btrim(COALESCE(sku.image_url, '')), '') IS NOT NULL THEN 1 ELSE 0 END
ELSE COALESCE(asset_summary.image_asset_count, 0)
END AS image_asset_count,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN CASE WHEN NULLIF(btrim(COALESCE(sku.model_url, '')), '') IS NOT NULL THEN 1 ELSE 0 END
ELSE COALESCE(asset_summary.model_asset_count, 0)
END AS model_asset_count,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN CASE
WHEN COALESCE(
NULLIF(btrim(COALESCE(sku.product_thumbnail_url, '')), ''),
NULLIF(btrim(COALESCE(sku.image_url, '')), '')
) IS NOT NULL
THEN 1
ELSE 0
END
ELSE COALESCE(asset_summary.thumbnail_asset_count, 0)
END AS thumbnail_asset_count,
COALESCE(
asset_summary.first_model_asset_url,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN NULLIF(btrim(COALESCE(sku.model_url, '')), '')
ELSE NULL
END
) AS first_model_asset_url,
COALESCE(
asset_summary.first_thumbnail_asset_url,
CASE
WHEN sku.catalog_source = 'INTAKE_READY'
THEN COALESCE(
NULLIF(btrim(COALESCE(sku.product_thumbnail_url, '')), ''),
NULLIF(btrim(COALESCE(sku.image_url, '')), '')
)
ELSE NULL
END
) AS first_thumbnail_asset_url,
COALESCE(bom_summary.bom_item_count, 0) AS bom_item_count,
COALESCE(bom_summary.panel_item_count, 0) AS panel_item_count,
COALESCE(bom_summary.hardware_item_count, 0) AS hardware_item_count,
COALESCE(bom_summary.material_codes, ARRAY[]::text[]) AS material_codes,
COALESCE(bom_summary.material_names, ARRAY[]::text[]) AS material_names,
COALESCE(packaged_summary.packaged_item_count, 0) AS packaged_item_count,
COALESCE(packaged_summary.packaged_stock_quantity, 0) AS packaged_stock_quantity,
COALESCE(quality_summary.quality_record_count, 0) AS quality_record_count,
COALESCE(quality_summary.warehouse_quality_record_count, 0) AS warehouse_quality_record_count,
COALESCE(quality_summary.first_batch_quality_record_count, 0) AS first_batch_quality_record_count,
latest_quality.latest_quality_record_key,
latest_quality.latest_quality_source_type,
latest_quality.latest_quality_batch_no,
latest_quality.latest_quality_inspected_at,
latest_quality.latest_quality_conclusion,
latest_quality.latest_quality_status,
sku.created_at,
sku.updated_at,
sku.catalog_source,
sku.appearance_designer,
sku.structure_designer,
sku.spu_owner,
sku.planning_owner,
sku.space_designer,
sku.purchase_owner,
sku.supplier_name,
sku.supplier_code,
sku.factory_warehouse,
sku.shipping_method,
sku.minimum_order_quantity,
sku.tax_included_unit_price,
sku.lead_time,
sku.install_method
FROM sku_source sku
LEFT JOIN external.sku_warehouse_stock_v1 stock
ON stock.barcode_business_key = sku.barcode_business_key
LEFT JOIN limit_by_sku
ON limit_by_sku.identity_value = upper(btrim(COALESCE(sku.sku_code, '')))
LEFT JOIN limit_by_barcode
ON limit_by_barcode.identity_value = sku.barcode_business_key
LEFT JOIN asset_summary
ON asset_summary.product_business_key = sku.product_business_key
AND asset_summary.sku_business_key = sku.sku_business_key
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS bom_item_count,
COUNT(*) FILTER (WHERE item_type = 'PANEL')::integer AS panel_item_count,
COUNT(*) FILTER (WHERE item_type = 'HARDWARE')::integer AS hardware_item_count,
array_remove(array_agg(DISTINCT material_code ORDER BY material_code), NULL) AS material_codes,
array_remove(array_agg(DISTINCT material_name ORDER BY material_name), NULL) AS material_names
FROM external.product_bom_v1 bom
WHERE bom.product_business_key = sku.product_business_key
AND (
bom.sku_business_key = sku.sku_business_key
OR bom.sku_business_key IS NULL
)
) bom_summary ON true
LEFT JOIN packaged_summary
ON packaged_summary.barcode_business_key = sku.barcode_business_key
LEFT JOIN quality_summary
ON quality_summary.barcode_business_key = sku.barcode_business_key
LEFT JOIN latest_quality
ON latest_quality.barcode_business_key = sku.barcode_business_key
)
SELECT DISTINCT ON (catalog_by_sku.barcode_business_key)
catalog_by_sku.*
FROM catalog_by_sku
WHERE catalog_by_sku.barcode_business_key IS NOT NULL
AND btrim(catalog_by_sku.barcode_business_key) <> ''
ORDER BY
catalog_by_sku.barcode_business_key,
CASE WHEN catalog_by_sku.catalog_source IS NULL THEN 1
WHEN catalog_by_sku.catalog_source = 'FORMAL' THEN 0
ELSE 1 END,
catalog_by_sku.updated_at DESC NULLS LAST,
catalog_by_sku.created_at DESC;

View File

@ -1,18 +0,0 @@
ALTER TABLE "InstallationDemoConfig"
ADD COLUMN IF NOT EXISTS "toolIds" JSONB;
CREATE TABLE IF NOT EXISTS "InstallationDemoTool" (
"id" TEXT NOT NULL,
"toolCode" TEXT NOT NULL,
"name" TEXT NOT NULL,
"modelUrl" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mountOffset" JSONB,
"mountRotation" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InstallationDemoTool_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "InstallationDemoTool_toolCode_key"
ON "InstallationDemoTool"("toolCode");

View File

@ -1,4 +0,0 @@
ALTER TYPE "OperationLogAction" ADD VALUE IF NOT EXISTS 'INTAKE_PRODUCT_CREATE';
ALTER TYPE "OperationLogAction" ADD VALUE IF NOT EXISTS 'INTAKE_PRODUCT_DELETE';
ALTER TYPE "OperationLogAction" ADD VALUE IF NOT EXISTS 'INTAKE_FILE_UPLOAD';
ALTER TYPE "OperationLogAction" ADD VALUE IF NOT EXISTS 'INTAKE_WORKSPACE_CONFIRMATION';

View File

@ -1,180 +0,0 @@
CREATE TYPE "BomCostingCaseStatus" AS ENUM (
'DRAFT',
'QUEUED',
'ANALYZING',
'READY',
'FAILED'
);
CREATE TYPE "BomCostingLineCategory" AS ENUM (
'BOARD',
'STEEL',
'ACCESSORY',
'PACKAGING',
'PROCESSING',
'LABOR'
);
CREATE TYPE "BomCostingMatchStatus" AS ENUM (
'UNMATCHED',
'MATCHED',
'NEEDS_CONFIRMATION'
);
CREATE TABLE "BomCostingCase" (
"id" TEXT NOT NULL,
"sourceUrl" TEXT NOT NULL,
"sourceHost" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT '',
"providerCode" TEXT NOT NULL DEFAULT '',
"providerTaskId" TEXT NOT NULL DEFAULT '',
"analysisSchemaVersion" TEXT NOT NULL DEFAULT 'competitor-analysis/v1',
"status" "BomCostingCaseStatus" NOT NULL DEFAULT 'DRAFT',
"progress" INTEGER NOT NULL DEFAULT 0,
"statusMessage" TEXT NOT NULL DEFAULT '',
"targetErrorPercent" DECIMAL(5,2) NOT NULL DEFAULT 10,
"currency" TEXT NOT NULL DEFAULT 'CNY',
"rawAnalysisSnapshot" JSONB,
"createdById" TEXT NOT NULL,
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingCase_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "BomCostingSku" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"externalSkuId" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT '',
"structureGroupKey" TEXT NOT NULL DEFAULT '',
"imageUrl" TEXT,
"attributes" JSONB,
"lengthMm" DECIMAL(12,3),
"widthMm" DECIMAL(12,3),
"heightMm" DECIMAL(12,3),
"weightKg" DECIMAL(12,3),
"packageLengthMm" DECIMAL(12,3),
"packageWidthMm" DECIMAL(12,3),
"packageHeightMm" DECIMAL(12,3),
"packageWeightKg" DECIMAL(12,3),
"estimatedCost" DECIMAL(12,2),
"costCalculatedAt" TIMESTAMP(3),
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingSku_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "BomCostingLine" (
"id" TEXT NOT NULL,
"skuId" TEXT NOT NULL,
"externalLineId" TEXT NOT NULL,
"category" "BomCostingLineCategory" NOT NULL,
"partName" TEXT NOT NULL,
"materialCode" TEXT NOT NULL DEFAULT '',
"materialName" TEXT NOT NULL DEFAULT '',
"materialSpec" TEXT NOT NULL DEFAULT '',
"processName" TEXT NOT NULL DEFAULT '',
"packagingKind" TEXT NOT NULL DEFAULT '',
"lengthMm" DECIMAL(12,3),
"widthMm" DECIMAL(12,3),
"heightMm" DECIMAL(12,3),
"quantity" DECIMAL(12,4) NOT NULL DEFAULT 1,
"unit" TEXT NOT NULL DEFAULT '',
"weightKg" DECIMAL(12,4),
"lossFactor" DECIMAL(8,4) NOT NULL DEFAULT 1,
"sourceConfidence" DECIMAL(5,4),
"evidence" JSONB,
"matchStatus" "BomCostingMatchStatus" NOT NULL DEFAULT 'UNMATCHED',
"matchedLibraryType" TEXT NOT NULL DEFAULT '',
"matchedLibraryRecordId" TEXT NOT NULL DEFAULT '',
"matchedLibraryName" TEXT NOT NULL DEFAULT '',
"matchedLibrarySpec" TEXT NOT NULL DEFAULT '',
"resolvedUnitPrice" DECIMAL(12,4),
"calculatedQuantity" DECIMAL(14,6),
"subtotal" DECIMAL(12,2),
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingLine_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "BomCostingPriceSnapshot" (
"id" TEXT NOT NULL,
"lineId" TEXT NOT NULL,
"sourceType" TEXT NOT NULL,
"sourceRecordId" TEXT NOT NULL,
"sourceName" TEXT NOT NULL,
"sourceSpec" TEXT NOT NULL DEFAULT '',
"unitPrice" DECIMAL(12,4) NOT NULL,
"unit" TEXT NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'CNY',
"lossFactor" DECIMAL(8,4) NOT NULL DEFAULT 1,
"isCurrent" BOOLEAN NOT NULL DEFAULT true,
"matchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BomCostingPriceSnapshot_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "BomCostingTaskEvent" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"status" TEXT NOT NULL,
"progress" INTEGER NOT NULL DEFAULT 0,
"message" TEXT NOT NULL DEFAULT '',
"providerTaskId" TEXT NOT NULL DEFAULT '',
"payload" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BomCostingTaskEvent_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "BomCostingSku_caseId_externalSkuId_key"
ON "BomCostingSku"("caseId", "externalSkuId");
CREATE UNIQUE INDEX "BomCostingLine_skuId_externalLineId_key"
ON "BomCostingLine"("skuId", "externalLineId");
CREATE INDEX "BomCostingCase_createdById_deletedAt_createdAt_idx"
ON "BomCostingCase"("createdById", "deletedAt", "createdAt");
CREATE INDEX "BomCostingCase_status_deletedAt_idx"
ON "BomCostingCase"("status", "deletedAt");
CREATE INDEX "BomCostingCase_providerTaskId_idx"
ON "BomCostingCase"("providerTaskId");
CREATE INDEX "BomCostingSku_caseId_sortOrder_idx"
ON "BomCostingSku"("caseId", "sortOrder");
CREATE INDEX "BomCostingSku_structureGroupKey_idx"
ON "BomCostingSku"("structureGroupKey");
CREATE INDEX "BomCostingLine_skuId_sortOrder_idx"
ON "BomCostingLine"("skuId", "sortOrder");
CREATE INDEX "BomCostingLine_category_matchStatus_idx"
ON "BomCostingLine"("category", "matchStatus");
CREATE INDEX "BomCostingLine_matchedLibraryType_matchedLibraryRecordId_idx"
ON "BomCostingLine"("matchedLibraryType", "matchedLibraryRecordId");
CREATE INDEX "BomCostingPriceSnapshot_lineId_isCurrent_idx"
ON "BomCostingPriceSnapshot"("lineId", "isCurrent");
CREATE INDEX "BomCostingPriceSnapshot_sourceType_sourceRecordId_idx"
ON "BomCostingPriceSnapshot"("sourceType", "sourceRecordId");
CREATE INDEX "BomCostingTaskEvent_caseId_createdAt_idx"
ON "BomCostingTaskEvent"("caseId", "createdAt");
CREATE INDEX "BomCostingTaskEvent_providerTaskId_idx"
ON "BomCostingTaskEvent"("providerTaskId");
ALTER TABLE "BomCostingCase"
ADD CONSTRAINT "BomCostingCase_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "BomCostingSku"
ADD CONSTRAINT "BomCostingSku_caseId_fkey"
FOREIGN KEY ("caseId") REFERENCES "BomCostingCase"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingLine"
ADD CONSTRAINT "BomCostingLine_skuId_fkey"
FOREIGN KEY ("skuId") REFERENCES "BomCostingSku"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingPriceSnapshot"
ADD CONSTRAINT "BomCostingPriceSnapshot_lineId_fkey"
FOREIGN KEY ("lineId") REFERENCES "BomCostingLine"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingTaskEvent"
ADD CONSTRAINT "BomCostingTaskEvent_caseId_fkey"
FOREIGN KEY ("caseId") REFERENCES "BomCostingCase"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,84 +0,0 @@
-- CreateTable
CREATE TABLE "WorkRecordScoreRule" (
"id" TEXT NOT NULL,
"primary" TEXT NOT NULL,
"secondary" TEXT NOT NULL DEFAULT '',
"unitScore" DECIMAL(12,4),
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkRecordScoreRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkRecord" (
"id" TEXT NOT NULL,
"workDate" DATE NOT NULL,
"designerId" TEXT NOT NULL,
"designerName" TEXT NOT NULL,
"primary" TEXT NOT NULL,
"secondary" TEXT NOT NULL DEFAULT '',
"scoreRuleId" TEXT,
"approvalOrTask" TEXT NOT NULL DEFAULT '',
"spu" TEXT NOT NULL DEFAULT '',
"quantity" DECIMAL(12,2) NOT NULL DEFAULT 1,
"unitScore" DECIMAL(12,4),
"taskTotal" DECIMAL(14,4),
"fileStoragePath" TEXT NOT NULL DEFAULT '',
"createdById" TEXT NOT NULL,
"updatedById" TEXT NOT NULL,
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkRecordMonthlyArchive" (
"id" TEXT NOT NULL,
"monthKey" TEXT NOT NULL,
"periodStart" DATE NOT NULL,
"periodEnd" DATE NOT NULL,
"fileName" TEXT NOT NULL,
"summary" JSONB NOT NULL,
"records" JSONB NOT NULL,
"recordCount" INTEGER NOT NULL,
"generatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkRecordMonthlyArchive_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WorkRecordScoreRule_primary_sortOrder_idx" ON "WorkRecordScoreRule"("primary", "sortOrder");
-- CreateIndex
CREATE INDEX "WorkRecordScoreRule_isActive_sortOrder_idx" ON "WorkRecordScoreRule"("isActive", "sortOrder");
-- CreateIndex
CREATE INDEX "WorkRecord_workDate_deletedAt_idx" ON "WorkRecord"("workDate", "deletedAt");
-- CreateIndex
CREATE INDEX "WorkRecord_designerId_workDate_idx" ON "WorkRecord"("designerId", "workDate");
-- CreateIndex
CREATE INDEX "WorkRecord_primary_workDate_idx" ON "WorkRecord"("primary", "workDate");
-- CreateIndex
CREATE INDEX "WorkRecord_scoreRuleId_idx" ON "WorkRecord"("scoreRuleId");
-- CreateIndex
CREATE UNIQUE INDEX "WorkRecordMonthlyArchive_monthKey_key" ON "WorkRecordMonthlyArchive"("monthKey");
-- CreateIndex
CREATE INDEX "WorkRecordMonthlyArchive_periodStart_idx" ON "WorkRecordMonthlyArchive"("periodStart");
-- AddForeignKey
ALTER TABLE "WorkRecord" ADD CONSTRAINT "WorkRecord_designerId_fkey" FOREIGN KEY ("designerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkRecord" ADD CONSTRAINT "WorkRecord_scoreRuleId_fkey" FOREIGN KEY ("scoreRuleId") REFERENCES "WorkRecordScoreRule"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -1,7 +0,0 @@
UPDATE "WorkRecordScoreRule"
SET
"secondary" = '将图纸上传图纸系统并生成对应二维码需存入对应SPU文件夹',
"updatedAt" = CURRENT_TIMESTAMP
WHERE
"primary" = '上传图纸+制作二维码'
AND "secondary" = '将图纸上传图纸系统并生成对应二维码 / 需存入对应SPU文件夹';

View File

@ -1,36 +0,0 @@
CREATE TYPE "ReleaseNoteStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
CREATE TABLE "ReleaseNote" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"content" JSONB NOT NULL,
"targetBusinessRoles" "BusinessAccountType"[] NOT NULL DEFAULT ARRAY[]::"BusinessAccountType"[],
"status" "ReleaseNoteStatus" NOT NULL DEFAULT 'DRAFT',
"publishedAt" TIMESTAMP(3),
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReleaseNote_pkey" PRIMARY KEY ("id"),
CONSTRAINT "ReleaseNote_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE TABLE "ReleaseNoteAcknowledgement" (
"id" TEXT NOT NULL,
"releaseNoteId" TEXT NOT NULL,
"readerId" TEXT NOT NULL,
"readAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReleaseNoteAcknowledgement_pkey" PRIMARY KEY ("id"),
CONSTRAINT "ReleaseNoteAcknowledgement_releaseNoteId_fkey"
FOREIGN KEY ("releaseNoteId") REFERENCES "ReleaseNote"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ReleaseNoteAcknowledgement_readerId_fkey"
FOREIGN KEY ("readerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "ReleaseNote_status_publishedAt_idx" ON "ReleaseNote"("status", "publishedAt");
CREATE INDEX "ReleaseNote_createdById_idx" ON "ReleaseNote"("createdById");
CREATE UNIQUE INDEX "ReleaseNoteAcknowledgement_releaseNoteId_readerId_key" ON "ReleaseNoteAcknowledgement"("releaseNoteId", "readerId");
CREATE INDEX "ReleaseNoteAcknowledgement_readerId_readAt_idx" ON "ReleaseNoteAcknowledgement"("readerId", "readAt");

View File

@ -1,91 +0,0 @@
CREATE TYPE "BomCostingSourceStatus" AS ENUM (
'PENDING',
'FETCHING',
'ARCHIVING',
'READY',
'FAILED'
);
CREATE TYPE "BomCostingSourceAssetKind" AS ENUM (
'MAIN',
'DETAIL',
'SKU'
);
CREATE TYPE "BomCostingSourceAssetStatus" AS ENUM (
'PENDING',
'STORED',
'FAILED'
);
CREATE TABLE "BomCostingSourceSnapshot" (
"id" TEXT NOT NULL,
"sourceKey" TEXT NOT NULL,
"sourceUrl" TEXT NOT NULL,
"sourceHost" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"externalItemId" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT '',
"shopName" TEXT NOT NULL DEFAULT '',
"detailUrl" TEXT,
"status" "BomCostingSourceStatus" NOT NULL DEFAULT 'PENDING',
"skuCount" INTEGER NOT NULL DEFAULT 0,
"imageCount" INTEGER NOT NULL DEFAULT 0,
"storedImageCount" INTEGER NOT NULL DEFAULT 0,
"failedImageCount" INTEGER NOT NULL DEFAULT 0,
"rawSnapshot" JSONB,
"errorMessage" TEXT NOT NULL DEFAULT '',
"fetchedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingSourceSnapshot_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "BomCostingSourceAsset" (
"id" TEXT NOT NULL,
"sourceSnapshotId" TEXT NOT NULL,
"assetKey" TEXT NOT NULL,
"kind" "BomCostingSourceAssetKind" NOT NULL,
"status" "BomCostingSourceAssetStatus" NOT NULL DEFAULT 'PENDING',
"originalUrl" TEXT NOT NULL,
"storedUrl" TEXT NOT NULL DEFAULT '',
"storageKey" TEXT NOT NULL DEFAULT '',
"contentType" TEXT NOT NULL DEFAULT '',
"byteSize" INTEGER,
"externalSkuIds" JSONB NOT NULL DEFAULT '[]',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingSourceAsset_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "BomCostingCase"
ADD COLUMN "sourceSnapshotId" TEXT,
ADD COLUMN "sourceCacheHit" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "processingStartedAt" TIMESTAMP(3),
ADD COLUMN "processingFinishedAt" TIMESTAMP(3);
CREATE UNIQUE INDEX "BomCostingSourceSnapshot_sourceKey_key"
ON "BomCostingSourceSnapshot"("sourceKey");
CREATE INDEX "BomCostingSourceSnapshot_status_updatedAt_idx"
ON "BomCostingSourceSnapshot"("status", "updatedAt");
CREATE INDEX "BomCostingSourceSnapshot_channel_externalItemId_idx"
ON "BomCostingSourceSnapshot"("channel", "externalItemId");
CREATE UNIQUE INDEX "BomCostingSourceAsset_sourceSnapshotId_assetKey_key"
ON "BomCostingSourceAsset"("sourceSnapshotId", "assetKey");
CREATE INDEX "BomCostingSourceAsset_sourceSnapshotId_status_sortOrder_idx"
ON "BomCostingSourceAsset"("sourceSnapshotId", "status", "sortOrder");
CREATE INDEX "BomCostingCase_sourceSnapshotId_idx"
ON "BomCostingCase"("sourceSnapshotId");
ALTER TABLE "BomCostingCase"
ADD CONSTRAINT "BomCostingCase_sourceSnapshotId_fkey"
FOREIGN KEY ("sourceSnapshotId") REFERENCES "BomCostingSourceSnapshot"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "BomCostingSourceAsset"
ADD CONSTRAINT "BomCostingSourceAsset_sourceSnapshotId_fkey"
FOREIGN KEY ("sourceSnapshotId") REFERENCES "BomCostingSourceSnapshot"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,188 +0,0 @@
CREATE TYPE "IntakeAiRenderOutputMode" AS ENUM ('WHITE_BACKGROUND', 'EIGHT_VIEW', 'SCENE');
CREATE TYPE "IntakeAiRenderStorageClass" AS ENUM ('TEMPORARY', 'PERMANENT');
CREATE TABLE "IntakeProductModelAsset" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"modelKey" TEXT NOT NULL,
"displayName" TEXT NOT NULL DEFAULT '',
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IntakeProductModelAsset_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "IntakeProductModelVersion" (
"id" TEXT NOT NULL,
"assetId" TEXT NOT NULL,
"versionNo" INTEGER NOT NULL,
"originalFileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"checksumSha256" TEXT NOT NULL DEFAULT '',
"sourceStorageKey" TEXT NOT NULL,
"sourceUrl" TEXT NOT NULL,
"previewStorageKey" TEXT,
"previewUrl" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeProductModelVersion_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "IntakeSkuAiProfile" (
"id" TEXT NOT NULL,
"skuItemId" TEXT NOT NULL,
"currentModelVersionId" TEXT,
"currentMaterialVersionId" TEXT,
"createdBy" TEXT NOT NULL,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IntakeSkuAiProfile_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "IntakeSkuAiMaterialVersion" (
"id" TEXT NOT NULL,
"profileId" TEXT NOT NULL,
"versionNo" INTEGER NOT NULL,
"materialColor" TEXT NOT NULL DEFAULT '#d8c6ad',
"textureStorageKey" TEXT,
"textureUrl" TEXT,
"nodeMaterials" JSONB,
"uvConfig" JSONB,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeSkuAiMaterialVersion_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "AssistedAssemblyAiRender"
ADD COLUMN "intakeSkuItemId" TEXT,
ADD COLUMN "modelVersionId" TEXT,
ADD COLUMN "materialVersionId" TEXT,
ADD COLUMN "outputMode" "IntakeAiRenderOutputMode",
ADD COLUMN "batchId" TEXT,
ADD COLUMN "viewName" TEXT,
ADD COLUMN "viewIndex" INTEGER,
ADD COLUMN "storageClass" "IntakeAiRenderStorageClass" NOT NULL DEFAULT 'TEMPORARY';
CREATE UNIQUE INDEX "IntakeProductModelAsset_productId_modelKey_key"
ON "IntakeProductModelAsset"("productId", "modelKey");
CREATE INDEX "IntakeProductModelAsset_productId_createdAt_idx"
ON "IntakeProductModelAsset"("productId", "createdAt");
CREATE INDEX "IntakeProductModelAsset_createdBy_idx"
ON "IntakeProductModelAsset"("createdBy");
CREATE UNIQUE INDEX "IntakeProductModelVersion_assetId_versionNo_key"
ON "IntakeProductModelVersion"("assetId", "versionNo");
CREATE INDEX "IntakeProductModelVersion_assetId_createdAt_idx"
ON "IntakeProductModelVersion"("assetId", "createdAt");
CREATE INDEX "IntakeProductModelVersion_checksumSha256_idx"
ON "IntakeProductModelVersion"("checksumSha256");
CREATE INDEX "IntakeProductModelVersion_createdBy_idx"
ON "IntakeProductModelVersion"("createdBy");
CREATE UNIQUE INDEX "IntakeSkuAiProfile_skuItemId_key"
ON "IntakeSkuAiProfile"("skuItemId");
CREATE UNIQUE INDEX "IntakeSkuAiProfile_currentMaterialVersionId_key"
ON "IntakeSkuAiProfile"("currentMaterialVersionId");
CREATE INDEX "IntakeSkuAiProfile_currentModelVersionId_idx"
ON "IntakeSkuAiProfile"("currentModelVersionId");
CREATE INDEX "IntakeSkuAiProfile_createdBy_idx"
ON "IntakeSkuAiProfile"("createdBy");
CREATE UNIQUE INDEX "IntakeSkuAiMaterialVersion_profileId_versionNo_key"
ON "IntakeSkuAiMaterialVersion"("profileId", "versionNo");
CREATE INDEX "IntakeSkuAiMaterialVersion_profileId_createdAt_idx"
ON "IntakeSkuAiMaterialVersion"("profileId", "createdAt");
CREATE INDEX "IntakeSkuAiMaterialVersion_createdBy_idx"
ON "IntakeSkuAiMaterialVersion"("createdBy");
CREATE INDEX "AssistedAssemblyAiRender_intakeSkuItemId_createdAt_idx"
ON "AssistedAssemblyAiRender"("intakeSkuItemId", "createdAt");
CREATE INDEX "AssistedAssemblyAiRender_intakeSkuItemId_outputMode_createdAt_idx"
ON "AssistedAssemblyAiRender"("intakeSkuItemId", "outputMode", "createdAt");
CREATE INDEX "AssistedAssemblyAiRender_modelVersionId_idx"
ON "AssistedAssemblyAiRender"("modelVersionId");
CREATE INDEX "AssistedAssemblyAiRender_materialVersionId_idx"
ON "AssistedAssemblyAiRender"("materialVersionId");
CREATE INDEX "AssistedAssemblyAiRender_storageClass_createdAt_idx"
ON "AssistedAssemblyAiRender"("storageClass", "createdAt");
ALTER TABLE "IntakeProductModelAsset"
ADD CONSTRAINT "IntakeProductModelAsset_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "IntakeNonIntegratedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "IntakeProductModelAsset"
ADD CONSTRAINT "IntakeProductModelAsset_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "IntakeProductModelVersion"
ADD CONSTRAINT "IntakeProductModelVersion_assetId_fkey"
FOREIGN KEY ("assetId") REFERENCES "IntakeProductModelAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "IntakeProductModelVersion"
ADD CONSTRAINT "IntakeProductModelVersion_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiProfile"
ADD CONSTRAINT "IntakeSkuAiProfile_skuItemId_fkey"
FOREIGN KEY ("skuItemId") REFERENCES "IntakeNonIntegratedSkuItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiProfile"
ADD CONSTRAINT "IntakeSkuAiProfile_currentModelVersionId_fkey"
FOREIGN KEY ("currentModelVersionId") REFERENCES "IntakeProductModelVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiProfile"
ADD CONSTRAINT "IntakeSkuAiProfile_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiMaterialVersion"
ADD CONSTRAINT "IntakeSkuAiMaterialVersion_profileId_fkey"
FOREIGN KEY ("profileId") REFERENCES "IntakeSkuAiProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiMaterialVersion"
ADD CONSTRAINT "IntakeSkuAiMaterialVersion_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "IntakeSkuAiProfile"
ADD CONSTRAINT "IntakeSkuAiProfile_currentMaterialVersionId_fkey"
FOREIGN KEY ("currentMaterialVersionId") REFERENCES "IntakeSkuAiMaterialVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_intakeSkuItemId_fkey"
FOREIGN KEY ("intakeSkuItemId") REFERENCES "IntakeNonIntegratedSkuItem"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_modelVersionId_fkey"
FOREIGN KEY ("modelVersionId") REFERENCES "IntakeProductModelVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "AssistedAssemblyAiRender"
ADD CONSTRAINT "AssistedAssemblyAiRender_materialVersionId_fkey"
FOREIGN KEY ("materialVersionId") REFERENCES "IntakeSkuAiMaterialVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
UPDATE "AssistedAssemblyAiRender"
SET
"outputMode" = CASE
WHEN "params"->>'mode' = 'white-background' THEN 'WHITE_BACKGROUND'::"IntakeAiRenderOutputMode"
WHEN "params"->>'mode' = 'eight-view' THEN 'EIGHT_VIEW'::"IntakeAiRenderOutputMode"
WHEN "params"->>'mode' = 'scene' THEN 'SCENE'::"IntakeAiRenderOutputMode"
ELSE NULL
END,
"batchId" = NULLIF("params"->>'batchId', ''),
"viewName" = NULLIF("params"->>'viewName', ''),
"viewIndex" = CASE
WHEN ("params"->>'viewIndex') ~ '^[0-9]+$' THEN ("params"->>'viewIndex')::INTEGER
ELSE NULL
END;
WITH exact_matches AS (
SELECT render."id" AS "renderId", MIN(item."id") AS "skuItemId"
FROM "AssistedAssemblyAiRender" render
JOIN "AiRenderWorkbenchSku" workbench_sku ON workbench_sku."id" = render."workbenchSkuId"
JOIN "AiRenderWorkbenchSpu" workbench_spu ON workbench_spu."id" = workbench_sku."spuId"
JOIN "IntakeNonIntegratedSkuItem" item
ON UPPER(BTRIM(item."barcode")) = UPPER(BTRIM(workbench_sku."name"))
JOIN "IntakeNonIntegratedProduct" product
ON product."id" = item."productId"
AND UPPER(BTRIM(product."spu")) = UPPER(BTRIM(workbench_spu."name"))
WHERE BTRIM(item."barcode") <> ''
GROUP BY render."id"
HAVING COUNT(*) = 1
)
UPDATE "AssistedAssemblyAiRender" render
SET "intakeSkuItemId" = exact_matches."skuItemId"
FROM exact_matches
WHERE render."id" = exact_matches."renderId";

View File

@ -1,49 +0,0 @@
-- CreateEnum
CREATE TYPE "BomCostingFactAnalysisStatus" AS ENUM (
'PENDING',
'PROCESSING',
'READY',
'FAILED'
);
-- CreateTable
CREATE TABLE "BomCostingSourceFactAnalysis" (
"id" TEXT NOT NULL,
"sourceSnapshotId" TEXT NOT NULL,
"schemaVersion" TEXT NOT NULL,
"assetFingerprint" TEXT NOT NULL,
"status" "BomCostingFactAnalysisStatus" NOT NULL DEFAULT 'PENDING',
"model" TEXT NOT NULL DEFAULT '',
"factSnapshot" JSONB,
"analyzedImageCount" INTEGER NOT NULL DEFAULT 0,
"totalImageCount" INTEGER NOT NULL DEFAULT 0,
"batchCount" INTEGER NOT NULL DEFAULT 0,
"promptTokens" INTEGER NOT NULL DEFAULT 0,
"completionTokens" INTEGER NOT NULL DEFAULT 0,
"durationMs" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT NOT NULL DEFAULT '',
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingSourceFactAnalysis_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BomCostingSourceFactAnalysis_sourceSnapshotId_schemaVersion_key"
ON "BomCostingSourceFactAnalysis"("sourceSnapshotId", "schemaVersion");
-- CreateIndex
CREATE INDEX "BomCostingSourceFactAnalysis_sourceSnapshotId_status_idx"
ON "BomCostingSourceFactAnalysis"("sourceSnapshotId", "status");
-- CreateIndex
CREATE INDEX "BomCostingSourceFactAnalysis_status_updatedAt_idx"
ON "BomCostingSourceFactAnalysis"("status", "updatedAt");
-- AddForeignKey
ALTER TABLE "BomCostingSourceFactAnalysis"
ADD CONSTRAINT "BomCostingSourceFactAnalysis_sourceSnapshotId_fkey"
FOREIGN KEY ("sourceSnapshotId") REFERENCES "BomCostingSourceSnapshot"("id")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,27 +0,0 @@
-- Barcode imports are append-only business records. Rows with the same SKU,
-- barcode, or supplier must remain independently printable.
ALTER TABLE "BarcodePrintImportRecord"
ADD COLUMN "normalizedSupplierName" TEXT NOT NULL DEFAULT '';
WITH supplier_names AS (
SELECT
"id",
btrim(
CASE
WHEN btrim("supplierName") <> '' THEN "supplierName"
ELSE COALESCE("updates" ->> 'supplierName', '')
END
) AS "supplierName"
FROM "BarcodePrintImportRecord"
)
UPDATE "BarcodePrintImportRecord" AS records
SET
"supplierName" = supplier_names."supplierName",
"normalizedSupplierName" = lower(supplier_names."supplierName")
FROM supplier_names
WHERE records."id" = supplier_names."id";
DROP INDEX "BarcodePrintImportRecord_kind_normalizedSku_supplierCode_key";
CREATE INDEX "BarcodePrintImportRecord_normalizedSupplierName_idx"
ON "BarcodePrintImportRecord"("normalizedSupplierName");

View File

@ -1,140 +0,0 @@
CREATE TYPE "BomCostingFactAnalysisOrigin" AS ENUM (
'INITIAL',
'SELECTED_REANALYSIS',
'FULL_REANALYSIS'
);
CREATE TYPE "BomCostingAnalysisRunMode" AS ENUM (
'INITIAL',
'REUSE_FACTS',
'REANALYZE_SELECTED',
'REANALYZE_ALL'
);
CREATE TYPE "BomCostingAnalysisRunStatus" AS ENUM (
'PENDING',
'PROCESSING',
'READY',
'FAILED'
);
ALTER TABLE "BomCostingSourceFactAnalysis"
ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "origin" "BomCostingFactAnalysisOrigin" NOT NULL DEFAULT 'INITIAL',
ADD COLUMN "baseAnalysisId" TEXT,
ADD COLUMN "selectedAssetIds" JSONB NOT NULL DEFAULT '[]';
DROP INDEX "BomCostingSourceFactAnalysis_sourceSnapshotId_schemaVersion_key";
CREATE UNIQUE INDEX "BomCostingSourceFactAnalysis_sourceSnapshotId_schemaVersion_revision_key"
ON "BomCostingSourceFactAnalysis"("sourceSnapshotId", "schemaVersion", "revision");
CREATE INDEX "BomCostingSourceFactAnalysis_baseAnalysisId_idx"
ON "BomCostingSourceFactAnalysis"("baseAnalysisId");
ALTER TABLE "BomCostingSourceFactAnalysis"
ADD CONSTRAINT "BomCostingSourceFactAnalysis_baseAnalysisId_fkey"
FOREIGN KEY ("baseAnalysisId") REFERENCES "BomCostingSourceFactAnalysis"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE TABLE "BomCostingAnalysisRun" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"revision" INTEGER NOT NULL,
"mode" "BomCostingAnalysisRunMode" NOT NULL,
"status" "BomCostingAnalysisRunStatus" NOT NULL DEFAULT 'PENDING',
"factAnalysisId" TEXT,
"selectedAssetIds" JSONB NOT NULL DEFAULT '[]',
"instruction" TEXT NOT NULL DEFAULT '',
"resultSnapshot" JSONB,
"model" TEXT NOT NULL DEFAULT '',
"callId" TEXT NOT NULL DEFAULT '',
"promptTokens" INTEGER NOT NULL DEFAULT 0,
"completionTokens" INTEGER NOT NULL DEFAULT 0,
"durationMs" INTEGER NOT NULL DEFAULT 0,
"errorMessage" TEXT NOT NULL DEFAULT '',
"createdById" TEXT,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingAnalysisRun_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "BomCostingAnalysisRun_caseId_revision_key"
ON "BomCostingAnalysisRun"("caseId", "revision");
CREATE INDEX "BomCostingAnalysisRun_caseId_status_idx"
ON "BomCostingAnalysisRun"("caseId", "status");
CREATE INDEX "BomCostingAnalysisRun_factAnalysisId_idx"
ON "BomCostingAnalysisRun"("factAnalysisId");
CREATE INDEX "BomCostingAnalysisRun_status_updatedAt_idx"
ON "BomCostingAnalysisRun"("status", "updatedAt");
CREATE INDEX "BomCostingAnalysisRun_createdById_idx"
ON "BomCostingAnalysisRun"("createdById");
ALTER TABLE "BomCostingAnalysisRun"
ADD CONSTRAINT "BomCostingAnalysisRun_caseId_fkey"
FOREIGN KEY ("caseId") REFERENCES "BomCostingCase"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingAnalysisRun"
ADD CONSTRAINT "BomCostingAnalysisRun_factAnalysisId_fkey"
FOREIGN KEY ("factAnalysisId") REFERENCES "BomCostingSourceFactAnalysis"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "BomCostingAnalysisRun"
ADD CONSTRAINT "BomCostingAnalysisRun_createdById_fkey"
FOREIGN KEY ("createdById") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "BomCostingAnalysisRun" (
"id",
"caseId",
"revision",
"mode",
"status",
"factAnalysisId",
"resultSnapshot",
"model",
"callId",
"errorMessage",
"createdById",
"startedAt",
"completedAt",
"createdAt",
"updatedAt"
)
SELECT
'legacy-' || c."id",
c."id",
1,
'INITIAL'::"BomCostingAnalysisRunMode",
CASE c."status"
WHEN 'ANALYZING' THEN 'PROCESSING'::"BomCostingAnalysisRunStatus"
WHEN 'READY' THEN 'READY'::"BomCostingAnalysisRunStatus"
WHEN 'FAILED' THEN 'FAILED'::"BomCostingAnalysisRunStatus"
ELSE 'PENDING'::"BomCostingAnalysisRunStatus"
END,
(
SELECT f."id"
FROM "BomCostingSourceFactAnalysis" f
WHERE f."sourceSnapshotId" = c."sourceSnapshotId"
AND f."schemaVersion" = 'competitor-facts/v1'
ORDER BY f."revision" DESC
LIMIT 1
),
c."rawAnalysisSnapshot",
'',
c."providerTaskId",
CASE WHEN c."status" = 'FAILED' THEN c."statusMessage" ELSE '' END,
c."createdById",
c."processingStartedAt",
c."processingFinishedAt",
c."createdAt",
c."updatedAt"
FROM "BomCostingCase" c;

View File

@ -1,39 +0,0 @@
-- CreateEnum
CREATE TYPE "BomCostingReviewInputSource" AS ENUM ('MANUAL', 'LIBRARY_MATCH');
-- CreateTable
CREATE TABLE "BomCostingReviewInput" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"skuId" TEXT NOT NULL,
"lineId" TEXT NOT NULL,
"createdById" TEXT,
"source" "BomCostingReviewInputSource" NOT NULL DEFAULT 'MANUAL',
"lengthMm" DECIMAL(12,3),
"widthMm" DECIMAL(12,3),
"heightMm" DECIMAL(12,3),
"quantity" DECIMAL(12,4),
"materialCode" TEXT NOT NULL DEFAULT '',
"materialName" TEXT NOT NULL DEFAULT '',
"materialSpec" TEXT NOT NULL DEFAULT '',
"unitPrice" DECIMAL(12,4),
"priceUnit" TEXT NOT NULL DEFAULT '',
"currency" TEXT NOT NULL DEFAULT 'CNY',
"lossFactor" DECIMAL(8,4),
"subtotal" DECIMAL(12,2),
"note" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BomCostingReviewInput_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BomCostingReviewInput_caseId_createdAt_idx" ON "BomCostingReviewInput"("caseId", "createdAt");
CREATE INDEX "BomCostingReviewInput_lineId_createdAt_idx" ON "BomCostingReviewInput"("lineId", "createdAt");
CREATE INDEX "BomCostingReviewInput_createdById_createdAt_idx" ON "BomCostingReviewInput"("createdById", "createdAt");
-- AddForeignKey
ALTER TABLE "BomCostingReviewInput" ADD CONSTRAINT "BomCostingReviewInput_caseId_fkey" FOREIGN KEY ("caseId") REFERENCES "BomCostingCase"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingReviewInput" ADD CONSTRAINT "BomCostingReviewInput_skuId_fkey" FOREIGN KEY ("skuId") REFERENCES "BomCostingSku"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingReviewInput" ADD CONSTRAINT "BomCostingReviewInput_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "BomCostingLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BomCostingReviewInput" ADD CONSTRAINT "BomCostingReviewInput_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -1,23 +0,0 @@
-- Keep manual review history when a recalculation replaces the current SKU/BOM rows.
ALTER TABLE "BomCostingReviewInput"
ADD COLUMN "skuExternalId" TEXT NOT NULL DEFAULT '',
ADD COLUMN "lineExternalId" TEXT NOT NULL DEFAULT '',
ADD COLUMN "skuName" TEXT NOT NULL DEFAULT '',
ADD COLUMN "partName" TEXT NOT NULL DEFAULT '';
ALTER TABLE "BomCostingReviewInput"
ALTER COLUMN "skuId" DROP NOT NULL,
ALTER COLUMN "lineId" DROP NOT NULL;
ALTER TABLE "BomCostingReviewInput"
DROP CONSTRAINT "BomCostingReviewInput_skuId_fkey",
DROP CONSTRAINT "BomCostingReviewInput_lineId_fkey";
ALTER TABLE "BomCostingReviewInput"
ADD CONSTRAINT "BomCostingReviewInput_skuId_fkey"
FOREIGN KEY ("skuId") REFERENCES "BomCostingSku"("id") ON DELETE SET NULL ON UPDATE CASCADE,
ADD CONSTRAINT "BomCostingReviewInput_lineId_fkey"
FOREIGN KEY ("lineId") REFERENCES "BomCostingLine"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "BomCostingReviewInput_caseId_skuExternalId_lineExternalId_createdAt_idx"
ON "BomCostingReviewInput"("caseId", "skuExternalId", "lineExternalId", "createdAt");

View File

@ -1,15 +0,0 @@
-- Keep barcode printing page reads on indexed orderings instead of scanning imports.
CREATE INDEX "BarcodePrintImportRecord_kind_createdAt_id_idx"
ON "BarcodePrintImportRecord"("kind", "createdAt", "id");
CREATE INDEX "BarcodePrintImportRecord_kind_normalizedSupplierName_createdAt_id_idx"
ON "BarcodePrintImportRecord"("kind", "normalizedSupplierName", "createdAt", "id");
CREATE INDEX "BarcodePrintImportRecord_kind_supplierCode_createdAt_id_idx"
ON "BarcodePrintImportRecord"("kind", "supplierCode", "createdAt", "id");
CREATE INDEX "BarcodePrintImportRecord_kind_normalizedSku_createdAt_id_idx"
ON "BarcodePrintImportRecord"("kind", "normalizedSku", "createdAt", "id");
CREATE INDEX "IntakeNonIntegratedPricingRow_systemEnteredAt_productId_idx"
ON "IntakeNonIntegratedPricingRow"("systemEnteredAt", "productId");

View File

@ -1,173 +0,0 @@
-- Keep the barcode-print list in one append-only primary-record table.
-- Imported rows keep sourcePricingRowId NULL, so this does not introduce an
-- Excel import de-duplication rule.
ALTER TABLE "BarcodePrintImportRecord"
ADD COLUMN "sourcePricingRowId" TEXT;
CREATE UNIQUE INDEX "BarcodePrintImportRecord_sourcePricingRowId_key"
ON "BarcodePrintImportRecord"("sourcePricingRowId");
CREATE OR REPLACE FUNCTION "sync_barcode_print_system_pricing_row"()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
resolved_sku_code TEXT;
BEGIN
IF TG_OP = 'DELETE' THEN
DELETE FROM "BarcodePrintImportRecord"
WHERE "sourcePricingRowId" = OLD."id";
RETURN OLD;
END IF;
SELECT COALESCE(NULLIF(BTRIM(NEW."skuCode"), ''), NULLIF(BTRIM(s."skuCode"), ''))
INTO resolved_sku_code
FROM "IntakeNonIntegratedSkuItem" s
WHERE s."productId" = NEW."productId"
AND s."skuItemId" = NEW."skuItemId";
IF NEW."systemEnteredAt" IS NULL OR resolved_sku_code IS NULL THEN
DELETE FROM "BarcodePrintImportRecord"
WHERE "sourcePricingRowId" = NEW."id";
RETURN NEW;
END IF;
INSERT INTO "BarcodePrintImportRecord" (
"id", "kind", "sourcePricingRowId", "skuCode", "normalizedSku",
"supplierCode", "supplierName", "normalizedSupplierName",
"sourceFileName", "rowNumber", "importedAt", "updates", "createdBy",
"createdAt", "updatedAt"
)
SELECT
'barcode-print-system-' || NEW."id",
'barcode',
NEW."id",
resolved_sku_code,
UPPER(BTRIM(resolved_sku_code)),
COALESCE(s."manufacturerCode", ''),
COALESCE(s."manufacturer", ''),
LOWER(BTRIM(COALESCE(s."manufacturer", ''))),
'系统自动进入',
0,
NEW."systemEnteredAt",
jsonb_build_object(
'spu', product."spu",
'model', s."model",
'skuCode', resolved_sku_code,
'barcode', COALESCE(NULLIF(NEW."barcode", ''), s."barcode", ''),
'barcodeSpecName', s."specName",
'attributeCode', s."attributeCode",
'drawingId', s."drawingId",
'productCategory', COALESCE(profile."productCategory", ''),
'salesCategory', COALESCE(profile."salesCategory", ''),
'materialType', COALESCE(profile."materialType", s."boardMaterialType", ''),
'size', COALESCE(NULLIF(s."size", ''), profile."size", ''),
'color', COALESCE(NULLIF(s."color", ''), profile."color", ''),
'material', s."material",
'executeStandard', s."executeStandard",
'tagMaterial', COALESCE(NULLIF(profile."tagMaterial", ''), s."material", ''),
'supplierCode', s."manufacturerCode",
'supplierName', s."manufacturer",
'costPrice', COALESCE(NEW."costPrice"::TEXT, ''),
'productPlanning', COALESCE(profile."productPlanning", ''),
'visualPlanning', COALESCE(profile."visualPlanning", ''),
'spuOwner', COALESCE(profile."spuOwner", ''),
'operationsOwner', COALESCE(profile."operationsOwner", product."operator", ''),
'purchaseOwner', COALESCE(profile."purchaseOwner", product."purchaser", ''),
'productionType', COALESCE(profile."productionType", ''),
'assemblyDetail', s."installMethod",
'createdAt', TO_CHAR(NEW."systemEnteredAt", 'YYYY-MM-DD'),
'dataSource', '流程'
),
product."createdBy",
NEW."systemEnteredAt",
NOW()
FROM "IntakeNonIntegratedSkuItem" s
INNER JOIN "IntakeNonIntegratedProduct" product
ON product."id" = NEW."productId"
LEFT JOIN "IntakeNonIntegratedProductProfile" profile
ON profile."productId" = product."id"
WHERE s."productId" = NEW."productId"
AND s."skuItemId" = NEW."skuItemId"
ON CONFLICT ("sourcePricingRowId") DO UPDATE
SET
"skuCode" = EXCLUDED."skuCode",
"normalizedSku" = EXCLUDED."normalizedSku",
"supplierCode" = EXCLUDED."supplierCode",
"supplierName" = EXCLUDED."supplierName",
"normalizedSupplierName" = EXCLUDED."normalizedSupplierName",
"updates" = EXCLUDED."updates",
"updatedAt" = EXCLUDED."updatedAt";
RETURN NEW;
END;
$$;
CREATE TRIGGER "sync_barcode_print_system_pricing_row_trigger"
AFTER INSERT OR UPDATE OR DELETE ON "IntakeNonIntegratedPricingRow"
FOR EACH ROW
EXECUTE FUNCTION "sync_barcode_print_system_pricing_row"();
-- Existing system-entered SKUs need the same primary records before the list
-- switches to this table. The conflict target makes the backfill idempotent.
INSERT INTO "BarcodePrintImportRecord" (
"id", "kind", "sourcePricingRowId", "skuCode", "normalizedSku",
"supplierCode", "supplierName", "normalizedSupplierName",
"sourceFileName", "rowNumber", "importedAt", "updates", "createdBy",
"createdAt", "updatedAt"
)
SELECT
'barcode-print-system-' || pricing."id",
'barcode',
pricing."id",
COALESCE(NULLIF(BTRIM(pricing."skuCode"), ''), s."skuCode"),
UPPER(BTRIM(COALESCE(NULLIF(pricing."skuCode", ''), s."skuCode"))),
COALESCE(s."manufacturerCode", ''),
COALESCE(s."manufacturer", ''),
LOWER(BTRIM(COALESCE(s."manufacturer", ''))),
'系统自动进入',
0,
pricing."systemEnteredAt",
jsonb_build_object(
'spu', product."spu",
'model', s."model",
'skuCode', COALESCE(NULLIF(BTRIM(pricing."skuCode"), ''), s."skuCode"),
'barcode', COALESCE(NULLIF(pricing."barcode", ''), s."barcode", ''),
'barcodeSpecName', s."specName",
'attributeCode', s."attributeCode",
'drawingId', s."drawingId",
'productCategory', COALESCE(profile."productCategory", ''),
'salesCategory', COALESCE(profile."salesCategory", ''),
'materialType', COALESCE(profile."materialType", s."boardMaterialType", ''),
'size', COALESCE(NULLIF(s."size", ''), profile."size", ''),
'color', COALESCE(NULLIF(s."color", ''), profile."color", ''),
'material', s."material",
'executeStandard', s."executeStandard",
'tagMaterial', COALESCE(NULLIF(profile."tagMaterial", ''), s."material", ''),
'supplierCode', s."manufacturerCode",
'supplierName', s."manufacturer",
'costPrice', COALESCE(pricing."costPrice"::TEXT, ''),
'productPlanning', COALESCE(profile."productPlanning", ''),
'visualPlanning', COALESCE(profile."visualPlanning", ''),
'spuOwner', COALESCE(profile."spuOwner", ''),
'operationsOwner', COALESCE(profile."operationsOwner", product."operator", ''),
'purchaseOwner', COALESCE(profile."purchaseOwner", product."purchaser", ''),
'productionType', COALESCE(profile."productionType", ''),
'assemblyDetail', s."installMethod",
'createdAt', TO_CHAR(pricing."systemEnteredAt", 'YYYY-MM-DD'),
'dataSource', '流程'
),
product."createdBy",
pricing."systemEnteredAt",
NOW()
FROM "IntakeNonIntegratedPricingRow" pricing
INNER JOIN "IntakeNonIntegratedSkuItem" s
ON s."productId" = pricing."productId"
AND s."skuItemId" = pricing."skuItemId"
INNER JOIN "IntakeNonIntegratedProduct" product
ON product."id" = pricing."productId"
LEFT JOIN "IntakeNonIntegratedProductProfile" profile
ON profile."productId" = product."id"
WHERE pricing."systemEnteredAt" IS NOT NULL
AND BTRIM(COALESCE(NULLIF(pricing."skuCode", ''), s."skuCode")) <> ''
ON CONFLICT ("sourcePricingRowId") DO NOTHING;

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