Compare commits

...

70 Commits

Author SHA1 Message Date
居居
42b374801d fix(supply-chain): 清空停产退市商品限价 2026-10-20 17:15:02 +08:00
WuYongJie
65b9c7788c fix(installation-demo): synchronize clockwise screw and tool playback 2026-10-20 15:07:03 +08:00
居居
5330638d60 feat(supply-chain): 限价筛选条件支持自定义 2026-10-20 14:37:41 +08:00
居居
0ee5c91396 feat(supply-chain): 限价顶部新增编码筛选 2026-10-20 14:29:12 +08:00
WuYongJie
6e12f0318d feat(production-stop-demands): 完善档案字段、筛选、视图与通知中心联动
- 明细新增图片、产品标签、含大单近30日日销(条码/SKU 数值快照)

- 表头漏斗筛选(模糊/多选/数值比较/日期区间)、批量搜索、筛选设置

- 列设置拖动排序、快捷查询保存与套用

- 视图统计:日/周/月趋势、部门/需求人/原因/状态分布、饼图与明细下钻

- 通知中心:停产确认/待走流程/需求驳回通知、待办快照同步、钉钉全屏链接

- 新增迁移与历史数据回填脚本
2026-10-20 14:26:38 +08:00
居居
8c520de0aa feat(supply-chain): 新增组合限价导出 2026-10-20 13:04:59 +08:00
WuYongJie
1b6f0cd7ce feat(diy): add text replacement and template URL import
Add image text detection/replacement with original preservation, compact result actions, and stored template imports. Keep image editor and template layout dialogs at consistent viewport sizes with internal scrolling.
2026-10-20 12:12:24 +08:00
WuYongJie
5fc647ed05 feat(diy): add image adjustment, resizing and template layout tools 2026-10-20 09:42:36 +08:00
WuYongJie
37bbb43c12 Merge accepted installation demo Z-up coordinate convention 2026-10-19 17:55:04 +08:00
WuYongJie
243cfc9825 Use furniture Z-up axes in installation demo editing
Map X to width, Y to depth, and Z to height in numeric movement, drag and rotation controls, and feedback. Preserve existing Y-up model poses and saved data, and extract transform-anchor helpers.
2026-10-19 17:54:47 +08:00
居居
57cfe735a8 feat(supply-chain): 精简破价订单筛选条件 2026-10-19 17:39:59 +08:00
WuYongJie
3845817c02 fix(print): allow C-Lodop embedded preview frames 2026-10-19 17:07:33 +08:00
WuYongJie
61ae9b8444 fix(print): allow C-Lodop embedded preview frames 2026-10-19 17:07:09 +08:00
WuYongJie
e4d47f9c4e Merge accepted installation demo performance and explosion editing 2026-10-19 16:44:23 +08:00
WuYongJie
1f06f0a181 Merge accepted homepage website new-tab navigation fix 2026-10-19 16:42:50 +08:00
WuYongJie
682f0827e0 Optimize installation model preview and repair exploded layouts
Parse and cache OBJ geometry off the main thread, reuse the viewport, and adapt interaction rendering. Pull drawers out as complete groups, enforce panel and hardware clearance, and frame the applied layout. Add relative X/Y/Z movement in millimeters with existing persistence.
2026-10-19 16:42:09 +08:00
WuYongJie
7f864cc09e fix(home): open saved websites in a new tab 2026-10-19 16:41:25 +08:00
居居
b299207f44 feat(supply-chain): 品牌页纳入三类国标前缀 2026-10-19 15:57:17 +08:00
居居
f00d686c76 fix(supply-chain): 仅在品牌页按GBSFY归类 2026-10-19 15:50:59 +08:00
居居
9915d7764b fix(supply-chain): 按GBSFY前缀纠正索菲亚品牌 2026-10-19 15:46:05 +08:00
居居
ea9851fd98 feat(supply-chain): 限价列表按负责人限价降序 2026-10-19 15:37:20 +08:00
WuYongJie
793e72bd27 Merge accepted homepage fan, overview and full-width shell 2026-10-19 15:12:19 +08:00
WuYongJie
460b8fa6b9 fix(home): render homepage without the product sidebar 2026-10-19 15:12:17 +08:00
WuYongJie
b9294a0437 feat(home): add fan carousel and double-click overview 2026-10-19 15:09:53 +08:00
居居
fe742ed369 feat(supply-chain): 查询框直接支持批量筛选 2026-10-19 14:44:48 +08:00
WuYongJie
0861ad9603 feat(supply-chain): 商品库存快照落库并改读快照
- 新增 ProductInventorySnapshot / ProductInventorySnapshotRun 模型与正式迁移(条码×实际仓库)
- 同步任务复用现有库存/云仓/档案读取,临时表+事务原子替换,advisory lock 防重叠,失败保留上一份快照
- 新增 15 分钟 systemd timer 安装脚本与同步脚本(身份校验、凭据走环境变量)
- 列表/导出接口改读快照并展示 syncedAt,保留全零行过滤与导出字段选择
2026-10-19 14:41:18 +08:00
居居
4fba91cecf feat(supply-chain): 限价筛选支持批量查询 2026-10-19 14:29:51 +08:00
居居
e853adeed7 fix(supply-chain): 排除停产退市组合限价 2026-10-19 14:17:20 +08:00
居居
f9594a4260 fix(supply-chain): 限价筛选候选值联动 2026-10-19 14:15:05 +08:00
居居
ee4afbb875 style(supply-chain): 对齐表头列宽拖拽热区 2026-10-19 14:10:57 +08:00
居居
185276bb0e fix(supply-chain): 修复组合单行表头遮挡明细 2026-10-19 14:07:06 +08:00
居居
20b11b6b87 fix(supply-chain): 放行组合运营负责人筛选 2026-10-19 14:02:16 +08:00
居居
0102289abf fix(supply-chain): 修复组合限价筛选交互 2026-10-19 13:58:44 +08:00
居居
7eaa1b49ae fix(supply-chain): 完善组合限价筛选与运营负责人 2026-10-19 13:53:39 +08:00
居居
d22126e354 feat(supply-chain): 新增组合限价页 2026-10-19 13:46:16 +08:00
居居
6ae9e24016 style(supply-chain): 列名「非负责人限价(新)」改为「非负责人限价」
- 限价明细页与产品全量档案页的限价分组同步改名(导出列头随列定义一起生效)
2026-10-19 13:17:36 +08:00
居居
4a36d0ee58 fix(supply-chain): 限价明细「负责人限价」筛选补上候选值与筛选逻辑
- 负责人限价是 SupplyChainStyleLimitPrice.ownerLimitPrice 的派生列(按货号),此前 option 接口没有对应分支,
  下拉框显示「没有可选值」、选了也不生效
- 候选值:无其他筛选时取限价表的全部去重价格(两位小数,与界面一致);有筛选时只取当前结果集里货号的限价;
  无流程限价的商品提供「空值」选项
- 筛选:选中的价格先经 ROUND(price,2) 换回货号再按 sku 过滤;选中的价格无对应货号返回空结果;
  「空值」= 货号不在限价表里
- 补 3 条单测(价格→货号、无匹配、空值)
2026-10-19 13:15:34 +08:00
WuYongJie
82a1d25b17 feat(supply-chain): 产品全量档案新增安装方式列(定厂优先+商品标签推导) 2026-10-19 12:05:14 +08:00
居居
e656fa6d5d feat(supply-chain): 限价流程同步改为服务器端可运行 + systemd 每日定时器
- sync-limit-workflows.mjs 路径参数化:新增 --out-dir/--initial-dir/--env-file/--skip-excel,
  环境变量 LIMIT_SYNC_OUT_DIR / LIMIT_SYNC_INITIAL_DIR / LIMIT_SYNC_ENV_FILE / LIMIT_SYNC_SKIP_EXCEL / LIMIT_SYNC_HISTORY_KEEP;
  默认目录改为应用根目录,不再写死 Mac 桌面
- 期初数据:优先显式目录,其次仓库自带 scripts/data/limit-sync/期初数据(首次部署可直接使用),
  服务器上业务更新只需把新的「限价期初数据*.xlsx」放进 shared 目录
- 写库安全:校验 DATABASE_URL、打印目标库(不含密码)、DELETE+INSERT 包在单事务里,
  失败自动回滚保留上一版限价/授权;Excel 导出失败不影响已提交的库数据
- 输出治理:输出目录自动创建;历史版本默认保留最近 20 份,避免服务器磁盘每天累积 30MB
- 新增 run-limit-workflow-sync-production.cjs:预检 shared/.env 变量(DATABASE_URL / JST_REPORT_DB_*)、
  打印库目标、透传参数、30 分钟硬超时、失败写 job 日志
- 新增 install-limit-workflow-sync-timer.sh:照 marketing-sales 样板安装 systemd service+timer,
  EnvironmentFile=$BASE/shared/.env,默认每天北京时间 07:30,输出/期初数据放 $BASE/shared/limit-workflow-sync
- 文档补充「正式站同步」章节;顺带修正 supply-chain-page-permissions 测试的菜单页数(新增限价管理后 27 页)
- 未改动 20261014/20261015 三个迁移目录(正式库已按现名建表)
2026-10-19 09:59:18 +08:00
WuYongJie
6b2ff9159a Merge accepted DIY saved-scheme permanent deletion 2026-10-19 09:50:05 +08:00
WuYongJie
2d1486b64c feat(diy): add permanent deletion for saved schemes 2026-10-19 09:49:06 +08:00
WuYongJie
f3451de29b feat(supply-chain): 新增商品库存管理(库存/云仓/筛选/导出/快捷查询)
- 页面:成品库存 × 实际仓库,按条码匹配产品档案与仓库配置
- 服务:聚水潭成品库存全量读取与缓存、筛选/排序/分页、小计、Excel 导出
- 云仓:链接服务器 OPENQUERY 汇总 order_able_qty,支持云仓明细下钻
- 接入:菜单、标签页、权限节点 productInventory
2026-10-17 22:35:29 +08:00
居居
c4c3773dbb fix(supply-chain): 修复限价管理合并入 mainline 后的 18 处类型错误(type-check 0 错误)
提交自身的错误:
- breach-orders/route.ts:filtersSchema 去掉重复的 platform / shopName 键(保留带长度上限的一份)
- supply-chain-breach-order.service.ts:BreachOrderListFilters 去掉重复声明的 platform / shopName
- BreachOrderTab.tsx:MultiFilterChip 的 field 联合类型补齐店铺款式编码/店铺商品编码/申诉原因/平台/店铺(与 filter-options 接口支持的字段一致)
- breach-orders/[id]/route.ts 与 breach-orders/route.ts:多结果分支的处理器统一声明为 handleRequest<unknown>(…): Promise<NextResponse<unknown>>,
  与既有 reference-image-estimate 路由同一写法,解决 ctx.user 可选态与返回形状不匹配

补齐缺失的配套改动:
- errors.ts:新增 ErrorCode.RESOURCE_NOT_FOUND(含 ERROR_MESSAGES 与 HTTP 404 映射)
- ProductArchiveFilters:补 materialLevel / inspectionReport 两个筛选项类型(查询逻辑此前已随功能提交)
- bom-costing-multimodal-gateway.ts:BomCostingMultimodalOperation 增加 IMAGE_EXTRACTION(破价申诉凭证识别用),
  breach-evidence.service 对可空的 providerConfig 加空值保护
- supply-chain-product-archive-select.ts:去掉重复的 sabcRating 选择项
- supply-chain-product-archive.repository.ts:storageType 候选值已在专用分支处理,去掉恒为 false 的重复判断

验证:git 干净检出后 npm run type-check(next typegen + tsc --noEmit)0 错误;
breach/supply-chain/permission 相关 230 个测试套件 1650 用例通过,失败 7 个套件均为 mainline 原有问题。
2026-10-16 18:10:07 +08:00
WuYongJie
a07adbb558 Merge accepted DIY canvas layout and capsule controls 2026-10-16 18:04:38 +08:00
WuYongJie
1429b870f0 refactor(diy): simplify canvas controls and unify capsule styling 2026-10-16 18:03:53 +08:00
居居
83c3161ccb fix(supply-chain): 检测报告按 SPU 文本里的编码匹配(平台 SPU 是自由文本)
- 平台 spu 形如「SJ0380书架 / 顾家穿衣镜DS5154 / BJ0056」,与档案 SPU 编码不一致;
  解析时从 spu 与报告标题里提取编码(SJ0380、GBGJ-SJZ0021、DS5154…)再匹配,纯编码仍原样收下
- 通用报告(「茶几通用」等)不产出 SPU;无文件记录继续忽略
- 单测补自由文本/标题提取用例
2026-10-16 17:57:45 +08:00
居居
c555caccb0 feat(supply-chain): 限价明细「是否有检测报告」接入素材平台检测报告接口
- 新增检测报告索引服务:GET {素材平台}/api/external/certificate-reports(X-API-Key),只认有报告文件的记录,按 SPU 汇总,进程内缓存 10 分钟
- 列取自 external 接口:限价页数据接口与「导出明细(含图片)」都带上 inspectionReport(有 / 无 / 空)
- 表头筛选接入:候选值「有 / 无」,筛选按报告 SPU 清单(接口未配置时不加条件、候选值为空)
- 配置:MATERIAL_LIBRARY_CERTIFICATE_REPORT_API_BASE_URL/API_KEY,不填复用 MATERIAL_LIBRARY_WHITE_BACKGROUND_API_*;未配置时该列显示为空
- 增补单测(解析/缓存/未配置/接口报错)与文档说明
2026-10-16 17:55:20 +08:00
居居
a103a23d08 feat(supply-chain): 限价管理的查看/导出/申诉权限统一由管理员配置
- 查看:页面与数据仍走「限价管理 · 可见」;导出改按「限价管理 · 下载」,申诉/审核/生成/导入改按「限价管理 · 操作」(原来是经营管理 edit)
- 服务端:下载中心 limit-export 任务与产品档案导出接口的 columnSet=limit 都按「限价管理 · 下载」鉴权
- 前端:无下载权限不展示导出入口(限价明细两个导出、破价订单导出当前筛选/乐捐明细);规则配置仍单独走 breachRuleConfig 节点
- 申诉保留兜底:破价单负责人本人即使无「操作」权限也可申诉本人订单,审核必须「操作」
- 测试:CustomTableView 补导出入口权限用例并修正过期的表头断言;文档补充权限章节
2026-10-16 17:41:40 +08:00
居居
aec52f78ce feat(supply-chain): 限价管理(限价明细 + 破价订单审核 + 规则配置 + 流程同步)
- 限价明细:抬头列/筛选/列宽/排序可配置,批量查询(商品编码/订单号/负责人等),筛选条件回显与一键清空
- 列取数口径:入库/代发按仓库名推导(含仓库配置覆盖)、产品标签取商品SABC打标、材质等级从「材质」识别 E0/E1/ENF
- 导出:导出明细 / 导出明细(含图片)改为全局下载中心后台生成,进度/取消/保存都在下载中心;页签名与文件名一致
- 破价订单审核:按天增量生成、链接级与订单级申诉、审核通过/驳回/二次申诉冲销乐捐、豁免(同价/活动)与证据凭证要求
- 规则配置页与权限:管理员开通后可编辑(权限节点 breachRuleConfig),无权限不可见
- 限价流程同步脚本:4 类钉钉限价流程 + 产品限价页面已确认限价 + 限价期初数据;流程/页面/期初优先级与「期初更新日期更新则以期初为准」
- 非负责人限价统一按最新口径派生(×1.05 且不低于 +10 元)
- 迁移:breach order review / breach rule config / limit flow sync
- 与 mainline 合并:保留字段权限(blockedFields)、负责人在职接管、限价目标 limitManagement 等新特性;
  规则配置权限并入新的权限节点结构,档案列去掉重复的 opsPriceLimit 列,相关测试断言按当前口径更新
2026-10-16 17:37:13 +08:00
WuYongJie
a8b5c84759 feat(intake): notify quality inspectors and keep product photo uploads available 2026-10-16 17:26:59 +08:00
WuYongJie
8a10b7f72f Merge origin/codex/xuxiake-mainline into codex/local-3001-integration 2026-10-16 16:26:49 +08:00
WuYongJie
2816a0b23d fix(supply-chain): 产品全量档案 DF 条码成本价改为统一取 jst_sku.cost_price
- enrichment 价格补丁不再区分 DF/非 DF:purchasePrice 与 costPrice 均取 rpt.jst_sku(purchase_price / cost_price)
- 删除 dws_sn_sales_full_info_di.goods_cost 取价链路(dws-sales-goods-cost.repository.ts 整删,DWS_SALES_GOODS_COST 来源常量移除)
- 单测与《供应链产品全量档案字段来源对照表》同步更新

预期效果(部署后下一轮同步):约 1874 条 DF 成本价由空变有值、约 230 条由 dws 金额口径变为 jst 单价口径、约 4516 条数值不变、0 条丢值。
2026-10-16 16:26:43 +08:00
WuYongJie
01740656e4 Merge accepted DIY wood grain and compact inspector fixes 2026-10-16 14:46:33 +08:00
WuYongJie
9af20a14b0 fix(diy): keep wood grain continuous and compact inspector 2026-10-16 14:46:23 +08:00
WuYongJie
2965742bbc fix(supply-chain): 仓库管理列表只取聚水潭仓库档案,档案历史名称不再补充展示 2026-10-16 14:01:10 +08:00
WuYongJie
bb19f46301 Merge origin/codex/xuxiake-mainline into codex/local-3001-integration(数据字典入口删除提交前同步) 2026-10-16 13:55:36 +08:00
WuYongJie
55fa6be07b feat(supply-chain): 产品全量档案移除「数据字典」占位入口
数据字典属内部开发口径,不对外暴露;「更多」菜单仅保留使用说明,清理占位提示状态;使用说明对应条目同步。
2026-10-16 13:55:23 +08:00
Codex
3bad0e1a97 feat(supply-chain): 仓库管理模块全量源码(筛选排序/导入导出/属性覆盖/负责人对齐)
- 表头:除操作列外全列筛选下拉 + 升/降序排序(服务端排序,空值恒排末尾)
- 导入导出:模板含仓库名称/仓库属性/仓库归属/负责人/入库代发;导出支持字段勾选、跟随筛选与排序;导入弹窗(下载模板+选择文件)与进度面板,导入后可一键「仅查看导入的仓库」
- 仓库属性人工覆盖表(新增迁移 20261016103000)与单元格内联编辑
- 仓库负责人:工厂仓取产品档案采购,档案读取走只读库(回退主库)
- 菜单/页签/页面标题统一为「仓库管理」
2026-10-16 13:29:16 +08:00
WuYongJie
74d32544be fix(supply-chain): 产品全量档案导入入口补挂「操作」权限门控 2026-10-16 13:13:44 +08:00
WuYongJie
8915933678 fix(diy): fit transparent basket patterns with five millimeter clearance 2026-10-16 12:30:43 +08:00
WuYongJie
6daf4c38a7 Merge origin/codex/xuxiake-mainline into codex/local-3001-integration(字段权限提交前同步) 2026-10-16 11:26:27 +08:00
WuYongJie
da85e99511 feat(users): 字段权限集中配置——产品全量档案与店铺账号管理 2026-10-16 11:25:58 +08:00
居居
f26bd04093 Merge branch 'codex/xuxiake-sabc-sync-20260929' into codex/xuxiake-mainline 2026-10-16 11:16:26 +08:00
居居
2bad601fae fix(supply-chain): 创建天数正好 90 天归入「创建60-90天」
- 新品区间分布边界修正:90 天整算 60-90 天,91 天起才是「创建90天以上」
- 读取已生成月份快照时按快照里的创建天数重算该字段,旧月份无需重新核算即可按新口径显示
2026-10-16 11:16:20 +08:00
WuYongJie
465f5d5916 Merge accepted product archive filters and sales toolbar 2026-10-15 17:49:46 +08:00
WuYongJie
073ce48d7d feat(product-archive): add batch code filters and compact sales controls 2026-10-15 17:48:53 +08:00
居居
ee4a1bf489 Merge branch 'codex/xuxiake-sabc-sync-20260929' into codex/xuxiake-mainline 2026-10-15 17:08:46 +08:00
居居
b4c22c23de feat(supply-chain): 产品中心/采购中心不再依赖运营负责人
- 有设计师即计入产品中心、有采购即计入采购中心,即使该行没有运营负责人
- 运营中心仍只统计有运营负责人的行;「山东项目组」设计师/采购继续排除
- 快照增加口径版本号:读到旧口径快照时先按实时口径出数,并在后台自动重建该月快照(无需手动点保存配置)
2026-10-15 17:08:41 +08:00
居居
6880208b36 Merge branch 'codex/xuxiake-sabc-sync-20260929' into codex/xuxiake-mainline 2026-10-15 17:01:52 +08:00
居居
a7b495e9c2 feat(supply-chain): 各部门数据分类支持按事业部导出,事业部按一/二/三/四部排序
- 运营中心卡片「导出」改为先勾选事业部(默认全选;页面已筛事业部时默认只选它),只导出勾选范围
- 导出接口本已支持 department 过滤,前端补上多选入口(后端无需改动)
- 事业部/品类部名称排序改为按序号解析:事业一部→二部→三部→四部,品类九部→十部→十一部,不再按拼音乱序
- 新增部门名称排序工具与单测
2026-10-15 17:01:38 +08:00
417 changed files with 36602 additions and 2599 deletions

View File

@ -176,6 +176,9 @@ 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=

View File

