Compare commits
70 Commits
main
...
codex/xuxi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b374801d | ||
|
|
65b9c7788c | ||
|
|
5330638d60 | ||
|
|
0ee5c91396 | ||
|
|
6e12f0318d | ||
|
|
8c520de0aa | ||
|
|
1b6f0cd7ce | ||
|
|
5fc647ed05 | ||
|
|
37bbb43c12 | ||
|
|
243cfc9825 | ||
|
|
57cfe735a8 | ||
|
|
3845817c02 | ||
|
|
61ae9b8444 | ||
|
|
e4d47f9c4e | ||
|
|
1f06f0a181 | ||
|
|
682f0827e0 | ||
|
|
7f864cc09e | ||
|
|
b299207f44 | ||
|
|
f00d686c76 | ||
|
|
9915d7764b | ||
|
|
ea9851fd98 | ||
|
|
793e72bd27 | ||
|
|
460b8fa6b9 | ||
|
|
b9294a0437 | ||
|
|
fe742ed369 | ||
|
|
0861ad9603 | ||
|
|
4fba91cecf | ||
|
|
e853adeed7 | ||
|
|
f9594a4260 | ||
|
|
ee4afbb875 | ||
|
|
185276bb0e | ||
|
|
20b11b6b87 | ||
|
|
0102289abf | ||
|
|
7eaa1b49ae | ||
|
|
d22126e354 | ||
|
|
6ae9e24016 | ||
|
|
4a36d0ee58 | ||
|
|
82a1d25b17 | ||
|
|
e656fa6d5d | ||
|
|
6b2ff9159a | ||
|
|
2d1486b64c | ||
|
|
f3451de29b | ||
|
|
c4c3773dbb | ||
|
|
a07adbb558 | ||
|
|
1429b870f0 | ||
|
|
83c3161ccb | ||
|
|
c555caccb0 | ||
|
|
a103a23d08 | ||
|
|
aec52f78ce | ||
|
|
a8b5c84759 | ||
|
|
8a10b7f72f | ||
|
|
2816a0b23d | ||
|
|
01740656e4 | ||
|
|
9af20a14b0 | ||
|
|
2965742bbc | ||
|
|
bb19f46301 | ||
|
|
55fa6be07b | ||
|
|
3bad0e1a97 | ||
|
|
74d32544be | ||
|
|
8915933678 | ||
|
|
6daf4c38a7 | ||
|
|
da85e99511 | ||
|
|
f26bd04093 | ||
|
|
2bad601fae | ||
|
|
465f5d5916 | ||
|
|
073ce48d7d | ||
|
|
ee4a1bf489 | ||
|
|
b4c22c23de | ||
|
|
6880208b36 | ||
|
|
a7b495e9c2 |
@ -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=
|
||||
|
||||
@ -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 第一批字段覆盖率
|
||||
|
||||
124
furniture-3d-system/docs/限价与破价判定规则.md
Normal file
124
furniture-3d-system/docs/限价与破价判定规则.md
Normal 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 / 相似度)、关键字段一致性校验
|
||||
- [ ] 举证期限、乐捐财务对接流程
|
||||
- [ ] 首单礼金 / 新品礼金出资方确认后补录出资方字典
|
||||
@ -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:",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
@ -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")
|
||||
);
|
||||
@ -0,0 +1,2 @@
|
||||
-- 「图片」列:提单时按条码从产品全量档案库快照图片地址,列表在条码前展示缩略图。
|
||||
ALTER TABLE "ProductionStopDemandItem" ADD COLUMN "image" TEXT NOT NULL DEFAULT '';
|
||||
@ -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");
|
||||
@ -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");
|
||||
@ -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;
|
||||
@ -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");
|
||||
@ -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");
|
||||
@ -0,0 +1,2 @@
|
||||
-- 产品全量档案新增「安装方式」列:取值来源产品开发(产品定厂 SKU installMethod,整装/拆装)。
|
||||
ALTER TABLE "SupplyChainProductArchive" ADD COLUMN "installMethod" TEXT NOT NULL DEFAULT '';
|
||||
@ -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");
|
||||
@ -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");
|
||||
@ -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]+)?$';
|
||||
@ -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 |
@ -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)
|
||||
})
|
||||
Binary file not shown.
84
furniture-3d-system/scripts/install-limit-workflow-sync-timer.sh
Executable file
84
furniture-3d-system/scripts/install-limit-workflow-sync-timer.sh
Executable 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
|
||||
@ -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
|
||||
@ -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
|
||||
})
|
||||
925
furniture-3d-system/scripts/sync-limit-workflows.mjs
Normal file
925
furniture-3d-system/scripts/sync-limit-workflows.mjs
Normal 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()
|
||||
@ -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
|
||||
})
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
},
|
||||
{
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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, {
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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'] },
|
||||
)
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'] },
|
||||
)
|
||||
}
|
||||
@ -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'] },
|
||||
)
|
||||
}
|
||||
@ -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'] },
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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}/>
|
||||
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
63
furniture-3d-system/src/app/diy/hooks/useDiyImageEditor.ts
Normal file
63
furniture-3d-system/src/app/diy/hooks/useDiyImageEditor.ts
Normal 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>
|
||||
15
furniture-3d-system/src/app/diy/hooks/useDiyLayoutBatch.ts
Normal file
15
furniture-3d-system/src/app/diy/hooks/useDiyLayoutBatch.ts
Normal 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>
|
||||
46
furniture-3d-system/src/app/diy/hooks/useDiyTextReplace.ts
Normal file
46
furniture-3d-system/src/app/diy/hooks/useDiyTextReplace.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user