@ -1,6 +1,6 @@
# 供应链产品全量档案字段来源对照表
更新时间:2026-08-10
更新时间:2026-10-16
## 1. 合并与追踪规则
@ -70,7 +70,7 @@
| `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`;匹配不到时留空 |
| `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 第一批字段覆盖率

View File

@ -0,0 +1,124 @@
# 限价与破价判定规则(业务确认版)
> 适用范围:限价管理 · 破价订单审核(`/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` 不需要灌数:接口读取时用代码内置默认值,首次在「规则配置」保存时落库。
## 五、待办
- [ ] 限价流程数据接入 + 筛选逻辑(生效时间版本、货号维度)
- [ ] 非负责人限价 10-07 规则、负责人触发规则(前两个月同价 / 两个月后按销量)落到判定引擎
- [ ] 按天增量任务(cron / worker)与失败告警
- [ ] 凭证查重(hash / 相似度)、关键字段一致性校验
- [ ] 举证期限、乐捐财务对接流程
- [ ] 首单礼金 / 新品礼金出资方确认后补录出资方字典

View File

@ -74,6 +74,7 @@ const contentSecurityPolicy = [
"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:",

View File

@ -75,6 +75,7 @@
"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",
"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",

View File

@ -0,0 +1,85 @@
-- 限价管理 · 破价订单审核
CREATE TABLE "SupplyChainBreachOrder" (
"id" TEXT NOT NULL,
"platform" TEXT NOT NULL DEFAULT '',
"shopName" TEXT NOT NULL DEFAULT '',
"soId" TEXT NOT NULL DEFAULT '',
"oId" TEXT NOT NULL DEFAULT '',
"payDate" TIMESTAMP(3),
"itemCode" TEXT NOT NULL DEFAULT '',
"shopItemCode" TEXT NOT NULL DEFAULT '',
"shopStyleCode" TEXT NOT NULL DEFAULT '',
"linkGroupKey" TEXT NOT NULL DEFAULT '',
"qty" INTEGER NOT NULL DEFAULT 1,
"buyerPaidAmount" DECIMAL(18,4) NOT NULL DEFAULT 0,
"platformBearAmount" DECIMAL(18,4) NOT NULL DEFAULT 0,
"sellerIncomeAmount" DECIMAL(18,4) NOT NULL DEFAULT 0,
"limitUnitPrice" DECIMAL(18,4),
"limitTotal" DECIMAL(18,4),
"gapAmount" DECIMAL(18,4),
"ownerName" TEXT NOT NULL DEFAULT '',
"profitCenter" TEXT NOT NULL DEFAULT '',
"orderStatus" TEXT NOT NULL DEFAULT '',
"refundStatus" TEXT NOT NULL DEFAULT '',
"listedAt" TIMESTAMP(3),
"flowEndDate" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'PENDING_APPEAL',
"appealType" TEXT NOT NULL DEFAULT '',
"appealScope" TEXT NOT NULL DEFAULT 'ORDER',
"processNo" TEXT NOT NULL DEFAULT '',
"appealRound" INTEGER NOT NULL DEFAULT 1,
"previousAppeal" JSON,
"appealDesc" TEXT NOT NULL DEFAULT '',
"appealImages" JSON NOT NULL DEFAULT '[]',
"appliedFromLink" BOOLEAN NOT NULL DEFAULT false,
"autoSuggested" TEXT NOT NULL DEFAULT '',
"autoSuggestedNote" TEXT NOT NULL DEFAULT '',
"reviewedBy" TEXT NOT NULL DEFAULT '',
"reviewedAt" TIMESTAMP(3),
"reviewDecision" TEXT NOT NULL DEFAULT '',
"reviewReason" TEXT NOT NULL DEFAULT '',
"reviewNote" TEXT NOT NULL DEFAULT '',
"fineAmount" DECIMAL(10,2) NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupplyChainBreachOrder_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SupplyChainBreachOrder_oId_itemCode_key" ON "SupplyChainBreachOrder"("oId", "itemCode");
CREATE INDEX "SupplyChainBreachOrder_status_payDate_idx" ON "SupplyChainBreachOrder"("status", "payDate" DESC);
CREATE INDEX "SupplyChainBreachOrder_linkGroupKey_idx" ON "SupplyChainBreachOrder"("linkGroupKey");
CREATE INDEX "SupplyChainBreachOrder_ownerName_status_idx" ON "SupplyChainBreachOrder"("ownerName", "status");
CREATE INDEX "SupplyChainBreachOrder_shopName_itemCode_idx" ON "SupplyChainBreachOrder"("shopName", "itemCode");
CREATE TABLE "SupplyChainBreachOrderLog" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"operatorId" TEXT NOT NULL DEFAULT '',
"operatorName" TEXT NOT NULL DEFAULT '',
"detail" JSON NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SupplyChainBreachOrderLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "SupplyChainBreachOrderLog_orderId_createdAt_idx" ON "SupplyChainBreachOrderLog"("orderId", "createdAt");
CREATE TABLE "SupplyChainLinkLimitPrice" (
"id" TEXT NOT NULL,
"shopName" TEXT NOT NULL,
"itemCode" TEXT NOT NULL,
"spu" TEXT NOT NULL DEFAULT '',
"limitUnitPrice" DECIMAL(18,4) NOT NULL,
"listedAt" TIMESTAMP(3),
"flowEndDate" TIMESTAMP(3),
"source" TEXT NOT NULL DEFAULT 'MANUAL',
"ownerName" TEXT NOT NULL DEFAULT '',
"updatedById" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupplyChainLinkLimitPrice_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SupplyChainLinkLimitPrice_shopName_itemCode_key" ON "SupplyChainLinkLimitPrice"("shopName", "itemCode");
CREATE INDEX "SupplyChainLinkLimitPrice_itemCode_idx" ON "SupplyChainLinkLimitPrice"("itemCode");

View File

@ -0,0 +1,9 @@
CREATE TABLE "SupplyChainBreachRuleConfig" (
"id" TEXT NOT NULL DEFAULT 'default',
"data" JSON NOT NULL DEFAULT '{}',
"updatedById" TEXT NOT NULL DEFAULT '',
"updatedByName" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupplyChainBreachRuleConfig_pkey" PRIMARY KEY ("id")
);

View File

@ -0,0 +1,2 @@
-- 「图片」列:提单时按条码从产品全量档案库快照图片地址,列表在条码前展示缩略图。
ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "image" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,37 @@
-- 限价流程同步:货号负责人限价(来源:限价流程的期望日常限价,按货号维度)
CREATE TABLE "SupplyChainStyleLimitPrice" (
"id" TEXT NOT NULL,
"styleCode" TEXT NOT NULL,
"ownerLimitPrice" DECIMAL(18,4),
"processInstanceId" TEXT NOT NULL DEFAULT '',
"processNo" TEXT NOT NULL DEFAULT '',
"applyType" TEXT NOT NULL DEFAULT '',
"initiator" TEXT NOT NULL DEFAULT '',
"approvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupplyChainStyleLimitPrice_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SupplyChainStyleLimitPrice_styleCode_key" ON "SupplyChainStyleLimitPrice"("styleCode");
-- 限价流程同步:价格授权(同价申请 / 活动限价;供破价判定豁免与超期判定)
CREATE TABLE "SupplyChainLimitFlowAuth" (
"id" TEXT NOT NULL,
"processInstanceId" TEXT NOT NULL,
"processNo" TEXT NOT NULL DEFAULT '',
"applyType" TEXT NOT NULL DEFAULT '',
"styleCode" TEXT NOT NULL DEFAULT '',
"spu" TEXT NOT NULL DEFAULT '',
"approvedAt" TIMESTAMP(3),
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"adjustedLimitPrice" DECIMAL(18,4),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupplyChainLimitFlowAuth_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "SupplyChainLimitFlowAuth_processInstanceId_styleCode_applyType_key" ON "SupplyChainLimitFlowAuth"("processInstanceId", "styleCode", "applyType");
CREATE INDEX "SupplyChainLimitFlowAuth_styleCode_idx" ON "SupplyChainLimitFlowAuth"("styleCode");
CREATE INDEX "SupplyChainLimitFlowAuth_applyType_idx" ON "SupplyChainLimitFlowAuth"("applyType");

View File

@ -0,0 +1,19 @@
-- 产品全量档案字段权限(黑名单):主体对字段的屏蔽条目,只存被屏蔽的字段。
-- CreateTable
CREATE TABLE "ProductArchiveFieldPermissionBlock" (
"id" TEXT NOT NULL,
"subjectKey" TEXT NOT NULL,
"fieldKey" TEXT NOT NULL,
"createdBy" TEXT,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductArchiveFieldPermissionBlock_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ProductArchiveFieldPermissionBlock_subjectKey_fieldKey_key" ON "ProductArchiveFieldPermissionBlock"("subjectKey", "fieldKey");
-- CreateIndex
CREATE INDEX "ProductArchiveFieldPermissionBlock_subjectKey_idx" ON "ProductArchiveFieldPermissionBlock"("subjectKey");

View File

@ -0,0 +1,20 @@
-- Preserve explicit empty personal overrides separately from following a position.
CREATE TABLE "ProductArchiveFieldPermissionSubject" (
"subjectKey" TEXT NOT NULL,
"createdBy" TEXT,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductArchiveFieldPermissionSubject_pkey" PRIMARY KEY ("subjectKey")
);
-- Keep every existing blocked field and its subject.
INSERT INTO "ProductArchiveFieldPermissionSubject" ("subjectKey", "createdAt", "updatedAt")
SELECT "subjectKey", MIN("createdAt"), MAX("updatedAt")
FROM "ProductArchiveFieldPermissionBlock"
GROUP BY "subjectKey";
ALTER TABLE "ProductArchiveFieldPermissionBlock"
ADD CONSTRAINT "ProductArchiveFieldPermissionBlock_subjectKey_fkey"
FOREIGN KEY ("subjectKey") REFERENCES "ProductArchiveFieldPermissionSubject"("subjectKey")
ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,13 @@
-- 仓库属性人工覆盖:数据源 remark2 异常时在本系统修正展示值,不影响聚水潭原始数据。
CREATE TABLE "SupplyChainProductArchiveWarehouseAttributeOverride" (
"warehouseName" TEXT NOT NULL,
"attribute" TEXT NOT NULL DEFAULT '',
"updatedById" TEXT,
"updatedByName" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SupplyChainProductArchiveWarehouseAttributeOverride_pkey" PRIMARY KEY ("warehouseName")
);
CREATE INDEX "SupplyChainProductArchiveWarehouseAttributeO_updatedAt_idx" ON "SupplyChainProductArchiveWarehouseAttributeOverride"("updatedAt");

View File

@ -0,0 +1,13 @@
CREATE TABLE "IntakeProductPhotoUploadGrant" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"grantedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IntakeProductPhotoUploadGrant_pkey" PRIMARY KEY ("id"),
CONSTRAINT "IntakeProductPhotoUploadGrant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "IntakeNonIntegratedProduct"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "IntakeProductPhotoUploadGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "IntakeProductPhotoUploadGrant_grantedBy_fkey" FOREIGN KEY ("grantedBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "IntakeProductPhotoUploadGrant_productId_userId_key" ON "IntakeProductPhotoUploadGrant"("productId", "userId");
CREATE INDEX "IntakeProductPhotoUploadGrant_userId_idx" ON "IntakeProductPhotoUploadGrant"("userId");

View File

@ -0,0 +1,2 @@
-- 产品全量档案新增「安装方式」列:取值来源产品开发(产品定厂 SKU installMethod,整装/拆装)。
ALTER TABLE "SupplyChainProductArchive" ADD COLUMN "installMethod" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,48 @@
-- 商品库存快照:条码 × 实际仓库粒度,由 15 分钟定时任务全量替换写入;失败保留上一份快照。
CREATE TABLE "ProductInventorySnapshot" (
"id" TEXT NOT NULL,
"barcode" TEXT NOT NULL,
"wmsCoId" INTEGER NOT NULL,
"productName" TEXT NOT NULL DEFAULT '',
"category" TEXT NOT NULL DEFAULT '',
"sku" TEXT NOT NULL DEFAULT '',
"productionStatus" TEXT NOT NULL DEFAULT '',
"supplier" TEXT NOT NULL DEFAULT '',
"purchaser" TEXT NOT NULL DEFAULT '',
"stockQty" INTEGER NOT NULL DEFAULT 0,
"orderLockQty" INTEGER NOT NULL DEFAULT 0,
"availableQty" INTEGER NOT NULL DEFAULT 0,
"cloudAvailableQty" INTEGER NOT NULL DEFAULT 0,
"warehouseName" TEXT NOT NULL DEFAULT '',
"warehouseAttribute" TEXT NOT NULL DEFAULT '',
"warehouseOwnership" TEXT NOT NULL DEFAULT '',
"warehouseHead" TEXT NOT NULL DEFAULT '',
"syncedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductInventorySnapshot_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ProductInventorySnapshot_barcode_wmsCoId_key" ON "ProductInventorySnapshot"("barcode", "wmsCoId");
CREATE INDEX "ProductInventorySnapshot_warehouseName_idx" ON "ProductInventorySnapshot"("warehouseName");
CREATE INDEX "ProductInventorySnapshot_syncedAt_idx" ON "ProductInventorySnapshot"("syncedAt");
-- 同步运行状态:每次定时任务一行,记录 RUNNING / COMPLETED / FAILED。
CREATE TABLE "ProductInventorySnapshotRun" (
"id" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3),
"status" VARCHAR(24) NOT NULL,
"rowsRead" INTEGER NOT NULL DEFAULT 0,
"rowsWritten" INTEGER NOT NULL DEFAULT 0,
"syncedAt" TIMESTAMP(3),
"errorMessage" TEXT,
CONSTRAINT "ProductInventorySnapshotRun_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ProductInventorySnapshotRun_startedAt_idx" ON "ProductInventorySnapshotRun"("startedAt");
CREATE INDEX "ProductInventorySnapshotRun_status_startedAt_idx" ON "ProductInventorySnapshotRun"("status", "startedAt");

View File

@ -0,0 +1,17 @@
-- 通知中心待办同步状态(停产确认 / 待走流程两类)。
CREATE TABLE "ProductionStopNotificationSync" (
"id" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"recipientKey" TEXT NOT NULL,
"recipientName" TEXT NOT NULL DEFAULT '',
"pendingCount" INTEGER NOT NULL DEFAULT 0,
"snapshot" TEXT NOT NULL DEFAULT '',
"lastSentAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProductionStopNotificationSync_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "ProductionStopNotificationSync_kind_recipientKey_key"
ON "ProductionStopNotificationSync"("kind", "recipientKey");

View File

@ -0,0 +1,11 @@
-- 含大单近30日日销的数值快照列:用于 >、>=、<、<= 数值筛选与数值排序。
ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "sales30dWithBigValue" DECIMAL(18,4);
ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "sales30dWithBigSkuValue" DECIMAL(18,4);
UPDATE "ProductionStopDemandItem"
SET "sales30dWithBigValue" = "sales30dWithBig"::numeric
WHERE "sales30dWithBig" ~ '^-?[0-9]+(\.[0-9]+)?$';
UPDATE "ProductionStopDemandItem"
SET "sales30dWithBigSkuValue" = "sales30dWithBigSku"::numeric
WHERE "sales30dWithBigSku" ~ '^-?[0-9]+(\.[0-9]+)?$';

View File

@ -482,6 +482,7 @@ model IntakeNonIntegratedProduct {
fixedVirtualInventoryTasks IntakeJushuitanFixedVirtualInventoryTask[]
profile IntakeNonIntegratedProductProfile?
modelAssets IntakeProductModelAsset[]
photoUploadGrants IntakeProductPhotoUploadGrant[]
@@index([spu])
@@index([spuBase])
@ -492,6 +493,20 @@ model IntakeNonIntegratedProduct {
@@index([deletedAt, createdAt])
}
model IntakeProductPhotoUploadGrant {
id String @id
productId String
userId String
grantedBy String
createdAt DateTime @default(now())
product IntakeNonIntegratedProduct @relation(fields: [productId], references: [id], onDelete: Cascade)
user User @relation("IntakePhotoUploadRecipient", fields: [userId], references: [id], onDelete: Cascade)
grantor User @relation("IntakePhotoUploadGrantor", fields: [grantedBy], references: [id], onDelete: Restrict)
@@unique([productId, userId])
@@index([userId])
}
model BarcodePrintImportRecord {
id String @id @default(cuid())
kind String
@ -696,6 +711,7 @@ model SupplyChainProductArchive {
productAttribute String @default("")
productTag String @default("")
installDocId String @default("")
installMethod String @default("")
hasDrawing String @default("")
hasVideo String @default("")
spec String @default("")
@ -1056,6 +1072,18 @@ model SupplyChainProductArchiveWarehouseConfig {
@@index([status])
}
/// 仓库属性人工覆盖:聚水潭 remark2 异常时,在这里改成正确的展示值。
model SupplyChainProductArchiveWarehouseAttributeOverride {
warehouseName String @unique
attribute String @default("")
updatedById String?
updatedByName String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([updatedAt])
}
model DownloadTask {
id String @id @default(cuid())
userId String
@ -1187,10 +1215,13 @@ model ProductionStopDemandItem {
brand String @default("")
productTag String @default("")
productLabel String @default("")
image String @default("")
barcodeCreatedAt DateTime?
postStopSkuStatus String @default("")
sales30dWithBig String @default("")
sales30dWithBigSku String @default("")
sales30dWithBigValue Decimal? @db.Decimal(18, 4)
sales30dWithBigSkuValue Decimal? @db.Decimal(18, 4)
opsDirector String @default("")
topSalesOpsDirector String @default("")
stopSuggestion String @default("")
@ -1274,6 +1305,23 @@ model ProductionStopFlowOwner {
@@index([isActive, sortOrder])
}
// 通知中心待办同步状态:按「待办类型 + 接收人」记录上次推送的待办快照。
// 待办集合变化时推送新的条数卡片;集合清空时推送一次「已全部处理完成」。
model ProductionStopNotificationSync {
id String @id @default(cuid())
kind String
recipientKey String
recipientName String @default("")
pendingCount Int @default(0)
snapshot String @default("")
lastSentAt DateTime?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([kind, recipientKey])
}
// 盘停建议配置:人工批量维护「条码 → 盘停建议」,提单时按条码自动带出。
model ProductionStopSuggestionConfig {
@ -3425,6 +3473,8 @@ model User {
// 关联
createdProducts Product[] @relation("ProductCreator")
intakePhotoUploadGrants IntakeProductPhotoUploadGrant[] @relation("IntakePhotoUploadRecipient")
issuedIntakePhotoUploadGrants IntakeProductPhotoUploadGrant[] @relation("IntakePhotoUploadGrantor")
ownedSKUs ProductSKU[] @relation("SKUOwner")
createdPlanningTemplates PlanningTemplate[] @relation("PlanningTemplateCreator")
createdPlanningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCreator")
@ -4297,6 +4347,34 @@ model SupplyChainPermissionOverride {
@@index([subjectKey])
}
/// 显式配置主体;个人配置即使没有屏蔽字段,也与「跟随岗位」区分。
model ProductArchiveFieldPermissionSubject {
subjectKey String @id
createdBy String?
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
blocks ProductArchiveFieldPermissionBlock[]
}
/// 产品全量档案字段权限(黑名单):subjectKey(岗位/角色/个人)对 fieldKey(列 prop)的屏蔽条目。
/// 只存被屏蔽的字段;未出现即默认可见,与页面级权限的「先有页面可见权,再看字段」层级一致。
model ProductArchiveFieldPermissionBlock {
id String @id @default(cuid())
subjectKey String
fieldKey String
subject ProductArchiveFieldPermissionSubject @relation(fields: [subjectKey], references: [subjectKey], onDelete: Cascade)
createdBy String?
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([subjectKey, fieldKey])
@@index([subjectKey])
}
// Store account data is maintained manually in this system.
model StoreAccount {
id String @id @default(cuid())
@ -5013,3 +5091,180 @@ model LogisticsArrivalSigningDurationSnapshot {
/// 看板热路径的覆盖索引:时间戳与 4 个维度列直接随索引读取,避免整表扫描。
@@index([snapshotDate, signTime], map: "LogisticsArrivalSigningDurationSnapshot_cover_idx")
}
/// 限价管理 · 破价订单审核:聚水潭订单实收与限价表比对后生成的破价单
model SupplyChainBreachOrder {
id String @id @default(cuid())
platform String @default("")
shopName String @default("")
soId String @default("")
oId String @default("")
payDate DateTime?
itemCode String @default("")
shopItemCode String @default("")
shopStyleCode String @default("")
linkGroupKey String @default("")
qty Int @default(1)
buyerPaidAmount Decimal @default(0) @db.Decimal(18, 4)
platformBearAmount Decimal @default(0) @db.Decimal(18, 4)
sellerIncomeAmount Decimal @default(0) @db.Decimal(18, 4)
limitUnitPrice Decimal? @db.Decimal(18, 4)
limitTotal Decimal? @db.Decimal(18, 4)
gapAmount Decimal? @db.Decimal(18, 4)
ownerName String @default("")
profitCenter String @default("")
orderStatus String @default("")
refundStatus String @default("")
listedAt DateTime?
flowEndDate DateTime?
status String @default("PENDING_APPEAL")
appealType String @default("")
appealScope String @default("ORDER")
processNo String @default("")
appealRound Int @default(1)
previousAppeal Json?
appealDesc String @default("") @db.Text
appealImages Json @default("[]")
appliedFromLink Boolean @default(false)
autoSuggested String @default("")
autoSuggestedNote String @default("") @db.Text
reviewedBy String @default("")
reviewedAt DateTime?
reviewDecision String @default("")
reviewReason String @default("")
reviewNote String @default("") @db.Text
fineAmount Decimal @default(0) @db.Decimal(10, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([oId, itemCode])
@@index([status, payDate(sort: Desc)])
@@index([linkGroupKey])
@@index([ownerName, status])
@@index([shopName, itemCode])
}
/// 破价单操作日志(系统生成、申诉、审核)
model SupplyChainBreachOrderLog {
id String @id @default(cuid())
orderId String
action String
operatorId String @default("")
operatorName String @default("")
detail Json @default("{}")
createdAt DateTime @default(now())
@@index([orderId, createdAt])
}
/// 限价表(链接限价):店铺名称 + 商品编码 → 限价单价、上架时间、限价流程结束日期
model SupplyChainLinkLimitPrice {
id String @id @default(cuid())
shopName String
itemCode String
spu String @default("")
limitUnitPrice Decimal @db.Decimal(18, 4)
listedAt DateTime?
flowEndDate DateTime?
source String @default("MANUAL")
ownerName String @default("")
updatedById String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([shopName, itemCode])
@@index([itemCode])
}
/// 限价管理 · 破价判定规则配置(单行 JSON + 修改留痕)
model SupplyChainBreachRuleConfig {
id String @id @default("default")
data Json @default("{}")
updatedById String @default("")
updatedByName String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// 限价流程同步 · 货号负责人限价(按货号维度;来源:限价流程的「期望日常限价」,
/// 同价申请 / 活动限价类流程不覆盖)。
model SupplyChainStyleLimitPrice {
id String @id @default(cuid())
styleCode String @unique
ownerLimitPrice Decimal? @db.Decimal(18, 4)
processInstanceId String @default("")
processNo String @default("")
applyType String @default("")
initiator String @default("")
/// 流程审核通过时间(限价生效时间)
approvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// 限价流程同步 · 价格授权(同价申请 / 活动限价;供破价判定豁免与超期判定)。
model SupplyChainLimitFlowAuth {
id String @id @default(cuid())
processInstanceId String
processNo String @default("")
applyType String @default("")
styleCode String @default("")
spu String @default("")
/// 同价开始时间 = 流程审核通过时间
approvedAt DateTime?
/// 活动开始时间(同价类型为空)
startDate DateTime?
/// 结束时间(同价 / 活动共用)
endDate DateTime?
/// 活动限价:调整后限价(价格 ≥ 该值不判破价)
adjustedLimitPrice Decimal? @db.Decimal(18, 4)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([processInstanceId, styleCode, applyType])
@@index([styleCode])
@@index([applyType])
}
/// 商品库存快照:条码 × 实际仓库粒度的库存数据,由 15 分钟定时任务全量替换写入。
/// 数据来源于聚水潭库存明细/云仓链路与产品档案,失败时保留上一份快照。
model ProductInventorySnapshot {
id String @id @default(cuid())
barcode String
wmsCoId Int
productName String @default("")
category String @default("")
sku String @default("")
productionStatus String @default("")
supplier String @default("")
purchaser String @default("")
stockQty Int @default(0)
orderLockQty Int @default(0)
availableQty Int @default(0)
cloudAvailableQty Int @default(0)
warehouseName String @default("")
warehouseAttribute String @default("")
warehouseOwnership String @default("")
warehouseHead String @default("")
syncedAt DateTime
createdAt DateTime @default(now())
@@unique([barcode, wmsCoId])
@@index([warehouseName])
@@index([syncedAt])
}
/// 商品库存快照同步运行记录:用于排查定时任务状态与失败原因,不记录连接串或 SQL 载荷。
model ProductInventorySnapshotRun {
id String @id @default(cuid())
startedAt DateTime @default(now())
completedAt DateTime?
status String @db.VarChar(24)
rowsRead Int @default(0)
rowsWritten Int @default(0)
syncedAt DateTime?
errorMessage String? @db.Text
@@index([startedAt])
@@index([status, startedAt])
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,129 @@
/**
* 回填停产需求明细的档案快照字段:「含大单近30日日销-条码 / -SKU」「产品标签」「图片」。
* 口径:条码维度 = 产品全量档案库该条码行的「含大单近30日日销」;
* SKU 维度 = 同 SKU 全部条码求和;
* 产品标签 = SABC 标签优先,未打标按销售状态 × 考核周期真实业绩兜底;
* 图片 = 档案库该条码行的图片地址。
* 只回填当前为空的值,不覆盖已有快照。
*
* 用法:npx tsx scripts/backfill-production-stop-archive-snapshot.ts
*/
import 'dotenv/config'
import { prisma } from '../src/lib/prisma'
import { resolveProductionStopProductLabel } from '../src/lib/production-stop-demands'
import {
productionStopDemandRepository,
type ProductionStopArchiveProfile,
} from '../src/server/repositories/production-stop-demands.repository'
const ARCHIVE_BATCH_SIZE = 300
function numericValue(value: string) {
const normalized = value.trim()
return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null
}
async function main() {
const items = await prisma.productionStopDemandItem.findMany({
where: {
OR: [
{ sales30dWithBig: '' },
{ sales30dWithBigSku: '' },
{ productLabel: '' },
{ image: '' },
],
},
select: {
id: true,
barcode: true,
sku: true,
productStatus: true,
productLabel: true,
image: true,
sales30dWithBig: true,
sales30dWithBigSku: true,
},
orderBy: { createdAt: 'asc' },
})
if (!items.length) {
console.log('[production-stop-archive-snapshot] 没有需要回填的明细')
return
}
const barcodes = [...new Set(
items.map((item) => item.barcode.trim().toUpperCase()).filter(Boolean),
)]
const skus = [...new Set(items.map((item) => item.sku.trim().toUpperCase()).filter(Boolean))]
const profilesByBarcode = new Map<string, ProductionStopArchiveProfile>()
for (let index = 0; index < barcodes.length; index += ARCHIVE_BATCH_SIZE) {
const chunk = barcodes.slice(index, index + ARCHIVE_BATCH_SIZE)
const profiles = await productionStopDemandRepository.findArchivedProfiles(chunk)
for (const [barcode, profile] of profiles) profilesByBarcode.set(barcode, profile)
}
const skuTotals = new Map<string, string>()
for (let index = 0; index < skus.length; index += ARCHIVE_BATCH_SIZE) {
const chunk = skus.slice(index, index + ARCHIVE_BATCH_SIZE)
const totals = await productionStopDemandRepository.sumSkuSales30dWithBig(chunk)
for (const [sku, total] of totals) {
if (total) skuTotals.set(sku, total)
}
}
let updated = 0
let unmatched = 0
for (const item of items) {
const profile = profilesByBarcode.get(item.barcode.trim().toUpperCase())
const barcodeValue = item.sales30dWithBig
|| (profile?.sales30dWithBig ?? '')
const skuValue = item.sales30dWithBigSku
|| skuTotals.get(item.sku.trim().toUpperCase())
|| ''
const productLabel = item.productLabel
|| (profile
? resolveProductionStopProductLabel({
sabcRating: profile.sabcRating,
productStatus: profile.productStatus,
realPerformance: profile.sabcRealPerformance,
})
: '')
const image = item.image || (profile?.image ?? '')
if (!barcodeValue && !skuValue && !productLabel && !image) {
unmatched += 1
continue
}
if (
barcodeValue === item.sales30dWithBig
&& skuValue === item.sales30dWithBigSku
&& productLabel === item.productLabel
&& image === item.image
) {
continue
}
await prisma.productionStopDemandItem.update({
where: { id: item.id },
data: {
sales30dWithBig: barcodeValue,
sales30dWithBigSku: skuValue,
sales30dWithBigValue: numericValue(barcodeValue),
sales30dWithBigSkuValue: numericValue(skuValue),
productLabel,
image,
},
})
updated += 1
}
console.log(
`[production-stop-archive-snapshot] 待回填明细 ${items.length} 条:更新 ${updated} 条,档案未匹配 ${unmatched} 条`,
)
}
main()
.catch((error) => {
console.error('[production-stop-archive-snapshot] FAILED:', error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect().catch(() => null)
})

View File

@ -0,0 +1,84 @@
#!/usr/bin/env bash
# 限价流程同步 · 正式站 systemd 定时器安装脚本(与 marketing-sales timer 同一套样板)。
#
# 用途:每天把钉钉限价流程 / 产品限价页面已确认限价 / 限价期初数据同步到正式库
# (SupplyChainStyleLimitPrice、SupplyChainLimitFlowAuth),并输出全量明细 Excel。
#
# 前置:
# - 正式站目录:$FURNITURE_PROD_BASE(默认 /opt/furniture-3d-system-prod)
# - 环境变量:$BASE/shared/.env(DATABASE_URL / JST_REPORT_DB_* / PROD_ARCHIVE_DB_*)
# - 期初数据:默认从仓库自带 scripts/data/limit-sync/期初数据 复制到 shared 目录;
# 之后业务更新期初数据,直接把新的「限价期初数据*.xlsx」放进 shared 目录即可。
#
# 时间:默认每天北京时间 07:30(可用 LIMIT_SYNC_ON_CALENDAR 覆盖)。
# 破价订单扫描如果由别处调度,请安排在本任务之后。
set -euo pipefail
BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}"
APP_ROOT="$BASE/current/furniture-3d-system"
SERVICE="furniture-limit-workflow-sync"
SHARED_DIR="$BASE/shared/limit-workflow-sync"
OUT_DIR="$SHARED_DIR/导出结果"
INITIAL_DIR="$SHARED_DIR/期初数据"
LOG_DIR="$BASE/shared/logs/limit-workflow-sync"
ON_CALENDAR="${LIMIT_SYNC_ON_CALENDAR:-*-*-* 07:30:00 Asia/Shanghai}"
if [ "$(id -u)" -ne 0 ]; then echo "Root required" >&2; exit 1; fi
if [ ! -f "$APP_ROOT/scripts/run-limit-workflow-sync-production.cjs" ] || [ ! -f "$APP_ROOT/scripts/sync-limit-workflows.mjs" ]; then
echo "Missing deployed limit sync runner ($APP_ROOT/scripts)" >&2; exit 1
fi
if [ ! -f "$BASE/shared/.env" ]; then
echo "Missing $BASE/shared/.env(需要 DATABASE_URL / JST_REPORT_DB_* 等既有变量)" >&2; exit 1
fi
chmod 600 "$BASE/shared/.env"
mkdir -p "$OUT_DIR" "$INITIAL_DIR" "$LOG_DIR"
# 首次部署:把仓库自带的期初数据放到 shared 目录(已有文件不覆盖,方便业务更新)
if ! compgen -G "$INITIAL_DIR/限价期初数据*.xlsx" >/dev/null; then
if compgen -G "$APP_ROOT/scripts/data/limit-sync/期初数据/限价期初数据*.xlsx" >/dev/null; then
cp "$APP_ROOT"/scripts/data/limit-sync/期初数据/限价期初数据*.xlsx "$INITIAL_DIR/"
echo "已初始化期初数据:$INITIAL_DIR"
else
echo "警告:没有找到期初数据,本次只同步流程与产品限价页面数据" >&2
fi
fi
cat >"/etc/systemd/system/$SERVICE.service" <<EOF
[Unit]
Description=Synchronize limit workflow prices and price authorizations
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=$APP_ROOT
EnvironmentFile=$BASE/shared/.env
Environment=NODE_ENV=production
Environment=LIMIT_SYNC_OUT_DIR=$OUT_DIR
Environment=LIMIT_SYNC_INITIAL_DIR=$INITIAL_DIR
Environment=LIMIT_SYNC_JOB_LOG_DIR=$LOG_DIR
Environment=LIMIT_SYNC_HISTORY_KEEP=20
ExecStart=/usr/bin/node scripts/run-limit-workflow-sync-production.cjs
TimeoutStartSec=35min
KillMode=control-group
EOF
cat >"/etc/systemd/system/$SERVICE.timer" <<EOF
[Unit]
Description=Limit workflow sync once a day at Beijing 07:30
[Timer]
OnCalendar=$ON_CALENDAR
OnBootSec=5min
AccuracySec=1s
Persistent=true
Unit=$SERVICE.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable "$SERVICE.timer"
systemctl restart "$SERVICE.timer"
systemctl list-timers --all "$SERVICE*" --no-pager

View File

@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}"
APP_ROOT="$BASE/current/furniture-3d-system"
SERVICE_NAME="${PRODUCT_INVENTORY_SNAPSHOT_SERVICE_NAME:-furniture-product-inventory-snapshot}"
SYNC_ENV="$BASE/shared/product-inventory-snapshot.env"
CALENDAR="${PRODUCT_INVENTORY_SNAPSHOT_CALENDAR:-*:0/15}"
if [ "$(id -u)" -ne 0 ]; then
echo "This installer requires root." >&2
exit 1
fi
if [ ! -f "$APP_ROOT/scripts/sync-product-inventory-snapshot.ts" ]; then
echo "Missing deployed snapshot script: $APP_ROOT/scripts/sync-product-inventory-snapshot.ts" >&2
exit 1
fi
if [ ! -f "$SYNC_ENV" ]; then
umask 077
cat >"$SYNC_ENV" <<EOF
# 商品库存管理 · 15 分钟库存快照
# 身份校验值必须填生产实际值,否则同步脚本拒绝写入。
# 取值方式:node_modules/.bin/tsx -e "import {loadEnvConfig} from '@next/env';loadEnvConfig(process.cwd());console.log(process.env.DATABASE_URL)"
PRODUCT_INVENTORY_SNAPSHOT_EXPECTED_DATABASE_IDENTITY=
PRODUCT_INVENTORY_SNAPSHOT_EXPECTED_SOURCE_DATABASE_IDENTITY=
EOF
echo "Created env template: $SYNC_ENV"
fi
chmod 600 "$SYNC_ENV"
cat >"/etc/systemd/system/$SERVICE_NAME.service" <<EOF
[Unit]
Description=Materialize product inventory snapshot
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=$APP_ROOT
Environment=NODE_ENV=production
EnvironmentFile=$BASE/shared/.env
EnvironmentFile=$SYNC_ENV
ExecStart=/bin/bash -lc 'exec ./node_modules/.bin/tsx scripts/sync-product-inventory-snapshot.ts --apply --confirm=sync-product-inventory-snapshot --expected-database-identity="\${PRODUCT_INVENTORY_SNAPSHOT_EXPECTED_DATABASE_IDENTITY:?Missing expected local identity}" --expected-source-database-identity="\${PRODUCT_INVENTORY_SNAPSHOT_EXPECTED_SOURCE_DATABASE_IDENTITY:?Missing expected source identity}"'
TimeoutStartSec=30min
Restart=on-failure
RestartSec=15min
EOF
cat >"/etc/systemd/system/$SERVICE_NAME.timer" <<EOF
[Unit]
Description=Product inventory snapshot every 15 minutes
[Timer]
OnCalendar=$CALENDAR
Persistent=true
Unit=$SERVICE_NAME.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME.timer"
systemctl list-timers --all "$SERVICE_NAME.timer" --no-pager

View File

@ -0,0 +1,95 @@
/**
* 限价流程同步 · 服务器运行入口(systemd oneshot 调用)。
*
* 职责:
* 1. 从应用根目录加载 shared 环境变量(systemd EnvironmentFile 已注入的优先);
* 2. 预检数据库连接所需变量,避免带着空配置去跑同步;
* 3. 以子进程运行 scripts/sync-limit-workflows.mjs,并设置硬超时;
* 4. 退出码透传,systemd / 值班同学据 journal 或日志目录判断结果。
*
* 用法:
* node scripts/run-limit-workflow-sync-production.cjs [--skip-excel] [...透传给同步脚本的参数]
*/
const path = require('node:path')
const fs = require('node:fs')
const { spawn } = require('node:child_process')
const { loadEnvConfig } = require('@next/env')
const appRoot = path.resolve(__dirname, '..')
loadEnvConfig(appRoot)
const RUN_TIMEOUT_MS = Number(process.env.LIMIT_SYNC_RUN_TIMEOUT_MS || 30 * 60_000)
const jobLogDir = process.env.LIMIT_SYNC_JOB_LOG_DIR || path.join(appRoot, '导出结果')
function fail(code, detail) {
console.error(code, detail ? `- ${detail}` : '')
process.exitCode = 1
}
function writeFailureJournal(code) {
try {
fs.mkdirSync(jobLogDir, { recursive: true })
const stamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '')
fs.writeFileSync(
path.join(jobLogDir, `limit-sync-failure-${stamp}.json`),
JSON.stringify({ failureAt: new Date().toISOString(), code }) + '\n',
{ flag: 'wx' },
)
} catch (error) {
if (error.code !== 'EEXIST') console.error('LIMIT_SYNC_FAILURE_JOURNAL_UNAVAILABLE')
}
}
if (!String(process.env.DATABASE_URL || '').trim()) {
fail('DATABASE_URL_REQUIRED', '请在 shared/.env 配置业务库连接(限价表写入目标)')
process.exit(1)
}
const requiredReportVars = ['JST_REPORT_DB_HOST', 'JST_REPORT_DB_NAME', 'JST_REPORT_DB_USER', 'JST_REPORT_DB_PASSWORD']
const missingReportVars = requiredReportVars.filter((name) => !String(process.env[name] || '').trim())
if (missingReportVars.length) {
fail('JST_REPORT_DB_CREDENTIALS_REQUIRED', `缺少 ${missingReportVars.join(', ')}`)
process.exit(1)
}
// 只打印 host/database,避免把密码写进 journal
try {
const target = new URL(process.env.DATABASE_URL)
console.log(`[limit-sync] 业务库目标 ${target.hostname}:${target.port || 5432}${target.pathname}`)
console.log(`[limit-sync] 报表库目标 ${process.env.JST_REPORT_DB_HOST}/${process.env.JST_REPORT_DB_NAME}`)
if (process.env.PROD_ARCHIVE_DB_HOST) {
console.log(`[limit-sync] 产品限价页面 ${process.env.PROD_ARCHIVE_DB_HOST}/${process.env.PROD_ARCHIVE_DB_NAME || ''}`)
} else {
console.log('[limit-sync] 未配置 PROD_ARCHIVE_DB_*:产品限价页面明细将退回本地库')
}
} catch {
fail('DATABASE_URL_INVALID')
process.exit(1)
}
const child = spawn(
process.execPath,
[path.join(appRoot, 'scripts/sync-limit-workflows.mjs'), ...process.argv.slice(2)],
{ cwd: appRoot, env: process.env, stdio: 'inherit' },
)
const timeout = setTimeout(() => {
console.error('LIMIT_SYNC_RUN_TIMEOUT')
writeFailureJournal('LIMIT_SYNC_RUN_TIMEOUT')
try { child.kill('SIGKILL') } catch {}
}, RUN_TIMEOUT_MS)
timeout.unref()
child.on('error', (error) => {
clearTimeout(timeout)
fail('LIMIT_SYNC_RUNNER_LAUNCH_FAILED', error.message)
})
child.on('exit', (code, signal) => {
clearTimeout(timeout)
if (code === 0) {
process.exitCode = 0
return
}
console.error(`LIMIT_SYNC_CHILD_FAILED code=${code ?? 'null'} signal=${signal ?? 'null'}`)
writeFailureJournal('LIMIT_SYNC_CHILD_FAILED')
process.exitCode = 1
})

View File

@ -0,0 +1,925 @@
// 限价流程同步:拉取「限价」系列钉钉流程(四个模板、仅已完结),写入限价表并导出全量明细 Excel。
//
// 用法(服务器 / 本地通用):
// node scripts/sync-limit-workflows.mjs \
// [--out-dir <目录>] [--initial-dir <目录>] [--env-file <env 文件>] [--skip-excel]
//
// 环境变量(服务器上由 shared/.env 提供):
// DATABASE_URL 业务库(限价 / 授权写入目标)
// JST_REPORT_DB_* 钉钉流程报表库(只读)
// PROD_ARCHIVE_DB_* 正式库 external.product_catalog_wide_v1(产品限价页面)
// LIMIT_SYNC_OUT_DIR Excel 输出目录(默认 <app>/导出结果)
// LIMIT_SYNC_INITIAL_DIR 限价期初数据目录(默认 <app>/期初数据,其次用仓库自带 scripts/data/limit-sync/期初数据)
// LIMIT_SYNC_ENV_FILE 额外 env 文件(服务器上一般由 systemd EnvironmentFile 注入,无需设置)
// LIMIT_SYNC_SKIP_EXCEL=1 只写库、不导出 Excel(定时任务可选)
// LIMIT_SYNC_HISTORY_KEEP 历史版本保留份数(默认 20)
//
// 输出:<out-dir>/限价流程_完整明细表.xlsx(固定文件名,每次覆盖为最新全量;
// 旧文件自动归档到 <out-dir>/历史版本/,超过保留份数自动清理)。
import envPkg from '@next/env'
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomUUID } from 'node:crypto'
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
/** 极简参数解析:支持 `--key value`(值缺省为 true)。 */
function readArgs(argv) {
const parsed = {}
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index]
if (!token.startsWith('--')) continue
const key = token.slice(2)
const next = argv[index + 1]
if (next && !next.startsWith('--')) {
parsed[key] = next
index += 1
} else {
parsed[key] = true
}
}
return parsed
}
/** 读取 `KEY=VALUE` 形式的 env 文件(不覆盖已存在的进程变量,和 systemd EnvironmentFile 行为一致)。 */
function loadEnvFile(filePath) {
if (!existsSync(filePath)) throw new Error(`env 文件不存在:${filePath}`)
const content = readFileSyncUtf8(filePath)
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const separator = line.indexOf('=')
if (separator <= 0) continue
const key = line.slice(0, separator).trim()
let value = line.slice(separator + 1).trim()
if (
(value.startsWith("'") && value.endsWith("'"))
|| (value.startsWith('"') && value.endsWith('"'))
) {
value = value.slice(1, -1)
}
if (process.env[key] === undefined) process.env[key] = value
}
}
function readFileSyncUtf8(filePath) {
return readFileSync(filePath, 'utf8')
}
function resolveFromAppRoot(value) {
const text = String(value ?? '').trim()
if (!text) return ''
return isAbsolute(text) ? text : resolve(APP_ROOT, text)
}
const args = readArgs(process.argv.slice(2))
const envFile = resolveFromAppRoot(args['env-file'] || process.env.LIMIT_SYNC_ENV_FILE || '')
if (envFile) loadEnvFile(envFile)
// 仍按 Next 的方式加载 app root 的 .env(已有变量不会被覆盖)
envPkg.loadEnvConfig(APP_ROOT)
import sql from 'mssql'
import pg from 'pg'
import * as XLSX from 'xlsx'
/** ESM 下 xlsx 的命名导出不全(readFile 取不到),统一走这个命名空间兜底。 */
const xlsx = XLSX.default ?? XLSX
const OUT_DIR = resolveFromAppRoot(args['out-dir'] || process.env.LIMIT_SYNC_OUT_DIR || join(APP_ROOT, '导出结果'))
const OUT_FILE = join(OUT_DIR, '限价流程_完整明细表.xlsx')
const HISTORY_DIR = join(OUT_DIR, '历史版本')
/** 限价期初数据(业务整理的存量限价)目录:优先显式目录,其次仓库自带样例目录。 */
const INITIAL_LIMIT_DIR = resolveFromAppRoot(
args['initial-dir'] || process.env.LIMIT_SYNC_INITIAL_DIR || join(APP_ROOT, '期初数据'),
)
const BUNDLED_INITIAL_LIMIT_DIR = join(APP_ROOT, 'scripts/data/limit-sync/期初数据')
const SKIP_EXCEL = Boolean(args['skip-excel']) || /^(1|true|yes)$/i.test(process.env.LIMIT_SYNC_SKIP_EXCEL ?? '')
const HISTORY_KEEP = Math.max(1, Number(process.env.LIMIT_SYNC_HISTORY_KEEP ?? 20) || 20)
// 数据源口径(业务确认):
// A. 「新品限价流程」→ 限价明细取自系统「产品限价页面」(按流程链接中的 SPU 关联);
// B. 「新品限价及限价调整」→ 限价明细取钉钉流程表单内的明细表;
// C. 「各中心特殊限价需求申请」→ 明细表「最低售价」作为负责人限价(2026-10-16 纳入);
// D. 「GB特殊限价需求申请」→ 明细表「期望日常限价」作为负责人限价(2026-10-16 纳入)。
// A 的「产品限价页面」数据实际来自正式库 external.product_catalog_wide_v1(本地库该视图为空),
// 没配正式库连接时退回本地 IntakeNonIntegratedProduct / IntakeNonIntegratedProductLimitRow。
// E. 「产品限价页面」上已确认(CONFIRMED)的限价全量纳入(2026-10-16),
// 给没有限价流程的货号兜底(如 CY012801);同货号冲突时以限价流程为准。
// 都只保留已完结(COMPLETED)的流程;其他限价类模板(毛利调整等)暂不抓取。
const TEMPLATE_FILTER = `(title LIKE N'%提交的新品限价及限价调整' OR title LIKE N'%提交的新品限价流程' OR title LIKE N'%提交的各中心特殊限价需求申请' OR title LIKE N'%提交的GB特殊限价需求申请') AND status = 'COMPLETED'`
// 固定 55 列,与 2026-09-28《限价流程_完整明细表》保持一致。
const COLUMNS = [
'流程名称', '标题', '流程实例ID', '流程状态', '处理结果', '发起人', '发起部门名称', '创建时间', '完成时间',
'发起人用户ID', '业务ID', '流水号', '申请类型', '品牌', '品牌限价单号', '品牌运营负责人', '部门负责人', '销售类目',
'二级类目', '品类', '类目', '商品企划', '是否龙晨简尚共有产品', '由壹点产品开发流程链接地址', '调整理由',
'限价附件', '调整限价附件', '同SPU负责人沟通记录', '来源明细表', '明细行号', 'SPU', '货号', '货号(非商品编码)',
'商品编码', '主销渠道', '最低售价', '当下限价价格', '期望限价价格', '期望日常限价', '当下限价毛利率',
'期望限价毛利率', '当前限价', '调整后限价', '当前限价毛利率', '调整后限价毛利率', '当前非SPU负责人限价',
'调整后非SPU负责人限价', '非SPU负责人限价', '主销多多百补限价', '百补限价', '期望活动性限价',
'期望活动毛利率', '近7日日销', '报备价格(商家到手价)', '开始时间', '结束时间', '图片',
// 各中心特殊限价需求申请的明细列
'主销渠道', '最低售价', '期望限价毛利率', '货号',
]
const DETAIL_COLUMNS = new Set(COLUMNS.slice(COLUMNS.indexOf('SPU')))
const STATUS_MAP = { COMPLETED: '已完成', RUNNING: '进行中', TERMINATED: '已终止', CANCELED: '已取消', DRAFT: '草稿' }
const RESULT_MAP = { agree: '同意', refuse: '拒绝' }
/** 从标题提取流程名称:「10-15+醉夏提交的新品限价及限价调整」→「新品限价及限价调整」。 */
function extractFlowName(title) {
const text = String(title ?? '')
const marker = '提交的'
return text.includes(marker) ? text.slice(text.indexOf(marker) + marker.length).trim() : text
}
/** 时间显示格式与历史导出对齐:「2026-10-15T10:56Z」→「2026-10-15 10:56」。 */
function formatDateTime(value) {
return String(value ?? '').replace('T', ' ').replace(/Z$/, '').trim()
}
/** 明细单元格取值:附件数组取文件名;对象转 JSON;其余原样。 */
function normalizeCellValue(value) {
if (Array.isArray(value)) {
const files = value.map((item) => item?.fileName).filter(Boolean)
if (files.length) return files.join(';')
return value.map((item) => (item === null || item === undefined ? '' : String(item))).join(';')
}
if (typeof value === 'object' && value !== null) return JSON.stringify(value)
return value ?? ''
}
/** 明细列在各模板中命名不同,且可能出现「列存在但值为空」,按候选顺序取第一个非空值。 */
function firstNonEmpty(record, labels) {
for (const label of labels) {
const text = String(record[label] ?? '').trim()
if (text) return text
}
return ''
}
const pool = await new sql.ConnectionPool({
server: process.env.JST_REPORT_DB_HOST.trim(),
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
database: process.env.JST_REPORT_DB_NAME.trim(),
user: process.env.JST_REPORT_DB_USER.trim(),
password: process.env.JST_REPORT_DB_PASSWORD.trim(),
options: {
encrypt: process.env.JST_REPORT_DB_ENCRYPT?.trim() !== 'false',
trustServerCertificate: process.env.JST_REPORT_DB_TRUST_SERVER_CERTIFICATE?.trim() !== 'false',
},
requestTimeout: 300_000,
}).connect()
console.log('模式: 全量导出(新品限价及限价调整 / 新品限价流程 / 各中心特殊限价需求申请 / GB特殊限价需求申请,仅已完结)')
const flows = (await pool.request().query(`
SELECT
processInstanceId, title, status, result, originatorDeptName, originatorUserId, businessId,
createAt, updateAt, finishTime,
processCode
FROM rpt.ding_Workflow
WHERE ${TEMPLATE_FILTER}
ORDER BY createAt DESC
`)).recordset
console.log(`流程数量: ${flows.length}`)
if (!flows.length) {
console.log('没有符合条件的流程,未生成文件')
await pool.close()
process.exit(0)
}
const ids = flows.map((row) => `'${row.processInstanceId}'`).join(',')
const schemeRows = (await pool.request().query(`
SELECT processInstanceId, componentType, name, value, extValue
FROM rpt.ding_WorkflowScheme
WHERE processInstanceId IN (${ids})
ORDER BY processInstanceId, id
`)).recordset
const fieldsById = new Map()
for (const row of schemeRows) {
if (!fieldsById.has(row.processInstanceId)) fieldsById.set(row.processInstanceId, [])
fieldsById.get(row.processInstanceId).push(row)
}
const pickField = (fields, name) => fields.find((field) => field.name === name)?.value?.trim() || ''
// 发起人名字映射(本地员工目录:钉钉用户 ID → 姓名)
const nameById = new Map()
try {
const client = new pg.Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
const userIds = [...new Set(flows.map((row) => String(row.originatorUserId ?? '')).filter(Boolean))]
if (userIds.length) {
const employees = await client.query(
`SELECT "dingtalkUserId", "name" FROM "EmployeeDirectory" WHERE "dingtalkUserId" = ANY($1)`,
[userIds],
)
for (const row of employees.rows) nameById.set(row.dingtalkUserId, row.name)
}
await client.end()
} catch (error) {
console.warn('员工名字映射加载失败(发起人列将留空):', error.message)
}
/** 附件字段(JSON 数组)转文件名文本。 */
const attachmentText = (raw) => {
try {
const list = JSON.parse(raw)
return Array.isArray(list) ? list.map((item) => item.fileName).filter(Boolean).join(';') : raw
} catch { return raw }
}
const rows = []
// 写库用收集器:每条明细的货号限价 / 价格授权(同价、活动)数据。
const collected = []
// 数据源 A 预扫:「新品限价流程」的限价明细取自系统「产品限价页面」。
// 从流程表单的「由壹点产品开发流程链接地址」提取 SPU(形如 /intake/non-integrated/DSG0039-1786756641270),
// 再按 SPU 批量读取限价行:优先正式库 external.product_catalog_wide_v1(产品限价页面的数据源),
// 正式库没配 / 查不到时退回本地 IntakeNonIntegratedProduct + IntakeNonIntegratedProductLimitRow。
const legacyFlowSpu = new Map()
for (const flow of flows) {
if (extractFlowName(flow.title) !== '新品限价流程') continue
const fields = fieldsById.get(flow.processInstanceId) ?? []
const link = pickField(fields, '由壹点产品开发流程链接地址')
const matched = link.match(/non-integrated\/([^/?#]+)/)
if (matched) {
// 链接形如 /non-integrated/BJ0218-1783663599778,末尾是流水时间戳;SPU 本身可能带连字符(GBGJ-SJ0380)。
const raw = decodeURIComponent(matched[1]).trim()
const spu = raw.replace(/-\d{6,}$/, '')
legacyFlowSpu.set(flow.processInstanceId, spu || raw)
}
}
const limitRowsBySpu = new Map()
if (legacyFlowSpu.size) {
const spus = [...new Set(legacyFlowSpu.values())].filter(Boolean)
/** 把一行限价按 SPU 归并(去重键:货号 + 条码),避免同 SPU 的多列/多行重复。 */
const pushLimitRow = (spu, row) => {
if (!spu) return
const list = limitRowsBySpu.get(spu) ?? []
const key = `${String(row.skuCode ?? '').trim()}|${String(row.barcode ?? '').trim()}`
if (list.some((item) => `${String(item.skuCode ?? '').trim()}|${String(item.barcode ?? '').trim()}` === key)) return
list.push(row)
limitRowsBySpu.set(spu, list)
}
let loadedFromProd = false
try {
const prodHost = (process.env.PROD_ARCHIVE_DB_HOST ?? '').trim()
if (prodHost && spus.length) {
const prod = new pg.Client({
host: prodHost,
port: Number((process.env.PROD_ARCHIVE_DB_PORT ?? '').trim() || '5432'),
database: (process.env.PROD_ARCHIVE_DB_NAME ?? '').trim(),
user: (process.env.PROD_ARCHIVE_DB_USER ?? '').trim(),
password: (process.env.PROD_ARCHIVE_DB_PASSWORD ?? '').trim(),
ssl: { rejectUnauthorized: false },
connectionTimeoutMillis: 15_000,
})
await prod.connect()
const upperToSpu = new Map(spus.map((spu) => [spu.toUpperCase(), spu]))
const catalogRows = (await prod.query(
`SELECT upper(btrim("product_business_key")) AS product_key,
upper(btrim("archive_spu")) AS archive_spu,
btrim("sku_code") AS "skuCode",
btrim("barcode") AS "barcode",
btrim("expected_daily_limit_price") AS "expectedDailyLimitPrice",
btrim("expected_limit_gross_margin_rate") AS "expectedLimitGrossMarginRate",
btrim("non_spu_owner_limit_price") AS "nonSpuOwnerLimitPrice"
FROM external.product_catalog_wide_v1
WHERE upper(btrim("product_business_key")) = ANY($1) OR upper(btrim("archive_spu")) = ANY($1)`,
[spus.map((spu) => spu.toUpperCase())],
)).rows
await prod.end()
for (const row of catalogRows) {
for (const key of new Set([row.product_key, row.archive_spu])) {
pushLimitRow(upperToSpu.get(key), row)
}
}
loadedFromProd = [...limitRowsBySpu.values()].some((list) => list.length > 0)
const matchedSpus = spus.filter((spu) => (limitRowsBySpu.get(spu) ?? []).length > 0).length
console.log(
`产品限价页面(正式库 external.product_catalog_wide_v1):SPU ${spus.length} 个,命中 ${matchedSpus} 个,明细 ${catalogRows.length} 行`,
)
if (!loadedFromProd) limitRowsBySpu.clear()
}
} catch (error) {
limitRowsBySpu.clear()
console.warn('正式库产品限价页面读取失败,改用本地库:', error.message)
}
if (!loadedFromProd) {
// 本地库兜底:直接读非集成产品主档 + 限价明细表(本地库常常是空的)。
try {
const client = new pg.Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
if (spus.length) {
const products = (await client.query(
`SELECT "id", "spu", "spuBase" FROM "IntakeNonIntegratedProduct"
WHERE "deletedAt" IS NULL AND ("spu" = ANY($1) OR "spuBase" = ANY($1))`,
[spus],
)).rows
const productIds = products.map((row) => row.id)
if (productIds.length) {
const limitRows = (await client.query(
`SELECT "productId", "skuCode", "barcode", "expectedDailyLimitPrice",
"expectedLimitGrossMarginRate", "nonSpuOwnerLimitPrice"
FROM "IntakeNonIntegratedProductLimitRow" WHERE "productId" = ANY($1)`,
[productIds],
)).rows
const productById = new Map(products.map((row) => [row.id, row]))
for (const limitRow of limitRows) {
const product = productById.get(limitRow.productId)
if (!product) continue
for (const spu of new Set([product.spu, product.spuBase])) {
pushLimitRow(spu, limitRow)
}
}
}
}
await client.end()
} catch (error) {
console.warn('产品限价数据读取失败(新品限价流程的明细将留空):', error.message)
}
}
}
console.log(`新品限价流程 ${legacyFlowSpu.size} 条,其中关联到产品限价明细 ${[...legacyFlowSpu.values()].filter((spu) => (limitRowsBySpu.get(spu) ?? []).length > 0).length} 条`)
// —— 数据源 E 预扫:产品限价页面上「已确认」的限价(正式库 external.product_catalog_wide_v1)——
// 这类货号没有限价流程(例如 CY012801),只靠流程抓不到;按货号兜底写入,冲突时仍以流程为准。
const pageLimitItems = []
try {
const prodHost = (process.env.PROD_ARCHIVE_DB_HOST ?? '').trim()
if (!prodHost) {
console.log('产品限价页面(已确认限价):未配置正式库连接,跳过')
} else {
const prod = new pg.Client({
host: prodHost,
port: Number((process.env.PROD_ARCHIVE_DB_PORT ?? '').trim() || '5432'),
database: (process.env.PROD_ARCHIVE_DB_NAME ?? '').trim(),
user: (process.env.PROD_ARCHIVE_DB_USER ?? '').trim(),
password: (process.env.PROD_ARCHIVE_DB_PASSWORD ?? '').trim(),
ssl: { rejectUnauthorized: false },
connectionTimeoutMillis: 15_000,
})
await prod.connect()
const confirmedRows = (await prod.query(
// 确认时间:视图里存的是 UTC 墙钟(Prisma 语义),+8 小时换成北京时间墙钟,
// 与流程完成时间的存储口径一致(页面直存直显)。
`SELECT btrim("sku_code") AS "skuCode",
btrim("barcode") AS "barcode",
btrim("product_business_key") AS "spu",
btrim("expected_daily_limit_price") AS "ownerLimitPrice",
btrim("non_spu_owner_limit_price") AS "nonSpuOwnerLimitPrice",
to_char("limit_confirmed_at" + interval '8 hours', 'YYYY-MM-DD HH24:MI:SS') AS "confirmedAt"
FROM external.product_catalog_wide_v1
WHERE "limit_price_status" = 'CONFIRMED'
AND COALESCE(btrim("sku_code"), '') <> ''
AND COALESCE(btrim("expected_daily_limit_price"), '') <> ''`,
)).rows
await prod.end()
// 同一货号多条(多规格条码)时保留确认时间最新的一条,价格一致时取任意
const byStyle = new Map()
for (const row of confirmedRows) {
// 注意:toNumberOrNull 在文件后面才定义,这里不能用(TDZ),直接就地判断
const price = Number(String(row.ownerLimitPrice ?? '').replace(/[^\d.-]/g, ''))
if (!Number.isFinite(price) || price <= 0) continue
const current = byStyle.get(row.skuCode)
const currentAt = current?.confirmedAt ? new Date(current.confirmedAt).getTime() : 0
const nextAt = row.confirmedAt ? new Date(row.confirmedAt).getTime() : 0
if (!current || nextAt >= currentAt) byStyle.set(row.skuCode, row)
}
pageLimitItems.push(...byStyle.values())
console.log(
`产品限价页面(已确认限价):${confirmedRows.length} 行,去重后 ${pageLimitItems.length} 个货号`,
)
}
} catch (error) {
console.warn('产品限价页面(已确认限价)读取失败,本次不纳入:', error.message)
}
// —— 数据源 F:限价期初数据(业务整理的存量限价 Excel,最低优先级兜底)——
// 取「期初数据」目录里最新的一份;同货号多行取更新日期最新的一条;限价为空/0 的剔除。
const initialLimitItems = []
let initialLimitFileName = ''
try {
// 先看显式/环境变量指定的目录,没有再回落到仓库自带的 scripts/data 目录
const candidateDirs = [...new Set([INITIAL_LIMIT_DIR, BUNDLED_INITIAL_LIMIT_DIR].filter(Boolean))]
const candidates = candidateDirs.flatMap((dir) => (
existsSync(dir)
? readdirSync(dir)
.filter((name) => /^限价期初数据.*\.xlsx$/.test(name) && !name.startsWith('~$'))
.map((name) => ({ name, dir, path: join(dir, name) }))
: []
)).sort((a, b) => statSync(b.path).mtimeMs - statSync(a.path).mtimeMs)
if (!candidates.length) {
console.log(`限价期初数据:${candidateDirs.join(' / ')} 里没有「限价期初数据*.xlsx」,跳过`)
} else {
initialLimitFileName = candidates[0].name
console.log(`限价期初数据目录:${candidates[0].dir}`)
const workbook = xlsx.readFile(candidates[0].path)
const sheetName = workbook.SheetNames[0]
const sheetRows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: '' })
const byStyle = new Map()
for (const row of sheetRows) {
const styleCode = String(row['货号'] ?? '').trim()
const price = Number(String(row['负责人限价'] ?? '').replace(/[^\d.-]/g, ''))
if (!styleCode || !Number.isFinite(price) || price <= 0) continue
const updatedAt = String(row['更新日期'] ?? '').trim()
const current = byStyle.get(styleCode)
if (!current || updatedAt >= current.updatedAt) {
byStyle.set(styleCode, {
styleCode,
spu: String(row['SPU'] ?? '').trim(),
ownerLimitPrice: price,
nonOwnerLimitPrice: String(row['非负责人限价'] ?? '').trim(),
updatedAt,
owner: String(row['限价负责人'] ?? '').trim(),
})
}
}
initialLimitItems.push(...byStyle.values())
console.log(
`限价期初数据(${initialLimitFileName}):${sheetRows.length} 行,去重后有价货号 ${initialLimitItems.length} 个`,
)
}
} catch (error) {
console.warn('限价期初数据读取失败,本次不纳入:', error.message)
}
for (const flow of flows) {
const fields = fieldsById.get(flow.processInstanceId) ?? []
const flowBase = {
流程名称: extractFlowName(flow.title),
标题: flow.title,
流程实例ID: flow.processInstanceId,
流程状态: STATUS_MAP[flow.status] ?? flow.status ?? '',
处理结果: RESULT_MAP[flow.result] ?? flow.result ?? '',
发起人: nameById.get(String(flow.originatorUserId ?? '')) ?? '',
发起部门名称: flow.originatorDeptName ?? '',
创建时间: formatDateTime(flow.createAt),
完成时间: formatDateTime(flow.finishTime),
发起人用户ID: String(flow.originatorUserId ?? ''),
业务ID: flow.businessId ?? '',
流水号: flow.businessId ?? '',
申请类型: pickField(fields, '申请类型'),
品牌: pickField(fields, '品牌'),
品牌限价单号: pickField(fields, '品牌限价单号'),
品牌运营负责人: pickField(fields, '品牌运营负责人'),
部门负责人: pickField(fields, '部门负责人'),
销售类目: pickField(fields, '销售类目'),
二级类目: pickField(fields, '二级类目'),
品类: pickField(fields, '品类'),
类目: pickField(fields, '类目'),
商品企划: pickField(fields, '商品企划'),
是否龙晨简尚共有产品: pickField(fields, '是否龙晨简尚共有产品'),
由壹点产品开发流程链接地址: pickField(fields, '由壹点产品开发流程链接地址'),
调整理由: pickField(fields, '调整理由'),
限价附件: attachmentText(pickField(fields, '限价附件')),
调整限价附件: attachmentText(pickField(fields, '调整限价附件')),
同SPU负责人沟通记录: pickField(fields, '同SPU负责人沟通记录'),
}
const flowName = extractFlowName(flow.title)
let producedInThisFlow = 0
if (flowName === '新品限价流程') {
// 数据源 A:明细取自系统「产品限价页面」(按流程链接中的 SPU 关联)。
const spu = legacyFlowSpu.get(flow.processInstanceId) ?? ''
const systemLimits = spu ? (limitRowsBySpu.get(spu) ?? []) : []
systemLimits.forEach((limit, index) => {
rows.push({
...flowBase,
来源明细表: '产品限价页面',
明细行号: index + 1,
SPU: spu,
'货号(非商品编码)': limit.skuCode ?? '',
商品编码: limit.barcode ?? '',
期望日常限价: limit.expectedDailyLimitPrice ?? '',
期望限价毛利率: limit.expectedLimitGrossMarginRate ?? '',
非SPU负责人限价: limit.nonSpuOwnerLimitPrice ?? '',
})
collected.push({
flow,
applyType: flowBase.申请类型,
spu,
styleCode: String(limit.skuCode ?? '').trim(),
barcode: String(limit.barcode ?? '').trim(),
expectedDailyLimitPrice: String(limit.expectedDailyLimitPrice ?? '').trim(),
adjustedLimitPrice: '',
startDate: '',
endDate: '',
})
producedInThisFlow += 1
})
} else {
// 数据源 B:解析钉钉流程表单内的限价明细表。
for (const field of fields) {
if (field.componentType !== 'TableField' || !field.value) continue
let table = []
try { table = JSON.parse(field.value) } catch { continue }
if (!Array.isArray(table)) continue
table.forEach((tableRow, tableIndex) => {
const cells = tableRow?.rowValue ?? []
const record = { ...flowBase, 来源明细表: field.name, 明细行号: tableIndex + 1 }
for (const cell of cells) {
// 钉钉表格里存在复合列:label 是数组(如 ["开始时间","结束时间"]),value 按位置对应。
const composite = Array.isArray(cell.label)
const labels = composite ? cell.label : [cell.label]
labels.forEach((rawLabel, labelIndex) => {
const label = String(rawLabel ?? '').trim()
if (!label || !DETAIL_COLUMNS.has(label)) return
const rawValue = composite
? (Array.isArray(cell.value) ? cell.value[labelIndex] : cell.value)
: cell.value
record[label] = normalizeCellValue(rawValue)
})
}
rows.push(record)
// 货号列命名:新品限价=「货号(非商品编码)」;各中心特殊限价 / GB特殊限价=「货号」。
// 限价列命名:新品限价及限价调整 / GB特殊限价=「期望日常限价」;各中心特殊限价=「最低售价」。
// 「调整限价明细表」的子表只填「调整后限价」(申请类型=调整原货号限价),作为兜底列一起取,
// 否则这类流程的调价会被漏掉(同一流程内同一货号不会同时出现这两列,无冲突)。
const limitPriceLabels = flowName.includes('各中心')
? ['最低售价', '期望日常限价', '调整后限价']
: ['期望日常限价', '最低售价', '调整后限价']
collected.push({
flow,
applyType: flowBase.申请类型,
spu: String(record['SPU'] ?? '').trim(),
styleCode: firstNonEmpty(record, ['货号(非商品编码)', '货号']),
barcode: String(record['商品编码'] ?? '').trim(),
expectedDailyLimitPrice: firstNonEmpty(record, limitPriceLabels),
adjustedLimitPrice: String(record['调整后限价'] || record['调整后非SPU负责人限价'] || '').trim(),
startDate: String(record['开始时间'] ?? '').trim(),
endDate: String(record['结束时间'] ?? '').trim(),
})
producedInThisFlow += 1
})
}
}
// 没有线上明细表的流程(如「新品限价流程」,限价明细在附件 Excel 中)也要保留一行,
// 与历史导出格式一致:来源明细表填附件文件名,明细列为空,避免整条流程丢失。
if (producedInThisFlow === 0) {
let attachmentName = ''
for (const name of ['限价附件', '调整限价附件']) {
const raw = pickField(fields, name)
try {
const list = JSON.parse(raw)
if (Array.isArray(list) && list[0]?.fileName) { attachmentName = String(list[0].fileName); break }
} catch {}
}
rows.push({ ...flowBase, 来源明细表: attachmentName, 明细行号: '' })
}
}
console.log(`更新流程: ${flows.length} 条, 明细行: ${rows.length} 行`)
// —— 写入数据库:货号负责人限价 + 价格授权(同价 / 活动)——
const isOverrideApplyType = (applyType) => applyType !== '申请与SPU负责人同价' && applyType !== '活动限价'
const toDateOrNull = (value) => {
const text = String(value ?? '').trim()
if (!text) return null
const normalized = text.includes('T') ? text : (text.length <= 10 ? `${text}T00:00:00Z` : text.replace(' ', 'T'))
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? null : date
}
/** 写库用的墙钟时间字符串:报表里的时间即北京时间墙钟,直存直显(不做时区换算)。 */
const toWallClockString = (value) => {
const text = String(value ?? '').trim()
if (!text) return null
const matched = text.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?)?/)
if (!matched) return null
const pad = (part, fallback) => String(part ?? fallback).padStart(2, '0')
return `${matched[1]}-${pad(matched[2])}-${pad(matched[3])} ${pad(matched[4], '00')}:${pad(matched[5], '00')}:${pad(matched[6], '00')}`
}
const toNumberOrNull = (value) => {
const text = String(value ?? '').trim()
if (!text) return null
const parsed = Number(text.replace(/[^\d.-]/g, ''))
return Number.isFinite(parsed) ? parsed : null
}
/**
* 非负责人限价(最新口径,与《限价抬头及逻辑》一致):
* 负责人限价 × 1.05,且不低于 +10 元。历史文件/页面里的「非负责人限价」不采用。
*/
const deriveNonOwnerLimitPrice = (ownerLimitPrice) => {
const owner = toNumberOrNull(ownerLimitPrice)
if (owner === null) return ''
return (owner + Math.max(owner * 0.05, 10)).toFixed(2)
}
/** 导出用:产品限价页面已确认的限价(含是否被流程覆盖),写库那段填充。 */
let pageLimitSheetRows = []
/** 导出用:限价期初数据(含是否被流程/页面覆盖),写库那段填充。 */
let initialLimitSheetRows = []
/** 货号 → 最终生效来源(flow / page / initial),写库那段填充,导出标记用。 */
let finalLimitSourceByStyle = new Map()
let writeClient = null
try {
if (!String(process.env.DATABASE_URL ?? '').trim()) {
throw new Error('缺少 DATABASE_URL:服务器上请通过 shared/.env(systemd EnvironmentFile)注入')
}
const client = new pg.Client({ connectionString: process.env.DATABASE_URL })
writeClient = client
await client.connect()
const target = (await client.query(
`SELECT current_database() AS db, COALESCE(inet_server_addr()::text, 'local') AS host`,
)).rows[0]
console.log(`写库目标:${target.host}/${target.db}(货号限价 + 价格授权)`)
// 全量重建:每次同步生成最新镜像(两表只由本脚本维护)。
// 生产库上必须保证「要么全量成功、要么保留旧数据」,所以整段包在事务里。
await client.query('BEGIN')
// 明细没有货号时,用商品编码(条码)匹配产品档案里的货号
const missingBarcodes = [...new Set(collected.filter((item) => !item.styleCode && item.barcode).map((item) => item.barcode))]
const barcodeToStyle = new Map()
if (missingBarcodes.length) {
const archive = (await client.query(
`SELECT "barcode", "sku" FROM "SupplyChainProductArchive" WHERE "barcode" = ANY($1)`,
[missingBarcodes],
)).rows
for (const row of archive) barcodeToStyle.set(row.barcode, row.sku)
}
let matchedByBarcode = 0
for (const item of collected) {
if (item.styleCode || !item.barcode) continue
const style = barcodeToStyle.get(item.barcode)
if (style) { item.styleCode = style; matchedByBarcode += 1 }
}
// 部分模板(如 GB特殊限价)的「货号」列实际填的是商品编码:若该值不是档案里的任何货号、
// 但能匹配到条码,则统一换算为该条码对应的货号,保证限价表按货号维度对齐。
const allStyleCodes = [
...new Set([
...collected.map((item) => item.styleCode),
...initialLimitItems.map((item) => item.styleCode),
].filter(Boolean)),
]
let matchedByStyleBarcode = 0
if (allStyleCodes.length) {
const knownSkus = new Set(
(await client.query(
`SELECT "sku" FROM "SupplyChainProductArchive" WHERE "sku" = ANY($1)`,
[allStyleCodes],
)).rows.map((row) => row.sku),
)
const barcodeLikeCodes = allStyleCodes.filter((code) => !knownSkus.has(code))
if (barcodeLikeCodes.length) {
const pairs = (await client.query(
`SELECT "barcode", "sku" FROM "SupplyChainProductArchive" WHERE "barcode" = ANY($1)`,
[barcodeLikeCodes],
)).rows
const barcodeToSku = new Map(pairs.map((row) => [row.barcode, row.sku]))
for (const item of collected) {
if (!item.styleCode) continue
const sku = barcodeToSku.get(item.styleCode)
if (sku) { item.styleCode = sku; matchedByStyleBarcode += 1 }
}
for (const item of initialLimitItems) {
const sku = barcodeToSku.get(item.styleCode)
if (sku) { item.styleCode = sku; matchedByStyleBarcode += 1 }
}
}
}
const initiatorOf = (flow) => nameById.get(String(flow.originatorUserId ?? '')) ?? ''
// —— 决出每个货号的最终生效限价 ——
// 优先级:限价流程 > 产品限价页面 > 限价期初数据;
// 例外(2026-10-16 确认):期初数据的「更新日期」晚于流程完成时间时,以期初数据为准(谁新用谁)。
/** 墙钟时间字符串(北京时间口径)转毫秒,用于跨来源比较新旧。 */
const wallClockMs = (value) => {
const matched = String(value ?? '').match(/(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?)?/)
if (!matched) return null
return Date.UTC(
Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]),
Number(matched[4] ?? 0), Number(matched[5] ?? 0), Number(matched[6] ?? 0),
)
}
// 流程限价:同价 / 活动类不写负责人限价;同货号取审核时间最新的流程;价格为 0 / 空的剔除。
const limitItems = collected
.filter((item) => {
if (!item.styleCode || !isOverrideApplyType(item.applyType)) return false
const price = toNumberOrNull(item.expectedDailyLimitPrice)
return price !== null && price > 0
})
.sort((a, b) => (toDateOrNull(a.flow.finishTime)?.getTime() ?? 0) - (toDateOrNull(b.flow.finishTime)?.getTime() ?? 0))
const flowLimitStyles = new Set(limitItems.map((item) => item.styleCode))
const finalLimitByStyle = new Map()
for (const item of limitItems) {
finalLimitByStyle.set(item.styleCode, {
styleCode: item.styleCode,
ownerLimitPrice: toNumberOrNull(item.expectedDailyLimitPrice),
processInstanceId: item.flow.processInstanceId,
processNo: item.flow.businessId ?? '',
applyType: item.applyType ?? '',
initiator: initiatorOf(item.flow),
approvedAt: toWallClockString(item.flow.finishTime),
source: 'flow',
dateMs: wallClockMs(item.flow.finishTime),
})
}
for (const item of pageLimitItems) {
if (finalLimitByStyle.has(item.skuCode)) continue
finalLimitByStyle.set(item.skuCode, {
styleCode: item.skuCode,
ownerLimitPrice: toNumberOrNull(item.ownerLimitPrice),
processInstanceId: '',
processNo: '',
applyType: '产品限价页面',
initiator: '',
approvedAt: toWallClockString(item.confirmedAt),
source: 'page',
dateMs: wallClockMs(item.confirmedAt),
})
}
for (const item of initialLimitItems) {
const candidate = {
styleCode: item.styleCode,
ownerLimitPrice: item.ownerLimitPrice,
processInstanceId: '',
processNo: '',
applyType: '限价期初数据',
initiator: item.owner,
approvedAt: toWallClockString(item.updatedAt),
source: 'initial',
dateMs: wallClockMs(item.updatedAt),
}
const current = finalLimitByStyle.get(item.styleCode)
if (!current) {
finalLimitByStyle.set(item.styleCode, candidate)
continue
}
// 只有对上「流程限价」时按时间比:期初更新日期更晚 → 期初生效
if (
current.source === 'flow'
&& candidate.dateMs !== null
&& current.dateMs !== null
&& candidate.dateMs > current.dateMs
) {
finalLimitByStyle.set(item.styleCode, candidate)
}
}
finalLimitSourceByStyle = new Map([...finalLimitByStyle].map(([styleCode, item]) => [styleCode, item.source]))
// 一次性写入最终生效限价(全表重建,直接 upsert 覆盖)
let limitUpserts = 0
for (const item of finalLimitByStyle.values()) {
await client.query(
`INSERT INTO "SupplyChainStyleLimitPrice"
("id","styleCode","ownerLimitPrice","processInstanceId","processNo","applyType","initiator","approvedAt","createdAt","updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now(), now())
ON CONFLICT ("styleCode") DO UPDATE SET
"ownerLimitPrice" = EXCLUDED."ownerLimitPrice",
"processInstanceId" = EXCLUDED."processInstanceId",
"processNo" = EXCLUDED."processNo",
"applyType" = EXCLUDED."applyType",
"initiator" = EXCLUDED."initiator",
"approvedAt" = EXCLUDED."approvedAt",
"updatedAt" = now()`,
[
randomUUID(), item.styleCode, item.ownerLimitPrice, item.processInstanceId,
item.processNo, item.applyType, item.initiator, item.approvedAt,
],
)
limitUpserts += 1
}
// 2) 价格授权:同价申请(批准时间~结束时间)/ 活动限价(开始~结束,调整后限价)
const authItems = collected.filter(
(item) => item.styleCode && (item.applyType === '申请与SPU负责人同价' || item.applyType === '活动限价'),
)
let authUpserts = 0
for (const item of authItems) {
await client.query(
`INSERT INTO "SupplyChainLimitFlowAuth"
("id","processInstanceId","processNo","applyType","styleCode","spu","approvedAt","startDate","endDate","adjustedLimitPrice","createdAt","updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10, now(), now())
ON CONFLICT ("processInstanceId","styleCode","applyType") DO UPDATE SET
"processNo" = EXCLUDED."processNo",
"spu" = EXCLUDED."spu",
"approvedAt" = EXCLUDED."approvedAt",
"startDate" = EXCLUDED."startDate",
"endDate" = EXCLUDED."endDate",
"adjustedLimitPrice" = EXCLUDED."adjustedLimitPrice",
"updatedAt" = now()`,
[
randomUUID(), item.flow.processInstanceId, item.flow.businessId ?? '', item.applyType, item.styleCode,
item.spu ?? '', toWallClockString(item.flow.finishTime),
toWallClockString(item.applyType === '活动限价' ? item.startDate : item.flow.finishTime),
toWallClockString(item.endDate),
item.applyType === '活动限价' ? toNumberOrNull(item.adjustedLimitPrice) : null,
],
)
authUpserts += 1
}
await client.query('COMMIT')
await client.end()
pageLimitSheetRows = pageLimitItems.map((item) => {
const source = finalLimitSourceByStyle.get(item.skuCode)
return {
货号: item.skuCode,
商品编码: item.barcode ?? '',
SPU: item.spu ?? '',
期望日常限价: item.ownerLimitPrice ?? '',
// 统一按最新口径派生,不用页面里的历史「非SPU负责人限价」
非负责人限价: deriveNonOwnerLimitPrice(item.ownerLimitPrice),
确认时间: toWallClockString(item.confirmedAt) ?? '',
限价来源: '产品限价页面',
是否被限价流程覆盖: source === 'flow'
? '是(以流程为准)'
: source === 'initial'
? '否(以期初数据为准:期初更新日期更新)'
: '否(按页面限价)',
}
})
const pageBeatenByInitial = pageLimitSheetRows.filter((row) => row.是否被限价流程覆盖.startsWith('否(以期初数据为准')).length
initialLimitSheetRows = initialLimitItems.map((item) => ({
货号: item.styleCode,
SPU: item.spu,
负责人限价: item.ownerLimitPrice,
// 统一按最新口径派生,不用期初数据里的历史「非负责人限价」
非负责人限价: deriveNonOwnerLimitPrice(item.ownerLimitPrice),
更新日期: item.updatedAt,
限价负责人: item.owner,
是否被流程或页面覆盖:
finalLimitSourceByStyle.get(item.styleCode) === 'flow'
? '是(以流程为准)'
: finalLimitSourceByStyle.get(item.styleCode) === 'page'
? '是(以产品限价页面为准)'
: '否(按期初数据)',
}))
const initialWonOverFlow = initialLimitSheetRows.filter((row) => (
finalLimitSourceByStyle.get(row.货号) === 'initial'
&& flowLimitStyles.has(row.货号)
)).length
console.log(
`写库完成:货号限价 ${limitUpserts} 条(含条码匹配货号 ${matchedByBarcode} 条、货号列按商品编码换算 ${matchedByStyleBarcode} 条)`
+ `;最终生效来源:流程 ${[...finalLimitSourceByStyle.values()].filter((source) => source === 'flow').length} 个、`
+ `产品限价页面 ${[...finalLimitSourceByStyle.values()].filter((source) => source === 'page').length} 个、`
+ `限价期初数据 ${[...finalLimitSourceByStyle.values()].filter((source) => source === 'initial').length} 个`
+ `(其中 ${initialWonOverFlow} 个是「期初更新日期比流程新」赢下的、${pageBeatenByInitial} 个页面价被更新的期初价覆盖)`
+ `,价格授权 ${authUpserts} 条`,
)
} catch (error) {
try { await writeClient?.query('ROLLBACK') } catch {}
try { await writeClient?.end() } catch {}
console.warn('写库失败(已回滚,正式库保留旧数据;Excel 导出继续):', error.message)
}
if (SKIP_EXCEL) {
console.log('已按 LIMIT_SYNC_SKIP_EXCEL 跳过 Excel 导出')
await pool.close()
process.exit(0)
}
const workbook = XLSX.utils.book_new()
const sheet = XLSX.utils.json_to_sheet(rows, { header: COLUMNS })
XLSX.utils.book_append_sheet(workbook, sheet, '限价流程明细')
// 第二个页签:产品限价页面已确认的限价(没有限价流程的货号靠它兜底)
if (pageLimitSheetRows.length) {
const pageSheet = XLSX.utils.json_to_sheet(pageLimitSheetRows, {
header: ['货号', '商品编码', 'SPU', '期望日常限价', '非负责人限价', '确认时间', '限价来源', '是否被限价流程覆盖'],
})
XLSX.utils.book_append_sheet(workbook, pageSheet, '产品限价页面限价')
}
// 第三个页签:限价期初数据(存量限价兜底)
if (initialLimitSheetRows.length) {
const initialSheet = XLSX.utils.json_to_sheet(initialLimitSheetRows, {
header: ['货号', 'SPU', '负责人限价', '非负责人限价', '更新日期', '限价负责人', '是否被流程或页面覆盖'],
})
XLSX.utils.book_append_sheet(workbook, initialSheet, '限价期初数据')
}
// 固定文件名覆盖式输出:旧文件先归档到「历史版本」,保证导出目录里永远只有一份最新全量表。
mkdirSync(OUT_DIR, { recursive: true })
if (existsSync(OUT_FILE)) {
mkdirSync(HISTORY_DIR, { recursive: true })
const now = new Date()
const archiveStamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`
renameSync(OUT_FILE, `${HISTORY_DIR}/限价流程_完整明细表_归档_${archiveStamp}.xlsx`)
// 只保留最近 N 份归档,避免服务器上每天 28MB 无限累积
const archives = readdirSync(HISTORY_DIR)
.filter((name) => /^限价流程_完整明细表_归档_.*\.xlsx$/.test(name))
.map((name) => ({ name, path: join(HISTORY_DIR, name), mtime: statSync(join(HISTORY_DIR, name)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime)
for (const stale of archives.slice(HISTORY_KEEP)) {
unlinkSync(stale.path)
}
}
XLSX.writeFile(workbook, OUT_FILE)
console.log(`已导出: ${OUT_FILE}`)
console.log(`历史版本保留:${HISTORY_KEEP} 份`)
await pool.close()

View File

@ -0,0 +1,69 @@
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
const CONFIRM_TOKEN = 'sync-product-inventory-snapshot'
function arg(name: string) {
const prefix = `--${name}=`
return process.argv.slice(2).find((value) => value.startsWith(prefix))?.slice(prefix.length) ?? ''
}
function required(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required.`)
return value
}
function identity(url: string) {
const parsed = new URL(url)
return [
parsed.protocol.replace(':', ''),
decodeURIComponent(parsed.username || ''),
parsed.hostname,
parsed.port || '',
parsed.pathname.replace(/^\//, ''),
parsed.searchParams.get('schema') ?? '',
].join('|')
}
function sourceIdentity() {
return [
'sqlserver',
required('JST_REPORT_DB_USER'),
required('JST_REPORT_DB_HOST'),
process.env.JST_REPORT_DB_PORT?.trim() || '1433',
required('JST_REPORT_DB_NAME'),
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
].join('|')
}
async function main() {
if (!process.argv.includes('--apply') || arg('confirm') !== CONFIRM_TOKEN) {
throw new Error(`写入商品库存快照需要 --apply 和 --confirm=${CONFIRM_TOKEN}`)
}
const databaseUrl = required('DATABASE_URL')
if (arg('expected-database-identity') !== identity(databaseUrl)) {
throw new Error('expected database identity mismatch')
}
if (arg('expected-source-database-identity') !== sourceIdentity()) {
throw new Error('expected source identity mismatch')
}
const { prisma } = await import('../src/lib/prisma')
const { productInventorySnapshotService } = await import(
'../src/server/services/product-inventory-snapshot.service'
)
try {
console.log(JSON.stringify({ stage: 'start' }))
const result = await productInventorySnapshotService.sync()
console.log(JSON.stringify({ stage: 'completed', ...result }, null, 2))
} finally {
await prisma.$disconnect()
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})

View File

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import { handleRequest } from '@/server/core/response'
import { readOwnedDiyAiRenderImage } from '@/server/services/diy-ai-render.image'
export const dynamic = 'force-dynamic'
export const maxDuration = 60
export async function GET(request: NextRequest) {
return handleRequest(request, async ctx => {
const image = await readOwnedDiyAiRenderImage({
recordId: request.nextUrl.searchParams.get('recordId'),
storageKey: request.nextUrl.searchParams.get('storageKey'),
}, ctx.user)
return new NextResponse(new Uint8Array(image.buffer), {
headers: {
'Content-Type': image.contentType,
'Content-Length': String(image.buffer.byteLength),
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
},
})
}, { requireAuth: true })
}

View File

@ -0,0 +1,20 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { createDiyLayoutReplace } from '@/server/services/diy-layout-replace.service'
import { diyAiRenderService } from '@/server/services/diy-ai-render.service'
export const maxDuration = 300
export async function GET(request: NextRequest) {
return handleRequest(request, async ctx => jsonSuccess(await diyAiRenderService.findForRequest(request.nextUrl.searchParams.get('requestId') || '', ctx.user), 200, ctx.requestId), { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async ctx => {
const text = await request.text()
if (Buffer.byteLength(text) > 12_000) return jsonError(ErrorCode.FILE_TOO_LARGE, '排版参数过大', 413, ctx.requestId)
let input: unknown
try { input = JSON.parse(text) } catch { return jsonError(ErrorCode.VALIDATION_FAILED, '排版参数格式无效', 422, ctx.requestId) }
const record = await createDiyLayoutReplace(input, ctx.user)
return jsonSuccess(record, record.status === 'PENDING' ? 202 : 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,15 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { importDiyLayoutTemplate } from '@/server/services/diy-layout-template-import.service'
export const maxDuration = 60
export async function POST(request: NextRequest) {
return handleRequest(request, async ctx => {
const text = await request.text()
if (Buffer.byteLength(text) > 16_384) return jsonError(ErrorCode.FILE_TOO_LARGE, '图片链接参数过大', 413, ctx.requestId)
let input: unknown
try { input = JSON.parse(text) } catch { return jsonError(ErrorCode.VALIDATION_FAILED, '图片链接参数格式无效', 422, ctx.requestId) }
return jsonSuccess(await importDiyLayoutTemplate(input, ctx.user), 201, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,13 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { uploadDiyLayoutTemplate } from '@/server/services/diy-image-template.service'
export async function POST(request: NextRequest) {
return handleRequest(request, async ctx => {
if (Number(request.headers.get('content-length')) > 21 * 1024 * 1024) return jsonError(ErrorCode.FILE_TOO_LARGE, '模板不能超过20MB', 413, ctx.requestId)
const form = await request.formData(), file = form.get('file')
if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_FAILED, '请选择模板图片', 422, ctx.requestId)
return jsonSuccess(await uploadDiyLayoutTemplate(file, ctx.user), 201, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,15 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { detectDiyImageText } from '@/server/services/diy-text-detect.service'
export const maxDuration = 120
export async function POST(request: NextRequest) {
return handleRequest(request, async ctx => {
const text = await request.text()
if (Buffer.byteLength(text) > 4000) return jsonError(ErrorCode.FILE_TOO_LARGE, '识别参数过大', 413, ctx.requestId)
let input: unknown
try { input = JSON.parse(text) } catch { return jsonError(ErrorCode.VALIDATION_FAILED, '识别参数格式无效', 422, ctx.requestId) }
return jsonSuccess(await detectDiyImageText(input, ctx.user), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,21 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { createDiyTextReplace } from '@/server/services/diy-text-replace.service'
import { diyAiRenderService } from '@/server/services/diy-ai-render.service'
export const maxDuration = 300
export async function GET(request: NextRequest) {
return handleRequest(request, async ctx => jsonSuccess(await diyAiRenderService.findForRequest(
request.nextUrl.searchParams.get('requestId') || '', ctx.user), 200, ctx.requestId), { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async ctx => {
const text = await request.text()
if (Buffer.byteLength(text) > 100_000) return jsonError(ErrorCode.FILE_TOO_LARGE, '文字参数过大,请分批修改', 413, ctx.requestId)
let input: unknown
try { input = JSON.parse(text) } catch { return jsonError(ErrorCode.VALIDATION_FAILED, '文字参数格式无效', 422, ctx.requestId) }
const record = await createDiyTextReplace(input, ctx.user)
return jsonSuccess(record, record.status === 'PENDING' ? 202 : 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -6,3 +6,8 @@ export async function GET(request: NextRequest, context: { params: Promise<{ cod
const { code } = await context.params
return handleRequest(request, async ctx => jsonSuccess(await diySchemeService.get(code, ctx.user), 200, ctx.requestId), { requireAuth: true })
}
export async function DELETE(request: NextRequest, context: { params: Promise<{ code: string }> }) {
const { code } = await context.params
return handleRequest(request, async ctx => jsonSuccess(await diySchemeService.delete(code, ctx.user), 200, ctx.requestId), { requireAuth: true })
}

View File

@ -22,15 +22,25 @@ export async function POST(request: NextRequest) {
}
const params = (body?.params && typeof body.params === 'object' ? body.params : {}) as Record<string, unknown>
// 任务级权限:目前唯一类型 archive-export 复用产品全量档案的导出权限
if (type === 'archive-export') {
// 任务级权限:产品档案导出走「产品全量档案 · 下载」,限价基础信息导出走「限价管理 · 下载」
if (type === 'archive-export' || type === 'limit-export' || type === 'combo-limit-export') {
const { supplyChainPermissionSettingsService } = await import(
'@/server/services/supply-chain-permission-settings.service'
)
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'productArchive')
const target = type === 'archive-export' ? 'productArchive' : 'limitManagement'
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, target)
if (!access.download) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有导出权限', 403, ctx.requestId)
}
// 字段权限在创建任务时快照进参数:后台执行器拿不到用户上下文,
// 且排队期间权限变化不应影响已建任务产物的口径。
if (type !== 'combo-limit-export') {
const { productArchiveFieldPermissionService } = await import(
'@/server/services/product-archive-field-permission.service'
)
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
params.fieldBlocks = [...blockedFields]
}
}
try {

View File

@ -0,0 +1,28 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { NotificationDeliveryError } from '@/server/services/notification-delivery.service'
import { intakeProductPhotoNotificationService } from '@/server/services/intake-product-photo-notification.service'
type RouteContext = { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, context: RouteContext) {
return handleRequest(request, async ({ user, requestId }) => {
const { id } = await context.params
const data = await intakeProductPhotoNotificationService.getAccess(id, user, request.nextUrl.searchParams.get('recipients') === 'true')
return jsonSuccess(data, 200, requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest, context: RouteContext) {
return handleRequest(request, async ({ user, requestId }) => {
const { id } = await context.params
const body: unknown = await request.json().catch(() => null)
try {
return jsonSuccess(await intakeProductPhotoNotificationService.send(id, body, user), 200, requestId)
} catch (error) {
if (error instanceof NotificationDeliveryError) return jsonError(ErrorCode.VALIDATION_FAILED, error.message, 400, requestId)
throw error
}
}, { requireAuth: true })
}

View File

@ -0,0 +1,17 @@
import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { intakeProductPhotoUploadService } from '@/server/services/intake-product-photo-upload.service'
export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
return handleRequest(request, async ({ user, requestId }) => {
const { id } = await context.params
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择产品实拍图片')
let metadata: unknown
try { metadata = JSON.parse(String(form.get('metadata') || 'null')) }
catch { throw new AppError(ErrorCode.VALIDATION_FAILED, '图片位置信息格式不正确') }
return jsonSuccess(await intakeProductPhotoUploadService.upload(id, file, metadata, user), 201, requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,26 @@
import { NextRequest } from 'next/server'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { dingtalkWorkflowClearanceService } from '@/server/services/dingtalk-workflow-clearance.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
'businessManagement',
)
if (!access.view) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有流程监控查看权限',
403,
ctx.requestId,
)
}
const refresh = request.nextUrl.searchParams.get('refresh') === '1'
const { overview, refreshing } = await dingtalkWorkflowClearanceService.getOverview({ refresh })
return jsonSuccess({ ...overview, refreshing }, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { dingtalkWorkflowMonitorService } from '@/server/services/dingtalk-workflow-monitor.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
const querySchema = z.object({
process: z.enum(['beihuo', 'chanpin']),
status: z.string().trim().max(40).optional(),
category: z.string().trim().max(200).optional(),
storageType: z.string().trim().max(100).optional(),
applicant: z.string().trim().max(100).optional(),
itemCode: z.string().trim().max(200).optional(),
dateFrom: z.string().trim().max(20).optional(),
dateTo: z.string().trim().max(20).optional(),
})
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
'businessManagement',
)
if (!access.view) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有流程监控查看权限',
403,
ctx.requestId,
)
}
const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '导出参数不正确', 400, ctx.requestId)
}
const { process, ...filters } = parsed.data
const result = await dingtalkWorkflowMonitorService.exportFlowDetails({
processKey: process,
filters,
})
return new NextResponse(result.csv, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(result.fileName)}`,
'Cache-Control': 'private, no-store, max-age=0',
'X-Exported-Row-Count': String(result.rowCount),
'X-Request-Id': ctx.requestId,
},
})
}, { requireAuth: true })
}

View File

@ -0,0 +1,42 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { dingtalkWorkflowMonitorService } from '@/server/services/dingtalk-workflow-monitor.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
const querySchema = z.object({
process: z.enum(['beihuo', 'chanpin']),
flowId: z.string().trim().min(1).max(200),
})
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
'businessManagement',
)
if (!access.view) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有流程监控查看权限',
403,
ctx.requestId,
)
}
const parsed = querySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '流程明细参数不正确', 400, ctx.requestId)
}
const details = await dingtalkWorkflowMonitorService.getFlowDetails(
parsed.data.process,
parsed.data.flowId,
)
if (!details) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '未找到对应的流程明细', 404, ctx.requestId)
}
return jsonSuccess(details, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,26 @@
import { NextRequest } from 'next/server'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { dingtalkWorkflowMonitorService } from '@/server/services/dingtalk-workflow-monitor.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
'businessManagement',
)
if (!access.view) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有流程监控查看权限',
403,
ctx.requestId,
)
}
const refresh = request.nextUrl.searchParams.get('refresh') === '1'
const { overview, refreshing } = await dingtalkWorkflowMonitorService.getOverview({ refresh })
return jsonSuccess({ ...overview, refreshing }, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainBreachOrderService } from '@/server/services/supply-chain-breach-order.service'
const actionSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('appeal'),
appealType: z.string().min(1).max(60),
processNo: z.string().max(60).optional(),
description: z.string().min(1).max(100),
// 凭证以 data URL 传递(base64),单张上限按 ~6MB base64 控制
images: z.array(z.string().max(6_000_000)).max(3).default([]),
scope: z.enum(['ORDER', 'LINK']).default('ORDER'),
}),
z.object({
action: z.literal('review'),
decision: z.enum(['PASS', 'REJECT', 'RETURN', 'LEFT']),
reason: z.string().max(60).optional(),
note: z.string().max(100).optional(),
}),
])
/** 把校验失败的字段路径换成用户能看懂的提示。 */
function friendlyValidationMessage(path: string) {
if (path === 'appealType') return '申诉类型不合法,请重新选择后再提交'
if (path === 'description') return '申诉说明最多 100 字,请精简后再提交'
if (path === 'processNo') return '流程号最多 60 个字符,请核对后重试'
if (path.startsWith('images')) return '凭证图片过大或超过 3 张,请压缩后重试(单张不超过 4MB)'
if (path === 'scope') return '申诉范围不合法,请刷新页面后重试'
if (path === 'note') return '审核意见最多 100 字,请精简后再提交'
if (path === 'reason') return '审核原因不合法,请重新选择'
if (path === 'decision') return '审核操作不合法,请刷新页面后重试'
return ''
}
export async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) {
return handleRequest(request, async (ctx) => {
const { id } = await context.params
const detail = await supplyChainBreachOrderService.getDetail(id)
if (!detail) return jsonError(ErrorCode.RESOURCE_NOT_FOUND, '破价订单不存在', 404, ctx.requestId)
const viewer = await supplyChainBreachOrderService.resolveViewer(ctx.user)
return jsonSuccess({ ...detail, canReview: viewer.canReview }, 200, ctx.requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
// 申诉与审核返回不同结果形状,统一按 unknown 返回(与既有路由一致)。
return handleRequest<unknown>(request, async (ctx): Promise<NextResponse<unknown>> => {
const { id } = await context.params
const parsed = actionSchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) {
const firstIssue = parsed.error.issues[0]
const detail = firstIssue?.path?.join('.') || ''
const message = friendlyValidationMessage(detail) || (detail ? `请求内容不正确:${detail}` : '请求内容不正确')
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, message, 400, ctx.requestId)
}
const viewer = await supplyChainBreachOrderService.resolveViewer(ctx.user)
try {
if (parsed.data.action === 'appeal') {
const result = await supplyChainBreachOrderService.submitAppeal({
id,
viewer,
appealType: parsed.data.appealType,
processNo: parsed.data.processNo,
description: parsed.data.description,
images: parsed.data.images,
scope: parsed.data.scope,
})
return jsonSuccess(result, 200, ctx.requestId)
}
const result = await supplyChainBreachOrderService.review({
id,
viewer,
decision: parsed.data.decision,
reason: parsed.data.reason,
note: parsed.data.note,
})
return jsonSuccess(result, 200, ctx.requestId)
} catch (error) {
const message = error instanceof Error ? error.message : '操作失败'
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, message, 400, ctx.requestId)
}
}, { requireAuth: true })
}

View File

@ -0,0 +1,27 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainBreachOrderService } from '@/server/services/supply-chain-breach-order.service'
const schema = z.object({
field: z.enum(['owner', 'itemCode', 'orderNo', 'shopStyleCode', 'shopItemCode', 'appealType', 'platform', 'shopName']),
q: z.string().max(120).optional(),
})
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = schema.safeParse({
field: request.nextUrl.searchParams.get('field'),
q: request.nextUrl.searchParams.get('q') ?? undefined,
})
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段不正确', 400, ctx.requestId)
const viewer = await supplyChainBreachOrderService.resolveViewer(ctx.user)
const options = await supplyChainBreachOrderService.listFilterOptions({
field: parsed.data.field,
keyword: parsed.data.q,
viewer,
})
return jsonSuccess({ options }, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainBreachOrderService } from '@/server/services/supply-chain-breach-order.service'
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
filters: z.string().optional(),
sort: z.string().optional(),
})
const filtersSchema = z.object({
status: z.array(z.string()).optional(),
platform: z.array(z.string().max(40)).max(50).optional(),
shopName: z.array(z.string().max(120)).max(50).optional(),
ownerName: z.array(z.string()).optional(),
keyword: z.string().optional(),
itemCode: z.array(z.string().max(120)).max(50).optional(),
shopStyleCode: z.array(z.string().max(120)).max(50).optional(),
shopItemCode: z.array(z.string().max(120)).max(50).optional(),
orderNo: z.array(z.string().max(80)).max(50).optional(),
ownerKeyword: z.array(z.string().max(60)).max(50).optional(),
appealType: z.array(z.string().max(60)).max(50).optional(),
listedFrom: z.string().max(10).optional(),
listedTo: z.string().max(10).optional(),
flowFrom: z.string().max(10).optional(),
flowTo: z.string().max(10).optional(),
sellerMin: z.coerce.number().optional(),
sellerMax: z.coerce.number().optional(),
gapMin: z.coerce.number().optional(),
gapMax: z.coerce.number().optional(),
dateFrom: z.string().max(10).optional(),
dateTo: z.string().max(10).optional(),
}).strict()
const actionSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('generate'), dateFrom: z.string().min(8).max(10), dateTo: z.string().min(8).max(10) }),
z.object({
action: z.literal('import-limits'),
rows: z.array(z.object({
shopName: z.string().min(1).max(120),
itemCode: z.string().min(1).max(120),
limitUnitPrice: z.coerce.number(),
spu: z.string().max(120).optional(),
ownerName: z.string().max(60).optional(),
listedAt: z.string().max(30).nullish(),
flowEndDate: z.string().max(30).nullish(),
})).min(1).max(20000),
}),
])
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = listQuerySchema.safeParse({
page: request.nextUrl.searchParams.get('page') ?? undefined,
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
filters: request.nextUrl.searchParams.get('filters') ?? undefined,
sort: request.nextUrl.searchParams.get('sort') ?? undefined,
})
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '查询参数不正确', 400, ctx.requestId)
let filters = {}
if (parsed.data.filters) {
const parsedFilters = filtersSchema.safeParse(JSON.parse(parsed.data.filters))
if (!parsedFilters.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
filters = parsedFilters.data
}
let sort: { field?: string; direction?: 'asc' | 'desc' } | undefined
if (parsed.data.sort) {
const parsedSort = z.object({ field: z.string().max(40), direction: z.enum(['asc', 'desc']) }).strict().safeParse(JSON.parse(parsed.data.sort))
if (!parsedSort.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序参数不正确', 400, ctx.requestId)
sort = parsedSort.data
}
const viewer = await supplyChainBreachOrderService.resolveViewer(ctx.user)
const data = await supplyChainBreachOrderService.listPage({
page: parsed.data.page,
pageSize: parsed.data.pageSize,
filters,
sort,
viewer,
})
return jsonSuccess({ ...data, canReview: viewer.canReview, viewerName: viewer.name }, 200, ctx.requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest) {
// 一个接口分支返回多种结果形状(生成 / 导入 / 标记离职…),统一按 unknown 返回,
// 与 reference-image-estimate 等既有路由保持一致。
return handleRequest<unknown>(request, async (ctx): Promise<NextResponse<unknown>> => {
const parsed = actionSchema.safeParse(await request.json().catch(() => null))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '请求内容不正确', 400, ctx.requestId)
const viewer = await supplyChainBreachOrderService.resolveViewer(ctx.user)
if (parsed.data.action === 'generate') {
const result = await supplyChainBreachOrderService.generateWithLeftCheck({
dateFrom: parsed.data.dateFrom,
dateTo: parsed.data.dateTo,
viewer,
})
return jsonSuccess(result, 200, ctx.requestId)
}
const result = await supplyChainBreachOrderService.importLimitPrices({ viewer, rows: parsed.data.rows })
return jsonSuccess(result, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,108 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import {
COMBO_LIMIT_FIELD_KEYS,
type ComboLimitFieldKey,
type ComboLimitFilters,
} from '@/lib/supply-chain-combo-limit-price'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainComboLimitService } from '@/server/services/supply-chain-combo-limit.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 300
const querySchema = z.object({
field: z.enum(COMBO_LIMIT_FIELD_KEYS as [ComboLimitFieldKey, ...ComboLimitFieldKey[]]),
q: z.string().max(200).optional(),
offset: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(200),
})
const filtersSchema = z.object({
comboCode: z.array(z.string().max(200)).max(20000).optional(),
opsHead: z.array(z.string().max(60)).max(20000).optional(),
ownerLimitPrice: z.array(z.string().max(40)).max(200).optional(),
nonOwnerLimitPriceNew: z.array(z.string().max(40)).max(200).optional(),
jxLimitPrice: z.array(z.string().max(40)).max(200).optional(),
alibabaPageLimitPrice: z.array(z.string().max(40)).max(200).optional(),
distributorPrice89: z.array(z.string().max(40)).max(200).optional(),
distributorPrice85: z.array(z.string().max(40)).max(200).optional(),
distributorPrice80: z.array(z.string().max(40)).max(200).optional(),
}).strict()
function parseJson(value: string | null) {
if (!value) return { success: true as const, data: null }
try {
return { success: true as const, data: JSON.parse(value) as unknown }
} catch {
return { success: false as const, data: null }
}
}
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId)
}
const query = querySchema.safeParse({
field: request.nextUrl.searchParams.get('field') ?? undefined,
q: request.nextUrl.searchParams.get('q') ?? undefined,
offset: request.nextUrl.searchParams.get('offset') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
})
if (!query.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段或分页参数不正确', 400, ctx.requestId)
}
const filtersPayload = parseJson(request.nextUrl.searchParams.get('filters'))
if (!filtersPayload.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const filters = filtersPayload.data === null
? { success: true as const, data: {} as ComboLimitFilters }
: filtersSchema.safeParse(filtersPayload.data)
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const data = await supplyChainComboLimitService.listOptions({
...query.data,
filters: filters.data,
})
return jsonSuccess(data, 200, ctx.requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId)
}
const query = querySchema.safeParse({
field: request.nextUrl.searchParams.get('field') ?? undefined,
q: request.nextUrl.searchParams.get('q') ?? undefined,
offset: request.nextUrl.searchParams.get('offset') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
})
if (!query.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段或分页参数不正确', 400, ctx.requestId)
}
const filters = filtersSchema.safeParse(await request.json().catch(() => null))
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const data = await supplyChainComboLimitService.listOptions({
...query.data,
filters: filters.data,
})
return jsonSuccess(data, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,130 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import {
COMBO_LIMIT_FIELD_KEYS,
type ComboLimitFieldKey,
type ComboLimitFilters,
type ComboLimitSort,
} from '@/lib/supply-chain-combo-limit-price'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainComboLimitService } from '@/server/services/supply-chain-combo-limit.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 300
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
})
const filtersSchema = z.object({
comboCode: z.array(z.string().max(200)).max(20000).optional(),
opsHead: z.array(z.string().max(60)).max(20000).optional(),
ownerLimitPrice: z.array(z.string().max(40)).max(200).optional(),
nonOwnerLimitPriceNew: z.array(z.string().max(40)).max(200).optional(),
jxLimitPrice: z.array(z.string().max(40)).max(200).optional(),
alibabaPageLimitPrice: z.array(z.string().max(40)).max(200).optional(),
distributorPrice89: z.array(z.string().max(40)).max(200).optional(),
distributorPrice85: z.array(z.string().max(40)).max(200).optional(),
distributorPrice80: z.array(z.string().max(40)).max(200).optional(),
}).strict()
const sortSchema = z.object({
field: z.enum(COMBO_LIMIT_FIELD_KEYS as [ComboLimitFieldKey, ...ComboLimitFieldKey[]]),
direction: z.enum(['asc', 'desc']),
}).strict()
function parseJson(value: string | null) {
if (!value) return { success: true as const, data: null }
try {
return { success: true as const, data: JSON.parse(value) as unknown }
} catch {
return { success: false as const, data: null }
}
}
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId)
}
const page = listQuerySchema.safeParse({
page: request.nextUrl.searchParams.get('page') ?? undefined,
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
})
if (!page.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
}
const filtersPayload = parseJson(request.nextUrl.searchParams.get('filters'))
if (!filtersPayload.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const filters = filtersPayload.data === null
? { success: true as const, data: {} as ComboLimitFilters }
: filtersSchema.safeParse(filtersPayload.data)
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const sortPayload = parseJson(request.nextUrl.searchParams.get('sort'))
if (!sortPayload.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序参数不正确', 400, ctx.requestId)
}
const sort = sortPayload.data === null
? { success: true as const, data: null as ComboLimitSort }
: sortSchema.safeParse(sortPayload.data)
if (!sort.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序参数不正确', 400, ctx.requestId)
}
const data = await supplyChainComboLimitService.listPage({
...page.data,
filters: filters.data,
sort: sort.data,
forceRefresh: request.nextUrl.searchParams.get('refresh') === '1',
})
return jsonSuccess(data, 200, ctx.requestId)
}, { requireAuth: true })
}
// 批量筛选项较多时 URL 会超长,前端改用 POST 传完整 filters / sort。
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement')
if (!access.view) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId)
}
const page = listQuerySchema.safeParse({
page: request.nextUrl.searchParams.get('page') ?? undefined,
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
})
if (!page.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
}
const body = await request.json().catch(() => null) as { filters?: unknown; sort?: unknown } | null
const filters = filtersSchema.safeParse(body?.filters ?? {})
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const sort = sortSchema.safeParse(body?.sort ?? null)
if (body?.sort !== null && body?.sort !== undefined && !sort.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序参数不正确', 400, ctx.requestId)
}
const data = await supplyChainComboLimitService.listPage({
...page.data,
filters: filters.data,
sort: sort.success ? sort.data : null,
forceRefresh: request.nextUrl.searchParams.get('refresh') === '1',
})
return jsonSuccess(data, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,91 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { PRODUCT_ARCHIVE_FILTER_OPTION_KEYS } from '@/lib/supply-chain-product-archive'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveService } from '@/server/services/supply-chain-product-archive.service'
import {
parseProductArchiveFilters,
parseProductArchiveFiltersPayload,
} from '@/server/services/supply-chain-product-archive-query'
// 限价管理 - 筛选候选值:与产品档案同源,权限走经营管理。
const optionsQuerySchema = z.object({
offset: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(200).default(100),
})
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsedField = z.enum(PRODUCT_ARCHIVE_FILTER_OPTION_KEYS).safeParse(
request.nextUrl.searchParams.get('field'),
)
if (!parsedField.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段不正确', 400, ctx.requestId)
}
const parsedPage = optionsQuerySchema.safeParse({
offset: request.nextUrl.searchParams.get('offset') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
})
if (!parsedPage.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '候选值分页参数不正确', 400, ctx.requestId)
}
const filters = parseProductArchiveFilters(request.nextUrl.searchParams.get('filters'))
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const result = await supplyChainProductArchiveService.listFilterOptions({
field: parsedField.data,
search: request.nextUrl.searchParams.get('q') ?? undefined,
offset: parsedPage.data.offset,
limit: parsedPage.data.limit,
filters: filters.data,
})
return jsonSuccess(result, 200, ctx.requestId)
},
{ requireAuth: true },
)
}
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsedField = z.enum(PRODUCT_ARCHIVE_FILTER_OPTION_KEYS).safeParse(
request.nextUrl.searchParams.get('field'),
)
if (!parsedField.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段不正确', 400, ctx.requestId)
}
const parsedPage = optionsQuerySchema.safeParse({
offset: request.nextUrl.searchParams.get('offset') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
})
if (!parsedPage.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '候选值分页参数不正确', 400, ctx.requestId)
}
const body = await request.json().catch(() => null) as Record<string, unknown> | null
const filters = parseProductArchiveFiltersPayload(body?.filters ?? body ?? null)
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const result = await supplyChainProductArchiveService.listFilterOptions({
field: parsedField.data,
search: request.nextUrl.searchParams.get('q') ?? undefined,
offset: parsedPage.data.offset,
limit: parsedPage.data.limit,
filters: filters.data,
})
return jsonSuccess(result, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,78 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveService } from '@/server/services/supply-chain-product-archive.service'
import {
parseProductArchiveFilters,
parseProductArchiveFiltersPayload,
} from '@/server/services/supply-chain-product-archive-query'
// 限价管理 - 商品列表:复用产品全量档案的分页/筛选能力,
// 但权限走经营管理(businessManagement),与该页面的入口权限一致。
const limitProductsQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
sort: z.enum(['limitPriceDesc']).default('limitPriceDesc'),
})
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsed = limitProductsQuerySchema.safeParse({
page: request.nextUrl.searchParams.get('page') ?? undefined,
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
sort: request.nextUrl.searchParams.get('sort') ?? undefined,
})
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
}
const filters = parseProductArchiveFilters(request.nextUrl.searchParams.get('filters'))
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const result = await supplyChainProductArchiveService.listPage({
...parsed.data,
filters: filters.data,
// 限价页需要「是否有检测报告」(外部检测报告接口按 SPU 匹配)
withInspectionReport: true,
})
return jsonSuccess(result, 200, ctx.requestId)
},
{ requireAuth: true },
)
}
// 大数据量筛选(例如全选几千个候选值)超出 URL 长度时,前端改用 POST 传 filters。
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null) as Record<string, unknown> | null
const parsed = limitProductsQuerySchema.safeParse({
page: request.nextUrl.searchParams.get('page') ?? undefined,
pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined,
sort: request.nextUrl.searchParams.get('sort') ?? undefined,
})
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId)
}
const filters = parseProductArchiveFiltersPayload(body?.filters ?? body ?? null)
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const result = await supplyChainProductArchiveService.listPage({
...parsed.data,
filters: filters.data,
withInspectionReport: true,
})
return jsonSuccess(result, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,24 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { supplyChainBreachRuleConfigService } from '@/server/services/supply-chain-breach-rule-config.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainBreachRuleConfigService.resolveAccess(ctx.user)
const config = await supplyChainBreachRuleConfigService.getConfig()
return jsonSuccess({ ...config, access }, 200, ctx.requestId)
}, { requireAuth: true })
}
export async function PUT(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null)
try {
const saved = await supplyChainBreachRuleConfigService.saveConfig({ user: ctx.user, data: body })
return jsonSuccess(saved, 200, ctx.requestId)
} catch (error) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, error instanceof Error ? error.message : '保存失败', 400, ctx.requestId)
}
}, { requireAuth: true })
}

View File

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { productArchiveSourceImageService } from '@/server/services/product-archive-source-image.service'
// 限价管理 - 商品图片:同源代理档案图片字节,避免浏览器直连外部 CDN 被拦截。
const querySchema = z.object({
barcode: z.string().trim().min(1).max(120),
})
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsed = querySchema.safeParse({
barcode: request.nextUrl.searchParams.get('barcode') || undefined,
})
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '条码参数不正确', 400, ctx.requestId)
}
const image = await productArchiveSourceImageService.readByBarcode(parsed.data.barcode)
if (!image) {
return jsonError(ErrorCode.FILE_NOT_FOUND, '商品图片不存在', 404, ctx.requestId)
}
return new NextResponse(image.bytes, {
headers: {
'Content-Type': image.contentType,
'Content-Length': String(image.bytes.byteLength),
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=604800',
},
})
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,139 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import {
PRODUCTION_STOP_ANALYTICS_GRANULARITIES,
PRODUCTION_STOP_CONFIRMATION_STATUSES,
PRODUCTION_STOP_DEMAND_STATUSES,
isProductionStopItemMultiFilterKey,
isProductionStopItemNumericFilterKey,
isProductionStopItemTextFilterKey,
type ProductionStopItemFilters,
type ProductionStopNumericFilterOperator,
} from '@/lib/production-stop-demands'
import { productionStopDemandsService } from '@/server/services/production-stop-demands.service'
const querySchema = z.object({
granularity: z.enum(PRODUCTION_STOP_ANALYTICS_GRANULARITIES),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
filters: z.string().trim().max(12000).optional(),
})
type AnalyticsFilters = Omit<
ProductionStopItemFilters,
'page' | 'pageSize' | 'sortKey' | 'sortDirection' | 'dateFrom' | 'dateTo'
>
const NUMERIC_OPERATORS = new Set<ProductionStopNumericFilterOperator>(['gt', 'gte', 'lt', 'lte'])
function cleanText(value: unknown, max: number) {
return String(value ?? '').trim().slice(0, max)
}
/** 解析主表当前筛选条件(与列表共用同一套字段白名单)。 */
function parseAnalyticsFilters(raw?: string): AnalyticsFilters | undefined {
if (!raw) return undefined
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return undefined
const result: AnalyticsFilters = {}
const keyword = cleanText(parsed.keyword, 2000)
if (keyword) result.keyword = keyword
const confirmationStatus = cleanText(parsed.confirmationStatus, 20)
if ((PRODUCTION_STOP_CONFIRMATION_STATUSES as readonly string[]).includes(confirmationStatus)) {
result.confirmationStatus = confirmationStatus as ProductionStopItemFilters['confirmationStatus']
}
const demandStatus = cleanText(parsed.demandStatus, 30)
if ((PRODUCTION_STOP_DEMAND_STATUSES as readonly string[]).includes(demandStatus)) {
result.demandStatus = demandStatus as ProductionStopItemFilters['demandStatus']
}
if (Array.isArray(parsed.demandStatuses)) {
const statuses = parsed.demandStatuses
.map((value) => cleanText(value, 30))
.filter((value): value is NonNullable<ProductionStopItemFilters['demandStatus']> =>
(PRODUCTION_STOP_DEMAND_STATUSES as readonly string[]).includes(value))
if (statuses.length) result.demandStatuses = statuses
}
for (const key of ['reasonCategory', 'purchaseOwner', 'requesterName'] as const) {
const value = cleanText(parsed[key], 60)
if (value) result[key] = value
}
if (parsed.fieldFilters && typeof parsed.fieldFilters === 'object') {
const fieldFilters: NonNullable<ProductionStopItemFilters['fieldFilters']> = {}
for (const [key, value] of Object.entries(parsed.fieldFilters as Record<string, unknown>)) {
if (!isProductionStopItemTextFilterKey(key)) continue
const text = cleanText(value, 120)
if (text) fieldFilters[key] = text
}
if (Object.keys(fieldFilters).length) result.fieldFilters = fieldFilters
}
if (parsed.numericFilters && typeof parsed.numericFilters === 'object') {
const numericFilters: NonNullable<ProductionStopItemFilters['numericFilters']> = {}
for (const [key, value] of Object.entries(parsed.numericFilters as Record<string, unknown>)) {
if (!isProductionStopItemNumericFilterKey(key)) continue
const entry = value as { op?: unknown; value?: unknown } | null
const op = cleanText(entry?.op, 8) as ProductionStopNumericFilterOperator
const numeric = Number(entry?.value)
if (!NUMERIC_OPERATORS.has(op) || !Number.isFinite(numeric)) continue
numericFilters[key] = { op, value: numeric }
}
if (Object.keys(numericFilters).length) result.numericFilters = numericFilters
}
if (parsed.multiFilters && typeof parsed.multiFilters === 'object') {
const multiFilters: NonNullable<ProductionStopItemFilters['multiFilters']> = {}
for (const [key, value] of Object.entries(parsed.multiFilters as Record<string, unknown>)) {
if (!isProductionStopItemMultiFilterKey(key) || !Array.isArray(value)) continue
const values = value.map((item) => cleanText(item, 120)).slice(0, 100)
if (values.length) multiFilters[key] = values
}
if (Object.keys(multiFilters).length) result.multiFilters = multiFilters
}
for (const key of ['barcodeCreatedAt', 'barcodeCreatedFrom', 'barcodeCreatedTo'] as const) {
const value = cleanText(parsed[key], 10)
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) result[key] = value
}
return Object.keys(result).length ? result : undefined
} catch {
return undefined
}
}
/** 领导视图:日/周/月需求趋势与部门、需求人、状态分布。 */
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries()),
)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'统计参数不正确',
400,
ctx.requestId,
)
}
const access = await productionStopDemandsService.resolveListAccess(ctx.user)
if (!access.canView) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有停产需求管理的查看权限',
403,
ctx.requestId,
)
}
const result = await productionStopDemandsService.getAnalytics({
granularity: parsed.data.granularity,
from: parsed.data.from,
to: parsed.data.to,
filters: parseAnalyticsFilters(parsed.data.filters),
})
return jsonSuccess(result, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,37 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { isProductionStopItemTextFilterKey } from '@/lib/production-stop-demands'
import { productionStopDemandRepository } from '@/server/repositories/production-stop-demands.repository'
const querySchema = z.object({
field: z.string().trim().min(1).max(64),
limit: z.coerce.number().int().min(1).max(500).default(200),
})
/** 表头漏斗筛选项:返回某字段的去重可选值。 */
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries()),
)
if (!parsed.success || !isProductionStopItemTextFilterKey(parsed.data.field)) {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'筛选字段不正确',
400,
ctx.requestId,
)
}
const values = await productionStopDemandRepository.listDistinctFilterValues(
parsed.data.field,
parsed.data.limit,
)
return jsonSuccess({ values }, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -5,7 +5,13 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import {
PRODUCTION_STOP_CONFIRMATION_STATUSES,
PRODUCTION_STOP_DEMAND_STATUSES,
isProductionStopItemMultiFilterKey,
isProductionStopItemNumericFilterKey,
isProductionStopItemSortKey,
isProductionStopItemTextFilterKey,
type ProductionStopNumericFilterOperator,
type ProductionStopItemFilters,
type ProductionStopItemTextFilterKey,
type ProductionStopItemSortKey,
} from '@/lib/production-stop-demands'
import { productionStopDemandsService } from '@/server/services/production-stop-demands.service'
@ -13,7 +19,7 @@ import { productionStopDemandsService } from '@/server/services/production-stop-
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
keyword: z.string().trim().max(160).optional(),
keyword: z.string().trim().max(2000).optional(),
confirmationStatus: z.enum(PRODUCTION_STOP_CONFIRMATION_STATUSES).optional(),
demandStatus: z.enum(PRODUCTION_STOP_DEMAND_STATUSES).optional(),
demandStatuses: z.string().trim().max(240).optional(),
@ -22,6 +28,12 @@ const listQuerySchema = z.object({
requesterName: z.string().trim().max(60).optional(),
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
fieldFilters: z.string().trim().max(4000).optional(),
numericFilters: z.string().trim().max(600).optional(),
multiFilters: z.string().trim().max(6000).optional(),
barcodeCreatedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
barcodeCreatedFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
barcodeCreatedTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
sortKey: z.string().trim().max(64).optional(),
sortDirection: z.enum(['asc', 'desc']).optional(),
})
@ -30,6 +42,65 @@ const listQuerySchema = z.object({
let lastFlowSyncAt = 0
const FLOW_SYNC_INTERVAL_MS = 5 * 60 * 1000
function parseFieldFilters(raw?: string) {
if (!raw) return undefined
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return undefined
const result: Partial<Record<ProductionStopItemTextFilterKey, string>> = {}
for (const [key, value] of Object.entries(parsed)) {
if (!isProductionStopItemTextFilterKey(key)) continue
const text = String(value ?? '').trim().slice(0, 120)
if (text) result[key] = text
}
return Object.keys(result).length ? result : undefined
} catch {
return undefined
}
}
const NUMERIC_FILTER_OPERATORS = new Set<ProductionStopNumericFilterOperator>(['gt', 'gte', 'lt', 'lte'])
function parseNumericFilters(raw?: string) {
if (!raw) return undefined
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return undefined
const result: NonNullable<ProductionStopItemFilters['numericFilters']> = {}
for (const [key, value] of Object.entries(parsed)) {
if (!isProductionStopItemNumericFilterKey(key)) continue
const entry = value as { op?: unknown; value?: unknown } | null
const op = String(entry?.op ?? '')
const numeric = Number(entry?.value)
if (!NUMERIC_FILTER_OPERATORS.has(op as ProductionStopNumericFilterOperator)) continue
if (!Number.isFinite(numeric)) continue
result[key] = { op: op as ProductionStopNumericFilterOperator, value: numeric }
}
return Object.keys(result).length ? result : undefined
} catch {
return undefined
}
}
function parseMultiFilters(raw?: string) {
if (!raw) return undefined
try {
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return undefined
const result: NonNullable<ProductionStopItemFilters['multiFilters']> = {}
for (const [key, value] of Object.entries(parsed)) {
if (!isProductionStopItemMultiFilterKey(key) || !Array.isArray(value)) continue
const values = value
.map((item) => String(item ?? '').slice(0, 120))
.slice(0, 100)
if (values.length) result[key] = values
}
return Object.keys(result).length ? result : undefined
} catch {
return undefined
}
}
export async function GET(request: NextRequest) {
return handleRequest(
request,
@ -79,6 +150,12 @@ export async function GET(request: NextRequest) {
requesterName: parsed.data.requesterName || undefined,
dateFrom: parsed.data.dateFrom || undefined,
dateTo: parsed.data.dateTo || undefined,
fieldFilters: parseFieldFilters(parsed.data.fieldFilters),
numericFilters: parseNumericFilters(parsed.data.numericFilters),
multiFilters: parseMultiFilters(parsed.data.multiFilters),
barcodeCreatedAt: parsed.data.barcodeCreatedAt || undefined,
barcodeCreatedFrom: parsed.data.barcodeCreatedFrom || undefined,
barcodeCreatedTo: parsed.data.barcodeCreatedTo || undefined,
sortKey: parsed.data.sortKey as ProductionStopItemSortKey | undefined,
sortDirection: parsed.data.sortDirection,
page: parsed.data.page,

View File

@ -1,8 +1,10 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { isProductArchiveChangeLogFieldBlocked } from '@/lib/product-archive-field-permissions'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveChangeLogService } from '@/server/services/supply-chain-product-archive-change-log.service'
import { productArchiveFieldPermissionService } from '@/server/services/product-archive-field-permission.service'
const barcodeSchema = z.string().trim().min(1).max(120)
@ -24,6 +26,11 @@ export async function GET(
)
}
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
if (blockedFields.has('barcode')) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有条码字段的查看权限', 403, ctx.requestId)
}
const page = Math.max(1, Number(request.nextUrl.searchParams.get('page') ?? '1') || 1)
const pageSize = Math.min(
100,
@ -34,6 +41,14 @@ export async function GET(
page,
pageSize,
})
// 字段权限:被屏蔽字段的变更历史不下发(无具体字段的导入条目保留)
if (blockedFields.size > 0) {
return jsonSuccess(
{ ...result, logs: result.logs.filter((log) => !isProductArchiveChangeLogFieldBlocked(log.details.field, blockedFields)) },
200,
ctx.requestId,
)
}
return jsonSuccess(result, 200, ctx.requestId)
},
{

View File

@ -5,6 +5,8 @@ import { handleRequest, jsonError } from '@/server/core/response'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { supplyChainProductArchiveExportService } from '@/server/services/supply-chain-product-archive-export.service'
import { parseProductArchiveFilters, parseProductArchiveFiltersPayload } from '@/server/services/supply-chain-product-archive-query'
import { productArchiveFieldPermissionService } from '@/server/services/product-archive-field-permission.service'
import { stripProductArchiveBlockedFilters } from '@/lib/product-archive-field-permissions'
function buildContentDisposition(fileName: string) {
return `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`
@ -15,17 +17,23 @@ function buildContentDisposition(fileName: string) {
*
* 导出改成流式后有两个必须显式处理的差异:
* 1. 体积在写完之前未知,所以不能再发 Content-Length,由 chunked 编码收尾
* (前端已支持「无 Content-Length → 不定量进度」这条分支);
* (前端已支持「无 Content-Length → 不定量进度」这条分支);
* 2. 响应头一旦发出,后面生成失败就只能掐断流,拿不到 JSON 错误,所以要在这里
* 记日志,不能让失败静默。
* 记日志,不能让失败静默。
*/
async function createExportResponse(params: {
filters: Parameters<typeof supplyChainProductArchiveExportService.openWorkbook>[0]['filters']
blockedFields: ReadonlySet<string>
includeImage?: boolean
columnSet?: 'archive' | 'limit'
requestId: string
signal: AbortSignal
}) {
const result = await supplyChainProductArchiveExportService.openWorkbook({
filters: params.filters,
filters: stripProductArchiveBlockedFilters(params.filters ?? {}, params.blockedFields),
blockedFields: params.blockedFields,
includeImage: params.includeImage,
columnSet: params.columnSet,
})
result.completion.catch((error) => {
@ -57,7 +65,12 @@ async function createExportResponse(params: {
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'productArchive')
// 限价界面的导出按「限价管理 · 下载」鉴权;产品档案导出仍按「产品全量档案 · 下载」。
const isLimitExport = request.nextUrl.searchParams.get('columnSet') === 'limit'
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
isLimitExport ? 'limitManagement' : 'productArchive',
)
if (!access.download) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有导出权限', 403, ctx.requestId)
}
@ -67,8 +80,14 @@ export async function GET(request: NextRequest) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
return createExportResponse({
filters: filters.data,
blockedFields,
// 默认含图片列;includeImage=0 时导出不含图片的明细(限价明细「导出明细」用)。
includeImage: request.nextUrl.searchParams.get('includeImage') !== '0',
// 限价管理页导出用「限价界面抬头」列单。
columnSet: isLimitExport ? 'limit' : 'archive',
requestId: ctx.requestId,
signal: request.signal,
})
@ -79,19 +98,28 @@ export async function GET(request: NextRequest) {
// that would exceed the URL length limit.
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'productArchive')
const body = await request.json().catch(() => null) as Record<string, unknown> | null
// 限价界面的导出按「限价管理 · 下载」鉴权;产品档案导出仍按「产品全量档案 · 下载」。
const isLimitExport = body?.columnSet === 'limit'
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
isLimitExport ? 'limitManagement' : 'productArchive',
)
if (!access.download) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有导出权限', 403, ctx.requestId)
}
const body = await request.json().catch(() => null) as Record<string, unknown> | null
const filters = parseProductArchiveFiltersPayload(body?.filters ?? body ?? null)
if (!filters.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId)
}
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
return createExportResponse({
filters: filters.data,
blockedFields,
includeImage: body?.includeImage !== false,
columnSet: body?.columnSet === 'limit' ? 'limit' : 'archive',
requestId: ctx.requestId,
signal: request.signal,
})

View File

@ -0,0 +1,43 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { productArchiveFieldPermissionService } from '@/server/services/product-archive-field-permission.service'
import { isProductArchiveFieldKey, isProductArchivePermissionPrincipalKey } from '@/lib/product-archive-field-permissions'
// 管理后台专用:读取/维护产品全量档案字段权限(黑名单)。
// 权限校验在服务层做(仅管理员),供应链页面级权限网关对本路由豁免。
const updateSchema = z.object({
subjectKey: z.string().trim().min(1).max(120).refine(isProductArchivePermissionPrincipalKey),
fields: z.array(z.string().trim().refine(isProductArchiveFieldKey)).max(200),
followPosition: z.boolean().optional(),
})
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const result = await productArchiveFieldPermissionService.getSettings(ctx.user)
return jsonSuccess(result, 200, ctx.requestId)
},
{ requireAuth: true },
)
}
export async function PUT(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null) as Record<string, unknown> | null
const parsed = updateSchema.safeParse(body)
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '字段权限参数不正确', 400, ctx.requestId)
}
const result = await productArchiveFieldPermissionService.updateSubjectSettings(ctx.user, parsed.data)
return jsonSuccess(result, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -4,6 +4,8 @@ import { handleRequest, jsonError } from '@/server/core/response'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { supplyChainProductArchiveImageExportService } from '@/server/services/supply-chain-product-archive-image-export.service'
import { parseProductArchiveFilters, parseProductArchiveFiltersPayload } from '@/server/services/supply-chain-product-archive-query'
import { productArchiveFieldPermissionService } from '@/server/services/product-archive-field-permission.service'
import { stripProductArchiveBlockedFilters } from '@/lib/product-archive-field-permissions'
function buildContentDisposition(fileName: string) {
return `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`
@ -23,6 +25,8 @@ export async function GET(request: NextRequest) {
ctx.requestId,
)
}
// 字段权限:图片列被屏蔽时,图片导出一并关闭
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
const filters = parseProductArchiveFilters(request.nextUrl.searchParams.get('filters'))
if (!filters.success) {
@ -35,7 +39,8 @@ export async function GET(request: NextRequest) {
}
const payload = await supplyChainProductArchiveImageExportService.createWorkbook({
filters: filters.data,
filters: stripProductArchiveBlockedFilters(filters.data, blockedFields),
blockedFields,
siteOrigin: request.nextUrl.origin,
})
return new NextResponse(payload.bytes, {
@ -67,6 +72,7 @@ export async function POST(request: NextRequest) {
ctx.requestId,
)
}
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
const body = await request.json().catch(() => null) as Record<string, unknown> | null
const filters = parseProductArchiveFiltersPayload(body?.filters ?? body ?? null)
@ -75,7 +81,8 @@ export async function POST(request: NextRequest) {
}
const payload = await supplyChainProductArchiveImageExportService.createWorkbook({
filters: filters.data,
filters: stripProductArchiveBlockedFilters(filters.data, blockedFields),
blockedFields,
siteOrigin: request.nextUrl.origin,
})
return new NextResponse(payload.bytes, {

View File

@ -3,6 +3,7 @@ import { IMAGE_SEARCH_UPSTREAM_LIMITS } from '@/lib/image-search-upstream'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { productArchiveImageSearchService } from '@/server/services/product-archive-image-search.service'
import { productArchiveFieldPermissionService } from '@/server/services/product-archive-field-permission.service'
// 图搜上游同步等待可能长达数分钟(对接文档建议 180-600s)
export const maxDuration = 300
@ -13,6 +14,9 @@ export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
// 字段权限:图片列被屏蔽时,以图搜图一并关闭
await productArchiveFieldPermissionService.assertFieldVisible(ctx.user, 'image', '当前账号没有图片字段的使用权限')
const formData = await request.formData()
const file = formData.get('image')
if (!(file instanceof File)) {

View File

@ -43,6 +43,15 @@ function parseRows(sheet: XLSX.WorkSheet) {
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
// 字段权限:负责人导入只写运营负责人/设计师两个字段,任一被屏蔽即拒绝
const { productArchiveFieldPermissionService } = await import(
'@/server/services/product-archive-field-permission.service'
)
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
if (blockedFields.has('opsHead') || blockedFields.has('designer')) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有负责人字段的维护权限', 403, ctx.requestId)
}
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) {

View File

@ -6,6 +6,7 @@ import {
getReachableMaterialPreviewUrlCandidates,
productArchiveMaterialImageService,
} from '@/server/services/product-archive-material-image.service'
import { productArchiveFieldPermissionService } from '@/server/services/product-archive-field-permission.service'
const querySchema = z.object({
spu: z.string().trim().max(120).optional(),
@ -21,6 +22,9 @@ export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
// 字段权限:图片列被屏蔽时,素材图查询一并关闭
await productArchiveFieldPermissionService.assertFieldVisible(ctx.user, 'image', '当前账号没有图片字段的使用权限')
const parsed = querySchema.safeParse({
spu: request.nextUrl.searchParams.get('spu') || undefined,
barcode: request.nextUrl.searchParams.get('barcode') || undefined,

View File

@ -3,6 +3,7 @@ import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { productArchiveMaterialImageService } from '@/server/services/product-archive-material-image.service'
import { productArchiveFieldPermissionService } from '@/server/services/product-archive-field-permission.service'
const batchItemSchema = z.object({
archiveId: z.string().trim().min(1).max(120),
@ -32,6 +33,9 @@ export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
// 字段权限:图片列被屏蔽时,素材图批量匹配一并关闭
await productArchiveFieldPermissionService.assertFieldVisible(ctx.user, 'image', '当前账号没有图片字段的使用权限')
const body = await request.json().catch(() => null)
const parsed = batchSchema.safeParse(body)
if (!parsed.success) {

View File

@ -1,10 +1,12 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { PRODUCT_ARCHIVE_FILTER_OPTION_KEYS } from '@/lib/supply-chain-product-archive'
import { stripProductArchiveBlockedFilters } from '@/lib/product-archive-field-permissions'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveService } from '@/server/services/supply-chain-product-archive.service'
import { parseProductArchiveFilters, parseProductArchiveFiltersPayload } from '@/server/services/supply-chain-product-archive-query'
import { productArchiveFieldPermissionService } from '@/server/services/product-archive-field-permission.service'
export async function GET(request: NextRequest) {
return handleRequest(
@ -16,6 +18,11 @@ export async function GET(request: NextRequest) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段不正确', 400, ctx.requestId)
}
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
if (blockedFields.has(parsedField.data)) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '该字段未开放筛选', 403, ctx.requestId)
}
const parsedPage = z.object({
offset: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(100),
@ -37,7 +44,7 @@ export async function GET(request: NextRequest) {
search: request.nextUrl.searchParams.get('q') ?? undefined,
offset: parsedPage.data.offset,
limit: parsedPage.data.limit,
filters: filters.data,
filters: stripProductArchiveBlockedFilters(filters.data, blockedFields),
})
return jsonSuccess(result, 200, ctx.requestId)
},
@ -59,6 +66,11 @@ export async function POST(request: NextRequest) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段不正确', 400, ctx.requestId)
}
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
if (blockedFields.has(parsedField.data)) {
return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '该字段未开放筛选', 403, ctx.requestId)
}
const parsedPage = z.object({
offset: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(100),
@ -81,7 +93,7 @@ export async function POST(request: NextRequest) {
search: request.nextUrl.searchParams.get('q') ?? undefined,
offset: parsedPage.data.offset,
limit: parsedPage.data.limit,
filters: filters.data,
filters: stripProductArchiveBlockedFilters(filters.data, blockedFields),
})
return jsonSuccess(result, 200, ctx.requestId)
},

View File

@ -4,6 +4,11 @@ import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveService } from '@/server/services/supply-chain-product-archive.service'
import { parseProductArchiveFilters, parseProductArchiveFiltersPayload } from '@/server/services/supply-chain-product-archive-query'
import { productArchiveFieldPermissionService } from '@/server/services/product-archive-field-permission.service'
import {
stripProductArchiveBlockedFilters,
trimProductArchiveRowForBlockedFields,
} from '@/lib/product-archive-field-permissions'
const productArchiveQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
@ -38,11 +43,22 @@ export async function GET(request: NextRequest) {
)
}
// 字段权限:被屏蔽字段不可筛选、不下发数据
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
const result = await supplyChainProductArchiveService.listPage({
...parsed.data,
filters: filters.data,
filters: stripProductArchiveBlockedFilters(filters.data, blockedFields),
})
return jsonSuccess(result, 200, ctx.requestId)
return jsonSuccess(
{
...result,
rows: result.rows.map((row) =>
trimProductArchiveRowForBlockedFields(row as unknown as Record<string, unknown>, blockedFields),
),
},
200,
ctx.requestId,
)
},
{
requireAuth: true,
@ -84,11 +100,21 @@ export async function POST(request: NextRequest) {
)
}
const blockedFields = await productArchiveFieldPermissionService.getBlockedFields(ctx.user)
const result = await supplyChainProductArchiveService.listPage({
...parsed.data,
filters: filters.data,
filters: stripProductArchiveBlockedFilters(filters.data, blockedFields),
})
return jsonSuccess(result, 200, ctx.requestId)
return jsonSuccess(
{
...result,
rows: result.rows.map((row) =>
trimProductArchiveRowForBlockedFields(row as unknown as Record<string, unknown>, blockedFields),
),
},
200,
ctx.requestId,
)
},
{
requireAuth: true,

View File

@ -3,6 +3,7 @@ import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { productArchiveSourceImageService } from '@/server/services/product-archive-source-image.service'
import { productArchiveFieldPermissionService } from '@/server/services/product-archive-field-permission.service'
const querySchema = z.object({
barcode: z.string().trim().min(1).max(120),
@ -12,6 +13,9 @@ export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
// 字段权限:图片列被屏蔽时,原图读取一并关闭
await productArchiveFieldPermissionService.assertFieldVisible(ctx.user, 'image', '当前账号没有图片字段的使用权限')
const parsed = querySchema.safeParse({
barcode: request.nextUrl.searchParams.get('barcode') || undefined,
})

View File

@ -0,0 +1,45 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { productInventoryService } from '@/server/services/product-inventory.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
// 运营云仓库存明细:按条码 + 实际仓库编号下钻。
const querySchema = z.object({
barcode: z.string().trim().min(1).max(80),
wmsCoId: z.coerce.number().int().positive(),
})
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries()),
)
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '条码或仓库编号不正确', 400, ctx.requestId)
}
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
'productInventory',
)
if (!access.view) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有商品库存管理的查看权限',
403,
ctx.requestId,
)
}
return jsonSuccess(
await productInventoryService.getCloudStockDetail(parsed.data),
200,
ctx.requestId,
)
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import {
PRODUCT_INVENTORY_COLUMNS,
isProductInventoryColumnKey,
sanitizeProductInventoryFilters,
sanitizeProductInventorySort,
type ProductInventoryColumnKey,
} from '@/lib/product-inventory'
import { productInventorySnapshotReadService } from '@/server/services/product-inventory-snapshot-read.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
// 商品库存导出:支持勾选字段,按当前筛选/排序导出全部匹配行。
const exportQuerySchema = z.object({
columns: z.string().max(4000).optional(),
filters: z.string().max(50_000).optional(),
sortKey: z.string().trim().max(64).optional(),
sortDirection: z.enum(['asc', 'desc']).optional(),
})
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsed = exportQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries()),
)
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '导出条件不正确', 400, ctx.requestId)
}
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
'productInventory',
)
if (!access.download) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有商品库存管理的导出权限',
403,
ctx.requestId,
)
}
let filters = {}
if (parsed.data.filters) {
try {
filters = sanitizeProductInventoryFilters(JSON.parse(parsed.data.filters))
} catch {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件不正确', 400, ctx.requestId)
}
}
const requestedColumns = (parsed.data.columns ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean)
const columns = (requestedColumns.length
? requestedColumns
: PRODUCT_INVENTORY_COLUMNS.map((column) => column.key)
).filter(isProductInventoryColumnKey) as ProductInventoryColumnKey[]
if (!columns.length) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少选择一个导出字段', 400, ctx.requestId)
}
const sort = parsed.data.sortKey
? sanitizeProductInventorySort({
key: parsed.data.sortKey,
direction: parsed.data.sortDirection ?? 'asc',
})
: null
if (parsed.data.sortKey && !sort) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序条件不正确', 400, ctx.requestId)
}
const payload = await productInventorySnapshotReadService.createExportWorkbook({
columns,
filters,
sort,
})
return new NextResponse(new Uint8Array(payload.content), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(payload.fileName)}`,
'Cache-Control': 'private, no-store, max-age=0',
'X-Content-Type-Options': 'nosniff',
},
})
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,93 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import {
PRODUCT_INVENTORY_MAX_PAGE_SIZE,
sanitizeProductInventoryFilters,
sanitizeProductInventorySort,
} from '@/lib/product-inventory'
import { productInventorySnapshotReadService } from '@/server/services/product-inventory-snapshot-read.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
// 商品库存管理列表:字段筛选(文本模糊/点选、数值大小)、排序、分页均在服务端完成。
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(PRODUCT_INVENTORY_MAX_PAGE_SIZE).default(50),
filters: z.string().max(50_000).optional(),
sortKey: z.string().trim().max(64).optional(),
sortDirection: z.enum(['asc', 'desc']).optional(),
refresh: z.enum(['1']).optional(),
})
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const parsed = listQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries()),
)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'筛选条件不正确',
400,
ctx.requestId,
)
}
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
'productInventory',
)
if (!access.view) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有商品库存管理的查看权限',
403,
ctx.requestId,
)
}
let filters = {}
if (parsed.data.filters) {
try {
filters = sanitizeProductInventoryFilters(JSON.parse(parsed.data.filters))
} catch {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'筛选条件不正确',
400,
ctx.requestId,
)
}
}
const sort = parsed.data.sortKey
? sanitizeProductInventorySort({
key: parsed.data.sortKey,
direction: parsed.data.sortDirection ?? 'asc',
})
: null
if (parsed.data.sortKey && !sort) {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'排序条件不正确',
400,
ctx.requestId,
)
}
const page = await productInventorySnapshotReadService.getPage({
page: parsed.data.page,
pageSize: parsed.data.pageSize,
filters,
sort,
refresh: parsed.data.refresh === '1',
})
return jsonSuccess(page, 200, ctx.requestId)
},
{ requireAuth: true },
)
}

View File

@ -6,9 +6,9 @@ import { supplyChainProductArchiveWarehouseConfigService } from '@/server/servic
const updateSchema = z.object({
warehouseName: z.string().trim().min(1).max(160),
warehouseOwnership: z.enum(['FACTORY', 'JIANSHANG']).optional(),
warehouseOwnership: z.enum(['FACTORY', 'JIANSHANG']),
warehouseHead: z.string().trim().max(80).default(''),
storageType: z.enum(['INBOUND', 'DROPSHIP']).optional(),
storageType: z.enum(['INBOUND', 'DROPSHIP']),
status: z.enum(['ACTIVE', 'INACTIVE']),
}).strict()

View File

@ -0,0 +1,41 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveWarehouseConfigService } from '@/server/services/supply-chain-product-archive-warehouse-config.service'
const payloadSchema = z.object({
warehouseName: z.string().trim().min(1).max(160),
/** 空字符串表示恢复数据源原值。 */
attribute: z.string().trim().max(200),
}).strict()
/** 保存仓库属性人工覆盖:聚水潭 remark2 异常时在这里改。 */
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = payloadSchema.safeParse(body)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'仓库名称不能为空,仓库属性不超过 200 个字符',
400,
ctx.requestId,
)
}
return jsonSuccess(
await supplyChainProductArchiveWarehouseConfigService.saveWarehouseAttribute({
warehouseName: parsed.data.warehouseName,
attribute: parsed.data.attribute,
updatedById: ctx.user.id,
updatedByName: ctx.user.name,
}),
200,
ctx.requestId,
)
},
{ requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], allowedBusinessRoles: ['MANAGEMENT'] },
)
}

View File

@ -3,6 +3,7 @@ import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { supplyChainProductArchiveWarehouseConfigExportService } from '@/server/services/supply-chain-product-archive-warehouse-config-export.service'
import { WAREHOUSE_CONFIG_SORT_KEYS } from '@/server/services/supply-chain-product-archive-warehouse-config.service'
const multiValuesSchema = <T extends readonly [string, ...string[]]>(values: T) =>
z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean))
@ -10,10 +11,21 @@ const multiValuesSchema = <T extends readonly [string, ...string[]]>(values: T)
const exportQuerySchema = z.object({
search: z.string().trim().max(160).optional(),
status: multiValuesSchema(['ACTIVE', 'INACTIVE', 'UNCONFIGURED'] as const).optional(),
/** 逗号分隔;支持配置状态(ACTIVE/INACTIVE/UNCONFIGURED)与聚水潭档案状态文案(如「生效」)。 */
status: z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean)).optional(),
ownerships: multiValuesSchema(['FACTORY', 'JIANSHANG', 'NONE'] as const).optional(),
storageTypes: multiValuesSchema(['INBOUND', 'DROPSHIP', 'NONE'] as const).optional(),
heads: z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean)).optional(),
// 其余列的表头筛选(值与表格显示格式一致)。
wmsCoIds: z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean)).optional(),
names: z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean)).optional(),
attributes: z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean)).optional(),
createdDates: z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean)).optional(),
updatedDates: z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean)).optional(),
sortBy: z.enum(WAREHOUSE_CONFIG_SORT_KEYS).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
/** 逗号分隔;不传表示导出全部字段。 */
fields: z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean)).optional(),
})
export async function GET(request: NextRequest) {
@ -21,7 +33,11 @@ export async function GET(request: NextRequest) {
const parsed = exportQuerySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '仓库配置导出条件不正确', 400, ctx.requestId)
const { status, ...rest } = parsed.data
const payload = await supplyChainProductArchiveWarehouseConfigExportService.createWorkbook({ ...rest, statuses: status })
const payload = await supplyChainProductArchiveWarehouseConfigExportService.createWorkbook({
...rest,
statuses: status,
fields: parsed.data.fields,
})
return new NextResponse(new Uint8Array(payload.content), {
status: 200,
headers: {

View File

@ -12,6 +12,21 @@ import { supplyChainProductArchiveWarehouseConfigImportService } from '@/server/
const MAX_FILE_SIZE = 10 * 1024 * 1024
const MAX_ROWS = 5_000
/** 允许出现在导入文件里的列名:仓库配置的字段(含只读列,导出文件可直接改完再导回)。 */
const ALLOWED_IMPORT_HEADERS = new Set([
'仓库名称',
'仓库编号',
'仓库属性',
'仓储属性',
'仓库归属',
'仓库负责人',
'入库/代发',
'状态',
'仓库创建日期',
'更新时间',
'warehouseName',
])
function cellText(value: unknown) {
return String(value ?? '').trim()
}
@ -24,12 +39,17 @@ function parseRows(sheet: XLSX.WorkSheet) {
})
const headerRow = matrix[0]?.map(cellText) ?? []
const headerIndex = new Map(headerRow.map((header, index) => [header, index]))
const unknownHeaders = headerRow.filter((header) => header && !ALLOWED_IMPORT_HEADERS.has(header))
if (unknownHeaders.length > 0) {
return { error: `导入文件包含无法识别的字段:${unknownHeaders.join('、')}`, rows: [] }
}
const nameIndex = headerIndex.get('仓库名称') ?? headerIndex.get('warehouseName')
if (nameIndex === undefined) return { error: '导入文件缺少“仓库名称”列', rows: [] }
const rows = matrix.slice(1).flatMap((values, index) => {
if (!values.some((value) => cellText(value))) return []
const values2 = {
warehouseAttribute: cellText(values[headerIndex.get('仓库属性') ?? headerIndex.get('仓储属性') ?? -1]),
warehouseOwnership: cellText(values[headerIndex.get('仓库归属') ?? -1]),
warehouseHead: cellText(values[headerIndex.get('仓库负责人') ?? -1]),
storageType: cellText(values[headerIndex.get('入库/代发') ?? -1]),
@ -80,6 +100,7 @@ export async function POST(request: NextRequest) {
failed: 0,
skipped: 0,
errors: [],
importedWarehouseNames: [],
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {

View File

@ -0,0 +1,21 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveWarehouseConfigService } from '@/server/services/supply-chain-product-archive-warehouse-config.service'
/** 仓库配置弹窗的可选仓库:来源为聚水潭仓库档案 jst_warehouseInfo.name。 */
export async function GET(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
const options = await supplyChainProductArchiveWarehouseConfigService.listWarehouseOptions()
const statuses = [...new Set(
options.map((option) => option.status).filter((status): status is string => Boolean(status)),
)].sort((left, right) => left.localeCompare(right, 'zh-CN'))
const attributes = [...new Set(
options.map((option) => option.attribute).filter((attribute): attribute is string => Boolean(attribute)),
)].sort((left, right) => left.localeCompare(right, 'zh-CN'))
return jsonSuccess({ options, statuses, attributes }, 200, ctx.requestId)
},
{ requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], allowedBusinessRoles: ['MANAGEMENT'] },
)
}

View File

@ -0,0 +1,16 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveWarehouseConfigService } from '@/server/services/supply-chain-product-archive-warehouse-config.service'
/** 「更新」:重新拉取仓库数据源(新增仓库会同步过来),不动任何人工配置。 */
export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => jsonSuccess(
await supplyChainProductArchiveWarehouseConfigService.refreshWarehouseSource(),
200,
ctx.requestId,
),
{ requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], allowedBusinessRoles: ['MANAGEMENT'] },
)
}

View File

@ -2,29 +2,37 @@ import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveWarehouseConfigService } from '@/server/services/supply-chain-product-archive-warehouse-config.service'
import {
supplyChainProductArchiveWarehouseConfigService,
WAREHOUSE_CONFIG_SORT_KEYS,
} from '@/server/services/supply-chain-product-archive-warehouse-config.service'
const storageTypeSchema = z.enum(['INBOUND', 'DROPSHIP'])
const ownershipSchema = z.enum(['FACTORY', 'JIANSHANG'])
const multiValuesSchema = <T extends readonly [string, ...string[]]>(values: T) =>
z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean))
.pipe(z.array(z.enum(values)))
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(100),
search: z.string().trim().max(160).optional(),
status: multiValuesSchema(['ACTIVE', 'INACTIVE', 'UNCONFIGURED'] as const).optional(),
ownerships: multiValuesSchema(['FACTORY', 'JIANSHANG', 'NONE'] as const).optional(),
storageTypes: multiValuesSchema(['INBOUND', 'DROPSHIP', 'NONE'] as const).optional(),
heads: z.string().transform((value) => value.split(',').map((item) => item.trim()).filter(Boolean)).optional(),
facets: z.enum(['1']).optional(),
// 'UNCONFIGURED' 筛本页未配置;其余按聚水潭仓库档案的 status 文案匹配。
status: z.string().trim().max(200).optional(),
ownerships: z.string().trim().max(200).optional(),
storageTypes: z.string().trim().max(200).optional(),
heads: z.string().trim().max(2000).optional(),
// 其余列的表头筛选(值与表格显示格式一致)。
wmsCoIds: z.string().trim().max(2000).optional(),
names: z.string().trim().max(8000).optional(),
attributes: z.string().trim().max(4000).optional(),
createdDates: z.string().trim().max(2000).optional(),
updatedDates: z.string().trim().max(6000).optional(),
// 表头排序:字段 + 方向。
sortBy: z.enum(WAREHOUSE_CONFIG_SORT_KEYS).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
})
const createSchema = z.object({
warehouseName: z.string().trim().min(1).max(160),
warehouseOwnership: ownershipSchema.optional(),
warehouseOwnership: ownershipSchema,
warehouseHead: z.string().trim().max(80).default(''),
storageType: storageTypeSchema.optional(),
status: z.enum(['ACTIVE', 'INACTIVE']).optional(),
storageType: storageTypeSchema,
}).strict()
export async function GET(request: NextRequest) {
@ -33,12 +41,19 @@ export async function GET(request: NextRequest) {
async (ctx) => {
const parsed = listQuerySchema.safeParse(Object.fromEntries(request.nextUrl.searchParams.entries()))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '仓库配置筛选条件不正确', 400, ctx.requestId)
const { status, ...rest } = parsed.data
const data = await supplyChainProductArchiveWarehouseConfigService.list({ ...rest, statuses: status })
if (parsed.data.facets === '1') {
const facets = await supplyChainProductArchiveWarehouseConfigService.listFacets()
return jsonSuccess({ ...data, facets }, 200, ctx.requestId)
}
const { ownerships, storageTypes, heads, wmsCoIds, names, attributes, createdDates, updatedDates, ...rest } = parsed.data
const split = (value?: string) => (value ? value.split(',').map((item) => item.trim()).filter(Boolean) : undefined)
const data = await supplyChainProductArchiveWarehouseConfigService.list({
...rest,
ownerships: split(ownerships) as never,
storageTypes: split(storageTypes) as never,
heads: split(heads),
wmsCoIds: split(wmsCoIds),
names: split(names),
attributes: split(attributes),
createdDates: split(createdDates),
updatedDates: split(updatedDates),
})
return jsonSuccess(data, 200, ctx.requestId)
},
{ requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], allowedBusinessRoles: ['MANAGEMENT'] },
@ -52,7 +67,7 @@ export async function POST(request: NextRequest) {
let body: unknown
try { body = await request.json() } catch { return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '请求体格式不正确', 400, ctx.requestId) }
const parsed = createSchema.safeParse(body)
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '仓库名称不能为空', 400, ctx.requestId)
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '仓库名称、仓库归属和入库/代发不能为空', 400, ctx.requestId)
return jsonSuccess(await supplyChainProductArchiveWarehouseConfigService.create(parsed.data), 201, ctx.requestId)
},
{ requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], allowedBusinessRoles: ['MANAGEMENT'] },

View File

@ -2,17 +2,14 @@
import { useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import { Move, PanelTop } from 'lucide-react'
import Button from '@/components/ui/Button'
import Modal from '@/components/ui/Modal'
import type { DiyAccess, DiyCatalogDTO, CabinetCell } from '@/lib/diy/types'
import { useDiyWorkbench } from './hooks/useDiyWorkbench'
import { saveDiyScheme } from './lib/api'
import { downloadDiyFile } from './lib/download'
import DiyCabinetLibrary from './components/DiyCabinetLibrary'
import DiyInspector from './components/DiyInspector'
import DiyHistoryDialog from './components/DiyHistoryDialog'
import DiyBomDialog from './components/DiyBomDialog'
import DiyManagementDialog from './components/DiyManagementDialog'
import DiyAiPanel from './components/DiyAiPanel'
import DiyInspectorTabs from './components/DiyInspectorTabs'
@ -23,10 +20,10 @@ import type { DiyAiRenderAccess } from '@/lib/diy/ai-render'
const DiyViewport=dynamic(()=>import('./components/DiyViewport'),{ssr:false,loading:()=> <div className="flex h-full items-center justify-center text-sm text-zinc-500">正在加载 3D 工作台…</div>})
export default function DiyWorkbench({ access, catalog,canDeleteMaterials=false, aiAccess }: {access:DiyAccess;catalog:DiyCatalogDTO;canDeleteMaterials?:boolean;aiAccess:DiyAiRenderAccess}) {
const controller=useDiyWorkbench(catalog),stage=useRef<HTMLDivElement>(null)
const controller=useDiyWorkbench(catalog)
const captureRef=useRef<DiyCaptureHandle|null>(null),[capturing,setCapturing]=useState(false)
const ai=useDiyAiRender(controller,captureRef,aiAccess)
const [historyOpen,setHistoryOpen]=useState(false),[bomOpen,setBomOpen]=useState(false),[saveOpen,setSaveOpen]=useState(false)
const [historyOpen,setHistoryOpen]=useState(false),[saveOpen,setSaveOpen]=useState(false)
const [confirmAction,setConfirmAction]=useState<'new'|'history'|null>(null)
const [name,setName]=useState('组合方案'),[saving,setSaving]=useState(false),[managementOpen,setManagementOpen]=useState(false)
const [mobilePanel,setMobilePanel]=useState<'library'|'inspector'|'ai'|null>(null)
@ -36,6 +33,7 @@ export default function DiyWorkbench({ access, catalog,canDeleteMaterials=false,
const editCells=(code:string)=>{setEditingStyleCode(code);setNewDoor(undefined);setNewVariant(undefined);setMobilePanel(null);setManagementOpen(true)}
const addPattern=(door:CabinetCell['door'])=>{setEditingStyleCode('');setNewVariant(undefined);setNewDoor({...door,spu:controller.selected?.spu||controller.seriesSpu});setMobilePanel(null);setManagementOpen(true)}
const addColor=(styleCode:string,materialCode:string)=>{setEditingStyleCode('');setNewDoor(undefined);setNewVariant({styleCode,materialCode});setMobilePanel(null);setManagementOpen(true)}
const openManagement=()=>{setEditingStyleCode('');setNewDoor(undefined);setNewVariant(undefined);setMobilePanel(null);setManagementOpen(true)}
const disabled=!access.canConfigure||saving||capturing
const switchRightPanel=(showAi:boolean)=>{
if(showAi){if(!ai.open)void ai.openPanel();if(window.innerWidth<1024)setMobilePanel('ai')}
@ -51,35 +49,21 @@ export default function DiyWorkbench({ access, catalog,canDeleteMaterials=false,
if(controller.dirty){setConfirmAction(action);return}
if(action==='new')controller.reset();else setHistoryOpen(true)
}
const capture=()=>{
try{const canvas=stage.current?.querySelector('canvas');if(!canvas)throw new Error('3D画面还未就绪')
controller.setFeedback({tone:'success',text:'正在生成预览截图…'})
canvas.toBlob(blob=>{if(!blob){controller.setFeedback({tone:'error',text:'截图生成失败'});return}
downloadDiyFile(blob,'DIY-preview.png');controller.setFeedback({tone:'success',text:'预览截图已开始下载'})},'image/png')
}catch(error){controller.setFeedback({tone:'error',text:error instanceof Error?error.message:'截图失败'})}
}
return <div className="flex h-full min-h-0 flex-col bg-[#f4f6f5]">
<DiyToolbar name={controller.scheme?.name||'组合方案'} dirty={controller.dirty} hasItems={Boolean(controller.snapshot.items.length)}
canConfigure={access.canConfigure} canDownload={access.canDownload} saving={saving} capturing={capturing}
onHistory={()=>requestAction('history')} onBom={()=>setBomOpen(true)} onCapture={capture} onNew={()=>requestAction('new')}
onManage={()=>{setEditingStyleCode('');setNewDoor(undefined);setNewVariant(undefined);setManagementOpen(true)}}
onSave={()=>{setName(controller.scheme?.name||'组合方案');setSaveOpen(true)}}/>
<h1 className="sr-only">DIY 组合方案</h1>
<div className="flex min-h-0 flex-1">
<aside className="hidden w-[250px] shrink-0 border-r border-zinc-200 bg-white lg:flex"><DiyCabinetLibrary controller={controller} disabled={disabled} onAddColor={addColor}/></aside>
<div className={`relative min-w-0 flex-1 ${capturing?'pointer-events-none':''}`} ref={stage}>
<aside className="hidden w-[280px] shrink-0 border-r border-zinc-200 bg-white lg:flex"><DiyCabinetLibrary controller={controller} disabled={disabled} onAddColor={addColor} onManage={access.canConfigure?openManagement:undefined}/></aside>
<div className={`relative min-w-0 flex-1 @container/diy ${capturing?'pointer-events-none':''}`}>
<DiyViewport controller={controller} disabled={disabled} captureRef={captureRef} onCapturing={setCapturing}/>
<div className="absolute left-4 top-4 flex items-center gap-1 rounded-full bg-white/80 p-1 shadow-sm ring-1 ring-black/5 backdrop-blur-xl">
{(['layout','doors'] as const).map(mode=><button type="button" key={mode} aria-pressed={controller.mode===mode}
disabled={disabled} onClick={()=>controller.setMode(mode)} className={`flex items-center gap-1.5 rounded-full px-3 py-2 text-xs ${controller.mode===mode?'bg-zinc-800 text-white':'text-zinc-500'}`}>
{mode==='layout'?<Move size={14}/>:<PanelTop size={14}/>}{mode==='layout'?'布局':'贴门'}
</button>)}
</div>
<div className="absolute right-4 top-4 rounded-full bg-white/80 px-3 py-2 text-xs text-zinc-500 ring-1 ring-black/5 backdrop-blur-xl">
{controller.snapshot.items.length} 柜体 · {controller.cells} 格口 · {Object.keys(controller.snapshot.doors).length} 柜门
</div>
{controller.feedback&&<div role={controller.feedback.tone==='error'?'alert':'status'} className={`absolute left-1/2 top-20 max-w-[90%] -translate-x-1/2 rounded-lg border bg-white px-4 py-2 text-sm shadow-sm ${controller.feedback.tone==='error'?'border-red-200 text-red-600':'border-emerald-200 text-emerald-700'}`}>{controller.feedback.text}</div>}
<DiyToolbar name={controller.scheme?.name||'组合方案'} dirty={controller.dirty}
itemCount={controller.snapshot.items.length} cellCount={controller.cells} doorCount={Object.keys(controller.snapshot.doors).length}
mode={controller.mode} onModeChange={controller.setMode}
canConfigure={access.canConfigure} saving={saving} capturing={capturing}
onHistory={()=>requestAction('history')} onNew={()=>requestAction('new')}
onSave={()=>{setName(controller.scheme?.name||'组合方案');setSaveOpen(true)}}/>
{controller.feedback&&<div role={controller.feedback.tone==='error'?'alert':'status'} className={`absolute left-1/2 top-44 max-w-[90%] -translate-x-1/2 rounded-full border bg-white/80 px-4 py-2 text-xs shadow-sm backdrop-blur-xl sm:top-28 ${controller.feedback.tone==='error'?'border-red-200 text-red-600':'border-emerald-200 text-emerald-700'}`}>{controller.feedback.text}</div>}
</div>
<aside className={`hidden shrink-0 flex-col border-l border-zinc-200 bg-white lg:flex ${ai.open?'w-[360px] xl:w-[420px] 2xl:w-[480px]':'w-[280px]'}`}>
<aside className="hidden w-[280px] shrink-0 flex-col border-l border-zinc-200 bg-white lg:flex">
{aiAccess.canView&&<DiyInspectorTabs ai={ai.open} disabled={capturing} onChange={switchRightPanel}/>}
<div className={`min-h-0 flex-1 ${ai.open?'hidden':'block'}`}><DiyInspector controller={controller} disabled={disabled} onEditCells={editCells} onAddPattern={addPattern} onAddColor={addColor}/></div>
{aiAccess.canView&&<div className={`min-h-0 flex-1 ${ai.open?'block':'hidden'}`}><DiyAiPanel ai={ai} name={controller.scheme?.name||'组合方案'} itemCount={controller.snapshot.items.length} doorCount={Object.keys(controller.snapshot.doors).length}/></div>}
@ -90,8 +74,7 @@ export default function DiyWorkbench({ access, catalog,canDeleteMaterials=false,
<Button size="small" variant="gray" className="flex-1" onClick={()=>setMobilePanel('inspector')}>配色与柜门</Button>
{aiAccess.canView&&<Button size="small" variant="gray" disabled={capturing} className="flex-1" onClick={()=>switchRightPanel(true)}>AI 出图</Button>}
</div>
<DiyHistoryDialog open={historyOpen} onClose={()=>setHistoryOpen(false)} controller={controller}/>
<DiyBomDialog open={bomOpen} onClose={()=>setBomOpen(false)} controller={controller} canDownload={access.canDownload}/>
<DiyHistoryDialog open={historyOpen} onClose={()=>setHistoryOpen(false)} controller={controller} canDelete={access.canConfigure}/>
{access.canConfigure&&managementOpen&&<DiyManagementDialog open onClose={()=>setManagementOpen(false)} controller={controller} initialStyleCode={editingStyleCode} initialDoor={newDoor} initialVariant={newVariant} canDeleteMaterials={canDeleteMaterials}/>}
<Modal isOpen={saveOpen} onClose={()=>{if(!saving)setSaveOpen(false)}} title="保存 DIY 方案" size="small"
footer={<Button disabled={saving||!name.trim()} loading={saving} onClick={()=>void save()}>保存新版本</Button>}>
@ -107,7 +90,7 @@ export default function DiyWorkbench({ access, catalog,canDeleteMaterials=false,
<p className="text-sm text-zinc-600">继续操作会放弃当前未保存的配置。</p>
</Modal>
<Modal isOpen={mobilePanel!==null} onClose={()=>{if(mobilePanel==='ai')ai.setOpen(false);setMobilePanel(null)}} title={mobilePanel==='library'?'柜体款式':mobilePanel==='ai'?'AI 出图':'配色与柜门'} size="medium">
<div className="h-[60vh] overflow-hidden">{mobilePanel==='library'?<DiyCabinetLibrary controller={controller} disabled={disabled} onAddColor={addColor}/>:mobilePanel==='ai'?<DiyAiPanel ai={ai} name={controller.scheme?.name||'组合方案'} itemCount={controller.snapshot.items.length} doorCount={Object.keys(controller.snapshot.doors).length}/>:<DiyInspector controller={controller} disabled={disabled} onEditCells={editCells} onAddPattern={addPattern} onAddColor={addColor}/>}</div>
<div className="h-[60vh] overflow-hidden">{mobilePanel==='library'?<DiyCabinetLibrary controller={controller} disabled={disabled} onAddColor={addColor} onManage={access.canConfigure?openManagement:undefined}/>:mobilePanel==='ai'?<DiyAiPanel ai={ai} name={controller.scheme?.name||'组合方案'} itemCount={controller.snapshot.items.length} doorCount={Object.keys(controller.snapshot.doors).length}/>:<DiyInspector controller={controller} disabled={disabled} onEditCells={editCells} onAddPattern={addPattern} onAddColor={addColor}/>}</div>
</Modal>
</div>
}

View File

@ -0,0 +1,59 @@
'use client'
import { Download, SlidersHorizontal, Trash2, Wand2 } from 'lucide-react'
import Button from '@/components/ui/Button'
import type { DiyAiRenderDTO } from '@/lib/diy/ai-render'
import type { DiyImageSelection } from '@/lib/diy/image-tools'
import type { DiyAiRenderController } from '../hooks/useDiyAiRender'
export const diyRenderLabel = (record: DiyAiRenderDTO) => record.operation === 'local-preview' ? '本地截图'
: record.operation === 'text-replace' ? '文字替换' : record.operation === 'layout-replace' ? '排版图' : record.mode === 'scene' ? '效果图' : '白底图'
const action = 'h-8! min-h-8! min-w-0! shrink-0 whitespace-nowrap px-1! text-[11px]! focus:px-[3px]! [&>span]:gap-1 [&_svg]:shrink-0'
export default function DiyAiResultCard({ record, ai, onPreview, onEdit, onLayout }: {
record: DiyAiRenderDTO; ai: DiyAiRenderController; onPreview: (url: string) => void;
onEdit: (selection: DiyImageSelection) => void; onLayout: (selection: DiyImageSelection) => void
}) {
const label = diyRenderLabel(record)
return <section className="space-y-2.5 rounded-xl border border-black/[0.06] bg-white p-2.5">
<div className="flex min-w-0 items-center justify-between gap-2 px-0.5">
<span className="min-w-0 truncate text-xs font-medium text-[#2d3335]" title={record.name}>{record.name}</span>
<span className="shrink-0 rounded-full bg-[#47646b]/[0.07] px-2 py-0.5 text-[10px] text-[#47646b]">{label}</span>
</div>
<div className="flex items-center justify-between gap-2 px-0.5 text-[10px] text-zinc-400">
<span>{record.snapshot.items.length} 柜体 · {Object.keys(record.snapshot.doors).length} 柜门</span>
<time dateTime={record.createdAt}>{new Date(record.createdAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</time>
</div>
{record.status === 'PENDING' && !record.errorMessage && <p role="status" className="px-0.5 py-3 text-xs text-zinc-500">正在生成…</p>}
{(record.status === 'FAILED' || record.errorMessage) && <p className="px-0.5 text-xs leading-5 text-red-600">{record.errorMessage || '生成失败'}</p>}
{record.status === 'FAILED' && !record.images.length && ai.access.canDelete && <Button size="small" className={action} variant="plain" tone="danger"
disabled={ai.deletion.deleting} onClick={() => ai.deletion.requestDelete(record, { target: 'record' })}><Trash2 size={13}/>删除失败记录</Button>}
{record.images.map((image, index) => {
const selection: DiyImageSelection = { recordId: record.id, storageKey: image.storageKey, url: image.originalUrl,
name: `${record.name}-${label}-${index + 1}`, width: image.width, height: image.height }
const canLayout = ai.access.canGenerate && record.mode === 'white-background' && (!record.operation || record.operation === 'local-preview') && record.status === 'SUCCEEDED'
return <div key={image.storageKey} className="overflow-hidden rounded-lg border border-black/[0.05]">
<button type="button" className="relative block w-full cursor-zoom-in bg-[#f5f6f6]" aria-label={`查看${label} ${index + 1}`} onClick={() => onPreview(image.originalUrl)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.thumbnailUrl} alt={`${record.name} ${index + 1}`} loading="lazy" className="aspect-[4/3] w-full object-contain"/>
<span className="absolute bottom-2 right-2 rounded-full bg-white/85 px-2 py-0.5 text-[10px] text-zinc-500">{image.width} × {image.height}</span>
</button>
<div className="flex items-center gap-0.5 border-t border-black/[0.04] bg-white p-1" aria-label="图片操作">
<Button size="small" variant="plain" className={action} onClick={() => onEdit(selection)}><SlidersHorizontal size={13}/>编辑</Button>
{canLayout && <Button size="small" variant="plain" className={action} onClick={() => onLayout(selection)}><Wand2 size={13}/>排版</Button>}
<div className="flex-1"/>
{ai.access.canDownload && <Button size="small" variant="plain" className={action} loading={ai.downloading === image.originalUrl} disabled={Boolean(ai.downloading)}
onClick={() => void ai.download(image.originalUrl, `${selection.name}.png`)}><Download size={13}/>下载</Button>}
{ai.access.canDelete && <Button size="small" variant="plain" tone="danger" className={`${action} text-red-500! hover:bg-red-50!`} aria-label={`删除${label} ${index + 1}`}
disabled={record.status === 'PENDING' || ai.deletion.deleting || Boolean(ai.downloading)}
title={record.status === 'PENDING' ? '生成中的任务暂不能删除' : '删除这张图片'}
onClick={() => ai.deletion.requestDelete(record, { target: 'image', storageKey: image.storageKey })}><Trash2 size={13}/>删除</Button>}
</div>
</div>
})}
{(record.referenceImageUrl || record.templateUrl) && <div className="flex gap-3 px-0.5 text-[10px] text-zinc-400">
{record.referenceImageUrl && <button type="button" className="hover:text-[#47646b]" onClick={() => onPreview(record.referenceImageUrl!)}>参考图</button>}
{record.templateUrl && <button type="button" className="hover:text-[#47646b]" onClick={() => onPreview(record.templateUrl!)}>排版模板</button>}
</div>}
</section>
}

View File

@ -1,57 +1,34 @@
'use client'
import { useEffect, useState } from 'react'
import { Download, Trash2 } from 'lucide-react'
import { useState } from 'react'
import dynamic from 'next/dynamic'
import Button from '@/components/ui/Button'
import type { DiyAiRenderDTO } from '@/lib/diy/ai-render'
import type { DiyAiRenderController } from '../hooks/useDiyAiRender'
import { RenderRecordGalleryLightbox } from '@/app/ai-render-workbench/RenderRecordGalleryLightbox'
import DiyAiDeleteConfirmation from './DiyAiDeleteConfirmation'
import DiyAiResultCard from './DiyAiResultCard'
import type { DiyImageSelection } from '@/lib/diy/image-tools'
import { useDiyLayoutBatch } from '../hooks/useDiyLayoutBatch'
const DiyImageEditor = dynamic(() => import('./image-tools/DiyImageEditor'), { ssr: false })
const DiyLayoutReplaceDialog = dynamic(() => import('./image-tools/DiyLayoutReplaceDialog'), { ssr: false })
export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[]; ai: DiyAiRenderController }) {
const [large,setLarge] = useState<string|null>(null)
useEffect(() => {
if (!large) return
const close = (event:KeyboardEvent) => { if(event.key==='Escape')setLarge(null) }
window.addEventListener('keydown',close); return () => window.removeEventListener('keydown',close)
},[large])
const [large, setLarge] = useState<string | null>(null)
const [editing, setEditing] = useState<DiyImageSelection | null>(null), [layout, setLayout] = useState<DiyImageSelection | null>(null)
const batch = useDiyLayoutBatch(ai.refresh)
return <div className="space-y-3" aria-label="DIY 出图结果">
<DiyAiDeleteConfirmation deletion={ai.deletion}/>
{editing && <DiyImageEditor key={editing.storageKey} source={editing} canDownload={ai.access.canDownload} canGenerate={ai.access.canGenerate} onGenerated={ai.refresh} onClose={() => setEditing(null)}/>}
{layout && <DiyLayoutReplaceDialog key={layout.storageKey} source={layout} records={ai.records} batch={batch} onClose={() => setLayout(null)}/>}
{batch.items.length > 0 && <div role="status" className="space-y-1 rounded-md bg-[#47646b]/5 px-3 py-2 text-[11px] text-[#47646b]">
<p>{batch.notice || '排版处理中…'}</p>
{batch.items.filter(item => item.error).map(item => <p key={item.input.requestId} className="text-red-600">{item.name}:{item.error}</p>)}
{batch.items.some(item => item.status !== 'succeeded' && item.status !== 'failed') && !batch.running && <Button size="small" variant="plain" onClick={() => void batch.resume()}>继续核对</Button>}
</div>}
{large && <RenderRecordGalleryLightbox imageUrl={large} onClose={() => setLarge(null)}/>}
{records.map(record => <section key={record.id} className="space-y-2 border-b border-black/[0.04] pb-3">
<div className="flex flex-wrap items-center justify-between gap-1 text-[10px] text-[#5a6062]/60">
<span className="font-semibold text-[#2d3335]">{record.name} · {record.mode==='scene'?'效果图':'白底图'}</span>
<span>{new Date(record.createdAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}</span>
</div>
<p className="text-[10px] text-[#5a6062]/60">{record.snapshot.items.length} 柜体 · {Object.keys(record.snapshot.doors).length} 柜门{record.schemeCode ? ' · 已保存方案' : ' · 生成时方案'}</p>
{record.status==='PENDING' && !record.errorMessage && <p role="status" className="text-[11px] text-zinc-500">正在生成…</p>}
{(record.status==='FAILED' || record.errorMessage) && <p className="text-[11px] text-red-600">{record.errorMessage || '生成失败'}</p>}
{record.status==='FAILED' && !record.images.length && ai.access.canDelete && <Button type="button" size="small" variant="plain" tone="danger"
disabled={ai.deletion.deleting} onClick={() => ai.deletion.requestDelete(record,{ target:'record' })}>
<Trash2 size={13}/>删除失败记录</Button>}
<div className="grid grid-cols-1 gap-2.5">
{record.images.map((image,index) => {
const filename = `${record.name}-${record.mode==='scene'?'效果图':'白底图'}-${index+1}.png`
return <div key={image.storageKey} className="overflow-hidden rounded-[7px] border border-black/[0.04] bg-white">
<button type="button" className="block w-full cursor-zoom-in bg-[#eef2f2]" aria-label={`查看${record.mode==='scene'?'效果图':'白底图'} ${index+1}`} onClick={() => setLarge(image.originalUrl)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.thumbnailUrl} alt={`${record.name} ${index+1}`} loading="lazy" className="aspect-[16/10] w-full object-contain"/>
</button>
<div className="flex items-center justify-between px-2 py-1.5 text-xs text-zinc-400"><span>{image.width} × {image.height}</span>
<div className="flex items-center gap-1">
{ai.access.canDownload && <Button size="small" variant="plain" loading={ai.downloading===image.originalUrl} disabled={Boolean(ai.downloading)}
onClick={() => void ai.download(image.originalUrl,filename)}><Download size={13}/>下载</Button>}
{ai.access.canDelete && <Button type="button" size="small" variant="plain" tone="danger"
aria-label={`删除${record.mode==='scene'?'效果图':'白底图'} ${index+1}`} disabled={record.status==='PENDING'||ai.deletion.deleting||Boolean(ai.downloading)}
title={record.status==='PENDING'?'生成中的任务暂不能删除':'删除这张图片'}
onClick={() => ai.deletion.requestDelete(record,{ target:'image',storageKey:image.storageKey })}><Trash2 size={13}/>删除</Button>}
</div>
</div>
</div>
})}
</div>
{record.referenceImageUrl && <button type="button" className="text-[10px] text-[#5a6062]/60 hover:text-[#2d3335]" onClick={() => setLarge(record.referenceImageUrl!)}>查看生成时参考图</button>}
</section>)}
{!records.length && <p className="py-8 text-center text-[11px] text-[#5a6062]/45">还没有{ai.mode==='scene'?'效果图':'白底图'}记录</p>}
{records.map(record => <DiyAiResultCard key={record.id} record={record} ai={ai} onPreview={setLarge} onEdit={setEditing} onLayout={setLayout}/>)}
{!records.length && <p className="py-8 text-center text-[11px] text-[#5a6062]/45">还没有{ai.mode === 'scene' ? '效果图' : '白底图'}记录</p>}
</div>
}

View File

@ -1,8 +1,9 @@
'use client'
import { LoaderCircle, Plus } from 'lucide-react'
import { LoaderCircle, Plus, Settings2 } from 'lucide-react'
import { useState } from 'react'
import dynamic from 'next/dynamic'
import Button from '@/components/ui/Button'
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
import { useDiyCabinetThumbnails } from '../hooks/useDiyCabinetThumbnails'
import { findDiyColorForMaterial } from '@/lib/diy/materials'
@ -10,7 +11,7 @@ import DiyMaterialSwatch from './DiyMaterialSwatch'
const DiyCabinetThumbnailRenderer = dynamic(() => import('./DiyCabinetThumbnailRenderer'), { ssr: false })
export default function DiyCabinetLibrary({ controller, disabled,onAddColor }: { controller: DiyWorkbenchController; disabled: boolean;onAddColor:(styleCode:string,materialCode:string)=>void }) {
export default function DiyCabinetLibrary({ controller, disabled,onAddColor,onManage }: { controller: DiyWorkbenchController; disabled: boolean;onAddColor:(styleCode:string,materialCode:string)=>void;onManage?:()=>void }) {
const { getCatalogStyle } = controller.catalogAccess
const [search,setSearch]=useState('')
const styles=controller.catalog.styles.filter(style=>(style.spu||'SJ0380')===controller.seriesSpu)
@ -21,7 +22,11 @@ export default function DiyCabinetLibrary({ controller, disabled,onAddColor }: {
return <div className="flex min-h-0 flex-col">
{task&&<DiyCabinetThumbnailRenderer catalog={controller.catalog} task={task} onComplete={complete} onError={fail}/>}
<div className="border-b border-zinc-200 p-4">
<label className="text-xs font-medium text-zinc-500" htmlFor="diy-series">柜体系列</label>
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-zinc-500" htmlFor="diy-series">柜体系列</label>
{onManage&&<Button type="button" variant="plain" size="small" ariaLabel="素材管理" title="素材管理" disabled={disabled} onClick={onManage}
className="-my-2 -mr-2 h-11 w-11 min-w-11 px-0 py-0 focus:px-0 focus:py-0 lg:my-0 lg:mr-0 lg:h-7 lg:min-h-7 lg:w-7 lg:min-w-7"><Settings2 size={15}/></Button>}
</div>
<select id="diy-series" value={controller.seriesSpu} onChange={event=>{controller.setSeriesSpu(event.target.value);setSearch('')}} className="mt-2 h-10 w-full rounded-md border border-zinc-300 bg-white px-2 text-sm text-zinc-800">
{series.map(item=><option key={item.spu} value={item.spu}>{item.name}</option>)}
</select>
@ -56,7 +61,7 @@ export default function DiyCabinetLibrary({ controller, disabled,onAddColor }: {
</span><Plus size={16} className="shrink-0 text-zinc-400 group-hover:text-blue-600" />
</button>
})}
{!styles.length&&<p className="px-1 py-6 text-xs text-zinc-500">此系列尚无款式,可通过顶部素材管理添加。</p>}
{!styles.length&&<p className="px-1 py-6 text-xs text-zinc-500">此系列尚无款式,可通过素材管理添加。</p>}
</div>
</div>
}

View File

@ -5,7 +5,7 @@ import { useTexture } from '@react-three/drei'
import type { ThreeEvent } from '@react-three/fiber'
import * as THREE from 'three'
import { buildCabinet, buildDoor, isInteriorShelf } from '@/lib/diy/geometry'
import { cellKey, createDiyCatalogAccess } from '@/lib/diy/catalog'
import { cellKey, createDiyCatalogAccess, getDiyDoorDimensions } from '@/lib/diy/catalog'
import { toThreeGeometry } from '../lib/threeGeometry'
import { diyMaterialDisplayColor } from '../lib/material-appearance'
import type { CabinetCell, DiyItem, DiyCatalogDTO } from '@/lib/diy/types'
@ -28,10 +28,10 @@ function DoorMesh({ item, cell, code, onClick, catalog }: { catalog:DiyCatalogDT
const loadedTexture=useTexture(pattern.image)
const texture=useMemo(()=>{const value=loadedTexture.clone();value.colorSpace=THREE.SRGBColorSpace;value.needsUpdate=true;return value},[loadedTexture])
useEffect(()=>()=>texture.dispose(),[texture])
const geometry=useMemo(()=>{const style=getStyle(item.code);return toThreeGeometry(buildDoor(cell,style.modelUrl?[0,0,0]:buildCabinet(style).center,2),item.quarterTurn,true)},[cell,item.code,item.quarterTurn,getStyle])
const geometry=useMemo(()=>{const style=getStyle(item.code),door=getDiyDoorDimensions(pattern,cell);return toThreeGeometry(buildDoor({...cell,door},style.modelUrl?[0,0,0]:buildCabinet(style).center,2),item.quarterTurn,true)},[cell,pattern,item.code,item.quarterTurn,getStyle])
useEffect(()=>()=>geometry.dispose(),[geometry])
return <mesh geometry={geometry} onClick={event=>{event.stopPropagation();onClick()}}>
<meshStandardMaterial map={texture} roughness={.85} />
<meshStandardMaterial map={texture} alphaTest={.5} alphaToCoverage roughness={.85} />
</mesh>
}
export default function DiyCabinetMesh({ catalog, item, doors, selected, mode, disabled, onDown, onMove, onUp, onDoor,onError,onReady,readyKey='' }:Props) {
@ -40,11 +40,11 @@ export default function DiyCabinetMesh({ catalog, item, doors, selected, mode, d
const rotateGrain=main?.name==='榆木黑色'||main?.name==='柚木色'
const loadedTexture=useTexture(main?.texture||'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7')
const loadedShelfTexture=useTexture(shelf?.texture||'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7')
const shelfTexture=useMemo(()=>{const value=loadedShelfTexture.clone();value.colorSpace=THREE.SRGBColorSpace;value.wrapS=value.wrapT=THREE.RepeatWrapping;value.needsUpdate=true;return value},[loadedShelfTexture])
const shelfTexture=useMemo(()=>{const value=loadedShelfTexture.clone();value.colorSpace=THREE.SRGBColorSpace;value.wrapS=value.wrapT=THREE.ClampToEdgeWrapping;value.needsUpdate=true;return value},[loadedShelfTexture])
useEffect(()=>()=>shelfTexture.dispose(),[shelfTexture])
const texture=useMemo(()=>{
const value=loadedTexture.clone();value.colorSpace=THREE.SRGBColorSpace
value.wrapS=value.wrapT=THREE.RepeatWrapping;value.anisotropy=8
value.wrapS=value.wrapT=THREE.ClampToEdgeWrapping;value.anisotropy=8
if(rotateGrain){value.center.set(.5,.5);value.rotation=Math.PI/2}
value.needsUpdate=true;return value
},[loadedTexture,rotateGrain])

View File

@ -1,3 +1,4 @@
import { Plus } from 'lucide-react'
import { resolveDiyMaterials } from '@/lib/diy/catalog'
import { findDiyColorForMaterial } from '@/lib/diy/materials'
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
@ -9,19 +10,25 @@ export default function DiyColorOptions({ controller: c, disabled, onAddColor }:
}) {
if (!c.selected) return null
const selected = c.selected, style = c.catalogAccess.getCatalogStyle(selected.code)
const selectedMaterials = resolveDiyMaterials(c.catalog, { code: style.code, colorName: selected.colorName })
const selectedName = `${selected.colorName}${selectedMaterials.main?.deletedAt || selectedMaterials.shelf?.deletedAt ? ' · 已删除' : ''}`
const available = c.catalog.materials.filter(material => material.kind === 'main' && !material.deletedAt && !findDiyColorForMaterial(c.catalog, style.code, material.itemCode))
return <div className="mt-3 max-h-60 space-y-1.5 overflow-y-auto" aria-label="柜体共用配色">
return <div className="mt-2 min-w-0" aria-label="柜体共用配色">
<div className="flex items-center gap-1.5 overflow-x-auto px-0.5 py-1">
{style.colors.filter(color=>selected.colorName===color.name||diyColorIsAvailable(c.catalog,style.code,color.name)).map(color => {
const { main, shelf } = resolveDiyMaterials(c.catalog, { code: style.code, colorName: color.name })
return <button type="button" key={color.itemCode} onClick={() => c.setColor(selected.instanceId, color.name)} disabled={disabled}
title={`${color.name}${main?.deletedAt || shelf?.deletedAt ? ' · 已删除' : ''}`}
aria-label={`柜体颜色 ${color.name}`} aria-pressed={selected.colorName === color.name}
className={`flex w-full items-center gap-2 rounded-md border p-2 text-left text-xs ${selected.colorName === color.name ? 'border-blue-400 bg-blue-50' : 'border-zinc-200'}`}>
<DiyMaterialSwatch main={main} shelf={shelf} className="h-8 w-8" /><span className="min-w-0 truncate">{color.name}{main?.deletedAt||shelf?.deletedAt?' · 已删除':''}</span>
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-md p-1 focus-visible:outline-2 focus-visible:outline-blue-500 disabled:opacity-50 ${selected.colorName === color.name ? 'bg-blue-50 ring-1 ring-inset ring-blue-500' : 'hover:bg-zinc-100'}`}>
<DiyMaterialSwatch main={main} shelf={shelf} className="h-8 w-8" />
</button>
})}
{available.map(material => <button type="button" key={material.itemCode} disabled={disabled} aria-label={`添加颜色款 ${style.code} ${material.itemCode}`}
onClick={() => onAddColor(style.code, material.itemCode)} className="flex w-full items-center gap-2 rounded-md border border-zinc-200 p-2 text-left text-xs hover:border-blue-300">
<DiyMaterialSwatch main={material} className="h-8 w-8" /><span className="min-w-0 flex-1 truncate">{material.name}</span><span className="shrink-0 text-[10px] text-blue-600">添加颜色款</span>
{available.map(material => <button type="button" key={material.itemCode} disabled={disabled} title={`${material.name} · 添加颜色款`} aria-label={`添加颜色款 ${material.name} ${style.code} ${material.itemCode}`}
onClick={() => onAddColor(style.code, material.itemCode)} className="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-md p-1 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-blue-500 disabled:opacity-50">
<DiyMaterialSwatch main={material} className="h-8 w-8" /><Plus size={12} aria-hidden="true" className="absolute bottom-0.5 right-0.5 rounded-full bg-white text-blue-600 ring-1 ring-zinc-200" />
</button>)}
</div>
<p title={selectedName} className="mt-1 truncate text-[11px] leading-4 text-zinc-500">{selectedName}</p>
</div>
}

View File

@ -1,58 +1,101 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import type { DiySchemeDTO } from '@/lib/diy/types'
import { listDiySchemes, readDiyScheme } from '../lib/api'
import { deleteDiyScheme, listDiySchemes, readDiyScheme } from '../lib/api'
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
export default function DiyHistoryDialog({ open, onClose, controller }: {open:boolean;onClose:()=>void;controller:DiyWorkbenchController}) {
export default function DiyHistoryDialog({ open, onClose, controller, canDelete }: {open:boolean;onClose:()=>void;controller:DiyWorkbenchController;canDelete:boolean}) {
const [records,setRecords]=useState<DiySchemeDTO[]>([]), [page,setPage]=useState(1),[pageSize,setPageSize]=useState(20)
const [total,setTotal]=useState(0),[pageInput,setPageInput]=useState('1')
const pages=Math.max(1,Math.ceil(total/pageSize))
const [busy,setBusy]=useState(false),[error,setError]=useState('')
const generation=useRef(0)
const load=async()=>{
const [selection,setSelection]=useState<DiySchemeDTO|null>(null),[deleting,setDeleting]=useState(false),[notice,setNotice]=useState('')
const generation=useRef(0),deleteLock=useRef(false)
const cancelRef=useRef<HTMLButtonElement>(null),listRef=useRef<HTMLDivElement>(null)
const invalidate=useCallback(()=>{generation.current++},[])
const disabled=busy||deleting
const load=useCallback(async()=>{
const token=++generation.current;setBusy(true);setError('')
try{const result=await listDiySchemes(page,pageSize);if(token!==generation.current)return
setRecords(result.schemes);setTotal(result.total)
if(page>result.totalPages)setPage(result.totalPages)
}catch(error){if(token===generation.current)setError(error instanceof Error?error.message:'读取失败')}
finally{if(token===generation.current)setBusy(false)}
}
useEffect(()=>{if(open)void load();return()=>{generation.current++}},[open,page,pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
},[page,pageSize])
useEffect(()=>{if(open)void load();return invalidate},[open,load,invalidate])
useEffect(()=>setPageInput(String(page)),[page])
const jump=()=>{const target=Math.min(pages,Math.max(1,Number(pageInput)||1));setPage(target);setPageInput(String(target))}
useEffect(()=>{(selection?cancelRef.current:listRef.current)?.focus()},[selection])
const jump=()=>{if(disabled)return;const target=Math.min(pages,Math.max(1,Number(pageInput)||1));setPage(target);setPageInput(String(target))}
const select=async(record:DiySchemeDTO)=>{
if(disabled||deleteLock.current)return
setBusy(true);setError('')
try{controller.load(await readDiyScheme(record.code));onClose()}
catch(error){setError(error instanceof Error?error.message:'读取失败')}
finally{setBusy(false)}
}
return <Modal isOpen={open} onClose={onClose} title="我的 DIY 方案" size="large">
const close=()=>{
if(deleteLock.current)return
setError('');setNotice('')
if(selection)setSelection(null);else onClose()
}
const requestDelete=(record:DiySchemeDTO)=>{
if(!canDelete||disabled||deleteLock.current)return
setError('');setNotice('');setSelection(record)
}
const confirmDelete=async()=>{
if(!canDelete||!selection||deleteLock.current)return
deleteLock.current=true;setDeleting(true);setError('');setNotice('')
try{
const result=await deleteDiyScheme(selection.code)
controller.onSchemeDeleted(result.code)
setRecords(previous=>previous.filter(record=>record.code!==result.code))
setTotal(previous=>Math.max(0,previous-1));setSelection(null);setNotice('方案已永久删除')
await load()
}catch(error){setError(error instanceof Error?error.message:'删除失败,请稍后重试')}
finally{deleteLock.current=false;setDeleting(false)}
}
return <Modal isOpen={open} onClose={close} title={selection?'删除 DIY 方案':'我的 DIY 方案'} size={selection?'small':'large'}
showCloseButton={!deleting} closeOnOverlayClick={!deleting}
footer={selection&&<div className="flex justify-end gap-2">
<Button ref={cancelRef} type="button" size="small" variant="gray" disabled={deleting} onClick={close}>取消</Button>
<Button type="button" size="small" tone="danger" loading={deleting} onClick={()=>void confirmDelete()}>永久删除</Button>
</div>}>
{error&&<p role="alert" className="mb-3 text-sm text-red-600">{error}</p>}
<div className="relative">
{selection?<>
<p className="break-words text-sm text-zinc-800">确定永久删除方案“{selection.name}”?</p>
<p className="mt-2 text-xs leading-5 text-zinc-500">删除后无法恢复。其他方案版本和 AI 出图保留。</p>
{controller.scheme?.code===selection.code&&<p className="mt-2 text-xs leading-5 text-zinc-500">当前画布将保留,转为未保存方案,可继续编辑并重新保存。</p>}
</>:<>
{notice&&<p role="status" className="mb-3 text-sm text-emerald-700">{notice}</p>}
<div ref={listRef} tabIndex={-1} className="relative outline-none" aria-label="我的方案列表" aria-busy={disabled}>
<div className="max-h-[55vh] space-y-2 overflow-y-auto pb-24" data-testid="diy-history-list">
{records.map(record=><button key={record.id} type="button" disabled={busy} onClick={()=>void select(record)}
className="w-full rounded-lg border border-zinc-200 p-3 text-left hover:bg-zinc-50">
<div className="flex items-center justify-between"><span className="font-medium text-zinc-800">{record.name}</span>
{records.map(record=><div key={record.id} className="flex items-center gap-2 rounded-lg border border-zinc-200">
<button type="button" disabled={disabled} onClick={()=>void select(record)}
className="min-w-0 flex-1 rounded-lg p-3 text-left hover:bg-zinc-50 disabled:cursor-wait">
<div className="flex flex-wrap items-center justify-between gap-1"><span className="break-words font-medium text-zinc-800">{record.name}</span>
<span className="text-xs text-zinc-500">{record.snapshot.items.length} 件柜体 · {Object.keys(record.snapshot.doors).length} 扇柜门</span></div>
<p className="mt-1 break-all text-xs text-zinc-500">{record.code}</p>
<p className="mt-1 text-xs text-zinc-400">{new Date(record.createdAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}</p>
</button>)}
{!busy&&!error&&!records.length&&<p className="py-8 text-center text-sm text-zinc-500">还没有保存的方案</p>}
</button>
{canDelete&&<Button type="button" size="small" variant="plain" disabled={disabled} onClick={()=>requestDelete(record)}
aria-label={`删除方案${record.name}`} className="mr-2 shrink-0 text-red-600 hover:bg-red-50">删除</Button>}
</div>)}
{!disabled&&!error&&!records.length&&<p className="py-8 text-center text-sm text-zinc-500">还没有保存的方案</p>}
</div>
<div className="absolute bottom-2 left-1/2 flex w-max max-w-full -translate-x-1/2 flex-wrap items-center justify-center gap-2 rounded-2xl border border-zinc-200 bg-white/95 px-3 py-2 text-xs text-zinc-600 shadow-md backdrop-blur-xl sm:rounded-full">
<span>{busy?'正在读取…':`共 ${total} 条`}</span>
<Button size="small" variant="gray" disabled={busy||page<=1} onClick={()=>setPage(page-1)}>上一页</Button>
<span className="flex items-center gap-1"><input aria-label="跳转页码" inputMode="numeric" value={pageInput} disabled={busy}
<span>{disabled?'正在读取…':`共 ${total} 条`}</span>
<Button size="small" variant="gray" disabled={disabled||page<=1} onClick={()=>setPage(page-1)}>上一页</Button>
<span className="flex items-center gap-1"><input aria-label="跳转页码" inputMode="numeric" value={pageInput} disabled={disabled}
onChange={event=>setPageInput(event.target.value.replace(/\D/g,''))} onBlur={jump}
onKeyDown={event=>{if(event.key==='Enter')jump()}} className="h-7 w-9 rounded border border-zinc-200 text-center"/> / {pages} 页</span>
<Button size="small" variant="gray" disabled={busy||page>=pages} onClick={()=>setPage(page+1)}>下一页</Button>
<label className="flex items-center gap-1">每页<select aria-label="每页条数" value={pageSize} disabled={busy}
<Button size="small" variant="gray" disabled={disabled||page>=pages} onClick={()=>setPage(page+1)}>下一页</Button>
<label className="flex items-center gap-1">每页<select aria-label="每页条数" value={pageSize} disabled={disabled}
onChange={event=>{setPageSize(Number(event.target.value));setPage(1)}} className="h-7 rounded border border-zinc-200 bg-white">
{[10,20,50].map(size=><option value={size} key={size}>{size}</option>)}
</select>条</label>
</div></div>
</div></div></>}
</Modal>
}

View File

@ -1,12 +1,14 @@
'use client'
import { RotateCw, Trash2, Layers } from 'lucide-react'
import { RotateCw, Trash2, Layers, ScanLine } from 'lucide-react'
import Button from '@/components/ui/Button'
import type { DiyWorkbenchController } from '../hooks/useDiyWorkbench'
import { doorPatternFits } from '@/lib/diy/catalog'
import { doorPatternFits, getDiyDoorDimensions } from '@/lib/diy/catalog'
import type { CabinetCell } from '@/lib/diy/types'
import DiyColorOptions from './DiyColorOptions'
const actionClassName = 'min-w-0 px-1 py-0 text-[11px] focus:px-1 focus:py-0 lg:h-8 lg:min-h-8 [&>span]:gap-1.5'
export default function DiyInspector({ controller: c, disabled, onEditCells, onAddPattern,onAddColor }: {
controller: DiyWorkbenchController; disabled: boolean; onEditCells:(code:string)=>void; onAddPattern:(door:CabinetCell['door'])=>void
onAddColor:(styleCode:string,materialCode:string)=>void
@ -14,37 +16,44 @@ export default function DiyInspector({ controller: c, disabled, onEditCells, onA
const { patterns, getCatalogStyle,getStyle } = c.catalogAccess
const style=c.selected?getStyle(c.selected.code):null
const visiblePatterns=patterns.filter(pattern=>!pattern.deletedAt&&(!style||style.cells.some(cell=>doorPatternFits(pattern,cell))))
const doorSizes=[...new Map(style?.cells.map(cell=>[`${cell.door.width} × ${cell.door.height} × ${cell.door.thickness}`,cell.door])).values()]
const selectedPattern=visiblePatterns.find(pattern=>pattern.itemCode===c.patternCode)
const doorSizes=[...new Map(style?.cells.map(cell=>{
const door=selectedPattern&&doorPatternFits(selectedPattern,cell)?getDiyDoorDimensions(selectedPattern,cell):cell.door
return [`${door.width} × ${door.height} × ${door.thickness}`,door] as const
})).values()]
return <div className="flex h-full min-h-0 flex-col bg-white">
<section className="shrink-0 border-b border-zinc-200 p-4">
<h2 className="text-sm font-semibold text-zinc-800">当前柜体</h2>
<section className="shrink-0 border-b border-zinc-200 p-3">
<div className="flex min-w-0 items-center gap-2">
<h2 className="shrink-0 text-xs font-semibold text-zinc-800">当前柜体</h2>
{c.selected&&<p className="min-w-0 truncate text-[11px] text-zinc-500" title={`${getCatalogStyle(c.selected.code).name} · ${c.selected.code}`}>{getCatalogStyle(c.selected.code).name} · {c.selected.code}</p>}
</div>
{c.selected ? <>
<p className="mt-2 text-sm text-zinc-600">{getCatalogStyle(c.selected.code).name} · {c.selected.code}</p>
<div className="mt-3 flex flex-wrap gap-2">
{style?.modelUrl&&<Button size="small" variant="gray" disabled={disabled} onClick={()=>onEditCells(c.selected!.code)}>编辑格口</Button>}
<Button size="small" variant="gray" disabled={disabled} onClick={() => c.rotate(c.selectedId)}><RotateCw size={14} />横竖切换</Button>
<Button size="small" variant="gray" disabled={disabled} onClick={c.stackSelected}><Layers size={14} />叠放</Button>
<Button size="small" variant="plain" disabled={disabled} onClick={() => c.remove(c.selectedId)}><Trash2 size={14} />删除</Button>
<div className={`mt-2 grid items-center gap-1 ${style?.modelUrl?'grid-cols-[1.4fr_1fr_1fr_32px]':'grid-cols-[1.4fr_1fr_1fr]'}`}>
<Button size="small" variant="plain" className={actionClassName} disabled={disabled} onClick={() => c.rotate(c.selectedId)}><RotateCw size={13} />横竖切换</Button>
<Button size="small" variant="plain" className={actionClassName} disabled={disabled} onClick={c.stackSelected}><Layers size={13} />叠放</Button>
<Button size="small" variant="plain" className={actionClassName} disabled={disabled} onClick={() => c.remove(c.selectedId)}><Trash2 size={13} />删除</Button>
{style?.modelUrl&&<Button size="small" variant="plain" className={actionClassName} title="编辑格口" ariaLabel="编辑格口" disabled={disabled} onClick={()=>onEditCells(c.selected!.code)}><ScanLine size={13} /></Button>}
</div>
<DiyColorOptions controller={c} disabled={disabled} onAddColor={onAddColor}/>
</> : <p className="mt-2 text-xs leading-5 text-zinc-500">点击画面中的柜体,查看颜色与摆放操作。</p>}
</section>
<section className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 px-4 pb-2 pt-4"><h2 className="text-sm font-semibold text-zinc-800">柜门图案</h2>
<p className="mt-1 text-xs text-zinc-500">{style&&!style.cells.length?'当前柜体尚未配置格口,请点击“编辑格口”自动识别。':'选择图案后,切换贴门并点击格口。'}</p>
{doorSizes.length>0&&<div className="mt-2 max-h-28 space-y-2 overflow-y-auto">{doorSizes.map(door=><div key={`${door.width}-${door.height}-${door.thickness}`} className="flex flex-wrap items-center gap-1 text-xs text-zinc-500">
<div className="shrink-0 px-3 pb-1 pt-3"><h2 className="text-xs font-semibold text-zinc-800">柜门图案</h2>
<p className="mt-1 text-[11px] text-zinc-500">{style&&!style.cells.length?'点击“编辑格口”自动识别后贴门。':'选择图案,再点击画面中的格口。'}</p>
{doorSizes.length>0&&<div className="mt-1 max-h-20 space-y-1 overflow-y-auto">{doorSizes.map(door=><div key={`${door.width}-${door.height}-${door.thickness}`} className="flex items-center gap-1 text-[10px] text-zinc-500">
<span className="mr-auto">门片 {door.width} × {door.height} × {door.thickness} mm</span>
<button type="button" disabled={disabled} className="text-blue-600 hover:underline disabled:opacity-50" onClick={()=>onAddPattern(door)}>新增适配柜门</button>
<button type="button" disabled={disabled} title="新增适配柜门" aria-label={`新增适配柜门 ${door.width} × ${door.height} × ${door.thickness} mm`} className="shrink-0 py-1 text-blue-600 hover:underline disabled:opacity-50" onClick={()=>onAddPattern(door)}>新增图案</button>
</div>)}</div>}
</div>
<div className="grid min-h-0 grid-cols-4 gap-2 overflow-y-auto p-3">
{visiblePatterns.map(pattern => <button type="button" key={pattern.itemCode} disabled={disabled} title={pattern.name}
aria-label={`柜门图案 ${pattern.name}`} aria-pressed={c.patternCode === pattern.itemCode}
<div className="grid min-h-0 flex-1 grid-cols-4 content-start gap-2 overflow-y-auto p-3 pt-2 [scrollbar-gutter:stable]">
{visiblePatterns.map(pattern => <button type="button" key={pattern.itemCode} disabled={disabled} title={`${pattern.name} · ${pattern.itemCode}`}
aria-label={`柜门图案 ${pattern.name},货号 ${pattern.itemCode}`} aria-pressed={c.patternCode === pattern.itemCode}
onClick={() => { c.setPatternCode(pattern.itemCode); c.setMode('doors') }}
className={`rounded-md border p-1 ${c.patternCode === pattern.itemCode ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-200' : 'border-zinc-200'}`}>
className={`min-w-0 rounded-md border p-1 ${c.patternCode === pattern.itemCode ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-200' : 'border-zinc-200'}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={pattern.image} alt={pattern.name} className="aspect-square w-full rounded object-cover" />
<span className="mt-1 block truncate text-[10px] text-zinc-500">{pattern.itemCode.slice(-3)}</span>
<span className="mt-1 line-clamp-2 break-words text-[10px] leading-4 text-zinc-700">{pattern.name}</span>
<span className="block truncate text-[10px] text-zinc-400">{pattern.itemCode.slice(-3)}</span>
</button>)}
{!visiblePatterns.length&&Boolean(style?.cells.length)&&<p className="col-span-4 py-3 text-xs text-zinc-500">暂无与当前格口尺寸匹配的柜门。</p>}
</div>

View File

@ -1,48 +1,54 @@
'use client'
import { Save } from 'lucide-react'
import { FilePlus2, FolderOpen, Move, PanelTop, Save } from 'lucide-react'
import Button from '@/components/ui/Button'
interface DiyToolbarProps {
name: string
dirty: boolean
hasItems: boolean
itemCount: number
cellCount: number
doorCount: number
mode: 'layout' | 'doors'
canConfigure: boolean
canDownload: boolean
saving: boolean
capturing: boolean
onHistory: () => void
onBom: () => void
onSave: () => void
onNew: () => void
onCapture: () => void
onManage: () => void
onModeChange: (mode: 'layout' | 'doors') => void
}
const actionStyle = 'h-11 min-h-11 min-w-0 whitespace-nowrap rounded-[6px] px-2 text-[11px] focus:px-[7px] sm:h-9 sm:min-h-9 sm:px-3 sm:text-xs sm:focus:px-[11px]'
const capsuleSurface = 'rounded-full bg-white/80 shadow-sm ring-1 ring-black/5 backdrop-blur-xl'
const capsuleStyle = `${capsuleSurface} h-[52px] items-center p-1 sm:h-10`
const actionStyle = 'h-11 min-h-11 min-w-0 shrink-0 whitespace-nowrap rounded-full px-2 py-0 text-[11px] focus:px-[7px] focus:py-0 sm:h-8 sm:min-h-8 [&>span]:gap-1.5'
export default function DiyToolbar(props: DiyToolbarProps) {
const busy = props.saving || props.capturing
return <header className="flex shrink-0 flex-wrap items-center gap-x-6 gap-y-2 border-b border-black/[0.06] bg-white px-4 py-2.5 lg:px-5">
<div className="flex w-full min-w-0 flex-1 basis-full items-center gap-3 sm:w-auto sm:basis-auto">
<h1 className="shrink-0 text-base font-semibold tracking-tight text-[#2d3335]">DIY</h1>
<span className="h-4 w-px shrink-0 bg-black/[0.08]" aria-hidden="true"/>
<span className="max-w-64 truncate text-xs text-[#5a6062]" title={props.name}>{props.name}</span>
{props.dirty && <span className="shrink-0 text-[10px] text-[#9b7942]">未保存</span>}
</div>
<div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-x-4 gap-y-1 sm:gap-x-5">
{props.canConfigure && <Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy} onClick={props.onManage}>素材管理</Button>}
<div className="flex items-center gap-1" role="group" aria-label="方案操作">
<Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy || !props.canConfigure} onClick={props.onNew}>新建方案</Button>
<Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy} onClick={props.onHistory}>我的方案</Button>
<Button type="button" variant="plain" size="small" className={`${actionStyle} ml-1`} loading={props.saving}
disabled={busy || !props.canConfigure || !props.hasItems} onClick={props.onSave}><Save size={14}/>保存方案</Button>
return <div className="pointer-events-none absolute inset-x-4 top-4 z-20 space-y-1" aria-label="DIY 方案操作">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<div className={`${capsuleStyle} pointer-events-auto flex shrink-0 gap-1`} role="group" aria-label="画布操作模式">
{(['layout','doors'] as const).map(mode=><button key={mode} type="button"
aria-pressed={props.mode===mode} disabled={busy||!props.canConfigure} onClick={()=>props.onModeChange(mode)}
className={`flex h-11 items-center gap-1.5 rounded-full px-3 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-500 disabled:opacity-40 sm:h-8 ${props.mode===mode?'bg-zinc-800 text-white':'text-zinc-500'}`}>
{mode==='layout'?<Move size={14}/>:<PanelTop size={14}/>}{mode==='layout'?'布局':'贴门'}
</button>)}
</div>
<div className="flex items-center gap-1" role="group" aria-label="方案输出">
<Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy || !props.hasItems || !props.canDownload} onClick={props.onCapture}>截图</Button>
<Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy || !props.hasItems} onClick={props.onBom}>下单明细</Button>
<div className={`${capsuleStyle} hidden min-w-0 max-w-60 gap-2 px-4 @min-[580px]/diy:flex`}>
<span className="truncate text-[11px] text-[#5a6062]/70" title={props.name}>{props.name}</span>
{props.dirty&&<span className="shrink-0 text-[10px] text-[#9b7942]">未保存</span>}
</div>
<div className={`${capsuleStyle} pointer-events-auto ml-auto flex shrink-0 gap-0.5`} role="group" aria-label="方案操作">
<Button type="button" variant="plain" size="small" className={actionStyle} disabled={busy} onClick={props.onHistory}><FolderOpen size={13}/>我的方案</Button>
<Button type="button" variant="plain" size="small" ariaLabel="新建方案" title="新建方案" className={actionStyle} disabled={busy || !props.canConfigure} onClick={props.onNew}><FilePlus2 size={13}/>新建</Button>
{props.dirty&&<span className="shrink-0 px-1 text-[10px] text-[#9b7942] @min-[580px]/diy:hidden">未保存</span>}
<Button type="button" variant="plain" size="small" ariaLabel="保存方案" title={props.dirty?'保存方案(有未保存更改)':'保存方案'} className={actionStyle} loading={props.saving}
disabled={busy || !props.canConfigure || !props.itemCount} onClick={props.onSave}><Save size={13}/>保存</Button>
</div>
</div>
</header>
<p className={`${capsuleSurface} ml-auto w-fit px-3 py-1.5 text-[10px] leading-4 text-[#5a6062]/70`} aria-label="方案数量统计">
{props.itemCount} 柜体 · {props.cellCount} 格口 · {props.doorCount} 柜门
</p>
</div>
}

View File

@ -68,7 +68,7 @@ export default function DiyViewport({ controller, disabled, captureRef, onCaptur
return <ViewportStageShell title="DIY" showSceneGrid={grid} onToggleSceneGrid={()=>setGrid(value=>!value)} enableTranslateGizmo={false}
onPresetChange={preset=>controller.setView(preset==='isometric'?.55:0,preset==='top'?Math.PI/2-.03:preset==='isometric'?.55:0)}
extraSidebarActions={<><button type="button" title="侧视" aria-label="侧视" className="h-8 w-8 rounded text-xs text-zinc-600 hover:bg-zinc-100" onClick={()=>controller.setView(Math.PI/2,0)}>侧</button>
<button type="button" title="恢复默认视角" aria-label="恢复默认视角" className="h-8 w-8 rounded text-xs text-zinc-600 hover:bg-zinc-100" onClick={()=>controller.setView(.35,.23)}>复位</button></>}>
<button type="button" title="恢复默认视角" aria-label="恢复默认视角" className="h-8 w-8 rounded text-xs text-zinc-600 hover:bg-zinc-100" onClick={()=>controller.setView(.35,.23)}>复</button></>}>
<Canvas camera={{position:[3,4,12],fov:35,near:.1,far:10000}} gl={{antialias:true,preserveDrawingBuffer:true}}
aria-label="DIY 3D预览" onCreated={({gl})=>{gl.setClearColor('#f2f4f4')}}>
<Scene controller={controller} disabled={disabled} grid={grid} capture={capture} captureRef={captureRef} onCapture={setCapture} onCapturing={onCapturing}/>

View File

@ -0,0 +1,26 @@
'use client'
import { useState } from 'react'
import Button from '@/components/ui/Button'
import { DEFAULT_IMAGE_ADJUSTMENTS, IMAGE_ADJUST_SECTIONS, type ImageAdjustPanelTab } from '@/lib/diy/image-tools/adjustments'
import type { DiyImageEditorController } from '../../hooks/useDiyImageEditor'
const tabs: { key: ImageAdjustPanelTab; label: string }[] = [
{ key: 'light', label: '光线' }, { key: 'color', label: '颜色' }, { key: 'detail', label: '细节' }, { key: 'effects', label: '效果' },
]
export default function DiyImageAdjustControls({ editor }: { editor: DiyImageEditorController }) {
const [tab, setTab] = useState<ImageAdjustPanelTab>('light')
return <div className="space-y-5">
<div className="flex gap-1 rounded-full bg-zinc-100 p-1" role="tablist" aria-label="调色分类">
{tabs.map(item => <button key={item.key} type="button" role="tab" aria-selected={tab === item.key}
onClick={() => setTab(item.key)} className={`flex-1 rounded-full py-1.5 text-xs ${tab === item.key ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500'}`}>{item.label}</button>)}
</div>
{IMAGE_ADJUST_SECTIONS[tab].map(control => <label key={control.key} className="block space-y-2 text-xs text-zinc-600">
<span className="flex justify-between"><span>{control.label}</span><span className="tabular-nums">{editor.adjustments[control.key]}</span></span>
<input type="range" min={control.min} max={control.max} value={editor.adjustments[control.key]} className="w-full accent-[#47646b]"
onChange={event => editor.setAdjustments(previous => ({ ...previous, [control.key]: Number(event.target.value) }))}/>
</label>)}
<Button variant="plain" size="small" onClick={() => editor.setAdjustments({ ...DEFAULT_IMAGE_ADJUSTMENTS })}>重置调色</Button>
<p className="text-xs leading-5 text-zinc-400">调色仅用于实时预览,尺寸导出使用原图。</p>
</div>
}

View File

@ -0,0 +1,37 @@
'use client'
import { useRef } from 'react'
import { buildImageAdjustmentFilterStyle, isDefaultImageAdjustments } from '@/lib/diy/image-tools/adjustments'
import { ImageNodeAdjustOverlays } from '@/lib/diy/image-tools/AdjustmentOverlays'
import { resizeGeometry } from '@/lib/diy/image-tools/resize'
import type { DiyImageEditorController } from '../../hooks/useDiyImageEditor'
export default function DiyImageCanvasPreview({ editor, draggable }: { editor: DiyImageEditorController; draggable: boolean }) {
const drag = useRef<{ x: number; y: number; offsetX: number; offsetY: number; factor: number } | null>(null)
const { image, options, adjustments, validation } = editor
const previewClassName = 'flex min-h-0 min-w-0 items-center justify-center overflow-hidden rounded-md bg-zinc-100 p-3 sm:p-5'
if (!image || validation) return <div aria-label="图片预览区域" className={`${previewClassName} text-sm ${validation ? 'text-red-600' : 'text-zinc-400'}`}>{validation || editor.error || '正在加载原图…'}</div>
const rect = resizeGeometry(image.naturalWidth, image.naturalHeight, options)
return <div aria-label="图片预览区域" className={previewClassName}>
<div className="flex h-full w-full items-center justify-center [container-type:size]">
<div className={`relative isolate overflow-hidden shadow-sm ${draggable ? 'cursor-move' : ''}`} style={{
width: `min(100cqw, ${100 * options.width / options.height}cqh)`, aspectRatio: `${options.width}/${options.height}`, touchAction: draggable ? 'none' : 'auto',
backgroundColor: options.background || '#fff', backgroundImage: options.background ? undefined : 'conic-gradient(#e4e4e7 25%, white 0 50%, #e4e4e7 0 75%, white 0)', backgroundSize: '16px 16px',
}} onPointerDown={event => {
if (!draggable || event.button !== 0) return
event.currentTarget.setPointerCapture(event.pointerId)
drag.current = { x: event.clientX, y: event.clientY, offsetX: options.offsetX || 0, offsetY: options.offsetY || 0, factor: options.width / event.currentTarget.getBoundingClientRect().width }
}} onPointerMove={event => {
const start = drag.current
if (!start) return
editor.setOptions(previous => ({ ...previous, offsetX: Math.round(start.offsetX + (event.clientX - start.x) * start.factor), offsetY: Math.round(start.offsetY + (event.clientY - start.y) * start.factor) }))
}} onPointerUp={() => { drag.current = null }} onPointerCancel={() => { drag.current = null }}>
<div className="absolute isolate" style={{ left: `${rect.x / options.width * 100}%`, top: `${rect.y / options.height * 100}%`, width: `${rect.width / options.width * 100}%`, height: `${rect.height / options.height * 100}%` }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.src} alt="编辑图片预览" draggable={false} className="h-full w-full select-none" style={buildImageAdjustmentFilterStyle(adjustments)}/>
<ImageNodeAdjustOverlays adjustments={adjustments} hasActiveAdjustments={!isDefaultImageAdjustments(adjustments)} style={{ inset: 0 }}/>
</div>
</div>
</div>
</div>
}

View File

@ -0,0 +1,44 @@
'use client'
import { useState } from 'react'
import { Download, Wand2 } from 'lucide-react'
import Button from '@/components/ui/Button'
import type { DiyImageSelection } from '@/lib/diy/image-tools'
import { useDiyImageEditor } from '../../hooks/useDiyImageEditor'
import DiyImageAdjustControls from './DiyImageAdjustControls'
import DiyImageResizeControls from './DiyImageResizeControls'
import DiyTextReplaceControls from './DiyTextReplaceControls'
import { useDiyTextReplace } from '../../hooks/useDiyTextReplace'
import DiyImageCanvasPreview from './DiyImageCanvasPreview'
import DiyImageToolModal from './DiyImageToolModal'
export default function DiyImageEditor({ source, canDownload, canGenerate, onGenerated, onClose }: {
source: DiyImageSelection; canDownload: boolean; canGenerate: boolean; onGenerated: () => Promise<void>; onClose: () => void
}) {
const [tab, setTab] = useState<'adjust' | 'resize' | 'text'>('adjust')
const editor = useDiyImageEditor(source)
const text = useDiyTextReplace(source, onGenerated)
return <DiyImageToolModal onClose={() => { if (!editor.exporting) onClose() }} title="编辑图片"
footer={<div className="flex items-center justify-between gap-3"><span className="text-xs text-zinc-400">{tab === 'text' ? '原图保留 · 新图存入出图记录' : '原图保留 · 导出为新 PNG'}</span>
{tab === 'text' ? <Button size="small" loading={text.submitting} disabled={!canGenerate || text.detecting || !text.changes.length} onClick={() => void text.submit()}><Wand2 size={14}/>生成新图</Button>
: <Button size="small" loading={editor.exporting} disabled={!canDownload || !editor.image || Boolean(editor.validation)} onClick={() => void editor.download()}><Download size={14}/>导出 PNG</Button>}</div>}>
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,2fr)_minmax(0,3fr)] gap-5 md:grid-cols-[minmax(0,1fr)_280px] md:grid-rows-1">
{tab === 'text' ? <div aria-label="图片预览区域" className="flex min-h-0 min-w-0 items-center justify-center overflow-hidden rounded-md bg-zinc-100 p-3 sm:p-5">
{/* eslint-disable-next-line @next/next/no-img-element */}
{editor.image ? <img src={editor.image.src} alt="文字替换原图" className="h-full w-full object-contain"/> : <p className="text-xs text-zinc-400">正在加载原图…</p>}
</div> : <DiyImageCanvasPreview editor={editor} draggable={tab === 'resize'}/>}
<div className="flex min-h-0 min-w-0 flex-col gap-5">
<div className="flex shrink-0 gap-1 border-b border-zinc-100 pb-3 [&>button]:min-w-0! [&>button]:flex-1 [&>button]:whitespace-nowrap [&>button]:px-2! [&>button]:text-xs!" role="tablist" aria-label="图片工具">
<Button variant={tab === 'adjust' ? 'tinted' : 'plain'} size="small" role="tab" aria-selected={tab === 'adjust'} onClick={() => setTab('adjust')}>调色预览</Button>
<Button variant={tab === 'resize' ? 'tinted' : 'plain'} size="small" role="tab" aria-selected={tab === 'resize'} onClick={() => setTab('resize')}>尺寸调整</Button>
<Button variant={tab === 'text' ? 'tinted' : 'plain'} size="small" role="tab" aria-selected={tab === 'text'} onClick={() => setTab('text')}>文字替换</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable]" aria-label="图片工具设置">
{tab === 'adjust' ? <DiyImageAdjustControls editor={editor}/> : tab === 'resize' ? <DiyImageResizeControls editor={editor}/> : <DiyTextReplaceControls editor={text} canGenerate={canGenerate}/>}
{editor.error && <p role="alert" className="mt-3 text-xs text-red-600">{editor.error}</p>}
{editor.notice && <p role="status" className="mt-3 text-xs text-[#47646b]">{editor.notice}</p>}
</div>
</div>
</div>
</DiyImageToolModal>
}

View File

@ -0,0 +1,33 @@
'use client'
import Button from '@/components/ui/Button'
import Input from '@/components/ui/Input'
import type { DiyImageEditorController } from '../../hooks/useDiyImageEditor'
export default function DiyImageResizeControls({ editor }: { editor: DiyImageEditorController }) {
const { options, setOptions } = editor
return <div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{(['width', 'height'] as const).map((key, index) => <Input key={key} label={index ? '高度(px)' : '宽度(px)'} type="number" min={1} max={8192} step={1}
value={options[key]} onChange={event => setOptions(previous => ({ ...previous, [key]: Number(event.target.value) }))}/>)}
</div>
<div className="flex flex-wrap gap-1">
{[800, 1024, 1600, 2048].map(size => <Button key={size} size="small" variant="plain" onClick={() => setOptions(previous => ({ ...previous, width: size, height: size }))}>{size}²</Button>)}
</div>
<Input label="等比缩放(%)" type="number" min={1} max={1600} step={1} value={Math.round((options.scale ?? 1) * 10000) / 100}
onChange={event => setOptions(previous => ({ ...previous, scale: Number(event.target.value) / 100 }))}/>
<div className="grid grid-cols-2 gap-3">
{(['offsetX', 'offsetY'] as const).map((key, index) => <Input key={key} label={index ? '垂直位移(px)' : '水平位移(px)'} type="number" step={1} value={options[key] || 0}
onChange={event => setOptions(previous => ({ ...previous, [key]: Number(event.target.value) }))}/>)}
</div>
<div className="flex items-center gap-3 text-xs">
<label className="flex items-center gap-2"><input type="checkbox" checked={options.background === null} onChange={event => setOptions(previous => ({ ...previous, background: event.target.checked ? null : '#ffffff' }))}/>透明画布</label>
{options.background !== null && <input type="color" aria-label="画布颜色" className="h-8 w-10 cursor-pointer" value={options.background || '#ffffff'} onChange={event => setOptions(previous => ({ ...previous, background: event.target.value }))}/>}
</div>
<div className="flex gap-2">
<Button size="small" variant="tinted" onClick={editor.fit}>适应画布</Button>
<Button size="small" variant="plain" onClick={() => setOptions(previous => ({ ...previous, offsetX: 0, offsetY: 0 }))}>居中</Button>
</div>
<p className="text-xs leading-5 text-zinc-400">可拖动图片调整位置。透明画布保留原图背景,不自动去除白底。单边最大 8192 像素,总像素不超过 3200 万。</p>
</div>
}

View File

@ -0,0 +1,15 @@
'use client'
import type { ReactNode } from 'react'
import Modal from '@/components/ui/Modal'
export default function DiyImageToolModal({ title, onClose, footer, children }: {
title: string; onClose: () => void; footer: ReactNode; children: ReactNode
}) {
return <Modal isOpen title={title} onClose={onClose} size="xlarge" variant="compact"
className="h-[min(800px,90dvh)] [&>div:first-child]:shrink-0 [&>div:last-child]:shrink-0"
bodyClassName="flex min-h-0 flex-col overflow-hidden! pt-4!"
footer={<div className="flex min-h-11 items-center [&>div]:w-full">{footer}</div>}>
{children}
</Modal>
}

View File

@ -0,0 +1,62 @@
'use client'
import { useState } from 'react'
import { Wand2 } from 'lucide-react'
import Button from '@/components/ui/Button'
import type { DiyAiRenderDTO } from '@/lib/diy/ai-render'
import type { DiyImageSelection, DiyLayoutTemplate } from '@/lib/diy/image-tools'
import type { DiyLayoutBatchController } from '../../hooks/useDiyLayoutBatch'
import DiyLayoutTemplateUpload from './DiyLayoutTemplateUpload'
import DiyImageToolModal from './DiyImageToolModal'
export default function DiyLayoutReplaceDialog({ source, records, batch, onClose }: {
source: DiyImageSelection; records: DiyAiRenderDTO[]; batch: DiyLayoutBatchController; onClose: () => void
}) {
const [selected, setSelected] = useState([source.storageKey]), [template, setTemplate] = useState<DiyLayoutTemplate | null>(null)
const [prompt, setPrompt] = useState(''), [uploading, setUploading] = useState(false), [error, setError] = useState('')
const candidates = records.filter(record => record.mode === 'white-background' && !record.operation && record.status === 'SUCCEEDED')
.flatMap(record => record.images.map((image, index) => ({ recordId: record.id, storageKey: image.storageKey, url: image.thumbnailUrl, name: `${record.name}-${index + 1}` })))
const blocked = batch.running || batch.items.some(item => ['queued', 'submitting', 'generating', 'needs-review'].includes(item.status))
const start = () => {
if (!template || blocked || uploading || !selected.length) return
setError('')
try {
const sources = selected.map(key => candidates.find(item => item.storageKey === key))
if (sources.some(item => !item)) throw new Error('所选原图已更新,请重新选择')
batch.start(sources.map(item => ({ name: item!.name, input: { requestId: crypto.randomUUID(), sourceRecordId: item!.recordId,
sourceStorageKey: item!.storageKey, templateStorageKey: template.storageKey, supplementalPrompt: prompt.trim() } })))
} catch (err) { setError(err instanceof Error ? err.message : '提交失败') }
}
return <DiyImageToolModal title="排版替换" onClose={() => { if (!uploading) onClose() }}
footer={<div className="flex items-center justify-between gap-3"><span className="text-xs text-zinc-400">原图保留 · 结果存入出图记录</span>
<Button size="small" loading={batch.running} disabled={blocked || uploading || !template || !selected.length} onClick={start}><Wand2 size={14}/>生成 {selected.length} 张排版图</Button></div>}>
<div className="grid min-h-0 flex-1 grid-rows-2 gap-5 md:grid-cols-2 md:grid-rows-1">
<section className="flex min-h-0 min-w-0 flex-col gap-3">
<h3 className="shrink-0 text-sm font-semibold">商品白底图 <span className="font-normal text-zinc-400">{selected.length}/5</span></h3>
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-3 content-start gap-2 overflow-y-auto overscroll-contain p-1 [scrollbar-gutter:stable]" aria-label="排版原图列表">
{candidates.map(item => <label key={item.storageKey} className={`relative min-w-0 cursor-pointer rounded-md border p-1 ${selected.includes(item.storageKey) ? 'border-[#47646b] bg-[#47646b]/5' : 'border-zinc-100'}`}>
<input type="checkbox" aria-label={`选择 ${item.name}`} className="absolute left-2 top-2 accent-[#47646b]" checked={selected.includes(item.storageKey)}
disabled={blocked || (!selected.includes(item.storageKey) && selected.length >= 5)} onChange={event => setSelected(previous => event.target.checked ? [...previous, item.storageKey] : previous.filter(key => key !== item.storageKey))}/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt={item.name} className="aspect-square w-full object-contain"/>
<span className="block truncate px-1 py-1 text-[10px] text-zinc-500">{item.name}</span>
</label>)}
</div>
<label className="block shrink-0 space-y-2 text-xs text-zinc-600"><span>补充要求(可选)</span>
<textarea value={prompt} maxLength={600} disabled={blocked} onChange={event => setPrompt(event.target.value)} rows={3}
placeholder="填写需要保留或调整的排版细节" className="w-full resize-none rounded-md border border-zinc-200 p-3 text-sm outline-none focus:border-[#47646b]"/>
</label>
</section>
<section className="min-h-0 min-w-0 space-y-3 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable]" aria-label="排版设置"><h3 className="text-sm font-semibold">模板图片</h3>
<DiyLayoutTemplateUpload value={template} disabled={blocked} onChange={setTemplate} onBusyChange={setUploading}/>
<p className="text-xs leading-5 text-zinc-400">保留商品结构、颜色和视角,按模板的场景与构图生成。每批 1–5 张,逐张处理;失败不影响其他结果。</p>
{batch.notice && <p role="status" className="mt-4 text-xs text-[#47646b]">{batch.notice}</p>}
{batch.items.length > 0 && <ul className="mt-3 space-y-1 text-xs text-zinc-500">
{batch.items.map(item => <li key={item.input.requestId}>{item.name} · {{ queued: '等待处理', submitting: '正在提交', generating: '正在生成', succeeded: '已完成', failed: '失败', 'needs-review': '待核对' }[item.status]}
{item.error && <span className="ml-2 text-red-600">{item.error}</span>}</li>)}
</ul>}
{error && <p role="alert" className="mt-3 text-xs text-red-600">{error}</p>}
</section>
</div>
</DiyImageToolModal>
}

View File

@ -0,0 +1,66 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link2, UploadCloud } from 'lucide-react'
import Button from '@/components/ui/Button'
import { diyLayoutTemplateImportSchema } from '@/lib/diy/image-tools'
import type { DiyLayoutTemplate } from '@/lib/diy/image-tools'
import { importDiyLayoutTemplateFromUrl, uploadDiyLayoutTemplate } from '../../lib/image-tools-api'
export default function DiyLayoutTemplateUpload({ value, disabled, onChange, onBusyChange }: {
value: DiyLayoutTemplate | null; disabled: boolean; onChange: (value: DiyLayoutTemplate) => void; onBusyChange: (value: boolean) => void
}) {
const input = useRef<HTMLInputElement>(null), lock = useRef(false)
const [busy, setBusy] = useState<'upload' | 'link' | null>(null), [error, setError] = useState('')
const [link, setLink] = useState(''), [notice, setNotice] = useState('')
const load = useCallback(async (source: File | string) => {
if (lock.current || disabled) return
lock.current = true; setBusy(typeof source === 'string' ? 'link' : 'upload'); onBusyChange(true); setError(''); setNotice('')
try {
let template: DiyLayoutTemplate
if (typeof source === 'string') {
const parsed = diyLayoutTemplateImportSchema.safeParse({ url: source })
if (!parsed.success) throw new Error(parsed.error.issues[0]?.message || '图片链接无效')
template = await importDiyLayoutTemplateFromUrl(parsed.data)
} else {
if (!['image/png', 'image/jpeg', 'image/webp'].includes(source.type) || source.size > 20 * 1024 * 1024) throw new Error('请选择20MB以内的 PNG、JPG、WebP 模板')
template = await uploadDiyLayoutTemplate(source)
}
onChange(template)
setNotice(typeof source === 'string' ? '链接图片已导入,可用于排版' : '模板已上传,可用于排版')
} catch (err) { setError(err instanceof Error ? err.message : '模板导入失败') }
finally { lock.current = false; setBusy(null); onBusyChange(false) }
}, [disabled, onChange, onBusyChange])
useEffect(() => {
const paste = (event: ClipboardEvent) => {
const file = Array.from(event.clipboardData?.items || []).find(item => item.type.startsWith('image/'))?.getAsFile()
if (file && !disabled && !lock.current) { event.preventDefault(); void load(file) }
}
document.addEventListener('paste', paste)
return () => document.removeEventListener('paste', paste)
}, [disabled, load])
return <div className="space-y-2">
<label htmlFor="diy-layout-template-link" className="block text-xs text-zinc-500">图片链接</label>
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Link2 size={14} aria-hidden className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400"/>
<input id="diy-layout-template-link" type="url" value={link} maxLength={4096} disabled={disabled || Boolean(busy)}
onChange={event => { setLink(event.target.value); setError(''); setNotice('') }}
onKeyDown={event => { if (event.key === 'Enter' && !event.nativeEvent.isComposing) { event.preventDefault(); if (link.trim()) void load(link) } }}
placeholder="粘贴图片直链" className="h-10 w-full rounded-md border border-zinc-200 bg-white pl-8 pr-2.5 text-xs outline-none focus:border-[#47646b] disabled:opacity-50"/>
</div>
<Button type="button" size="small" variant="tinted" className="shrink-0 whitespace-nowrap px-3! text-xs!" loading={busy === 'link'}
disabled={disabled || Boolean(busy) || !link.trim()} onClick={() => void load(link)}>导入</Button>
</div>
<input ref={input} type="file" accept="image/png,image/jpeg,image/webp" aria-label="上传排版模板" className="sr-only" disabled={disabled || Boolean(busy)}
onChange={event => { const file = event.target.files?.[0]; event.target.value = ''; if (file) void load(file) }}/>
<button type="button" disabled={disabled || Boolean(busy)} onClick={() => input.current?.click()} className="flex min-h-44 w-full flex-col items-center justify-center gap-3 rounded-md border border-dashed border-zinc-300 bg-zinc-50 p-4 text-xs text-zinc-500 disabled:opacity-50">
{/* eslint-disable-next-line @next/next/no-img-element */}
{value ? <img src={value.url} alt="排版模板" className="max-h-64 w-full object-contain"/> : <UploadCloud size={24}/>}
<span role={busy ? 'status' : undefined}>{busy === 'link' ? '正在读取并保存链接图片…' : busy === 'upload' ? '正在上传模板…' : value ? '点击更换模板 · 支持 Ctrl+V' : '上传模板图片 · 支持 Ctrl+V 粘贴'}</span>
</button>
{value && <p className="text-xs text-zinc-400">输出尺寸:{value.width} × {value.height} px</p>}
{error && <p role="alert" className="text-xs text-red-600">{error}</p>}
{!busy && notice && <p role="status" className="text-xs text-[#47646b]">{notice}</p>}
</div>
}

View File

@ -0,0 +1,21 @@
'use client'
import { TEXT_EDIT_COLOR_SWATCHES, normalizeOptionalHexColor } from '@/lib/diy/text-replace/colors'
export default function DiyTextColorField({ value, disabled, onChange }: { value?: string; disabled: boolean; onChange: (value?: string) => void }) {
return <div className="space-y-2">
<div className="flex items-center justify-between text-[11px] text-zinc-500">
<label className="flex items-center gap-2">文字颜色
<input type="color" aria-label="自定义文字颜色" value={normalizeOptionalHexColor(value) || '#111827'} disabled={disabled}
onChange={event => onChange(event.target.value)} className="h-6 w-7 cursor-pointer border-0 bg-transparent p-0"/>
<span>{value?.toUpperCase() || '保留原色'}</span>
</label>
{value && <button type="button" disabled={disabled} onClick={() => onChange(undefined)} className="hover:text-zinc-800">还原</button>}
</div>
<div className="flex flex-wrap gap-1.5">
{TEXT_EDIT_COLOR_SWATCHES.map(color => <button key={color} type="button" aria-label={`文字颜色 ${color}`} aria-pressed={value === color}
title={color} disabled={disabled} onClick={() => onChange(color)} style={{ background: color }}
className={`h-5 w-5 rounded-full border border-black/10 ${value === color ? 'ring-2 ring-[#47646b] ring-offset-2' : ''}`}/>)}
</div>
</div>
}

View File

@ -0,0 +1,23 @@
'use client'
import { Plus, ScanText } from 'lucide-react'
import Button from '@/components/ui/Button'
import type { DiyTextReplaceController } from '../../hooks/useDiyTextReplace'
import DiyTextReplaceItem from './DiyTextReplaceItem'
export default function DiyTextReplaceControls({ editor, canGenerate }: { editor: DiyTextReplaceController; canGenerate: boolean }) {
const busy = editor.detecting || editor.submitting
return <div className="space-y-3">
<div className="flex items-center justify-between gap-1">
<Button size="small" variant="tinted" loading={editor.detecting} disabled={!canGenerate || busy || editor.detected || editor.items.length > 0} onClick={() => void editor.detect()}><ScanText size={14}/>识别文字</Button>
<Button size="small" variant="plain" disabled={!canGenerate || busy || editor.items.length >= 50} onClick={editor.add}><Plus size={14}/>手动添加</Button>
</div>
<p className="text-[11px] leading-5 text-zinc-400">替换文字或修改颜色;新文字留空即删除。生成后保留原图。</p>
<div className="space-y-3">
{editor.items.map((item, index) => <DiyTextReplaceItem key={item.id} item={item} index={index} deleted={editor.deleted.includes(item.id)} disabled={busy || !canGenerate}
onChange={patch => editor.update(item.id, patch)} onDelete={() => editor.toggleDelete(item.id)}/>)}
</div>
{editor.error && <p role="alert" className="text-xs leading-5 text-red-600">{editor.error}</p>}
{editor.notice && <p role="status" className="text-xs leading-5 text-[#47646b]">{editor.notice}</p>}
</div>
}

View File

@ -0,0 +1,25 @@
'use client'
import { Trash2, Undo2 } from 'lucide-react'
import type { TextItem } from '@/lib/diy/text-replace/core'
import DiyTextColorField from './DiyTextColorField'
const field = 'w-full resize-y rounded-md border border-zinc-200 bg-white px-2.5 py-2 text-xs outline-none focus:border-[#47646b] disabled:opacity-50'
export default function DiyTextReplaceItem({ item, index, deleted, disabled, onChange, onDelete }: {
item: TextItem; index: number; deleted: boolean; disabled: boolean; onChange: (patch: Partial<TextItem>) => void; onDelete: () => void
}) {
return <div className={`space-y-2.5 rounded-lg border p-3 ${deleted ? 'border-red-100 bg-red-50/40' : 'border-zinc-200/70 bg-white'}`}>
<div className="flex items-center justify-between text-xs text-zinc-500"><span>文字 {index + 1}{deleted ? ' · 将删除' : ''}</span>
<button type="button" disabled={disabled} onClick={onDelete} title={deleted ? '恢复文字' : '删除这段文字'} aria-label={deleted ? `恢复文字 ${index + 1}` : `删除文字 ${index + 1}`}
className={`rounded p-1 ${deleted ? 'text-[#47646b]' : 'hover:bg-red-50 hover:text-red-600'}`}>{deleted ? <Undo2 size={14}/> : <Trash2 size={14}/>}</button></div>
<label className="block space-y-1 text-[11px] text-zinc-400"><span>图片原文</span>
<textarea aria-label={`原文 ${index + 1}`} value={item.originalText} placeholder="输入图片中需要替换的原文" maxLength={1000} rows={2} disabled={disabled || deleted}
className={`${field} text-zinc-500 ${deleted ? 'line-through' : ''}`} onChange={event => onChange({ originalText: event.target.value })}/>
</label>
{!deleted && <>
<textarea aria-label={`替换文字 ${index + 1}`} placeholder="输入新文字,留空则删除" value={item.editedText} disabled={disabled} rows={2} maxLength={1000}
className={field} onChange={event => onChange({ editedText: event.target.value })}/>
<DiyTextColorField value={item.textColor} disabled={disabled} onChange={textColor => onChange({ textColor })}/>
</>}
</div>
}

View File

@ -22,7 +22,7 @@ export function useDiyAiDeletion({ canDelete, records, onDeleted, refresh, setEr
if (!canDelete || lock.current || record.status === 'PENDING') return
setError('')
setSelection({ recordId: record.id, input, label: input.target === 'record'
? `失败的出图记录“${record.name}”` : `这张${record.mode === 'scene' ? '效果图' : '白底图'}` })
? `失败的出图记录“${record.name}”` : `这张${record.operation === 'layout-replace' ? '排版图' : record.mode === 'scene' ? '效果图' : '白底图'}` })
}
const cancel = () => { if (!lock.current) setSelection(null) }
const confirm = async () => {

View File

@ -0,0 +1,63 @@
'use client'
import { useEffect, useState } from 'react'
import type { DiyImageSelection } from '@/lib/diy/image-tools'
import { DEFAULT_IMAGE_ADJUSTMENTS } from '@/lib/diy/image-tools/adjustments'
import { resizeGeometry, resizeImage, type ResizeOptions } from '@/lib/diy/image-tools/resize'
export function useDiyImageEditor(source: Pick<DiyImageSelection, 'recordId' | 'storageKey' | 'name'>) {
const { recordId, storageKey, name: filename } = source
const [image, setImage] = useState<HTMLImageElement | null>(null)
const [adjustments, setAdjustments] = useState({ ...DEFAULT_IMAGE_ADJUSTMENTS })
const [options, setOptions] = useState<ResizeOptions>({ width: 1024, height: 1024, scale: 1, offsetX: 0, offsetY: 0, background: null })
const [error, setError] = useState(''), [notice, setNotice] = useState(''), [exporting, setExporting] = useState(false)
useEffect(() => {
const controller = new AbortController()
let objectUrl = '', active = true
void (async () => {
try {
const query = new URLSearchParams({ recordId, storageKey })
const response = await fetch(`/api/diy/ai-renders/image?${query}`, {
signal: controller.signal, credentials: 'same-origin', cache: 'no-store', redirect: 'error',
})
if (!response.ok) {
const result = await response.json().catch(() => null)
throw new Error(result?.error?.message || '原图加载失败,请关闭后重试')
}
if (!response.headers.get('content-type')?.startsWith('image/')) throw new Error('原图格式无效,请关闭后重试')
objectUrl = URL.createObjectURL(await response.blob())
const loaded = new Image()
loaded.src = objectUrl
await loaded.decode()
if (!active) return
setImage(loaded)
setOptions({ width: loaded.naturalWidth, height: loaded.naturalHeight, scale: 1, offsetX: 0, offsetY: 0, background: null })
} catch (err) { if (active) setError(err instanceof Error ? err.message : '原图加载失败') }
})()
return () => { active = false; controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl) }
}, [recordId, storageKey])
let validation = ''
if (image) {
try { resizeGeometry(image.naturalWidth, image.naturalHeight, options) }
catch (err) { validation = err instanceof Error ? err.message : '尺寸无效' }
}
const fit = () => {
if (!image || options.width <= 0 || options.height <= 0) return
setOptions(previous => ({ ...previous, scale: Math.min(previous.width / image.naturalWidth, previous.height / image.naturalHeight), offsetX: 0, offsetY: 0 }))
}
const download = async () => {
if (!image || validation || exporting) return
setExporting(true); setError(''); setNotice('正在导出 PNG…')
try {
const blob = await resizeImage(image, options)
const href = URL.createObjectURL(blob), anchor = document.createElement('a')
anchor.href = href; anchor.download = `${filename}-${options.width}x${options.height}.png`
document.body.appendChild(anchor); anchor.click(); anchor.remove()
setTimeout(() => URL.revokeObjectURL(href), 30_000)
setNotice('PNG 已开始下载')
} catch (err) { setError(err instanceof Error ? err.message : '导出失败'); setNotice('') }
finally { setExporting(false) }
}
return { image, adjustments, setAdjustments, options, setOptions, error, notice, exporting, validation, fit, download }
}
export type DiyImageEditorController = ReturnType<typeof useDiyImageEditor>

View File

@ -0,0 +1,15 @@
'use client'
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { emptyLayoutBatch, getLayoutBatch, initializeLayoutBatch, resumeLayoutBatch, startLayoutBatch, subscribeLayoutBatch } from '../lib/layout-replace-batch'
export function useDiyLayoutBatch(refresh: () => Promise<void>) {
const state = useSyncExternalStore(subscribeLayoutBatch, getLayoutBatch, () => emptyLayoutBatch)
const liveRefresh = useRef(refresh)
useEffect(() => { liveRefresh.current = refresh }, [refresh])
useEffect(() => { initializeLayoutBatch() }, [])
const progress = state.items.map(item => `${item.input.requestId}:${item.status}`).join(',')
useEffect(() => { if (progress) void liveRefresh.current() }, [progress])
return { ...state, start: startLayoutBatch, resume: resumeLayoutBatch }
}
export type DiyLayoutBatchController = ReturnType<typeof useDiyLayoutBatch>

View File

@ -0,0 +1,46 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { DiyImageSelection } from '@/lib/diy/image-tools'
import { buildTextEditItems, detectedTextItems, type TextItem } from '@/lib/diy/text-replace/core'
import { detectDiyImageText, submitDiyTextReplace } from '../lib/text-replace-api'
const message = (error: unknown) => error instanceof Error ? error.message : '操作失败,请重试'
export function useDiyTextReplace(source: DiyImageSelection, onGenerated: () => Promise<void>) {
const [items, setItems] = useState<TextItem[]>([]), [deleted, setDeleted] = useState<string[]>([])
const [detecting, setDetecting] = useState(false), [submitting, setSubmitting] = useState(false)
const [detected, setDetected] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState('')
const lock = useRef(false), abort = useRef<AbortController | null>(null), mounted = useRef(true)
useEffect(() => { mounted.current = true; return () => { mounted.current = false; abort.current?.abort() } }, [])
const changes = buildTextEditItems(items.filter(item => !deleted.includes(item.id)), items.filter(item => deleted.includes(item.id)))
const detect = async () => {
if (lock.current || detecting) return
setDetecting(true); setError(''); setNotice('正在识别图片中的文字…')
abort.current = new AbortController()
try {
const result = await detectDiyImageText({ recordId: source.recordId, storageKey: source.storageKey }, AbortSignal.any([abort.current.signal, AbortSignal.timeout(110_000)]))
if (!mounted.current) return
setItems(detectedTextItems(result.items)); setDeleted([]); setDetected(true)
setNotice(result.items.length ? `已识别 ${result.items.length} 段文字` : '未识别到文字,可手动添加原文')
} catch (err) { if (mounted.current) { setError(message(err)); setNotice('') } }
finally { if (mounted.current) setDetecting(false) }
}
const update = (id: string, patch: Partial<TextItem>) => setItems(previous => previous.map(item => item.id === id ? { ...item, ...patch } : item))
const toggleDelete = (id: string) => setDeleted(previous => previous.includes(id) ? previous.filter(value => value !== id) : [...previous, id])
const add = () => setItems(previous => [...previous, { id: crypto.randomUUID(), originalText: '', editedText: '' }])
const submit = async () => {
if (lock.current || detecting || !changes.length) return
lock.current = true; setSubmitting(true); setError(''); setNotice('正在提交文字替换…')
try {
const result = await submitDiyTextReplace({ requestId: crypto.randomUUID(), recordId: source.recordId, storageKey: source.storageKey, items: changes }, value => { if (mounted.current) setNotice(value) })
await onGenerated()
if (!mounted.current) return
if (result.status === 'FAILED') throw new Error(result.errorMessage || '文字替换失败')
setNotice(result.submissionRecovery ? '已找回上次提交的任务,请到出图记录查看' : '已提交,请到出图记录查看新图')
setItems([]); setDeleted([]); setDetected(false)
} catch (err) { if (mounted.current) { setError(message(err)); setNotice('') } }
finally { lock.current = false; if (mounted.current) setSubmitting(false) }
}
return { items, deleted, detecting, submitting, detected, error, notice, changes, detect, update, toggleDelete, add, submit }
}
export type DiyTextReplaceController = ReturnType<typeof useDiyTextReplace>

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