143 Commits
Author SHA1 Message Date
yeuimu 3a8e8e8948 merge: refactor/public-capacity-10k-yeuimu into develop (P0 性能整改——public 读路径分域缓存+全写路径失效、nginx 承压调优、连接池/PG 参数,已部署验证) 2026-09-03 09:32:45 +08:00
yeuimu 2d2aeb655a docs(perf): P0 生产部署结果回填——备份存档、23s 切换、缓存/gzip/限流/PG 参数实测数据 2026-09-03 08:44:57 +08:00
yeuimu 027ef2b296 docs: 性能整改落地文档——structs 补缓存层与部署参数说明、报告标注 P0 实施进度、AGENTS 沉淀切片缓存陷阱与 bullseye 跑测经验 2026-09-03 03:40:33 +08:00
yeuimu 9f856858e2 perf(deploy): 边缘 nginx 承压调优与连接池/PG 参数(P0-2/P0-3)
- 主配置 nginx.conf 进镜像(worker_connections 16384 + rlimit 65536 + gzip
  + 带上游耗时的访问日志):官方默认 1024×4=4096 连接硬顶是 1w 并发第一道墙
- admin.conf:upstream keepalive 64(proxy_http 1.1 + Connection "")、
  /public/ /v2-api/ 限流 50r/s burst 200(429)、代理超时 5s/30s、
  /uploads/ /assets/ 30d 缓存头;admin.v2.conf 的 /v2/h5/ 哈希产物长缓存
- compose:PG 显式参数(statement_timeout=10s/max_connections=200/
  shared_buffers=512MB)、DATABASE_URL connection_limit=50&pool_timeout=3
  (单位秒,勿写毫秒;勿加 statement_cache_size=0)、admin ulimits nofile 65536
- 部署注意:主配置走镜像须 rebuild;PG 参数重启前按全局约束 pg_dump+快照存档
2026-09-03 03:40:28 +08:00
yeuimu ab79325ab0 perf(public): 公开读路径进程内分域缓存——meta/goods/matrix 版本域 + TTL 兜底 + in-flight 合并
- PublicCacheService:分域版本号失效(bump 即作废,不等 TTL)、loader 期间
  bump 的竞态防护(返回但不回写)、并发 miss 单飞、PUBLIC_CACHE_DISABLED
  /TTL_MS/MAX_ENTRIES 应急开关;@Global 模块
- PublicService 六端点接缓存:列表缓存全量物化(分页切片在缓存外按请求执行,
  修复'所有页返回第一页'的切片缓存错误)、详情/首页/树/标签组/树序元数据/
  族最低价聚合各按依赖域缓存
- 全写路径挂钩 bump:admin CRUD(goods/categories/countries/tags/tag-groups/
  positions/origin-goods 标签)、sync 三同步、族重算、整理全家桶——
  事务提交成功后失效对应域,product-families 经 recompute 天然覆盖
- 测试:PublicCacheService 单测 13 例 + 失效链路集成 8 例(读命中/写后立即可见
  端到端/每条写路径域断言);既有两套件测数据语义改为显式禁缓存
2026-09-03 03:40:20 +08:00
yeuimu ca38476047 docs(perf): public 端口 1w 瞬时并发审查报告与整改计划——逐层瓶颈证据、P0/P1/P2 分级、全局约束(DB 变更先备份/写路径必失效缓存) 2026-09-03 03:40:12 +08:00
yeuimu 44de8e670f merge: bugfix/no-print-links-removal-yeuimu into develop (不打印链接移出族,SKU 矩阵恢复烫画) 2026-09-03 02:53:14 +08:00
yeuimu dd6a540bf3 fix(product-families): 不打印链接移出族(纯数据修复)——矩阵不再计算不打印工艺,BRTF004 恢复烫画×单面
- 生产数据操作(未改源代码):11 条光板/不打印链接 family_id 置空(族 1/2/63/135/148),
  名下 3 商品脱族,族 148 主链接 964→965、族 135 主链接置空
- 容器内直调已部署 FamilyRecomputeService 重算 5 族:全库矩阵不再含「不打印」,
  族 148 = 烫画×单面印花×不包邮(min 19),public 端口验证通过
- 回滚存档:deploy/backups/20260903-noprint-removal/(全库 dump + 受影响行 CSV + RESTORE.md)
- 文档:product-center/structs 记录数据约定与回流风险(organize/autoGroup/人工改标签会回挂散链接);
  AGENTS.md 沉淀 占位借词表值/物化JSON需显式重算/数据修复三件套 三条经验
2026-09-03 02:53:10 +08:00
yeuimu 0d2a954153 deploy(single-stack): 单栈收敛——v2 并入 deploy compose,v1 退役
- docker-compose.yml 重写为单栈 4 容器(admin 边缘 + v2-api/v2-admin/v2-postgres),
  v2 数据卷 deploy-v2_pgdata/uploads 以 external 引用,数据零迁移
- nginx:/uploads/ /assets/ 上游改 v2-api(product-center 覆盖块随之冗余移除),
  / 跳转 /v2/admin/;docker-compose.v2.yml 删除(血统已并入 develop)
- v1 api+postgres 已停用未删,终档/快照/回滚手册见 deploy/backups/consolidation-*/
- 切换验证:v2 库切换前后行数完全一致;/v2-api /public /v2/admin /v2/h5
  /assets 全路由冒烟 + 公网端到端通过
2026-09-03 02:09:24 +08:00
yeuimu a554b77379 fix(product-families): 热转印词表以 v2 为准——归因不再映射回烫画
合并冲突的行为级裁决:develop 侧 memberMatrixCombos 曾把 热转印→烫画
归一(旧别名方案),与 v2 线上一等公民词表(8903afd)矛盾,导致 v2 侧
三个矩阵用例失败。取 v2 版 family-recompute.service.{ts,spec}(即当前
线上行为)。另修复 v2 分支 structs.md 残留的冲突标记。

Tests: api 23 suites/217 passed (runInBand), admin 27 passed, tsc clean
2026-09-03 02:00:33 +08:00
yeuimu 5e69397adc merge: refactor/v2 完全并入 develop(v2 为准,v2 血统 20+ 提交收敛为主干) 2026-09-03 01:54:32 +08:00
yeuimu 37353470e7 fix(categories): 分类名去括号——同步入库整段删除括号段
左侧分类树/官网分类名的括号段(型号限定词(JSA002)与内容性括号
(黑+卡其)/(短裤 & 长裤))整段删除。分类名每小时被 SDS 同步覆盖,
清洗落在 syncCategories 入库路径(cleanCategoryDisplayName,款号 token
保留,剥空守卫),另附幂等存量脚本 fix:category-names。

已执行:V1/V2 各 33 条分类名去括号,复跑幂等 0。注意 api 容器重建
部署前旧镜像的下一轮同步会把名称写回原文,部署后自动恢复干净。

Tests: api 23 suites/206 passed(含新增 category-name.spec 7 例),tsc clean
2026-09-03 01:27:38 +08:00
yeuimu 9eafac7204 fix(goods): 商品展示名统一「品名 SKU」——剥 ASCII 型号限定词、纯款号补描述
左栏商品名显示成光款号(如 PLTK016)的根因是双层解析不一致:api 写路径按
首括号对解析产出「童装…(DTG180) PLTK016」,admin 展示解析按末括号取品名
误显示成 PLTK016。

- admin parseLinkName 对齐 api 首括号对解析 + 无 SKU 段中文品名守卫,
  cleanLinkName 剥离 ASCII 型号限定词(中文括号内容保留)
- api 新增 cleanGoodDisplayName/describePureSkuGoodName,接入 create/update/
  batchCreate(batchCreate 补上缺失的 normalize);纯款号按 SDS 分类名补描述
- 新增幂等存量修复脚本 fix:good-names(默认 dry-run,--apply 写库)
- 已执行:V2 栈 34 条限定词名、V1 栈 213 条原始链接名规范化,复跑幂等 0

Tests: api 22 suites/199 passed, admin 27 passed, tsc clean
2026-09-03 01:27:26 +08:00
yeuimu 0ffab120e9 merge: bugfix/category-name-parens-yeuimu into develop (分类名去括号) 2026-09-03 01:20:17 +08:00
yeuimu 814f45c5a3 fix(categories): 分类名去括号——同步入库整段删除括号段
左侧分类树/官网分类名的括号段(型号限定词(JSA002)与内容性括号
(黑+卡其)/(短裤 & 长裤))整段删除。分类名每小时被 SDS 同步覆盖,
清洗落在 syncCategories 入库路径(cleanCategoryDisplayName,款号 token
保留,剥空守卫),另附幂等存量脚本 fix:category-names。

已执行:V1/V2 各 33 条分类名去括号,复跑幂等 0。注意 api 容器重建
部署前旧镜像的下一轮同步会把名称写回原文,部署后自动恢复干净。

Tests: api 23 suites/206 passed(含新增 category-name.spec 7 例),tsc clean
2026-09-03 01:20:17 +08:00
yeuimu 39eb06b6a2 merge: bugfix/pure-sku-good-name-yeuimu into develop (商品名剥型号限定词 + 纯款号补描述) 2026-09-03 01:04:26 +08:00
yeuimu c3be8ab979 fix(goods): 商品展示名统一「品名 SKU」——剥 ASCII 型号限定词、纯款号补描述
左栏商品名显示成光款号(如 PLTK016)的根因是双层解析不一致:api 写路径按
首括号对解析产出「童装…(DTG180) PLTK016」,admin 展示解析按末括号取品名
误显示成 PLTK016。

- admin parseLinkName 对齐 api 首括号对解析 + 无 SKU 段中文品名守卫,
  cleanLinkName 剥离 ASCII 型号限定词(中文括号内容保留)
- api 新增 cleanGoodDisplayName/describePureSkuGoodName,接入 create/update/
  batchCreate(batchCreate 补上缺失的 normalize);纯款号按 SDS 分类名补描述
- 新增幂等存量修复脚本 fix:good-names(默认 dry-run,--apply 写库)
- 已执行:V2 栈 34 条限定词名、V1 栈 213 条原始链接名规范化,复跑幂等 0

Tests: api 22 suites/199 passed, admin 27 passed, tsc clean
2026-09-03 01:04:22 +08:00
yeuimu ae9901e6cc merge: bugfix/family-auto-merge-auth-cookie-yeuimu into develop (SKU 自动合并 + 掉线修复) 2026-09-02 18:40:22 +08:00
yeuimu f1e81872e7 fix(product-family): auto-merge SKUs after manual tag edits; fix session cookie path
问题2(SKU 无法自动合并,GBIU017 案例)四处叠加根因修复:
- 人工标签旧词「热转印」被封闭词表静默踢出合并矩阵 → CRAFT_TAG_ALIASES
  别名归一为「烫画」(矩阵归因与 CUSTOM 标签同路径生效)
- updateTags 缺任一定价维度组即整链掉出矩阵且人工接管后永不恢复 →
  缺啥补啥(按链接名派生补齐,人工勾选值不动,响应带 filledDimensionTags)
- autoGroup 只建新族从不并入已有族(产生 USIU005-2 类碎片)→ 并入已有
  autoManaged 同款族优先,无匹配才新建;同款仅人工锁定族则跳过并报告
- 名称回退分组键含物流备注,包邮/不包邮永不同组 → 新增族语义键
  familyNameKey(国家+品名+SKU),api/admin 两侧同构,合并默认勾选随之修复

附加:
- updateTags 后未归族链接自动并入匹配族(attachToMatchingFamily,响应带
  attachedFamilyId)
- 整理新增碎片族合并 consolidateFragments:纯碎片族并入带商品族并删除
  (保公开 goodId=族ID 稳定),带商品/覆盖价/人工锁定进人工复审报告
- admin:保存标签提示补齐明细,整理完成消息含并入/碎片合并/待人工数

问题1(后台频繁 Unauthorized 掉线):
- refresh cookie path '/auth' 与代理前缀(/api、/v2-api)不匹配导致浏览器
  永远带不上 refresh cookie → 两 cookie path 统一为 '/'
- v2 构建基址带尾斜杠 + 手工拼接产生 /v2-api//auth/refresh 双斜杠 404 →
  request.ts 规范拼接

测试:api jest 187/187、admin vitest 22/22、双侧 tsc 0 错误;
public.service 夹具改为自包含(不依赖共享库既有数据)。
2026-09-02 18:39:35 +08:00
yeuimu b81d944c38 docs(agent): add dimension-value onboarding lesson 2026-09-02 16:37:56 +08:00
yeuimu f09a629311 merge: add heat-transfer craft to matrix vocabularies 2026-09-02 16:34:27 +08:00
yeuimu 8903afdc82 feat(product-families): add heat-transfer (热转印) craft to matrix vocabularies
Append 热转印 to DIM_VALUES.craft so links manually tagged with the new
tag enter the price matrix (previously the closed vocabulary filtered it
out and the link's prices would silently disappear), and to
OPTION_DISPLAY_ORDER.craft so the button group renders it after 不打印.
No derivation rules changed: existing links are unaffected until an
operator explicitly tags them.
2026-09-02 16:33:41 +08:00
yeuimu fb85a56444 docs(plan): add heat-transfer craft tag feature plan 2026-09-02 16:31:26 +08:00
yeuimu 5f81b18388 docs(agent): add materialized-array order determinism lesson 2026-09-02 16:08:35 +08:00
yeuimu f828cc0bd9 merge: fix price-matrix option sort (canonical display order) 2026-09-02 16:05:05 +08:00
yeuimu a84b3b5daa fix(product-families): sort price-matrix option arrays by canonical display order
printCounts/crafts/logistics were materialized in first-encounter order
(originGoods had no orderBy), so the H5 print/craft/logistics button
groups rendered in an unstable, per-family arbitrary order. Add
OPTION_DISPLAY_ORDER (单面→双面, 烫画→直喷→不打印, 不包邮→包邮) and
stable-sort the aggregated arrays before materializing; out-of-vocabulary
free-text labels (e.g. 海运) sink to the end preserving encounter order.
Fix traversal order with orderBy id asc so recomputes are fully
deterministic. Add recompute:families script for one-off re-materialize.
2026-09-02 16:00:12 +08:00
yeuimu 1ffd73a1af docs(plan): add price-matrix option sort fix plan 2026-09-02 15:52:41 +08:00
yeuimu ae880ff346 docs(agent): add representative-row convergence lesson 2026-09-02 14:59:29 +08:00
yeuimu 26a5b08b56 merge: bugfix/public-goodname-representative (列表/首页族代表行对齐详情) 2026-09-02 14:58:59 +08:00
yeuimu 63820a3e6f fix(public): unify family representative selection with detail endpoint
List/home endpoints picked the family representative row via goods[0],
which drifted with the list sort parameter (cheapest under PRICE_ASC,
lowest id under DEFAULT ties), causing goodName and other
representative-derived fields to differ from the detail endpoint.
Extract pickFamilyRepresentative (goodPriority desc -> createdAt desc
-> id asc, same as getGoodByFamilyId) and use it in getGoods and
getHomeGoods grouping. Group ordering and API contracts unchanged.
2026-09-02 14:58:42 +08:00
yeuimu e34398a002 deploy(nginx): route /assets/product-center/ to v2 api for shared icons; ignore backups/ 2026-09-02 11:40:51 +08:00
yeuimu b32a5575df deploy(nginx): route /assets/product-center/ to v2 api for shared icons
沙特国旗等新增图标只需放入 v2 仓库 public 目录;nginx 按最长前缀把
product-center 静态资源转发给 v2 api,其余 /assets/、/uploads/ 仍走 v1。
同步沉淀 AGENTS.md:入口 nginx 真实挂载源在 v1 仓库、文件型 bind mount 的
inode 陷阱。
2026-09-02 11:19:49 +08:00
yeuimu a0ad6da9c7 chore(api): add Saudi Arabia flag icon 2026-09-02 11:01:11 +08:00
yeuimu 2bec7822f0 merge: feature/category-tree-sort (public goods style-order sorting) 2026-09-02 10:34:12 +08:00
yeuimu 76eb849b49 docs: record style-order semantics, container-run pitfalls and lessons 2026-09-02 10:33:54 +08:00
yeuimu bea0bf14d8 feat(api): order public goods by country > mid-category > style > priority 2026-09-02 10:27:23 +08:00
yeuimu 3ccddc5982 feat(api): backfill category/country sort order from reference table 2026-09-02 10:22:49 +08:00
yeuimu dc90712c4f feat(api): add sort_order column to categories 2026-09-02 10:16:29 +08:00
yeuimu d01c1e1b97 docs(plan): keep legacy tree as nav, treat sds leaves as style-order metadata, drop tree-switch tasks 2026-09-02 10:00:29 +08:00
yeuimu 3d431f8375 docs(plan): add backup/rollback section, fix backfill run channel and sink semantics 2026-09-02 09:36:17 +08:00
yeuimu 6973f863b5 chore: ignore local db snapshots (backups/) 2026-09-02 09:27:08 +08:00
yeuimu 88f60ba2e2 docs: add category-sort implementation plan (single categoryId, dual-path filter) 2026-09-01 19:26:50 +08:00
yeuimu 01fe444f93 docs: add category sort reference table (country > level2 > level3 order) 2026-09-01 19:00:24 +08:00
yeuimu 318f5c2a64 deploy(v2): update h5 build artifacts (new entry & products chunks) 2026-09-01 17:15:19 +08:00
yeuimu 5c5fb88f58 merge: refactor/v2 (v2 stack deploy, new h5 build, miniprogram assets) into develop 2026-09-01 16:30:43 +08:00
yeuimu 007ad2c831 deploy(v2): h5 build with base /v2/h5, admin dockerfile & nginx route, shared compose 2026-09-01 16:26:22 +08:00
yeuimu 60992cb3d9 deploy: new h5 build, /v2/h5 route, restore /v2-api & /public proxy
- replace deploy/h5 with latest h5 build (base /h5/)
- nginx: add /v2/h5/ passthrough to v2-admin
- nginx: restore lost /v2-api/ and /public/ proxy locations
- compose: join inkreach-shared network so v1 admin can resolve v2-admin
2026-09-01 16:21:45 +08:00
yeuimu 62f968c1c1 chore(api): add miniprogram static assets to /app/public (served at /assets/miniprogram) 2026-08-31 19:02:23 +08:00
yeuimu a5884c638c merge: fix/test-fixture-junk-badge into develop 2026-08-30 03:15:23 +08:00
yeuimu f55e7ba4bb merge: develop (fixture junk cleanup + badge text) into refactor/v2 2026-08-30 03:15:23 +08:00
yeuimu 674d38a9a7 fix(api,admin): stop test fixture junk leaking to shared dev DB; badge text x个配置
清理标签筛选里的「Upd {ts}-v2」空分组与原产品库树里的
「Tree Cat {ts}」垃圾分类——两者均为集成测试夹具残留:
- tag-groups spec「改名」用例把分组改成 -v2 后缀,afterAll 按原名
  删除永远漏掉它(累积 31 个空分组);修复:改名前把 v2 名一并登记
- origin-goods spec getTree 用例的 finally 先删 good,被
  goodOriginGood 外键限制挡住且错误被 .catch 吞掉,分类/商品/国家/
  链接全部残留;修复:先删引用行再删 good
- 已清库:31 个 Upd 空分组、Tree Cat 分类 + Tree Good 商品 +
  Tree Country 国家 + 2 条 tree-a/b 链接;复跑两 spec 复核零残留

左树一族一行徽标文案按用户反馈去掉「N 条链接」,仅显示「M 个配置」
(memberCount 字段保留在 DTO,悬浮弹层行为不变)

验证:tag-groups + origin-goods spec 13/13 绿且零残留;admin
vue-tsc 干净 + vitest 22/22
2026-08-30 03:15:18 +08:00
yeuimu 102d61c181 merge: develop (family composition badge) into refactor/v2 2026-08-30 03:07:21 +08:00
yeuimu 12882e7d5a merge: fix/family-composition-badge into develop 2026-08-30 03:07:20 +08:00
yeuimu a756c8c3c8 fix(admin,api): always show family composition badge N条链接·M个配置
按用户反馈(PLTM006 明明能查到配置数却不显示):左树徽标此前只在
同分类内 ≥2 个配置时才出现,单配置族一行上什么都不显示,而右树同族
链接都挂着绿色已配置徽标,两边数字对不上,反复造成「配置数没显示」
的误解。

- goods API:GOOD_INCLUDE 的 family 带 _count.originGoods,
  GoodDto.originGood.family 增加 memberCount(族成员链接数)
- admin:distinctByFamily 记录 linkCount;一族一行的徽标常显
  「N 条链接 · M 个配置」(链接数=族成员数,配置数=该族官网商品数),
  悬浮仍列出全部配置可打开
- 文档同步徽标语义

效果:PLTM006 行显示「4 条链接 · 1 个配置」(真值:4 条链接仅配了
1 个官网商品);PLTF026 的「4 条链接 · 4 个配置」同屏可对比,其中
4 个配置为历史同国家重复配置,建议另行清理。

验证:admin vue-tsc 干净 + vitest 22/22;api goods spec 18/18;
Prisma _count 查询实测返回 originGoods=4
2026-08-30 03:07:16 +08:00
yeuimu ab42cb49a3 merge: develop (block family re-config) into refactor/v2 2026-08-30 02:55:33 +08:00
yeuimu 5d089bdb43 merge: fix/block-family-reconfig into develop 2026-08-30 02:55:32 +08:00
yeuimu 49d28c8d4f fix(admin): block re-config for family-configured links
按用户反馈:族已配置后不允许再配置(撤回上一轮的「再配置」入口):
- 「配置」按钮仅在本链接及其同款族均未配置时显示(configuredCount
  与 familyConfiguredCount 均为 0),族已配置的链接只显示「同款已配置」徽标
- openConfigModal 增加统一拦截(按钮与右树拖拽两条路径都覆盖):
  已配置/族已配置的链接弹出警告并阻止打开配置弹窗,避免产生官网
  不可见的冗余商品
- 文档同步:撤回「再配置」语义,恢复拦截说明

已核实 PLTF004(族 786)实际仅 1 个官网商品,「四个配置」是右树
族聚合徽标造成的观感误解;149 个族无一跨分类,左树一族一行计数无误。

验证:admin vue-tsc 干净 + vitest 22/22 绿
2026-08-30 02:55:28 +08:00
yeuimu ab51052d26 merge: develop (family-configured badge fix) into refactor/v2 2026-08-30 02:40:44 +08:00
yeuimu 7ff12fc69b merge: fix/family-configured-badge into develop 2026-08-30 02:40:43 +08:00
yeuimu fa4a95af98 fix(admin): relabel family-configured badge to 同款已配置; button 再配置
按用户反馈修正右树「已配置徽标 + 配置按钮」并存的矛盾观感:
- 徽标改为链接级真值:本链接未单独建商品但所属族已配置时显示
  「同款已配置」(此前沿用拦截时代的「已配置」文案,与恢复出的
  配置入口语义打架)
- 该场景下按钮文案改为「再配置」,悬浮说明指向调整归族/加配国家;
  本链接自身已配置时按钮照旧隐藏,行为不变
- 文档撤回过时的「族已配置后配置入口会被拦截」说法(6f2d836 已
  撤回拦截,徽标文案此番补齐)

验证:admin vue-tsc 干净 + vitest 22/22 绿
2026-08-30 02:40:39 +08:00
yeuimu ff8aad171c merge: develop (organize refactor) into refactor/v2 2026-08-30 01:27:28 +08:00
yeuimu b6c19ae859 merge: refactor/organize-script-dev into develop (local only, not pushed) 2026-08-30 01:27:26 +08:00
yeuimu 93ac525c55 refactor(api): parsing out of runtime — pure-mirror sync, explicit organize, auto aggregate recompute
解析去运行时化(三层架构,plans/refactor/organize-script-refactor.md):
- 同步 = 纯镜像:upsertOriginGood 不再写解析列、不再自动挂族;详情同步仍触发重算
- 整理 = 显式人工动作(OrganizeService):解析列回填 → 派生标签(人工接管永不
  覆盖)→ auto-group 建族 → 全量重算;入口 CLI(pnpm --filter @inkreach/api
  organize)+ POST /product-families/organize + 后台「整理」按钮
- 重算 = 纯结构化聚合:不再按名称重派生标签(防上游改名倒灌,回归测试覆盖);
  矩阵维度只认标签/CUSTOM 显式标签,未整理成员不进矩阵;工艺=不打印时
  印花数量以单面占位(纯结构化规则);「恢复自动」走整理的单链接派生
- 派生默认补齐(脚本层假设):名称无单/双面且工艺非不打印 → 印花数量单面印花
- goods 服务建品/更新后仅镜像标签+重算(不派生);含商品名入库规范化
  (normalizeGoodName,管理员输入边界质检)
- organize.service.spec 由 tag-sync spec 迁移 + 防倒灌回归;sync/recompute/
  public/families spec 全部适配;API 173/173,admin typecheck+22/22
2026-08-30 01:27:01 +08:00
yeuimu 976f308e3c Merge remote-tracking branch 'origin/develop' into develop 2026-08-29 09:13:11 +08:00
yeuimu 64a6fa7004 merge: fix/size-chart-local-size-code-dev into develop (local only, not pushed) 2026-08-29 08:55:16 +08:00
yeuimu b5875d33b2 fix(sync): drop local size-code column (P/M/G/GG) from SDS size charts
巴西/西语市场上游尺码表带一个名为「尺码」的列,值是本地尺码码
P/M/G/GG(=S/M/L/XL),与行首尺码重复且非测量值;解析时整列丢弃。
其他非数字参考列(身高/体重/建议体重)保留。存量 2 条详情已修复
并重算受影响族。
2026-08-29 08:55:16 +08:00
yeuimu ce283dff78 merge: fix/origin-name-mixed-bracket-dev into develop (local only, not pushed) 2026-08-29 02:36:26 +08:00
yeuimu 6498a198ef fix(parser): normalize half/full-width bracket mix in link name segment 1
SDS 上游存在 '美国(不包邮)…' 混用括号:先归一为全角再配对,
否则国家/物流/品名三字段全部解析失败
2026-08-29 02:36:26 +08:00
yeuimu 7c46f15967 merge: feature/admin-ui-polish-dev into develop (local only, not pushed) 2026-08-29 02:32:41 +08:00
yeuimu d625b40873 fix(admin): merge-candidates by SKU/family; config dialog merge block always shown; cfg badge adjacency
- 修正合并同名候选匹配:此前用名称 3 段组键(含工艺段),工艺写法不同的
  同款(如 光板不打印 vs 单面印花(前印))全部漏配导致候选为空、合并同名
  区块被 v-if 隐藏 —— 用户看到的"checkbox 和搜索框被吞"即此因。
  改为 族归属精确匹配 或 款号(名称第 2 段,如 BRTF004)一致,全局跨分类;
  区块常显,无候选时空态提示
- 修左树「N 个配置」徽标错位:good-name-col 改 flex 布局,徽标紧贴名称
- 验证:admin typecheck + 22/22 + 构建绿;DB 实测 BRTF004 光板链接候选池
  恢复(单面印花(前印),同族 857)
2026-08-29 02:16:35 +08:00
yeuimu 6f2d836ef5 fix(admin): one row per family on left tree; restore right-tree config entry
按用户反馈调整上一批优化:
- 左树撤回族组展开层(虚线层+重复名称行):改为一族只显示一行(排序最前的
  代表商品),行内「N 个配置」徽标悬浮列出该族全部配置(名称/国家·分类/打开
  按钮),不再有额外层级与重复名称
- 右树恢复配置入口:撤回「族已配置拦截」,本链接未单独建商品即显示「配置」
  按钮(含族已配置情形,供调整归族/重新配置),拖拽路径同步解除拦截
- 国家模式拖拽排序回退为分组内简单排序(族组节点已不存在)
- 文档同步更新徽标/入口语义

验证:admin typecheck + 22/22 + 构建绿;浏览器实测美国分组 25 行一族一行、
「8 个配置」徽标悬浮列表可打开、国内工厂深层成族链接显示「配置」按钮
2026-08-29 01:53:09 +08:00
yeuimu 5e8183c220 feat(admin): family-folded left tree, toolbar regroup, sync log humanize
视觉走查驱动的后台优化(feature/admin-ui-polish-dev,确认后再合并):
- 左树(国家/品类模式)同款多配置折叠为一行族组节点:代表名 + "N 个配置"
  徽标,展开看各行;locateInLeftTree 沿父链展开保证定位有效;国家模式
  拖拽排序适配族嵌套(同容器内排序,按折叠后展示顺序保序重分配优先级)
- 工具栏分区重排:模式切换(品类/国家/全局)移至最左,筛选组居中,
  新增自定义商品靠右,消除 1680 宽下的折行错乱
- 商品行属性小字/国家徽标颜色加深提升对比度
- 右树原产品行加拖拽提示 title
- 数据同步日志人话化:inserted/updated/total/delisted 等报文渲染为
  "新增 0 · 更新 536 · 共计 536",原始报文进 tooltip
- 计划文档 plans/feature/admin-ui-polish-feature.md(含一项误报修正:
  右树 29/3 计数为截图裁切误读,实际 29/33 无 bug)

验证:admin typecheck + 22/22 + 构建绿;浏览器实机复拍国家模式折叠、
族组展开、同步页文案均生效
2026-08-28 23:22:04 +08:00
yeuimu 4ddbf486bd merge: fix/country-mode-good-reorder-dev into develop (local only, not pushed) 2026-08-28 21:37:27 +08:00
yeuimu 0e451d07e6 fix(admin): drag-to-reorder goods inside country groups
国家模式左树商品节点支持拖拽排序:行内 before/after 指示线,落点即保存。
优先级采用保序重排(该国家现有优先级集合不变、按新顺序重新分配),
尽量少扰动全局/品类维度的既有排序;跨国家拖动不支持(商品归属固定)。
2026-08-28 21:37:27 +08:00
yeuimu aba90cb011 merge: fix/config-dialog-global-siblings-dev into develop (local only, not pushed) 2026-08-28 20:04:30 +08:00
yeuimu fdfe435ecd fix(admin): config dialog merge candidates search globally by name group
合并同名候选不再局限于当前分类节点:collectSiblings 改为按名称 3 段组键
(sameOriginGroup)遍历整棵右树收集同款链接(含已配置,勾选归族幂等),
跨分类的同款链接(如 BRTF004 分布在多个分类组)也能勾选归族
2026-08-28 20:04:29 +08:00
yeuimu a14b8ecf4a merge: feat/country-sort-and-sync-btn-dev into develop (local only, not pushed) 2026-08-28 20:01:04 +08:00
yeuimu 1e07c62fc8 feat(admin): country drag-sort + always-available member detail sync
- Country 加 sort_order(默认 0 保持 id 序);PATCH /countries/sort 批量保存
  顺序(对齐 /tags/sort 模式);findAll 与公开 /public/countries 均按
  sortOrder 排序
- CountriesView 表格改为可拖拽行列表:拖动松开即全量保存新顺序,失败回滚
- 商品编辑弹窗成员展开面板:同步详情按钮常驻(已同步显示 重新同步详情),
  不再只在未同步态出现
- goods.service.spec 的 FamilyRecomputeService mock 补齐 syncFamilyTags 等
  方法(全量并行时其他套件的扫名归族会把本套件夹具收进族,create/update
  会调用到,mock 缺方法导致偶发 TypeError)
- api 164/164、admin typecheck+22/22+构建全绿
2026-08-28 20:00:56 +08:00
yeuimu 77f9c05346 merge: fix/tree-badge-family-aware-dev into develop (local only, not pushed) 2026-08-28 19:16:22 +08:00
yeuimu fe6ab3825d fix(admin): family-aware right-tree badges; flatten union size-chart cells
- 右树徽标/定位/配置按钮改按族语义:族内任一链接已配置 → 全族显示绿色
  已配置(本链接未单独建商品时标题注明并入该款),配置入口在全族未配置时
  才出现;拖拽已配置族链接配置时拦截提示(公开契约一族一款,重复商品官网
  不可见);分组头计数与仅未配置筛选同步族感知(AUTM003/DG120 场景)
- 编辑弹窗族级尺码表把 measurements 数组摊平成列键(与成员级渲染一致),
  修复列头在、格子全空的问题
2026-08-28 19:16:15 +08:00
yeuimu 8a6db1bdee docs(agents): lessons — stop dev server before full jest; Chinese sort assertions 2026-08-28 18:51:22 +08:00
yeuimu d0102ec4e8 merge: feature/public-family-id-dev home-goods test into develop (local only, not pushed) 2026-08-28 18:44:25 +08:00
yeuimu 63b436fe41 test(public): cover home-goods family dedupe 2026-08-28 18:44:24 +08:00
yeuimu 5a7a2841de merge: feature/public-family-id-dev into develop (local only, not pushed) 2026-08-28 18:43:33 +08:00
yeuimu 8a052773cd feat(public): family-first contract — goodId=familyId, strict 5-dim price matrix
公开契约族化(前端只需知道款号/族):
- GET /public/goods 一族一条(goodId=族ID,price=族起价,分页作用于分组后);
  无族商品(自定义)不进任何公开端点(列表/首页/分类树/标签统计)
- GET /public/goods/:goodId 仅认族 ID;公共字段取代表 Good,变体=全体成员并集,
  尺码表/包装规格=族物化并集;旧 SDS 链接 ID 寻址 404
- priceMatrix 严格五维:尺码×颜色×印花数量×工艺×物流;维度来源改为链接级
  标签(人工接管按人工标签),弃用原始 craftLabel;CUSTOM 成员尊重显式标签
- 名称派生补裸「单面/双面」写法(直喷双面→双面印花+直喷,18 条存量链接修复)
- family_price_overrides 加 print_count 列(五键唯一),PUT/DELETE/校验五键化
- admin 编辑弹窗矩阵消费适配(SKU 列直读 printCount,成员格子三维匹配,
  改价 payload 带 printCount)
- 存量 339 族已全量重算;api 162/162、admin 22/22、双端构建绿
2026-08-28 18:43:30 +08:00
yeuimu e5ec022834 merge: fix/swagger-csp-dev into develop (local only, not pushed) 2026-08-28 17:50:39 +08:00
yeuimu e018f16d0b fix(api): drop helmet CSP upgrade-insecure-requests so Swagger UI loads over plain http 2026-08-28 17:50:39 +08:00
yeuimu b055aedd3e merge: fix/swagger-proxy-dev into develop (local only, not pushed) 2026-08-28 17:43:41 +08:00
yeuimu 1ef82c4c14 fix(admin): proxy /api/docs without prefix strip so Swagger UI works through vite dev server 2026-08-28 17:43:41 +08:00
yeuimu f07e843130 fix(admin): move family union tabs to the bottom of edit dialog 2026-08-28 17:37:58 +08:00
yeuimu 6054cb556a docs: family union tabs and searchable merge list 2026-08-28 17:27:53 +08:00
yeuimu d4515631b1 merge: feat/family-detail-tabs-dev into develop (local only, not pushed) 2026-08-28 17:26:41 +08:00
yeuimu c7e6c35fbf feat(admin): family-level size/package/SKU union tabs in edit dialog; searchable merge-sibling list in config dialog 2026-08-28 17:26:41 +08:00
yeuimu 6d9305b878 merge: fix/config-dialog-siblings-dev into develop (local only, not pushed) 2026-08-28 17:11:36 +08:00
yeuimu 7c85f7fe9f fix(admin): config dialog lists all same-category sibling links (incl. configured); drop merge tip text 2026-08-28 17:11:36 +08:00
yeuimu f280b1daf5 docs: reflect simplified member price table 2026-08-28 17:03:12 +08:00
yeuimu d67858c69b merge: fix/session-restore-and-price-table-dev into develop (local only, not pushed) 2026-08-28 17:02:26 +08:00
yeuimu c2d50c9fa3 fix(admin): restore session via /auth/me in router guard (refresh no longer logs out); simplify member price table (no title/source/action columns) 2026-08-28 17:02:25 +08:00
yeuimu 9863346c6d merge: fix/goods-view-feedback-dev into develop (local only, not pushed) 2026-08-28 16:55:08 +08:00
yeuimu f28f51155f feat(goods): per-member detail tabs in edit dialog; revert right-tree to raw names; split configured/family badges; add GET /origin-goods/:id 2026-08-28 16:55:07 +08:00
yeuimu 753b554413 merge: feature/goods-dialog-split-dev into develop (local only, not pushed) 2026-08-28 16:40:48 +08:00
yeuimu 10197c47a8 refactor(admin): split GoodsView into dialog components; fix(GoodsView): raw member names, no primary badges, member price grid; feat(tag): 光板 maps to 不打印 2026-08-28 16:40:48 +08:00
yeuimu 34d84a7a7c merge: feature/link-tags-and-sku-dims-dev into develop (local only, not pushed) 2026-08-28 16:00:52 +08:00
yeuimu 6f22a6fd1f feat(admin): expandable family members with per-link tag config + SKU pricing dims columns 2026-08-28 16:00:46 +08:00
yeuimu 08201f18a4 feat(product-family): link-level tags (origin_good_tags) with manual override + member detail enrichment 2026-08-28 16:00:46 +08:00
yeuimu 0a2302c4e2 更新按钮尺寸 2026-08-28 15:42:39 +08:00
yeuimu 3ddc4c87e8 更新忽略文件 2026-08-28 15:34:24 +08:00
yeuimu 37b52c5ebb merge: feature/family-link-tags-ui-dev into develop (local only, not pushed) 2026-08-28 15:33:17 +08:00
yeuimu 1128167aef feat(admin): family-aware goods view - auto tags readonly, clean link names, config badges, locate highlight 2026-08-28 15:33:11 +08:00
yeuimu ee336968a0 feat(product-family): derive tags per link name (印花数量/工艺/物流 rules) 2026-08-28 15:33:11 +08:00
yeuimu f5a3b8c840 merge: bring in public media/variant dedup from origin/develop (local only) 2026-08-28 15:15:17 +08:00
yeuimu 6395d9e6ab feat(product-family): family-derived tags replace manual logistics/craft tagging 2026-08-28 14:57:26 +08:00
yeuimu eedfbb344e feat(product-family): auto-derive logistics/craft tags from family members 2026-08-28 14:57:25 +08:00
yeuimu a13931c84c revert(admin): keep original admin layout with family replacing primary/secondary sources 2026-08-28 14:35:34 +08:00
yeuimu 353e51e871 revert(admin): restore original layout, replace primary/secondary source UI with family members 2026-08-28 14:35:34 +08:00
yeuimu 7b17fff86f fix(admin): fail loudly when dev port 5173 is occupied 2026-08-28 14:21:37 +08:00
yeuimu f919577ddd feat(product-family): goods view family integration, family-based public variant union 2026-08-28 14:14:59 +08:00
yeuimu 4eadb67c36 feat(product-family): replace legacy good merge with family mechanism in config view and public detail 2026-08-28 14:14:58 +08:00
yeuimu 14c53504f0 feat(product-family): public detail family block and good family derivation 2026-08-28 13:40:19 +08:00
yeuimu 6418aa7302 feat(api): public good detail family block behind gray-release flag 2026-08-28 13:40:19 +08:00
yeuimu b4b9fbbe60 feat(api): good family_id column with backfill 2026-08-28 13:34:42 +08:00
yeuimu ba3c6d2d4f feat(admin): product family management tab, price override matrix and family grouping 2026-08-28 13:30:38 +08:00
yeuimu d70a03f6fa docs: admin product family references 2026-08-28 13:30:37 +08:00
yeuimu fd7ca5d53f feat(admin): goods view family badges and sibling matching 2026-08-28 13:29:54 +08:00
yeuimu 07f3134f79 feat(admin): product family management tab 2026-08-28 13:28:37 +08:00
yeuimu dcd2b235b9 feat(admin): family match util 2026-08-28 13:25:31 +08:00
yeuimu c492469d70 feat(admin): product families api client and types 2026-08-28 13:25:10 +08:00
yeuimu 8fad8de73e docs(agents): add lessons for db drift resolution and shared-db test isolation 2026-08-28 12:53:10 +08:00
yeuimu e49eebcedd fix(product-family): tolerant auto-group member attach 2026-08-28 12:52:22 +08:00
yeuimu ab450c350e fix(api): tolerant member attach in auto-group under concurrent cleanup 2026-08-28 12:52:22 +08:00
yeuimu f7c4252a91 feat(product-family): merge SPU family layer phase-1 backend 2026-08-28 12:50:49 +08:00
yeuimu 502b7eba6e docs: update references for product families; fix controller import and test isolation 2026-08-28 12:50:42 +08:00
yeuimu c8531bfe08 feat(api): category-based family grouping, tree family info and custom goods family attribution 2026-08-28 12:43:58 +08:00
yeuimu d63f1a6f35 feat(api): product families backfill script 2026-08-28 12:36:39 +08:00
yeuimu d20a933e34 feat(api): sync hooks for parsing, auto-attach and family recompute 2026-08-28 12:33:33 +08:00
yeuimu f68898dea4 feat(api): product families module with CRUD, auto-group, members, custom members and price overrides 2026-08-28 12:30:57 +08:00
yeuimu de0f5509b1 feat(api): add family recompute service with union and price matrix 2026-08-28 12:27:29 +08:00
yeuimu c07b28def7 feat(api): add product_families schema and migration 2026-08-28 12:24:24 +08:00
yeuimu 60946b5f64 feat(api): add origin good name parser 2026-08-28 12:23:35 +08:00
yeuimu 37b45a4679 docs(plans): add product family merge phase-1 implementation plan 2026-08-28 12:22:23 +08:00
yeuimu 573549eb15 docs(specs): reuse public endpoints and include admin frontend scope 2026-08-28 12:04:35 +08:00
yeuimu 14d77fbab2 docs(specs): allow manual price overrides and custom family members 2026-08-28 12:01:13 +08:00
yeuimu 1186d615e2 docs(specs): add product family merge design 2026-08-28 11:45:18 +08:00
174 changed files with 17299 additions and 5331 deletions
+3
View File
@@ -8,3 +8,6 @@ dist/
deploy/certbot/ deploy/certbot/
deploy/data-dump.json deploy/data-dump.json
uploads/ uploads/
.pnpm-store/
data-cleaning/
backups/
+28
View File
@@ -89,3 +89,31 @@ Prisma Postgres 最佳实践: prisma-postgres 技能
参考 nestjs-best-practices 技能 参考 nestjs-best-practices 技能
> 你可以自己增加规范 > 你可以自己增加规范
### 经验沉淀(跨项目通用)
- **入口 nginx 的真实挂载源在 v1 仓库**:线上 official.inkreach.cc 的 nginx 容器由 `/opt/inkreach/deploy` 创建,挂载的是那边的 `nginx/admin.conf``certbot/acme` 证书;v2 仓库 `deploy/nginx/admin.conf` 只是同步副本。改线上路由必须改 v1 的文件,再在该目录 `docker compose up -d --force-recreate admin`。v1/v2 目录同名 → compose 项目名相同 → pgdata/uploads 数据卷共享,重建不丢数据。
- **文件型 bind mount 的 inode 陷阱**:编辑器保存常替换 inode,容器内仍看到旧内容(`:ro` 挂载连 docker cp 都被拒);`docker compose restart` 也不会重新解析挂载。要让新文件内容生效只能 `--force-recreate`,新服务的挂载尽量挂目录而非单个文件。
- **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied <name>` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。
- **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。
- **跑全量 jest 前先停 dev server**`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。
- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。
- **pnpm 仓库容器化执行要挂仓库根**pnpm 的 node_modules 是相对符号链接指向根 `.pnpm` store,容器里只挂子包目录(如 `apps/api:/app`)会断链报 "Cannot find module";必须挂整个仓库根并 `-w` 到子包。另外 Prisma 引擎与系统 libssl 版本强绑定:node:20-alpine 缺 libssl1.1 会报 engine 加载失败;仓库 `.pnpm` store 里生成的 client 是 openssl-1.1.x target**bookworm 系镜像(node:20-slim)同样跑不了**,用 `node:20-bullseye-slim` 挂仓库根 + `--network host`(连本机一次性测试库)跑 jest/prisma 即可,无需依赖项目运行镜像。
- **缓存值里绝不能带请求级切片(分页/字段裁剪)**:把 `items.slice(page…)` 的结果整个塞进缓存后,缓存键不含 page → 所有页码命中同一条目、页页返回第一页(total 对得上更具迷惑性);正确做法是缓存"全量物化结果",切片在缓存命中后按请求执行。TDD 用例必须包含"同键翻页 + 页内容随页码变化"的断言才能抓住这类错。
- **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名。
- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。
- **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。
- **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。
- **"物化数组的顺序必须有确定性来源"**:把聚合结果物化进 JSONB(如 price_matrix 的 printCounts/crafts/logistics)时,"Set 去重保首现序"不是顺序保证——源查询没有 orderBy 时 Postgres 不承诺返回序,同一数据两次重算顺序可能漂移。必须:① 聚合后按业务词表序稳定排序(词表外自由文本沉底);② 源查询显式 `orderBy` 固定遍历序。测试用"打乱输入顺序断言输出全等"覆盖顺序确定性。
- **"新增维度取值 = 标签行 + 两处词表常量,缺一不可"**:工艺/物流/印花数量这类封闭维度新增可选值时,只往 `tags` 表插行不够——矩阵维度词表 `DIM_VALUES` 和展示序 `OPTION_DISPLAY_ORDER` 是硬编码常量,必须同步追加,否则打了新标签的链接会被词表过滤导致价格格子静默消失(比报错更危险)。同时确认自动派生规则(auto-tag-rules / admin origin-name)是否要认识新值;存量数据零影响的前提是"只放开准入,不主动打标"。
- **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态。
- **严禁直接热修运行容器内的代码**:本次线上 v2 镜像内被塞过未提交的词表改动(热转印),仓库重建镜像即复发且难排查;所有修复必须落在仓库并重建部署。排查"线上行为与代码不符"时先 `md5sum` 比对容器内源文件与仓库。
- **两端"同构"解析器必须连测试用例也同构**:admin 与 api 各有一份链接名解析器,本次展示 bug(`品名(DTG180 SKU` 显示成光款号)正是两端取括号策略不一致(api 首括号对 vs admin 末括号)所致。改任何一端解析行为时,另一端同样输入的用例必须同步补上(origin-name.spec ↔ origin-name.parser.spec/goods.service.spec)。
- **共享开发库不可达时用一次性 docker postgres 跑集成测试**`docker run -d --name inkreach-test-pg -e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=inkreach_test -p 127.0.0.1:54329:5432 postgres:16-alpine``DATABASE_URL=… npx prisma migrate deploy` → jest/vitest 指向该库;用完 `docker rm -f`。夹具自包含的套件在新库上直接绿。
- **宿主机即部署机时,改生产数据前先 `docker inspect` 拿容器真实注入的环境变量**:deploy/.env 里的密码可能与运行容器不一致(v2 栈独立 env);对两个栈的库做数据修复时,dry-run 清单必须逐栈分别核对后再 apply。
- **改"镜像字段"的数据前先查覆盖路径**:分类名每小时被 `syncCategories` 用 SDS 原名无条件覆盖——直接改库/界面改名都会被下一轮同步冲回。此类字段的清洗必须写进同步入库路径(如 `cleanCategoryDisplayName`),存量靠回填脚本;数据先改而镜像未重部署的窗口期内会被冲回一次,重部署后自动恢复。
- **合并分叉血统时警惕"文本无冲突、行为有冲突"**:git 自动合并可能把两侧对同一机制的不同设计(如 热转印=烫画别名 vs 一等公民词表)拼进一个文件,编译通过但行为矛盾——合并后必须跑两侧血统各自的测试才能暴露;行级冲突反而不是最危险的。另:`git status | head` 截断会漏看冲突文件,连续 cherry-pick 后要全仓 `grep '<<<<<<<'` 扫一遍。
- **生产栈收敛三件套**:动栈结构前先 `pg_dump -Fc` 双库 + 数据卷归档 + 配置快照落 `deploy/backups/<时间戳>/` 并写 RESTORE.md;切换用"外部卷 external 引用 + down 不带 -v + 切换前后行数 diff"保证数据零丢失;先 `docker compose build` 预构建再 down/up,把停机窗口压到秒级。
- **矩阵维度占位不得借用业务词表既有值**:曾让「不打印」链接的印花数量占位为「单面印花」,导致顾客端出现「不打印+单面印花」的自相矛盾组合(不打印根本无印花面)。2026-09-03 数据收口:光板/不打印链接不入族(`family_id=NULL`),矩阵不计算不打印工艺。**回流风险**:`organize`/`autoGroup` 会把散链接按分类重新归族、人工改链接标签会触发 `attachToMatchingFamily` 回挂——跑这两类操作前必须先排查光板链接(清单与回滚见 `deploy/backups/20260903-noprint-removal/`)。
- **重部署不会自愈物化 JSON**:`price_matrix` 是物化列,代码修好/词表修正后已物化的矩阵**不会自动重算**(重算只在归族/成员变化/详情同步/标签调整等事件时触发)——族 148 曾因旧容器物化的坏矩阵跨镜像存活,"代码明明对了线上却没变"。此类修复收尾必须显式触发受影响族重算(可容器内 `node -e` 直调 `FamilyRecomputeService.recomputeFamily`,用已部署代码、免重建免鉴权)。
- **批量数据修复前"全量 dump + 受影响行快照 + RESTORE.md"三件是底线**:全量 `pg_dump -Fc` 兜灾难恢复,受影响行 CSV(含改前外键值)支持精准回滚,RESTORE.md 写清逐表恢复命令与重算步骤;事务内逐步核对影响行数与预期一致再 COMMIT。
+1
View File
@@ -106,6 +106,7 @@ pnpm --filter @inkreach/api test # 单元测试
pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client pnpm --filter @inkreach/api prisma:generate # 生成 Prisma Client
pnpm --filter @inkreach/api prisma:migrate # 运行迁移 pnpm --filter @inkreach/api prisma:migrate # 运行迁移
pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio pnpm --filter @inkreach/api prisma:studio # 打开 Prisma Studio
pnpm --filter @inkreach/api backfill:product-families # 产品族回填(解析链接名→自动建族→全量重算,幂等)
# 官网 # 官网
pnpm --filter @inkreach/website dev # 开发 pnpm --filter @inkreach/website dev # 开发
+5
View File
@@ -32,4 +32,9 @@ export const countriesApi = {
deleteCountry: (id: string) => { deleteCountry: (id: string) => {
return request.delete(`/countries/${id}`) return request.delete(`/countries/${id}`)
}, },
// Batch save drag-reorder (items = full list in the new order)
sortCountries: (items: Array<{ id: string; sortOrder: number }>) => {
return request.patch<any, Country[]>('/countries/sort', { items })
},
} }
+20 -1
View File
@@ -1,5 +1,5 @@
import request from './request' import request from './request'
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types' import type { OriginGood, OriginGoodsTreeResponse, OriginGoodTagsResult, PaginatedResult } from '@/types'
export const originGoodsApi = { export const originGoodsApi = {
getTree: () => { getTree: () => {
@@ -9,4 +9,23 @@ export const originGoodsApi = {
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => { getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params }) return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
}, },
// 单链接详情(detail + variants,成员展开面板用)
getOriginGood: (id: string) => {
return request.get<any, Record<string, any>>(`/origin-goods/${id}`)
},
getTags: (id: string) => {
return request.get<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`)
},
// 人工接管链接标签(全量替换,自动同步不再覆盖)
updateTags: (id: string, tagIds: string[]) => {
return request.put<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`, { tagIds: tagIds.map(Number) })
},
// 恢复自动派生(清掉人工标签)
resetTags: (id: string) => {
return request.delete<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`)
},
} }
+80
View File
@@ -0,0 +1,80 @@
import request from './request'
import type {
AutoGroupPreviewGroup,
FamilyPriceOverrideRow,
PaginatedResult,
ProductFamily,
} from '@/types'
export const productFamiliesApi = {
list: (params: { keyword?: string; page?: number; pageSize?: number }) => {
return request.get<any, PaginatedResult<ProductFamily>>('/product-families', { params })
},
detail: (id: string) => {
return request.get<any, ProductFamily>(`/product-families/${id}`)
},
create: (data: {
familyName: string
familyCode?: string
familyImage?: string
originGoodIds?: string[]
primaryOriginGoodId?: string
}) => {
return request.post<any, ProductFamily>('/product-families', data)
},
patch: (id: string, data: Record<string, unknown>) => {
return request.patch<any, ProductFamily>(`/product-families/${id}`, data)
},
autoGroup: (apply: boolean) => {
return request.post<any, { applied: number; groups: AutoGroupPreviewGroup[] }>(
'/product-families/auto-group',
{ apply },
)
},
/** 整理原产品库(显式人工动作):解析列回填 → 派生标签 → 自动建族 → 全量重算 */
organize: () => {
return request.post<any, {
labelsParsed: number
labelsUnparsable: number
linksUpdated: number
goodsUpdated: number
familiesCreated: number
familiesMerged: number
familiesSkipped: number
fragmentsConsolidated: number
fragmentsManualReview: number
familiesRecomputed: number
}>('/product-families/organize')
},
updateMembers: (id: string, data: { addOriginGoodIds?: string[]; removeOriginGoodIds?: string[] }) => {
return request.post<any, ProductFamily>(`/product-families/${id}/members`, data)
},
createCustomMember: (id: string, data: Record<string, unknown>) => {
return request.post<any, unknown>(`/product-families/${id}/members/custom`, data)
},
recompute: (id: string) => {
return request.post<any, ProductFamily>(`/product-families/${id}/recompute`)
},
listOverrides: (id: string) => {
return request.get<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`)
},
putOverrides: (id: string, items: Array<Record<string, unknown>>) => {
return request.put<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`, { items })
},
deleteOverrides: (id: string, cells: Array<Record<string, unknown>>) => {
return request.delete<any, { items: FamilyPriceOverrideRow[] }>(`/product-families/${id}/price-overrides`, {
data: { cells },
})
},
}
+4 -1
View File
@@ -36,8 +36,11 @@ request.interceptors.response.use(
) { ) {
config._retried = true config._retried = true
try { try {
// 规范拼接:VITE_API_BASE 可能带尾斜杠(如 /v2-api/),直接相加会产生
// /v2-api//auth/refresh 双斜杠导致 404
const base = (import.meta.env.VITE_API_BASE || '/api').replace(/\/+$/, '')
await axios.post( await axios.post(
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`, `${base}/auth/refresh`,
{}, {},
{ withCredentials: true }, { withCredentials: true },
) )
-1
View File
@@ -42,7 +42,6 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
ElPopover: typeof import('element-plus/es')['ElPopover'] ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
+7 -2
View File
@@ -28,14 +28,19 @@ const routes: RouteRecordRaw[] = [
] ]
const router = createRouter({ const router = createRouter({
history: createWebHistory('/admin/'), history: createWebHistory('/v2/admin/'),
routes, routes,
}) })
router.beforeEach((to) => { router.beforeEach(async (to) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const isPublic = to.meta?.public === true const isPublic = to.meta?.public === true
// 会话在 HttpOnly cookie 里:刷新后先经 /auth/me 恢复登录态,再判定是否放行
if (!authStore.isLoggedIn && !isPublic) {
await authStore.ensureSessionChecked()
}
if (!authStore.isLoggedIn && !isPublic) { if (!authStore.isLoggedIn && !isPublic) {
return { path: '/login', replace: true } return { path: '/login', replace: true }
} }
+110
View File
@@ -360,6 +360,7 @@ export interface OriginGood {
sizeRowCount?: number sizeRowCount?: number
packageRowCount?: number packageRowCount?: number
productCode?: string | null productCode?: string | null
family?: { familyId: string; familyCode: string | null; familyName: string; memberCount?: number } | null
} }
// Origin Goods Tree types // Origin Goods Tree types
@@ -378,6 +379,10 @@ export interface OriginGoodsTreeNode {
variantCount: number variantCount: number
sizeRowCount: number sizeRowCount: number
packageRowCount: number packageRowCount: number
familyId: string | null
familyName: string | null
familyCode: string | null
familyStale: boolean | null
} }
export interface OriginGoodsTreeCategoryNode { export interface OriginGoodsTreeCategoryNode {
@@ -396,6 +401,111 @@ export interface OriginGoodsTreeResponse {
configuredCount: number configuredCount: number
} }
// Product Family (SPU layer) types
export interface OriginGoodTagInfo {
id: string
tagName: string
tagColor: string | null
tagFontColor: string | null
manual: boolean
}
/** 链接标签配置结果(origin-goods/:id/tags */
export interface OriginGoodTagsResult {
tagsManual: boolean
tags: OriginGoodTagInfo[]
/** 人工勾选缺定价维度时按链接名自动补齐的标签名(缺啥补啥) */
filledDimensionTags?: string[]
/** 保存时链接无族且自动并入成功的目标族 id */
attachedFamilyId?: string
}
export interface ProductFamilyMember {
id: string
sdsGoodId: string
goodName: string
goodImage: string | null
goodPrice: string | null
source: 'SDS' | 'CUSTOM'
delisted: boolean
skuCode: string | null
logisticsLabel: string | null
craftLabel: string | null
warehouseLabel: string | null
tagsManual: boolean
originGoodTags: Array<{ id: string; manual: boolean; tag: OriginGoodTagInfo }>
variantCount: number
}
export interface FamilyPriceOverrideRow {
id: string
familyId: string
sizeId: string
colorId: string
craft: string
logistics: string
price: string
note: string | null
derivedPrice: string | null
diff: string | null
}
export interface ProductFamilyPriceMatrix {
sizes: Array<{ key: string; name: string | null }>
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>
crafts: string[]
logistics: string[]
rows: Array<{
sizeId: string
sizeName: string | null
colorId: string
colorName: string | null
craft: string
logistics: string
price: string
manual: boolean
sources: Array<{ sdsGoodId: string; sdsVariantId: string; price: string }>
}>
}
export interface ProductFamily {
id: string
familyCode: string | null
familyName: string
familyImage: string | null
countryId: string | null
categoryId: string | null
primaryOriginGoodId: string | null
autoManaged: boolean
stale: boolean
detail: Record<string, unknown> | null
sizeChart: { columns?: unknown; rows?: Array<Record<string, unknown>> } | null
packageSpecs: { columns?: unknown; rows?: Array<Record<string, unknown>> } | null
priceMatrix: ProductFamilyPriceMatrix | null
originGoods: ProductFamilyMember[]
priceOverrides: FamilyPriceOverrideRow[]
_count: { originGoods: number; priceOverrides: number }
}
export interface AutoGroupPreviewGroup {
groupKey: string
familyName: string
familyCode: string | null
memberCount: number
sampleNames: string[]
}
export interface CustomMemberVariantInput {
sku: string
sizeId?: string | null
sizeName?: string | null
colorId?: string | null
colorName?: string | null
colorHex?: string | null
imageUrl?: string | null
price: number
}
// Sync types // Sync types
export interface SyncLog { export interface SyncLog {
id: string id: string
+28
View File
@@ -0,0 +1,28 @@
import type { CategoryTree } from '@/types'
/** 在分类树中找目标分类的祖先路径(含自身) */
export function findCategoryPath(nodes: CategoryTree[], targetId: string): string[] {
for (const n of nodes) {
if (n.id === targetId) return [n.id]
if (n.children?.length) {
const sub = findCategoryPath(n.children, targetId)
if (sub.length) return [n.id, ...sub]
}
}
return []
}
export interface CascadeNode {
value: string
label: string
children?: CascadeNode[]
}
/** 分类树 → el-cascader 选项 */
export function buildCascader(tree: CategoryTree[]): CascadeNode[] {
return tree.map((n) => ({
value: n.id,
label: n.categoryName,
children: n.children?.length ? buildCascader(n.children) : undefined,
}))
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { sameFamily } from './family-match'
const node = (familyId: string | null, goodName: string) => ({ familyId, goodName })
describe('sameFamily', () => {
it('同族(familyId 相等且非空)→ true,名称不同也不影响', () => {
expect(
sameFamily(node('12', '美国(包邮)T恤-DG001-单面印花'), node('12', '墨西哥(不包邮)卫衣-XX-双面印花')),
).toBe(true)
})
it('任一方 familyId 为空 → 回退族语义键(国家+品名+SKU,物流/工艺不分族)', () => {
const a = node(null, '美国(包邮)T恤-DG001-单面印花')
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG001-单面印花-某仓'))).toBe(true)
expect(sameFamily(a, node('12', '美国(包邮)T恤-DG001-单面印花'))).toBe(true)
// 物流/工艺是矩阵维度而非分族维度:包邮 vs 不包邮、单面 vs 双面 同款同族
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-单面印花'))).toBe(true)
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-双面印花-美西一仓'))).toBe(true)
// 不同款号不同族
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG002-单面印花'))).toBe(false)
// 不同国家不同族
expect(sameFamily(a, node(null, '英国(包邮)T恤-DG001-单面印花'))).toBe(false)
})
it('familyId 不同 → false(即使名称同段)', () => {
expect(
sameFamily(node('1', '美国(包邮)T恤-DG001-单面印花'), node('2', '美国(包邮)T恤-DG001-单面印花')),
).toBe(false)
})
it('双方均无族且名称不可比 → false', () => {
expect(sameFamily(node(null, ''), node(null, '随便'))).toBe(false)
expect(sameFamily(node(null, null as unknown as string), node(null, '随便'))).toBe(false)
})
})
+14
View File
@@ -0,0 +1,14 @@
import { familyNameKey } from './origin-name'
/**
* 两个原产品节点是否属于同一产品族:
* 优先用服务端返回的 familyId 精确匹配;无族归属时回退族语义键
* (国家+品名+SKU —— 物流/工艺是矩阵维度不分族;老数据/未回填场景兜底)。
*/
export function sameFamily(
a: { familyId?: string | null; goodName?: string | null },
b: { familyId?: string | null; goodName?: string | null },
): boolean {
if (a.familyId && b.familyId) return a.familyId === b.familyId
return familyNameKey(a.goodName) !== '' && familyNameKey(a.goodName) === familyNameKey(b.goodName)
}
+116 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { sameOriginGroup, truncateToProcess } from './origin-name'; import {
cleanLinkName,
deriveLinkTagNames,
linkDims,
parseLinkName,
sameOriginGroup,
truncateToProcess,
} from './origin-name';
describe('truncateToProcess', () => { describe('truncateToProcess', () => {
it('keeps first 3 dash segments (drops warehouse)', () => { it('keeps first 3 dash segments (drops warehouse)', () => {
@@ -44,3 +51,111 @@ describe('sameOriginGroup', () => {
expect(sameOriginGroup('', '')).toBe(false); expect(sameOriginGroup('', '')).toBe(false);
}); });
}); });
describe('parseLinkName / cleanLinkName', () => {
it('parses 国家(物流)品名-SKU-工艺 → 品名 + 型号', () => {
expect(parseLinkName('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual({
productName: '180g纯棉T恤成人款',
skuCode: 'DG001',
});
expect(cleanLinkName('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toBe(
'180g纯棉T恤成人款 DG001',
);
});
it('handles half-width parens and warehouse suffix', () => {
expect(
cleanLinkName('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
).toBe('180GT恤成人款 JSA002');
});
it('returns null / raw fallback for manual names without the country(remark) structure', () => {
expect(parseLinkName('180g纯棉T恤成人款')).toBeNull();
expect(cleanLinkName('180g纯棉T恤成人款')).toBe('180g纯棉T恤成人款');
expect(cleanLinkName(null)).toBe('');
});
it('returns null when product name after parens is empty', () => {
expect(parseLinkName('美国(包邮)-DG001-单面印花')).toBeNull();
});
});
describe('已规范化的「品名(型号限定词) SKU」防误解析 + 限定词剥离', () => {
it('品名内含(限定词)的已规范化名不再解析(否则展示会变成光款号)', () => {
expect(parseLinkName('童装纯色插肩短袖T恤(DTG180 PLTK016')).toBeNull();
expect(parseLinkName('牛奶丝T恤(女款) DG601')).toBeNull();
expect(parseLinkName('180G纯棉T恤 JSA002 DG001')).toBeNull();
});
it('cleanLinkName 对已规范化名剥离 ASCII 型号限定词', () => {
expect(cleanLinkName('童装纯色插肩短袖T恤(DTG180 PLTK016')).toBe(
'童装纯色插肩短袖T恤 PLTK016',
);
expect(cleanLinkName('180G纯棉T恤 JSA002 DG001')).toBe('180G纯棉T恤 DG001');
});
it('原始链接名品名内含限定词时,解析后一并剥离', () => {
expect(
cleanLinkName('波兰(包邮)童装纯色插肩短袖T恤(DTG180-PLTK016-双面印花'),
).toBe('童装纯色插肩短袖T恤 PLTK016');
});
it('中文括号内容(如(女款))不剥离', () => {
expect(cleanLinkName('牛奶丝T恤(女款) DG601')).toBe('牛奶丝T恤(女款) DG601');
});
it('无 SKU 段的单段链接名仍按首括号对解析(与 api 端一致)', () => {
expect(parseLinkName('波兰(包邮)童装T恤')).toEqual({
productName: '童装T恤',
skuCode: null,
});
});
});
describe('deriveLinkTagNames', () => {
it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => {
expect(deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual([
'单面印花',
'烫画',
'包邮',
]);
});
it('不打印 + 光板 + 不包邮 → 归并为单个不打印(光板即不打印)', () => {
expect(
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
).toEqual(['不打印', '不包邮']);
});
it('双面印花 + 不包邮,且不误命中包邮', () => {
expect(deriveLinkTagNames('美国(不包邮)T恤-DG001-双面印花·美东新泽西仓')).toEqual([
'双面印花',
'烫画',
'不包邮',
]);
});
it('空名称返回空数组', () => {
expect(deriveLinkTagNames(null)).toEqual([]);
});
});
describe('linkDims', () => {
it('解析物流备注/工艺/印花数量', () => {
expect(linkDims('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual({
logistics: '包邮',
crafts: ['烫画'],
printCount: '单面印花',
});
expect(linkDims('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓')).toEqual({
logistics: '不包邮光板',
crafts: ['不打印'],
printCount: null,
});
});
it('无括号结构时物流为 null,手工名称工艺默认烫画', () => {
expect(linkDims('纯棉T恤')).toEqual({ logistics: null, crafts: ['烫画'], printCount: null });
expect(linkDims(null)).toEqual({ logistics: null, crafts: [], printCount: null });
});
});
+128
View File
@@ -18,3 +18,131 @@ export function sameOriginGroup(
const ka = truncateToProcess(a); const ka = truncateToProcess(a);
return ka !== '' && ka === truncateToProcess(b); return ka !== '' && ka === truncateToProcess(b);
} }
/**
* 族语义分组键(与 api 端 familyNameKey 同构):第 1 段剥离(物流备注)括号,
* 保留 国家+品名+SKU,丢工艺/仓库段 —— 物流/工艺是价格矩阵维度而非分族维度。
*/
export function familyNameKey(name: string | null | undefined): string {
if (!name) return '';
const segs = name.split('-').map((s) => s.trim());
const head = segs[0] ?? '';
if (!head) return '';
const normalized = head.replace(/\(/g, '').replace(/\)/g, '');
const openAt = normalized.indexOf('');
let stripped = normalized;
if (openAt >= 0) {
const closeAt = normalized.indexOf('', openAt);
if (closeAt > openAt) {
stripped = normalized.slice(0, openAt) + normalized.slice(closeAt + 1);
}
}
stripped = stripped.trim();
const sku = segs[1] ?? '';
if (!stripped) return sku || '';
return sku ? `${stripped}-${sku}` : stripped;
}
/** 链接名结构化解析结果:品名 + 型号(SKU 代码) */
export interface ParsedLinkName {
productName: string;
skuCode: string | null;
}
/**
* 解析链接名称:`国家(物流备注)品名-SKU-工艺位置[-·仓库名]` → 品名 + 型号。
* 与 api 端 origin-name.parser.ts 同构(首括号对解析,半角/全角混用先归一);
* 解析失败(无「国家(备注)」前缀结构)返回 null,调用方应回退显示原始名称
* —— 手动维护的品名不走该解析。
*/
export function parseLinkName(name: string | null | undefined): ParsedLinkName | null {
if (!name) return null;
const segs = name.split('-').map((s) => s.trim());
const head = segs[0] ?? '';
const normalized = head.replace(/\(/g, '').replace(/\)/g, '');
const openAt = normalized.indexOf('');
const closeAt = openAt >= 0 ? normalized.indexOf('', openAt) : -1;
if (closeAt <= openAt || closeAt === normalized.length - 1) return null;
const productName = normalized.slice(closeAt + 1).trim();
if (!productName) return null;
// 无 SKU 段时仅当括号后的品名含中文才视为链接名 —— 防把已规范化的
// 「品名(型号限定词) SKU」(如「童装…(DTG180) PLTK016」)的尾段款号误当品名
if (segs.length < 2 && !/[\u4e00-\u9fff]/.test(productName)) return null;
const skuCode = segs[1] || null;
return { productName, skuCode };
}
/** ASCII 型号限定词括号组(SDS 品名内的供应商/工艺型号,如(DTG180)(JSA002)) */
const ASCII_PAREN_GROUP = /(?:|\()[A-Za-z0-9]+(?:|\))/g;
/** 展示名剥离 ASCII 型号限定词;剥空(名称仅剩限定词)时原样返回 */
export function stripAsciiParenQualifiers(name: string): string {
const stripped = name.replace(ASCII_PAREN_GROUP, ' ').replace(/\s+/g, ' ').trim();
return stripped || name;
}
/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名。品名内的 ASCII 型号限定词一并剥离 */
export function cleanLinkName(name: string | null | undefined): string {
if (!name) return '';
const parsed = parseLinkName(name);
const base = parsed
? parsed.skuCode
? `${parsed.productName} ${parsed.skuCode}`
: parsed.productName
: name;
return stripAsciiParenQualifiers(base);
}
const CRAFT_KEYWORD_MAP: Record<string, string> = {
: '直喷',
: '不打印',
: '不打印', // 光板即为不打印
};
/** 链接的定价维度(SKU 表展示用):物流备注 / 工艺 / 印花数量 */
export interface LinkDims {
logistics: string | null;
crafts: string[];
printCount: string | null;
}
/** 从链接名称解析定价维度;工艺无命中时默认烫画(与派生标签规则一致) */
export function linkDims(name: string | null | undefined): LinkDims {
if (!name) return { logistics: null, crafts: [], printCount: null };
const head = name.split('-')[0] ?? '';
const openCandidates = [head.indexOf(''), head.indexOf('(')].filter((i) => i >= 0);
const openIdx = openCandidates.length ? Math.min(...openCandidates) : -1;
const closeIdx = Math.max(head.lastIndexOf(''), head.lastIndexOf(')'));
const logistics =
openIdx >= 0 && closeIdx > openIdx ? head.slice(openIdx + 1, closeIdx).trim() || null : null;
const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))];
// 与 api 端一致:裸「双面/单面」也归入印花数量(如「直喷双面」)
const printCount = name.includes('双面')
? '双面印花'
: name.includes('单面')
? '单面印花'
: null;
return {
logistics,
crafts: craftHits.length > 0 ? craftHits : ['烫画'],
printCount,
};
}
/**
* 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示):
* 印花数量:双面 优先于 单面(含「直喷双面」这类工艺段写法);工艺:直喷/不打印/光板→不打印,皆无则默认烫画;
* 物流:不包邮 优先于 包邮。
*/
export function deriveLinkTagNames(name: string | null | undefined): string[] {
if (!name) return [];
const names: string[] = [];
if (name.includes('双面')) names.push('双面印花');
else if (name.includes('单面')) names.push('单面印花');
const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))];
if (craftHits.length > 0) names.push(...craftHits);
else names.push('烫画');
if (name.includes('不包邮')) names.push('不包邮');
else if (name.includes('包邮')) names.push('包邮');
return names;
}
+146 -52
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, Refresh, Search } from '@element-plus/icons-vue' import { Plus, Edit, Delete, Refresh, Search, Rank } from '@element-plus/icons-vue'
import type { import type {
Country, Country,
CreateCountryRequest, CreateCountryRequest,
@@ -44,6 +44,38 @@ function handleReset() {
fetchList() fetchList()
} }
// ─── 拖拽排序:松开即按新顺序全量保存(sortOrder = 下标) ───
const dragIndex = ref<number | null>(null)
const sorting = ref(false)
function onRowDragStart(index: number) {
dragIndex.value = index
}
async function onRowDrop(index: number) {
const from = dragIndex.value
dragIndex.value = null
if (from === null || from === index || sorting.value) return
const next = [...list.value]
const [moved] = next.splice(from, 1)
next.splice(index, 0, moved)
list.value = next
sorting.value = true
try {
const updated = await countriesApi.sortCountries(
next.map((c, i) => ({ id: String(c.id), sortOrder: i })),
)
const arr = Array.isArray(updated) ? updated : []
if (arr.length) list.value = arr
ElMessage.success('排序已保存')
} catch {
ElMessage.error('排序保存失败')
await fetchList()
} finally {
sorting.value = false
}
}
const dialogRef = ref() const dialogRef = ref()
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create') const dialogMode = ref<'create' | 'edit'>('create')
@@ -145,54 +177,43 @@ onMounted(fetchList)
</el-button> </el-button>
</div> </div>
<el-table v-loading="loading" :data="list" border stripe> <ul v-loading="loading" class="country-list">
<el-table-column label="图标" width="80"> <li
<template #default="{ row }: any"> v-for="(row, index) in list"
<el-image :key="row.id"
v-if="row.countryIcon" class="country-row"
:src="row.countryIcon" :class="{ 'is-dragging': dragIndex === index }"
:preview-src-list="[row.countryIcon]" draggable="true"
fit="cover" title="拖拽调整顺序,松开即保存"
style="width: 32px; height: 32px; border-radius: 4px;" @dragstart="onRowDragStart(index)"
/> @dragover.prevent
<span v-else>-</span> @drop="onRowDrop(index)"
</template> @dragend="dragIndex = null"
</el-table-column> >
<el-table-column prop="countryName" label="名称" min-width="200" /> <el-icon class="drag-handle"><Rank /></el-icon>
<el-table-column label="创建时间" width="180"> <el-image
<template #default="{ row }: any"> v-if="row.countryIcon"
{{ new Date(row.createdAt).toLocaleString() }} :src="row.countryIcon"
</template> :preview-src-list="[row.countryIcon]"
</el-table-column> fit="cover"
<el-table-column label="操作" width="180" fixed="right"> class="row-icon"
<template #default="{ row }: any"> />
<div class="table-actions"> <span v-else class="row-icon row-icon-placeholder">-</span>
<el-button size="small" type="primary" plain @click="openEditDialog(row)"> <span class="row-name">{{ row.countryName }}</span>
<el-icon><Edit /></el-icon> <span class="row-time">{{ new Date(row.createdAt).toLocaleString() }}</span>
<span>编辑</span> <div class="table-actions">
</el-button> <el-button size="small" type="primary" plain @click="openEditDialog(row)">
<el-button size="small" type="danger" plain @click="handleDelete(row)"> <el-icon><Edit /></el-icon>
<el-icon><Delete /></el-icon> <span>编辑</span>
<span>删除</span> </el-button>
</el-button> <el-button size="small" type="danger" plain @click="handleDelete(row)">
</div> <el-icon><Delete /></el-icon>
</template> <span>删除</span>
</el-table-column> </el-button>
<template #empty> </div>
<el-empty description="暂无国家" /> </li>
</template> <li v-if="!loading && !list.length" class="country-empty">暂无国家</li>
</el-table> </ul>
<el-pagination
class="pagination"
v-model:current-page="filter.page"
v-model:page-size="filter.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="(p: number) => { filter.page = p; fetchList() }"
@size-change="(s: number) => { filter.pageSize = s; filter.page = 1; fetchList() }"
/>
</div> </div>
<el-dialog <el-dialog
@@ -224,8 +245,81 @@ onMounted(fetchList)
flex: 1; flex: 1;
} }
.pagination { .country-list {
margin-top: 16px; list-style: none;
justify-content: flex-end; margin: 0;
padding: 0;
}
.country-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-top: none;
cursor: grab;
}
.country-row:first-child {
border-top: 1px solid var(--el-border-color-lighter);
border-radius: 4px 4px 0 0;
}
.country-row:last-child {
border-radius: 0 0 4px 4px;
}
.country-row:only-child {
border-radius: 4px;
}
.country-row.is-dragging {
opacity: 0.5;
}
.country-row:hover {
background: var(--el-fill-color-light);
}
.drag-handle {
color: var(--el-text-color-secondary);
font-size: 16px;
}
.row-icon {
width: 32px;
height: 32px;
border-radius: 4px;
flex: none;
}
.row-icon-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
border: 1px dashed var(--el-border-color);
}
.row-name {
font-weight: 500;
}
.row-time {
margin-left: auto;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.table-actions {
flex: none;
}
.country-empty {
padding: 32px 0;
text-align: center;
color: var(--el-text-color-secondary);
} }
</style> </style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { CategoryTree, Country, Tag } from '@/types'
import { goodsApi } from '@/api/goods'
import { countriesApi } from '@/api/countries'
import { buildCascader } from '@/utils/category-tree'
const props = defineProps<{
visible: boolean
countries: Country[]
categories: CategoryTree[]
/** 分组后的标签选项 {id,label,tags} */
tagOptionGroups: Array<{ id: string; label: string; tags: Tag[] }>
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'created', good: any): void
/** 快捷新建国家后由父级刷新字典 */
(e: 'dictionaries-changed'): void
}>()
const loading = ref(false)
const form = ref({
goodName: '', goodImage: '', goodPrice: '', countryId: '',
cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[],
goodPriority: 0,
})
watch(() => props.visible, (v) => {
if (v) {
form.value = {
goodName: '', goodImage: '', goodPrice: '', countryId: '',
cascaderCategory: [], categoryId: '', tagIds: [], goodPriority: 0,
}
}
})
const categoryCascader = computed(() => buildCascader(props.categories))
function onCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
form.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
async function quickCreateCountry() {
try {
const { value } = await ElMessageBox.prompt('请输入国家名称', '新增国家', {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
})
if (!value.trim()) return
const created = await countriesApi.createCountry({ countryName: value.trim() } as any)
emit('dictionaries-changed')
if (created?.id) form.value.countryId = created.id
ElMessage.success('已创建并选中')
} catch {}
}
async function handleSubmit() {
if (!form.value.goodName.trim()) { ElMessage.warning('请输入商品名称'); return }
if (!form.value.countryId) { ElMessage.warning('请选择国家'); return }
if (!form.value.categoryId) { ElMessage.warning('请选择分类'); return }
loading.value = true
try {
const created = await goodsApi.createCustomGood({
goodName: form.value.goodName.trim(),
goodImage: form.value.goodImage || undefined,
goodPrice: form.value.goodPrice || null,
countryId: Number(form.value.countryId),
categoryId: Number(form.value.categoryId),
tagIds: form.value.tagIds.map(Number),
goodPriority: form.value.goodPriority,
detail: {},
})
ElMessage.success('自定义商品已创建,可继续完善详情、尺码、包装和 SKU')
emit('update:visible', false)
emit('created', created)
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '自定义商品创建失败')
} finally { loading.value = false }
}
</script>
<template>
<el-dialog :model-value="visible" title="新增自定义商品" width="620px" destroy-on-close @update:model-value="emit('update:visible', $event)">
<el-alert
title="自定义商品不关联 SDS 原产品,名称、图片、价格、详情、尺码、包装和 SKU 均可维护。"
type="info"
:closable="false"
style="margin-bottom:16px"
/>
<el-form label-width="90px">
<el-form-item label="商品名称" required><el-input v-model="form.goodName" /></el-form-item>
<el-form-item label="商品图片"><ImageUpload v-model="form.goodImage" label="上传图片" /></el-form-item>
<el-form-item label="基础价格"><el-input v-model="form.goodPrice" placeholder="例如 28.00" /></el-form-item>
<el-form-item label="国家" required>
<div class="select-inline">
<el-select v-model="form.countryId" filterable placeholder="请选择国家" style="width:100%">
<el-option v-for="c in countries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
<el-button text :icon="Plus" @click="quickCreateCountry" />
</div>
</el-form-item>
<el-form-item label="分类" required>
<el-cascader v-model="form.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" style="width:100%" @change="onCascaderChange" />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="form.tagIds" multiple filterable placeholder="请选择标签" style="width:100%">
<el-option-group v-for="g in tagOptionGroups" :key="g.id" :label="g.label">
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="优先级"><el-input-number v-model="form.goodPriority" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">创建并完善详情</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { CategoryTree, Country } from '@/types'
import { goodsApi } from '@/api/goods'
import { productFamiliesApi } from '@/api/product-families'
import { findCategoryPath, buildCascader } from '@/utils/category-tree'
const props = defineProps<{
visible: boolean
/** 拖拽/配置的链接节点(含 rawId/goodName/goodImage/goodPrice/sdsGoodId/familyId */
og: any | null
/** 同分类下的可合并兄弟链接 */
siblings: any[]
/** 默认勾选的兄弟 rawId */
defaultCheckedIds: string[]
/** 左树拖放目标(国家/分类节点),为空时表单手动选 */
dropTarget: any | null
mode: 'category' | 'country' | 'global'
countries: Country[]
categories: CategoryTree[]
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'configured'): void
}>()
const loading = ref(false)
const form = ref({
countryId: '', cascaderCategory: [] as string[], categoryId: '', goodImage: '',
})
watch(() => props.visible, (v) => {
if (!v) return
const og = props.og
form.value = { countryId: '', cascaderCategory: [], categoryId: '', goodImage: og?.goodImage || '' }
if (props.dropTarget) {
if (props.mode === 'category') {
form.value.categoryId = props.dropTarget.id
form.value.cascaderCategory = findCategoryPath(props.categories, props.dropTarget.id)
} else {
form.value.countryId = props.dropTarget.id
}
}
})
const categoryCascader = computed(() => buildCascader(props.categories))
const checkedIds = ref<string[]>([])
watch(() => props.visible, (v) => {
if (v) checkedIds.value = [...props.defaultCheckedIds]
})
// 合并同名候选搜索(本地过滤同分类链接)
const mergeKeyword = ref('')
watch(() => props.visible, (v) => {
if (v) mergeKeyword.value = ''
})
const filteredSiblings = computed(() => {
const kw = mergeKeyword.value.trim()
if (!kw) return props.siblings
return props.siblings.filter((s) => (s.goodName ?? '').includes(kw))
})
function onCascaderChange(val: any) {
form.value.categoryId = val.length ? val[val.length - 1] : ''
}
/** 勾选的兄弟链接与当前链接静默归入同一族(无族则自动建族);失败不阻断原配置流程 */
async function ensureFamilyMembership() {
const og = props.og
const primaryId = String(og.rawId)
const checked = checkedIds.value.filter((id) => id !== primaryId)
if (!checked.length) return
try {
if (og.familyId) {
await productFamiliesApi.updateMembers(og.familyId, {
addOriginGoodIds: [...new Set([primaryId, ...checked])],
})
} else {
await productFamiliesApi.create({
familyName: og.goodName ?? '',
originGoodIds: [primaryId, ...checked],
primaryOriginGoodId: primaryId,
})
}
} catch (e: any) {
ElMessage.warning(e?.response?.data?.message || '挂族失败,商品仍按原方式配置')
}
}
async function handleSubmit() {
const og = props.og
if (!form.value.countryId) { ElMessage.warning('请选择国家'); return }
if (!form.value.categoryId) { ElMessage.warning('请选择分类'); return }
loading.value = true
try {
await ensureFamilyMembership()
await goodsApi.createGood({
goodName: og.goodName,
goodImage: form.value.goodImage || undefined,
originGoodId: Number(og.rawId),
countryId: Number(form.value.countryId),
categoryId: Number(form.value.categoryId),
positionId: undefined,
} as any)
ElMessage.success('配置成功')
emit('update:visible', false)
emit('configured')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '配置失败')
} finally { loading.value = false }
}
</script>
<template>
<el-dialog :model-value="visible" title="配置原产品" width="520px" destroy-on-close @update:model-value="emit('update:visible', $event)">
<div v-if="og" class="config-og-info">
<div>
<div class="config-og-name">{{ og.goodName }}</div>
<div class="config-og-meta">SDS ID: {{ og.sdsGoodId }}<template v-if="og.goodPrice"> · 价格: ¥{{ og.goodPrice }}</template></div>
</div>
</div>
<el-form label-width="80px" style="margin-top: 16px">
<el-form-item label="国家">
<div v-if="mode === 'category' || !dropTarget" class="select-inline">
<el-select v-model="form.countryId" placeholder="请选择国家" filterable>
<el-option v-for="c in countries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
</div>
<el-tag v-else>{{ countries.find(c => c.id === form.countryId)?.countryName }}</el-tag>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-if="mode === 'country' || !dropTarget" v-model="form.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onCascaderChange" style="width:100%" />
<el-tag v-else>{{ dropTarget?.label }}</el-tag>
</el-form-item>
<el-form-item label="合并同名">
<div class="config-merge-box">
<el-input
v-model="mergeKeyword"
size="small"
clearable
placeholder="搜索同款链接(全局,含已配置)…"
style="margin-bottom:8px"
/>
<el-checkbox-group v-model="checkedIds">
<el-checkbox v-for="s in filteredSiblings" :key="s.rawId" :value="String(s.rawId)">
{{ s.goodName }}<template v-if="s.goodPrice"> · ¥{{ s.goodPrice }}</template>
</el-checkbox>
</el-checkbox-group>
<div v-if="!filteredSiblings.length" class="config-merge-empty">
{{ siblings.length ? '没有匹配的链接' : '未找到同款链接同款号或同族' }}
</div>
</div>
</el-form-item>
<el-form-item label="图片">
<ImageUpload v-model="form.goodImage" label="上传图片" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">确认配置</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
.config-og-name { font-weight: 600; font-size: 14px; }
.config-og-meta { color: #909399; font-size: 12px; margin-top: 2px; }
.config-merge-box { width: 100%; }
.config-merge-empty { font-size: 12px; color: #909399; padding: 4px 0; }
.config-merge-box .el-checkbox-group { display: flex; flex-direction: column; align-items: flex-start; max-height: 200px; overflow-y: auto; }
</style>
@@ -0,0 +1,929 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, ArrowDown, QuestionFilled } from '@element-plus/icons-vue'
import type { CategoryTree, Country, Good, GoodDetail, Tag } from '@/types'
import { goodsApi } from '@/api/goods'
import { originGoodsApi } from '@/api/origin-goods'
import { productFamiliesApi } from '@/api/product-families'
import { countriesApi } from '@/api/countries'
import { syncApi } from '@/api/sync'
import { cleanLinkName, deriveLinkTagNames, linkDims } from '@/utils/origin-name'
import type { OriginGoodTagsResult } from '@/types'
import { findCategoryPath, buildCascader } from '@/utils/category-tree'
const props = defineProps<{
visible: boolean
goodId: string
countries: Country[]
categories: CategoryTree[]
/** 分组后的标签选项 {id,label,tags} */
tagOptionGroups: Array<{ id: string; label: string; tags: Tag[] }>
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
/** 商品数据变化(保存/同步/族变更),父级刷新左右树 */
(e: 'changed'): void
(e: 'deleted'): void
/** 快捷新建国家后由父级刷新字典 */
(e: 'dictionaries-changed'): void
}>()
const editLoading = ref(false)
const editDetailLoading = ref(false)
/** 正在同步详情的成员行 id */
const detailSyncing = ref<string>('')
const editGood = ref<Good | GoodDetail | null>(null)
const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', positionId: '',
})
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
const categoryCascader = computed(() => buildCascader(props.categories))
// ─── 自定义商品内容编辑 ───
const customContentForm = ref({
goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined,
minWeightG: '', productionProcess: '', materialDescription: '',
blankDesignUrl: '', detailsPageVideoUrl: '', textureName: '', reminder: '',
productPerformance: '', applicableScenarios: '', washingInstructions: '', specialDescription: '',
designExplanation: '', designArea: '', pictureRequest: '',
sizeChartJson: '{\n "columns": [],\n "rows": []\n}',
packageSpecsJson: '{\n "rows": []\n}',
optionsJson: '{}',
mediaJson: '{}',
variants: [] as Array<{
sku: string; sizeId: string; sizeName: string; colorId: string; colorName: string; colorHex: string; imageUrl: string;
price: string; originalPrice: string; weightG: string; boxLengthCm: string;
boxWidthCm: string; boxHeightCm: string; designDataJson: string; enabled: boolean
}>,
})
function fillCustomContent(g: GoodDetail) {
const detail = g.originDetail ?? {}
customContentForm.value = {
goodPrice: g.originGood?.goodPrice ?? '',
productCode: String(detail.productCode ?? ''),
englishName: String(detail.englishName ?? ''),
productionCycleHours: detail.productionCycleHours == null ? undefined : Number(detail.productionCycleHours),
minWeightG: String(detail.minWeightG ?? ''),
productionProcess: String(detail.productionProcess ?? ''),
materialDescription: String(detail.materialDescription ?? ''),
blankDesignUrl: String(detail.blankDesignUrl ?? ''),
detailsPageVideoUrl: String(detail.detailsPageVideoUrl ?? ''),
textureName: String(detail.textureName ?? ''),
reminder: String(detail.reminder ?? ''),
productPerformance: String(detail.productPerformance ?? ''),
applicableScenarios: String(detail.applicableScenarios ?? ''),
washingInstructions: String(detail.washingInstructions ?? ''),
specialDescription: String(detail.specialDescription ?? ''),
designExplanation: String(detail.designExplanation ?? ''),
designArea: String(detail.designArea ?? ''),
pictureRequest: String(detail.pictureRequest ?? ''),
sizeChartJson: JSON.stringify(detail.sizeChart ?? { columns: [], rows: [] }, null, 2),
packageSpecsJson: JSON.stringify(detail.packageSpecs ?? { rows: [] }, null, 2),
optionsJson: JSON.stringify(detail.options ?? {}, null, 2),
mediaJson: JSON.stringify(detail.media ?? {}, null, 2),
variants: g.variants.map((variant) => ({
sku: variant.sku,
sizeId: String(variant.sizeId ?? ''),
sizeName: String(variant.sizeName ?? ''),
colorId: String(variant.colorId ?? ''),
colorName: String(variant.colorName ?? ''),
colorHex: String(variant.colorHex ?? ''),
imageUrl: String(variant.imageUrl ?? ''),
price: String(variant.price ?? ''),
originalPrice: String(variant.originalPrice ?? ''),
weightG: String(variant.weightG ?? ''),
boxLengthCm: String(variant.boxLengthCm ?? ''),
boxWidthCm: String(variant.boxWidthCm ?? ''),
boxHeightCm: String(variant.boxHeightCm ?? ''),
designDataJson: JSON.stringify(variant.designData ?? {}, null, 2),
enabled: variant.enabled,
})),
}
}
function addCustomVariant() {
customContentForm.value.variants.push({
sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', colorHex: '', imageUrl: '', price: '',
originalPrice: '', weightG: '', boxLengthCm: '', boxWidthCm: '', boxHeightCm: '', designDataJson: '{}', enabled: true,
})
}
// ─── 关联原产品(族成员,可展开查看详情 / 配标签 / 改价格) ───
interface FamilyMemberRow {
id: string
goodName: string
goodImage: string | null
source: string
delisted: boolean
sdsGoodId: string
goodPrice: string | null
variantCount: number
logisticsLabel: string | null
craftLabel: string | null
warehouseLabel: string | null
tagsManual: boolean
tags: Array<{ id: string; tagName: string; tagColor: string | null; manual: boolean }>
}
interface MemberPriceCell {
sizeId: string
colorId: string
sizeName: string | null
colorName: string | null
printCount: string
craft: string
logistics: string
price: string
manual: boolean
editPrice: string
}
const editFamilyId = ref<string>('')
const editFamilyCode = ref<string | null>(null)
const editFamilyMembers = ref<FamilyMemberRow[]>([])
const familyMemberCount = ref<number | null>(null)
const editFamilyLoaded = ref(false)
const editFamilyAddKw = ref('')
const editFamilyCandidates = ref<Array<{ id: string; goodName: string }>>([])
const expandedMemberKeys = ref<Set<string>>(new Set())
const memberTagEdits = ref<Record<string, string[]>>({})
const memberTagSaving = ref<string>('')
/** 成员展开面板的 尺码×颜色→价格 编辑态(key = og id */
const memberPrices = ref<Record<string, MemberPriceCell[]>>({})
const memberPriceSaving = ref<string>('')
/** 族原始详情(含 priceMatrix */
const familyDetailRaw = ref<any>(null)
// ─── 族级并集视图:尺码表/包装规格(族物化并集)+ SKU(价格矩阵 = 各链接动态合并) ───
// 尺码行把 measurements 数组摊平成 { 列key: cm }(与成员级渲染一致,否则格子全空)
const familySizeRows = computed(() =>
((familyDetailRaw.value?.sizeChart?.rows ?? []) as any[]).map((row) => ({
...row,
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
out[item.key] = item.cm ?? '-'
return out
}, {}),
})),
)
const familySizeColumns = computed(() => familyDetailRaw.value?.sizeChart?.columns ?? [])
const familyPackageRows = computed(() => familyDetailRaw.value?.packageSpecs?.rows ?? [])
const familySkuRows = computed(() => {
const rows = (familyDetailRaw.value?.priceMatrix?.rows ?? []) as any[]
return rows.map((r) => ({ ...r, sourceCount: Array.isArray(r.sources) ? r.sources.length : 0 }))
})
function toggleMemberExpand(key: string) {
const next = new Set(expandedMemberKeys.value)
if (next.has(key)) next.delete(key)
else {
next.add(key)
loadMemberPrices(key)
ensureMemberDetail(key)
}
expandedMemberKeys.value = next
}
// ─── 成员详情(懒加载:GET /origin-goods/:id ───
const memberDetailCache = ref<Record<string, any>>({})
function memberDetail(id: string) {
return memberDetailCache.value[id]
}
async function ensureMemberDetail(ogId: string) {
const row = editFamilyMembers.value.find((m) => m.id === ogId)
if (!row || row.source !== 'SDS' || memberDetailCache.value[ogId] !== undefined) return
memberDetailCache.value[ogId] = { loading: true }
try {
memberDetailCache.value[ogId] = await originGoodsApi.getOriginGood(ogId)
} catch {
memberDetailCache.value[ogId] = null
}
}
function memberSizeColumns(id: string) {
return memberDetail(id)?.detail?.sizeChart?.columns ?? []
}
function memberSizeRows(id: string) {
const rows = memberDetail(id)?.detail?.sizeChart?.rows ?? []
return rows.map((row: any) => ({
...row,
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
out[item.key] = item.cm ?? '-'
return out
}, {}),
}))
}
function memberPackageRows(id: string) {
return memberDetail(id)?.detail?.packageSpecs?.rows ?? []
}
function memberVariants(id: string) {
return memberDetail(id)?.variants ?? []
}
// 矩阵三个归因维度的合法取值(与 api 端 auto-tag-rules 一致)
const PRINT_COUNT_VALUES = ['单面印花', '双面印花']
const CRAFT_VALUES = ['烫画', '直喷', '不打印']
const LOGISTICS_VALUES = ['包邮', '不包邮']
/** 成员的矩阵归因维度:优先取有效标签(人工接管后仍准确),回退按名称解析 */
function memberDims(id: string) {
const row = editFamilyMembers.value.find((m) => m.id === id)
const fallback = linkDims(row?.goodName ?? null)
const tagNames = ((row as any)?.originGoodTags ?? [])
.map((r: any) => r.tag?.tagName)
.filter(Boolean) as string[]
const crafts = CRAFT_VALUES.filter((v) => tagNames.includes(v))
return {
logistics: LOGISTICS_VALUES.find((v) => tagNames.includes(v)) ?? fallback.logistics,
crafts: crafts.length ? crafts : fallback.crafts,
printCount: PRINT_COUNT_VALUES.find((v) => tagNames.includes(v)) ?? fallback.printCount,
}
}
async function handleSyncMemberDetail(row: { id: string; sdsGoodId: string }) {
if (!row.sdsGoodId || detailSyncing.value) return
detailSyncing.value = row.id
try {
const result = await syncApi.syncOneProductDetail(row.sdsGoodId)
memberDetailCache.value[row.id] = await originGoodsApi.getOriginGood(row.id)
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
emit('changed')
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
} finally { detailSyncing.value = '' }
}
/** 该链接在族价格矩阵中的格子(印花数量/工艺/物流 三维即该链接的归因标签) */
function loadMemberPrices(ogId: string) {
const dims = memberDims(ogId)
if (!dims.printCount || !dims.crafts.length || !dims.logistics) {
memberPrices.value[ogId] = []
return
}
const matrixRows = (familyDetailRaw.value?.priceMatrix?.rows ?? []) as any[]
memberPrices.value[ogId] = matrixRows
.filter((r: any) =>
r.printCount === dims.printCount &&
dims.crafts.includes(r.craft) &&
r.logistics === dims.logistics)
.map((r: any) => ({
sizeId: String(r.sizeId),
colorId: String(r.colorId),
sizeName: r.sizeName ?? null,
colorName: r.colorName ?? null,
printCount: r.printCount,
craft: r.craft,
logistics: r.logistics,
price: String(r.price ?? ''),
manual: Boolean(r.manual),
editPrice: String(r.price ?? ''),
}))
}
async function saveMemberPrices(row: { id: string }) {
const cells = (memberPrices.value[row.id] ?? []).filter((c) => c.editPrice !== '' && c.editPrice !== c.price)
if (!cells.length) { ElMessage.info('价格没有修改'); return }
memberPriceSaving.value = row.id
try {
await productFamiliesApi.putOverrides(editFamilyId.value, cells.map((c) => ({
sizeId: c.sizeId, colorId: c.colorId, printCount: c.printCount, craft: c.craft, logistics: c.logistics, price: c.editPrice,
})))
ElMessage.success('价格已保存')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally { memberPriceSaving.value = '' }
}
function initEditFamily(family: { familyId?: string; familyCode?: string | null } | null) {
editFamilyId.value = family?.familyId ?? ''
editFamilyCode.value = family?.familyCode ?? null
editFamilyMembers.value = []
familyMemberCount.value = null
editFamilyLoaded.value = false
editFamilyAddKw.value = ''
editFamilyCandidates.value = []
expandedMemberKeys.value = new Set()
memberTagEdits.value = {}
memberPrices.value = {}
familyDetailRaw.value = null
if (editFamilyId.value) loadEditFamily()
}
async function loadEditFamily() {
if (!editFamilyId.value) return
try {
const f = await productFamiliesApi.detail(editFamilyId.value) as any
familyDetailRaw.value = f
editFamilyCode.value = f.familyCode
familyMemberCount.value = f._count.originGoods
const primaryOgId = String(editGood.value?.originGoodId ?? '')
const members: FamilyMemberRow[] = (f.originGoods ?? []).map((m: any) => ({
id: String(m.id),
goodName: m.goodName ?? '',
goodImage: m.goodImage ?? null,
source: m.source,
delisted: Boolean(m.delisted),
sdsGoodId: m.sdsGoodId ?? '',
goodPrice: m.goodPrice ?? null,
variantCount: m._count?.variants ?? 0,
logisticsLabel: m.logisticsLabel ?? null,
craftLabel: m.craftLabel ?? null,
warehouseLabel: m.warehouseLabel ?? null,
tagsManual: Boolean(m.tagsManual),
tags: (m.originGoodTags ?? []).map((t: any) => ({
id: String(t.tag.id),
tagName: t.tag.tagName,
tagColor: t.tag.tagColor ?? null,
manual: Boolean(t.manual),
})),
}))
// 当前商品的链接排最前,其余按 id 稳定排序(界面上不区分主次)
members.sort((a, b) => {
const aP = a.id === primaryOgId ? 0 : 1
const bP = b.id === primaryOgId ? 0 : 1
return aP - bP || a.id.localeCompare(b.id)
})
editFamilyMembers.value = members
const edits: Record<string, string[]> = {}
for (const m of members) edits[m.id] = m.tags.map((t) => t.id)
memberTagEdits.value = edits
for (const key of expandedMemberKeys.value) loadMemberPrices(key)
} finally {
editFamilyLoaded.value = true
}
}
async function saveMemberTags(row: { id: string }) {
memberTagSaving.value = row.id
try {
const res = await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
const filled = (res as OriginGoodTagsResult | undefined)?.filledDimensionTags ?? []
ElMessage.success(filled.length ? `标签已保存(缺维度已自动补齐:${filled.join('、')}` : '标签已保存')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally { memberTagSaving.value = '' }
}
async function resetMemberTags(row: { id: string }) {
memberTagSaving.value = row.id
try {
await originGoodsApi.resetTags(row.id)
ElMessage.success('已恢复按链接名称自动解析')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '恢复失败')
} finally { memberTagSaving.value = '' }
}
async function searchFamilyCandidates(q: string) {
if (!q) { editFamilyCandidates.value = []; return }
try {
const res = await originGoodsApi.getOriginGoodsList({ keyword: q, page: 1, pageSize: 20 })
editFamilyCandidates.value = res.items.map((o: any) => ({ id: String(o.id), goodName: o.goodName ?? o.sdsGoodId }))
} catch { editFamilyCandidates.value = [] }
}
async function addFamilyMember(c: { id: string; goodName: string }) {
if (!editFamilyId.value) { ElMessage.warning('该商品尚未成族'); return }
try {
await productFamiliesApi.updateMembers(editFamilyId.value, { addOriginGoodIds: [c.id] })
ElMessage.success('已加入族')
editFamilyAddKw.value = ''
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '加入失败')
}
}
async function removeFamilyMember(m: { id: string }) {
if (!editFamilyId.value) return
try {
await ElMessageBox.confirm('确定把该链接移出族吗?', '确认', {
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
})
} catch { return }
try {
await productFamiliesApi.updateMembers(editFamilyId.value, { removeOriginGoodIds: [m.id] })
ElMessage.success('已移除出族')
await loadEditFamily()
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '移除失败')
}
}
// ─── 打开 / 保存 / 删除 / 同步 ───
watch(() => props.visible, (v) => {
if (v && props.goodId) initLoad()
})
async function initLoad() {
editGood.value = null
editDetailLoading.value = true
try {
const detail = await goodsApi.getGoodById(props.goodId)
applyGood(detail)
} catch {
ElMessage.warning('商品详情加载失败')
} finally {
editDetailLoading.value = false
}
}
function applyGood(g: GoodDetail) {
editGood.value = g
// 名称展示解析为「品名 型号」,保存后即采用解析名
editForm.value = {
id: g.id,
goodName: cleanLinkName(g.goodName) || g.goodName,
goodImage: g.goodImage || g.originGood?.goodImage || '',
countryId: g.countryId,
cascaderCategory: findCategoryPath(props.categories, g.categoryId),
categoryId: g.categoryId,
positionId: g.positionId || '',
}
initEditFamily((g as any).originGood?.family ?? null)
if (g.originGood?.isCustom) fillCustomContent(g)
}
async function quickCreateCountry() {
try {
const { value } = await ElMessageBox.prompt('请输入国家名称', '新增国家', {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
})
if (!value.trim()) return
const created = await countriesApi.createCountry({ countryName: value.trim() } as any)
emit('dictionaries-changed')
if (created?.id) editForm.value.countryId = created.id
ElMessage.success('已创建并选中')
} catch {}
}
function onEditCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
editForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
async function handleEditSubmit() {
let customPayload: any = null
if (editIsCustom.value) {
let sizeChart: Record<string, unknown>
let packageSpecs: Record<string, unknown>
let options: Record<string, unknown>
let media: Record<string, unknown>
try {
sizeChart = JSON.parse(customContentForm.value.sizeChartJson)
packageSpecs = JSON.parse(customContentForm.value.packageSpecsJson)
options = JSON.parse(customContentForm.value.optionsJson)
media = JSON.parse(customContentForm.value.mediaJson)
for (const variant of customContentForm.value.variants) JSON.parse(variant.designDataJson)
} catch {
ElMessage.error('尺码表、包装规格、选项、媒体或 SKU 设计数据不是有效 JSON')
return
}
if (customContentForm.value.variants.some((variant) => !variant.sku.trim())) {
ElMessage.error('SKU 不能为空')
return
}
customPayload = {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
goodPrice: customContentForm.value.goodPrice || null,
detail: {
productCode: customContentForm.value.productCode || null,
englishName: customContentForm.value.englishName || null,
productionCycleHours: customContentForm.value.productionCycleHours ?? null,
minWeightG: customContentForm.value.minWeightG || null,
productionProcess: customContentForm.value.productionProcess || null,
materialDescription: customContentForm.value.materialDescription || null,
blankDesignUrl: customContentForm.value.blankDesignUrl || null,
detailsPageVideoUrl: customContentForm.value.detailsPageVideoUrl || null,
textureName: customContentForm.value.textureName || null,
reminder: customContentForm.value.reminder || null,
productPerformance: customContentForm.value.productPerformance || null,
applicableScenarios: customContentForm.value.applicableScenarios || null,
washingInstructions: customContentForm.value.washingInstructions || null,
specialDescription: customContentForm.value.specialDescription || null,
designExplanation: customContentForm.value.designExplanation || null,
designArea: customContentForm.value.designArea || null,
pictureRequest: customContentForm.value.pictureRequest || null,
sizeChart,
packageSpecs,
options,
media,
},
variants: customContentForm.value.variants.map((variant: any, index: number) => ({
sku: variant.sku.trim(),
sizeId: variant.sizeId || null,
sizeName: variant.sizeName || null,
colorId: variant.colorId || null,
colorName: variant.colorName || null,
colorHex: variant.colorHex || null,
imageUrl: variant.imageUrl || null,
price: variant.price || null,
originalPrice: variant.originalPrice || null,
weightG: variant.weightG || null,
boxLengthCm: variant.boxLengthCm || null,
boxWidthCm: variant.boxWidthCm || null,
boxHeightCm: variant.boxHeightCm || null,
designData: JSON.parse(variant.designDataJson),
enabled: variant.enabled,
sortOrder: index,
})),
}
}
editLoading.value = true
try {
await goodsApi.updateGood(editForm.value.id, {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
countryId: Number(editForm.value.countryId),
categoryId: Number(editForm.value.categoryId),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
} as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
ElMessage.success('更新成功')
emit('update:visible', false)
emit('changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '更新失败')
} finally { editLoading.value = false }
}
async function handleDeleteGood() {
const g = editGood.value
if (!g) return
try {
await ElMessageBox.confirm(`确定删除「${g.goodName}」吗?`, '确认', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
} catch { return }
await goodsApi.deleteGood(g.id)
ElMessage.success('删除成功')
emit('update:visible', false)
emit('deleted')
}
</script>
<template>
<el-dialog :model-value="visible" :title="cleanLinkName(editGood?.goodName) || '编辑商品'" width="900px" destroy-on-close @update:model-value="emit('update:visible', $event)">
<div v-if="!editIsCustom" class="edit-merged-box">
<div class="edit-merged-title">
关联原产品
<el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵。展开成员可查看详情、修正标签、调整价格。">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<span v-if="editFamilyCode" class="edit-family-code"> {{ editFamilyCode }} · {{ familyMemberCount ?? editFamilyMembers.length }} 条链接</span>
</div>
<div class="edit-merged-list">
<template v-for="row in editFamilyMembers" :key="row.id">
<div class="edit-merged-item">
<el-icon
class="member-expand-toggle"
:class="{ 'is-expanded': expandedMemberKeys.has(row.id) }"
@click="toggleMemberExpand(row.id)"
><ArrowDown /></el-icon>
<span class="edit-merged-name" :title="row.goodName">{{ row.goodName }}</span>
<span v-if="row.tags.length || deriveLinkTagNames(row.goodName).length" class="member-chips">
<template v-if="row.tags.length">
<span v-for="c in row.tags" :key="c.id" class="member-chip" :class="{ 'is-manual': c.manual }">{{ c.tagName }}</span>
</template>
<template v-else>
<span v-for="c in deriveLinkTagNames(row.goodName)" :key="c" class="member-chip is-preview">{{ c }}</span>
</template>
</span>
<span v-if="row.tagsManual" class="member-manual-flag" title="人工接管:自动同步不再覆盖该链接的标签">人工</span>
<div class="edit-merged-actions">
<el-button size="small" link type="danger" @click="removeFamilyMember(row)">移除出族</el-button>
</div>
</div>
<div v-if="expandedMemberKeys.has(row.id)" class="member-detail-panel">
<div class="member-detail-grid">
<span class="md-label">SDS ID</span><span class="md-val">{{ row.sdsGoodId || '-' }}</span>
<span class="md-label">链接价格</span><span class="md-val">{{ row.goodPrice ? `¥${row.goodPrice}` : '-' }}</span>
<span class="md-label">SKU </span><span class="md-val">{{ row.id === editGood?.originGoodId ? (editGood?.originGood?.variantCount ?? row.variantCount) : row.variantCount }}</span>
<span class="md-label">物流备注</span><span class="md-val">{{ row.logisticsLabel || '-' }}</span>
<span class="md-label">工艺位置</span><span class="md-val">{{ row.craftLabel || '-' }}</span>
<span class="md-label">仓库</span><span class="md-val">{{ row.warehouseLabel || '-' }}</span>
</div>
<div class="member-tags-edit">
<span class="md-label">标签</span>
<el-select
v-model="memberTagEdits[row.id]"
multiple filterable size="small"
placeholder="选择标签"
style="flex:1"
>
<el-option-group v-for="g in tagOptionGroups" :key="g.id" :label="g.label">
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
<el-button size="small" type="primary" :loading="memberTagSaving === row.id" @click="saveMemberTags(row)">保存标签</el-button>
<el-button v-if="row.tagsManual" size="small" :loading="memberTagSaving === row.id" @click="resetMemberTags(row)">恢复自动</el-button>
</div>
<div v-if="row.craftLabel && row.logisticsLabel" class="member-price-block">
<div class="member-price-head">
<el-button size="small" type="primary" :loading="memberPriceSaving === row.id" @click="saveMemberPrices(row)">保存价格</el-button>
</div>
<el-table :data="memberPrices[row.id] ?? []" border size="small" max-height="240">
<el-table-column prop="sizeName" label="尺码" width="100" />
<el-table-column prop="colorName" label="颜色" min-width="120" />
<el-table-column label="价格" width="130">
<template #default="{ row: cell }">
<el-input v-model="cell.editPrice" size="small" @input="cell.editPrice = String(cell.editPrice)" />
</template>
</el-table-column>
</el-table>
</div>
<div v-if="row.source === 'SDS'" class="member-extra">
<div class="member-detail-head">
<span v-if="!memberDetail(row.id) || memberDetail(row.id).loading" class="md-empty">详情加载中</span>
<span v-else-if="!memberDetail(row.id).hasDetail" class="md-empty">尚未同步详情</span>
<el-button
size="small"
:icon="Refresh"
:loading="detailSyncing === row.id"
@click="handleSyncMemberDetail(row)"
>{{ memberDetail(row.id)?.hasDetail ? '重新同步详情' : '同步详情' }}</el-button>
</div>
<el-tabs v-if="memberDetail(row.id) && memberDetail(row.id).hasDetail" class="member-detail-tabs">
<el-tab-pane label="商品详情">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品编码">{{ memberDetail(row.id).detail.productCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="英文名称">{{ memberDetail(row.id).detail.englishName || '-' }}</el-descriptions-item>
<el-descriptions-item label="生产周期">{{ memberDetail(row.id).detail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
<el-descriptions-item label="净重">{{ memberDetail(row.id).detail.minWeightG ?? '-' }} g</el-descriptions-item>
<el-descriptions-item label="生产工艺">{{ memberDetail(row.id).detail.productionProcess || '-' }}</el-descriptions-item>
<el-descriptions-item label="材质">{{ memberDetail(row.id).detail.materialDescription || '-' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane :label="`尺码表 (${memberSizeRows(row.id).length})`">
<el-table :data="memberSizeRows(row.id)" border size="small" max-height="240">
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
<el-table-column
v-for="column in memberSizeColumns(row.id)"
:key="column.key"
:prop="column.key"
:label="`${column.name} (cm)`"
min-width="110"
/>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`包装规格 (${memberPackageRows(row.id).length})`">
<el-table :data="memberPackageRows(row.id)" border size="small" max-height="240">
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
<el-table-column label="包装尺寸 (cm)" min-width="150">
<template #default="{ row: pkg }">
{{ pkg.dimensionsCm ? `${pkg.dimensionsCm.length}×${pkg.dimensionsCm.width}×${pkg.dimensionsCm.height}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="volumeCm3" label="体积 (cm³)" width="110" />
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="140" />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`SKU (${memberVariants(row.id).length})`">
<el-table :data="memberVariants(row.id)" border size="small" max-height="240">
<el-table-column prop="sku" label="SKU" min-width="160" fixed />
<el-table-column label="物流" width="100">
<template #default>{{ memberDims(row.id).logistics || '-' }}</template>
</el-table-column>
<el-table-column label="工艺" width="90">
<template #default>{{ memberDims(row.id).crafts.join(' / ') || '-' }}</template>
</el-table-column>
<el-table-column label="印花数量" width="95">
<template #default>{{ memberDims(row.id).printCount || '-' }}</template>
</el-table-column>
<el-table-column prop="sizeName" label="尺码" width="85" />
<el-table-column prop="colorName" label="颜色" width="100" />
<el-table-column prop="price" label="价格" width="90" />
<el-table-column label="状态" width="80">
<template #default="{ row: variant }">
<el-tag :type="variant.enabled ? 'success' : 'info'" size="small">{{ variant.enabled ? '可用' : '停用' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中</div>
<div v-else-if="!editFamilyId" class="edit-family-loading">暂未成族配置合并时自动成族</div>
</div>
<el-select v-model="editFamilyAddKw" filterable remote :remote-method="searchFamilyCandidates"
placeholder="搜索原产品名称添加到族" clearable style="width:100%">
<el-option v-for="c in editFamilyCandidates" :key="c.id" :label="c.goodName" :value="String(c.id)"
@click="addFamilyMember(c)" />
</el-select>
</div>
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
<el-form-item label="图片">
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
</el-form-item>
<el-form-item label="国家">
<div class="select-inline">
<el-select v-model="editForm.countryId" filterable placeholder="请选择国家">
<el-option v-for="c in countries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
<el-button text :icon="Plus" @click="quickCreateCountry" />
</div>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" />
</el-form-item>
<el-form-item v-if="editIsCustom" label="基础价格">
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
</el-form-item>
</el-form>
<!-- 自定义商品内容编辑SDS 商品的详情在关联原产品成员面板内查看 -->
<el-tabs v-if="editIsCustom" class="detail-tabs">
<el-tab-pane label="商品详情">
<el-form label-width="100px" class="custom-detail-form">
<el-form-item label="商品编码"><el-input v-model="customContentForm.productCode" /></el-form-item>
<el-form-item label="英文名称"><el-input v-model="customContentForm.englishName" /></el-form-item>
<el-form-item label="生产周期"><el-input-number v-model="customContentForm.productionCycleHours" :min="0" /><span style="margin-left:8px">小时</span></el-form-item>
<el-form-item label="净重"><el-input v-model="customContentForm.minWeightG"><template #append>g</template></el-input></el-form-item>
<el-form-item label="生产工艺"><el-input v-model="customContentForm.productionProcess" type="textarea" /></el-form-item>
<el-form-item label="材质"><el-input v-model="customContentForm.materialDescription" type="textarea" /></el-form-item>
<el-form-item label="空白设计图"><el-input v-model="customContentForm.blankDesignUrl" /></el-form-item>
<el-form-item label="详情视频"><el-input v-model="customContentForm.detailsPageVideoUrl" /></el-form-item>
<el-form-item label="面料名称"><el-input v-model="customContentForm.textureName" /></el-form-item>
<el-form-item label="温馨提示"><el-input v-model="customContentForm.reminder" type="textarea" /></el-form-item>
<el-form-item label="产品性能"><el-input v-model="customContentForm.productPerformance" type="textarea" /></el-form-item>
<el-form-item label="适用场景"><el-input v-model="customContentForm.applicableScenarios" type="textarea" /></el-form-item>
<el-form-item label="洗涤说明"><el-input v-model="customContentForm.washingInstructions" type="textarea" /></el-form-item>
<el-form-item label="特殊说明"><el-input v-model="customContentForm.specialDescription" type="textarea" /></el-form-item>
<el-form-item label="设计说明"><el-input v-model="customContentForm.designExplanation" type="textarea" /></el-form-item>
<el-form-item label="设计区域"><el-input v-model="customContentForm.designArea" /></el-form-item>
<el-form-item label="图片要求"><el-input v-model="customContentForm.pictureRequest" type="textarea" /></el-form-item>
<el-form-item label="商品选项 JSON"><el-input v-model="customContentForm.optionsJson" type="textarea" :rows="6" /></el-form-item>
<el-form-item label="媒体数据 JSON"><el-input v-model="customContentForm.mediaJson" type="textarea" :rows="6" /></el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="尺码表">
<el-input v-model="customContentForm.sizeChartJson" type="textarea" :rows="14" placeholder="尺码表 JSON" />
</el-tab-pane>
<el-tab-pane label="包装规格">
<el-input v-model="customContentForm.packageSpecsJson" type="textarea" :rows="14" placeholder="包装规格 JSON" />
</el-tab-pane>
<el-tab-pane :label="`SKU (${customContentForm.variants.length})`">
<div style="display:flex;justify-content:flex-end;margin-bottom:8px"><el-button size="small" :icon="Plus" @click="addCustomVariant">添加 SKU</el-button></div>
<el-table :data="customContentForm.variants" border max-height="320">
<el-table-column label="SKU" min-width="160"><template #default="{ row }"><el-input v-model="row.sku" /></template></el-table-column>
<el-table-column label="尺码 ID" width="120"><template #default="{ row }"><el-input v-model="row.sizeId" /></template></el-table-column>
<el-table-column label="尺码" width="120"><template #default="{ row }"><el-input v-model="row.sizeName" /></template></el-table-column>
<el-table-column label="颜色 ID" width="120"><template #default="{ row }"><el-input v-model="row.colorId" /></template></el-table-column>
<el-table-column label="颜色" width="120"><template #default="{ row }"><el-input v-model="row.colorName" /></template></el-table-column>
<el-table-column label="色值" width="120"><template #default="{ row }"><el-input v-model="row.colorHex" placeholder="#FFFFFF" /></template></el-table-column>
<el-table-column label="图片" min-width="180"><template #default="{ row }"><el-input v-model="row.imageUrl" /></template></el-table-column>
<el-table-column label="价格" width="120"><template #default="{ row }"><el-input v-model="row.price" /></template></el-table-column>
<el-table-column label="原价" width="120"><template #default="{ row }"><el-input v-model="row.originalPrice" /></template></el-table-column>
<el-table-column label="重量(g)" width="120"><template #default="{ row }"><el-input v-model="row.weightG" /></template></el-table-column>
<el-table-column label="包装长(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxLengthCm" /></template></el-table-column>
<el-table-column label="包装宽(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxWidthCm" /></template></el-table-column>
<el-table-column label="包装高(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxHeightCm" /></template></el-table-column>
<el-table-column label="设计数据 JSON" min-width="220"><template #default="{ row }"><el-input v-model="row.designDataJson" type="textarea" :rows="2" /></template></el-table-column>
<el-table-column label="启用" width="80"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
<el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<!-- 族级并集视图弹窗最底部尺码表/包装规格为族并集SKU 为价格矩阵动态合并 -->
<el-tabs v-if="!editIsCustom && editFamilyId && editFamilyLoaded" class="detail-tabs">
<el-tab-pane :label="`尺码表 (${familySizeRows.length})`">
<el-table :data="familySizeRows" border size="small" max-height="260">
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
<el-table-column
v-for="column in familySizeColumns"
:key="column.key"
:prop="column.key"
:label="`${column.name} (cm)`"
min-width="110"
/>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`包装规格 (${familyPackageRows.length})`">
<el-table :data="familyPackageRows" border size="small" max-height="260">
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
<el-table-column label="包装尺寸 (cm)" min-width="150">
<template #default="{ row }">
{{ row.dimensionsCm ? `${row.dimensionsCm.length}×${row.dimensionsCm.width}×${row.dimensionsCm.height}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="volumeCm3" label="体积 (cm³)" width="110" />
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="140" />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`SKU (${familySkuRows.length})`">
<el-table :data="familySkuRows" border size="small" max-height="260">
<el-table-column label="物流" width="110">
<template #default="{ row }">{{ row.logistics || '-' }}</template>
</el-table-column>
<el-table-column label="工艺" width="100">
<template #default="{ row }">{{ row.craft || '-' }}</template>
</el-table-column>
<el-table-column label="印花数量" width="100">
<template #default="{ row }">{{ row.printCount || '-' }}</template>
</el-table-column>
<el-table-column prop="sizeName" label="尺码" width="90" />
<el-table-column prop="colorName" label="颜色" min-width="110" />
<el-table-column label="价格" width="100">
<template #default="{ row }">{{ row.price }}</template>
</el-table-column>
<el-table-column label="链接数" width="80">
<template #default="{ row }">{{ row.sourceCount || 1 }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.manual ? 'warning' : 'success'" size="small">{{ row.manual ? '人工价' : '推导价' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button type="danger" @click="handleDeleteGood">删除</el-button>
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="handleEditSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.edit-merged-box { margin-bottom: 4px; }
.edit-merged-title {
display: flex; align-items: center; gap: 4px;
font-weight: 600; font-size: 13px; margin-bottom: 8px;
}
.edit-family-code { margin-left: auto; font-weight: 400; font-size: 12px; color: var(--el-color-primary); }
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.edit-merged-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: #f5f7fa; border-radius: 6px; font-size: 13px;
}
.edit-merged-name {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.edit-merged-actions { flex-shrink: 0; display: flex; align-items: center; }
.edit-merged-actions .el-button + .el-button { margin-left: 4px; }
.member-chips { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: nowrap; overflow: hidden; }
.member-chip {
font-size: 11px; line-height: 16px; padding: 1px 7px; border-radius: 8px;
background: #ecf5ff; color: var(--el-color-primary); white-space: nowrap;
}
.member-chip.is-manual { background: #f0f9eb; color: var(--el-color-success); }
.member-chip.is-preview { background: #f4f4f5; color: #909399; border: 1px dashed #dcdfe6; }
.member-manual-flag {
font-size: 11px; line-height: 16px; padding: 0 6px; border-radius: 4px;
background: #f0f9eb; color: var(--el-color-success); flex-shrink: 0;
}
.member-expand-toggle { cursor: pointer; flex-shrink: 0; color: #909399; transition: transform 0.18s; }
.member-expand-toggle.is-expanded { transform: rotate(180deg); }
.member-detail-panel {
background: #fbfcfe; border: 1px solid #ebeef5; border-radius: 6px;
padding: 10px 12px; margin: -2px 0 2px 30px;
}
.member-detail-grid {
display: grid; grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr);
gap: 4px 10px; font-size: 12px; margin-bottom: 8px;
}
.md-label { color: #909399; font-size: 12px; }
.md-val { color: #606266; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.member-tags-edit { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.member-tags-edit .md-label { flex-shrink: 0; }
.member-price-block { border-top: 1px dashed #e4e7ed; padding-top: 8px; }
.member-price-head { display: flex; align-items: center; justify-content: flex-end; margin-bottom: 6px; }
.member-extra { border-top: 1px dashed #e4e7ed; padding-top: 8px; }
.md-empty {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-size: 12px; color: var(--el-text-color-placeholder); padding: 4px 0;
}
.member-detail-head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin-bottom: 4px;
}
.member-detail-head .md-empty { padding: 0; }
.member-detail-tabs :deep(.el-tabs__header) { margin-bottom: 8px; }
.edit-family-loading { color: var(--el-text-color-placeholder); font-size: 12px; padding: 4px 0; }
.family-detail-tabs { margin-top: 10px; }
.family-detail-tabs :deep(.el-tabs__header) { margin-bottom: 8px; }
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
.select-inline { display: flex; align-items: center; gap: 6px; width: 100%; }
</style>
@@ -0,0 +1,478 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, ArrowDown } from '@element-plus/icons-vue'
import type { Tag, TagGroup } from '@/types'
import { tagsApi } from '@/api/tags'
import { tagGroupsApi } from '@/api/tag-groups'
const props = defineProps<{
tags: Tag[]
tagGroups: TagGroup[]
selected: string[]
}>()
const emit = defineEmits<{
(e: 'update:selected', v: string[]): void
/** 标签/分组变化后由父级刷新字典 */
(e: 'dictionaries-changed'): void
}>()
const selectedTagIds = computed({
get: () => props.selected,
set: (v: string[]) => emit('update:selected', v),
})
interface TreeNode {
id: string
rawId: string | null
type: 'group' | 'tag'
label: string
disabled?: boolean
children?: TreeNode[]
}
const tagPopoverVisible = ref(false)
const hoveredNodeId = ref<string | null>(null)
const MAX_VISIBLE_TAGS = 1
const displayedSelectedTagIds = computed(() =>
selectedTagIds.value.slice(0, MAX_VISIBLE_TAGS),
)
const hiddenSelectedCount = computed(() =>
Math.max(0, selectedTagIds.value.length - MAX_VISIBLE_TAGS),
)
function getTagName(id: string): string {
return props.tags.find((t) => t.id === id)?.tagName ?? id
}
function removeSelectedTag(id: string): void {
selectedTagIds.value = selectedTagIds.value.filter((v) => v !== id)
}
function toggleTagInSelection(tagId: string): void {
if (selectedTagIds.value.includes(tagId)) {
selectedTagIds.value = selectedTagIds.value.filter((v) => v !== tagId)
} else {
selectedTagIds.value = [...selectedTagIds.value, tagId]
}
}
const tagTreeData = computed<TreeNode[]>(() => {
const groupNodes: TreeNode[] = props.tagGroups
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((g) => ({
id: `g-${g.id}`,
rawId: g.id,
type: 'group' as const,
label: g.groupName,
children: props.tags
.filter((t) => t.tagGroupId === g.id)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((t) => ({
id: `t-${t.id}`,
rawId: t.id,
type: 'tag' as const,
label: t.tagName,
})),
}))
const ungrouped: TreeNode[] = props.tags
.filter((t) => !t.tagGroupId)
.sort((a, b) => a.tagName.localeCompare(b.tagName))
.map((t) => ({
id: `t-${t.id}`,
rawId: t.id,
type: 'tag' as const,
label: t.tagName,
}))
if (ungrouped.length > 0) {
groupNodes.push({
id: 'g-ungrouped',
rawId: null,
type: 'group',
label: '未分组',
disabled: true,
children: ungrouped,
})
}
return groupNodes
})
function onTreeNodeClick(data: TreeNode): void {
if (data.type === 'group' && !data.disabled) {
// 点击分组 = 全选/取消该组全部标签
const tagIds = (data.children ?? []).map((c) => c.rawId!).filter(Boolean)
if (tagIds.length === 0) return
const allSelected = tagIds.every((id) => selectedTagIds.value.includes(id))
if (allSelected) {
selectedTagIds.value = selectedTagIds.value.filter((id) => !tagIds.includes(id))
} else {
selectedTagIds.value = [...new Set([...selectedTagIds.value, ...tagIds])]
}
}
}
// ─── 标签编辑小弹窗 ───
const tagEditVisible = ref(false)
const tagEditForm = ref({ id: '', tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff' })
const tagEditLoading = ref(false)
function openTagEdit(t: Tag) {
tagEditForm.value = { id: t.id, tagName: t.tagName, tagColor: t.tagColor || '#ff6800', tagFontColor: t.tagFontColor || '#ffffff' }
tagEditVisible.value = true
}
function openTagEditById(id: string): void {
const t = props.tags.find((tag) => tag.id === id)
if (t) openTagEdit(t)
}
async function handleTagEditSubmit() {
if (!tagEditForm.value.tagName.trim()) { ElMessage.warning('请输入标签名称'); return }
tagEditLoading.value = true
try {
await tagsApi.updateTag(tagEditForm.value.id, { tagName: tagEditForm.value.tagName.trim(), tagColor: tagEditForm.value.tagColor, tagFontColor: tagEditForm.value.tagFontColor } as any)
ElMessage.success('保存成功')
tagEditVisible.value = false
emit('dictionaries-changed')
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
finally { tagEditLoading.value = false }
}
async function handleTagEditDelete() {
try {
await ElMessageBox.confirm(`确定删除标签「${tagEditForm.value.tagName}」吗?`, '确认', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
} catch { return }
try {
await tagsApi.deleteTag(tagEditForm.value.id)
ElMessage.success('删除成功')
tagEditVisible.value = false
emit('dictionaries-changed')
} catch { ElMessage.error('删除失败') }
}
// ─── 分组编辑小弹窗 ───
const groupEditVisible = ref(false)
const groupEditLoading = ref(false)
const groupEditForm = ref<{ id: string; groupName: string }>({ id: '', groupName: '' })
function openGroupEdit(node: TreeNode): void {
// 虚拟「未分组」节点不可编辑
if (node.id === 'g-ungrouped') return
groupEditForm.value = { id: node.rawId!, groupName: node.label }
groupEditVisible.value = true
}
async function handleGroupSave(): Promise<void> {
const name = groupEditForm.value.groupName.trim()
if (!name) { ElMessage.warning('请输入分组名称'); return }
groupEditLoading.value = true
try {
await tagGroupsApi.updateTagGroup(groupEditForm.value.id, { groupName: name })
ElMessage.success('已保存')
groupEditVisible.value = false
emit('dictionaries-changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally {
groupEditLoading.value = false
}
}
async function handleGroupDelete(): Promise<void> {
try {
await ElMessageBox.confirm(
`删除分组「${groupEditForm.value.groupName}」后,组内标签将归为「未分组」。确认删除?`,
'确认删除',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch { return }
groupEditLoading.value = true
try {
await tagGroupsApi.deleteTagGroup(groupEditForm.value.id)
ElMessage.success('已删除')
groupEditVisible.value = false
emit('dictionaries-changed')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '删除失败')
} finally {
groupEditLoading.value = false
}
}
async function quickCreateTagGroup() {
try {
const { value } = await ElMessageBox.prompt('请输入分组名称', '新建分组', {
confirmButtonText: '新建', cancelButtonText: '取消', inputPlaceholder: '分组名称',
})
if (!value.trim()) return
const maxSort = Math.max(0, ...props.tagGroups.map((g) => g.sortOrder))
await tagGroupsApi.createTagGroup({ groupName: value.trim(), sortOrder: maxSort + 1 } as any)
ElMessage.success('已创建分组')
emit('dictionaries-changed')
} catch {}
}
</script>
<template>
<el-popover
v-model:visible="tagPopoverVisible"
placement="bottom-start"
:width="280"
trigger="click"
transition="el-zoom-in-top"
popper-class="tag-filter-popper"
>
<template #reference>
<div
class="tag-select-trigger"
:class="{ 'is-filled': selectedTagIds.length > 0, 'is-active': tagPopoverVisible }"
>
<template v-if="selectedTagIds.length === 0">
<span class="placeholder">标签筛选</span>
</template>
<template v-else>
<el-tag
v-for="id in displayedSelectedTagIds"
:key="id"
size="small"
closable
type="info"
@close.stop="removeSelectedTag(id)"
>
{{ getTagName(id) }}
</el-tag>
<span v-if="hiddenSelectedCount > 0" class="more-tag">+{{ hiddenSelectedCount }}</span>
</template>
<el-icon class="tag-arrow"><ArrowDown /></el-icon>
</div>
</template>
<div class="tag-tree-panel">
<div class="tag-tree-toolbar">
<el-button text size="small" :icon="Plus" @click="quickCreateTagGroup">新建分组</el-button>
</div>
<el-tree
:data="tagTreeData"
node-key="id"
default-expand-all
:props="{ label: 'label', children: 'children' }"
@node-click="onTreeNodeClick"
>
<template #default="{ data }">
<div
class="tree-row"
:class="{
'is-group': data.type === 'group',
'is-tag': data.type === 'tag',
'is-disabled': data.disabled,
'is-checked': data.type === 'tag' && selectedTagIds.includes(data.rawId),
}"
@mouseenter="hoveredNodeId = data.id"
@mouseleave="hoveredNodeId = null"
>
<template v-if="data.type === 'group'">
<i class="fa-solid fa-folder node-icon" />
<span class="node-label">
{{ data.label }}
<span v-if="data.children && data.children.length" class="node-count">{{ data.children.length }}</span>
</span>
<span v-show="hoveredNodeId === data.id" class="node-actions">
<el-button text size="small" :icon="Edit" title="编辑分组" @click.stop="openGroupEdit(data)" />
</span>
</template>
<template v-else>
<el-checkbox
:model-value="selectedTagIds.includes(data.rawId)"
@change="toggleTagInSelection(data.rawId)"
@click.stop
/>
<span class="node-label">{{ data.label }}</span>
<span v-show="hoveredNodeId === data.id" class="node-actions">
<el-button text size="small" :icon="Edit" title="编辑标签" @click.stop="openTagEditById(data.rawId)" />
</span>
</template>
</div>
</template>
</el-tree>
</div>
</el-popover>
<!-- Tag Edit Modal -->
<el-dialog v-model="tagEditVisible" title="编辑标签" width="420px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="tagEditForm.tagName" placeholder="标签名称" /></el-form-item>
<el-form-item label="背景色">
<div style="display: flex; align-items: center; gap: 8px;">
<el-color-picker v-model="tagEditForm.tagColor" />
<el-input v-model="tagEditForm.tagColor" placeholder="#ff6800" style="width: 120px" />
</div>
</el-form-item>
<el-form-item label="字体色">
<div style="display: flex; align-items: center; gap: 8px;">
<el-color-picker v-model="tagEditForm.tagFontColor" />
<el-input v-model="tagEditForm.tagFontColor" placeholder="#ffffff" style="width: 120px" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" :loading="tagEditLoading" @click="handleTagEditDelete">删除</el-button>
<el-button @click="tagEditVisible = false">取消</el-button>
<el-button type="primary" :loading="tagEditLoading" @click="handleTagEditSubmit">保存</el-button>
</template>
</el-dialog>
<!-- Group Edit Modal -->
<el-dialog
v-model="groupEditVisible"
:title="`编辑分组「${groupEditForm.groupName}」`"
width="420px"
destroy-on-close
>
<el-form label-width="80px">
<el-form-item label="分组名称">
<el-input v-model="groupEditForm.groupName" placeholder="请输入分组名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" :loading="groupEditLoading" @click="handleGroupDelete">删除</el-button>
<el-button @click="groupEditVisible = false">取消</el-button>
<el-button type="primary" :loading="groupEditLoading" @click="handleGroupSave">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
/* Custom trigger that looks like el-select */
.tag-select-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
width: 180px;
min-height: 24px;
padding: 0 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
font-size: 12px;
color: #606266;
cursor: pointer;
transition: border-color 0.2s;
box-sizing: border-box;
}
.tag-select-trigger:hover {
border-color: #c0c4cc;
}
.tag-select-trigger.is-active {
border-color: var(--brand-color);
}
.tag-select-trigger .placeholder {
color: #a8abb2;
}
.tag-select-trigger .tag-arrow {
margin-left: auto;
font-size: 12px;
color: #a8abb2;
transition: transform 0.3s ease, color 0.2s;
flex-shrink: 0;
}
.tag-select-trigger.is-active .tag-arrow {
transform: rotate(180deg);
color: var(--brand-color);
}
.tag-select-trigger.is-filled {
color: #111;
}
.tag-select-trigger .more-tag {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 6px;
background: #f4f4f5;
border-radius: 3px;
font-size: 11px;
color: #606266;
}
</style>
<style>
/* Popover 挂载在 body 下,需要全局样式 */
.tag-filter-popper {
padding: 0 !important;
}
.tag-filter-popper .tag-tree-panel {
max-height: 380px;
overflow-y: auto;
padding: 4px 0;
}
.tag-tree-toolbar {
padding: 4px 8px 6px;
border-bottom: 1px solid #f0f0f0;
}
.tag-filter-popper .el-tree {
padding: 0 4px;
}
/* Tree row layout */
.tag-filter-popper .tree-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 6px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
position: relative;
}
.tag-filter-popper .tree-row.is-group {
font-weight: 600;
color: #111;
background: #fafafa;
}
.tag-filter-popper .tree-row.is-group:hover {
background: #f0f0f0;
}
.tag-filter-popper .tree-row.is-tag:hover {
background: #f5f7fa;
}
.tag-filter-popper .tree-row.is-tag.is-checked {
background: #fff2e8;
color: #ff6a00;
}
.tag-filter-popper .node-icon {
color: #f59e0b;
font-size: 12px;
width: 14px;
text-align: center;
flex-shrink: 0;
}
.tag-filter-popper .node-label {
flex: 1;
user-select: none;
}
.tag-filter-popper .node-count {
display: inline-block;
margin-left: 4px;
padding: 0 5px;
font-size: 10px;
color: #909399;
background: #e9e9eb;
border-radius: 8px;
font-weight: 400;
}
.tag-filter-popper .node-actions {
display: inline-flex;
gap: 0;
margin-left: auto;
flex-shrink: 0;
}
.tag-filter-popper .node-actions .el-button {
padding: 2px 4px;
}
</style>
+22 -1
View File
@@ -115,6 +115,27 @@ const stats = computed(() => {
return { total, success, failed, lastLog } return { total, success, failed, lastLog }
}) })
const SYNC_KEY_ZH: Record<string, string> = {
inserted: '新增',
updated: '更新',
total: '共计',
delisted: '下架',
reactivated: '恢复上架',
leafCategories: '叶子分类',
}
/** 后端报文(inserted=0 updated=536 ...)渲染为中文摘要;未知键原样保留 */
function humanizeSyncMessage(message?: string | null): string {
if (!message) return ''
const prefixMatch = message.match(/^([^:]+)[:]\s*/)
const prefix = prefixMatch ? prefixMatch[1] : ''
const body = prefixMatch ? message.slice(prefixMatch[0].length) : message
const pairs = [...body.matchAll(/(\w+)=([^\s=]+)/g)]
if (!pairs.length) return message
const text = pairs.map(([, k, v]) => `${SYNC_KEY_ZH[k] ?? k} ${v}`).join(' · ')
return prefix ? `${prefix}${text}` : text
}
onMounted(() => { onMounted(() => {
refreshLogs() refreshLogs()
timer = setInterval(refreshLogs, 60_000) timer = setInterval(refreshLogs, 60_000)
@@ -229,7 +250,7 @@ onUnmounted(() => {
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }} {{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
</span> </span>
</div> </div>
<p v-if="log.message" class="timeline-message">{{ log.message }}</p> <p v-if="log.message" class="timeline-message" :title="log.message">{{ humanizeSyncMessage(log.message) }}</p>
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span> <span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
</div> </div>
</div> </div>
+8 -1
View File
@@ -7,7 +7,7 @@ import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
base: '/admin/', base: '/v2/admin/',
plugins: [ plugins: [
vue(), vue(),
AutoImport({ AutoImport({
@@ -28,7 +28,14 @@ export default defineConfig({
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: 5173,
// 端口被占时直接报错退出,而不是静默换端口导致浏览器看到旧实例
strictPort: true,
proxy: { proxy: {
// Swagger 挂载在后端 /api/docs(不走去掉 /api 前缀的改写),需放在 '/api' 之前
'/api/docs': {
target: 'http://127.0.0.1:3001',
changeOrigin: true,
},
'/api': { '/api': {
target: 'http://127.0.0.1:3001', target: 'http://127.0.0.1:3001',
changeOrigin: true, changeOrigin: true,
+4
View File
@@ -18,3 +18,7 @@ CORS_ORIGINS=http://localhost:5173
THROTTLE_LIMIT=120 THROTTLE_LIMIT=120
PORT=3001 PORT=3001
# Product-family public read path (family block + family variant union in
# GET /public/goods/:goodId). Set to 'false' for emergency rollback to legacy behavior.
PUBLIC_DETAIL_FROM_FAMILY=true
+7 -1
View File
@@ -22,7 +22,13 @@
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts", "configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
"import:product-detail": "ts-node prisma/import-product-detail.ts" "import:product-detail": "ts-node prisma/import-product-detail.ts",
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
"backfill:product-families": "ts-node prisma/backfill-product-families.ts",
"fix:good-names": "ts-node prisma/fix-pure-sku-good-names.ts",
"fix:category-names": "ts-node prisma/fix-category-parens.ts",
"organize": "ts-node prisma/backfill-product-families.ts",
"recompute:families": "ts-node prisma/recompute-all-families.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^3.0.1", "@nestjs/axios": "^3.0.1",
@@ -0,0 +1,133 @@
/**
* 一次性回填:解析 排序表.md → categories.sort_order(二级/款)+ countries.sort_order
* + 沙特国家行 + 中东根更名
* 幂等:可重复执行;SDS 同步若覆盖根名称,重跑本脚本即可恢复
*/
import { PrismaClient } from '@prisma/client';
import { readFileSync } from 'fs';
const prisma = new PrismaClient();
// 排序表国家名 → 新树根分类名(与库内 category_name 精确对应)
const ROOT_MAP: Record<string, string> = {
'美国': '美国工厂直发',
'英国': '英国本地直发',
'日本': '日本本地工厂直发',
'墨西哥': '墨西哥工厂本地直发',
'巴西': '巴西本地工厂直发',
'中东': '中东本地工厂直发', // 同时更名沙特
'波兰': '欧洲波兰工厂直发',
'西班牙': '欧洲西班牙工厂直发',
'德国': '欧洲德国工厂本地直发',
'意大利': '欧洲意大利工厂直发',
'加拿大': '加拿大本地工厂直发',
'澳大利亚': '澳大利亚本地工厂直发',
'韩国': '韩国本地直发',
'中国(国内工厂)': '国内工厂',
};
const norm = (s: string) => s.trim().replace(/\s+/g, '');
const codeOf = (s: string) => (s.trim().match(/^[A-Za-z0-9]+/) ?? [''])[0];
async function main() {
const raw = readFileSync(process.env.SORT_TABLE_PATH ?? '/repo/排序表.md', 'utf8');
let country: string | null = null;
let l2: string | null = null;
const tree: Array<{ country: string; l2: string; l3: string }> = [];
const countryOrder: string[] = [];
for (const line of raw.split('\n')) {
const t = line.trim();
if (t.startsWith('# ')) {
const name = t.slice(2).trim();
if (name === '全部' || name.includes('工厂直发国家')) continue;
country = name;
if (!countryOrder.includes(name)) countryOrder.push(name);
} else if (t.startsWith('## ') && country) {
l2 = t.slice(3).trim();
} else if (t.startsWith('### ') && country && l2) {
tree.push({ country, l2, l3: t.slice(4).trim() });
}
}
console.log(`parsed: ${countryOrder.length} countries, ${tree.length} leaves`);
const roots = await prisma.category.findMany({
where: { parentCategoryId: null, sdsCategoryId: { not: null } },
include: { children: { include: { children: true } } },
});
const rootByName = new Map(roots.map((r) => [r.categoryName, r]));
const unmatched: string[] = [];
// 1) countries:沙特 upsert + 顺序重写(中国无国家行,跳过)
for (let i = 0; i < countryOrder.length; i++) {
const name = countryOrder[i];
const dbCountry = name === '中东' ? '沙特' : name;
if (dbCountry === '中国(国内工厂)') continue;
const sortOrder = i + 1;
const existing = await prisma.country.findUnique({ where: { countryName: dbCountry } });
if (existing) {
await prisma.country.update({ where: { id: existing.id }, data: { sortOrder } });
} else if (dbCountry === '沙特') {
await prisma.country.create({ data: { countryName: '沙特', sortOrder } });
console.log('created country: 沙特');
}
}
// 2) 中东根 → 沙特
const meRoot = rootByName.get('中东本地工厂直发');
if (meRoot) {
await prisma.category.update({ where: { id: meRoot.id }, data: { categoryName: '沙特本地工厂直发' } });
rootByName.set('沙特本地工厂直发', meRoot);
console.log('renamed root: 中东本地工厂直发 -> 沙特本地工厂直发');
}
// 3) 二级/款 sort_order
for (const countryName of countryOrder) {
const rootName = countryName === '中东' ? '沙特本地工厂直发' : ROOT_MAP[countryName];
const root = rootByName.get(rootName);
if (!root) {
unmatched.push(`ROOT MISS: ${countryName} (expect root "${rootName}")`);
continue;
}
const l2s = root.children;
const l2NamesInOrder: string[] = [];
for (const row of tree) {
if (row.country === countryName && !l2NamesInOrder.includes(row.l2)) l2NamesInOrder.push(row.l2);
}
for (let i = 0; i < l2NamesInOrder.length; i++) {
const target = l2NamesInOrder[i];
const mid = l2s.find((m) => norm(m.categoryName) === norm(target));
if (!mid) {
unmatched.push(`L2 MISS: ${countryName} / ${target}`);
continue;
}
await prisma.category.update({ where: { id: mid.id }, data: { sortOrder: i + 1 } });
const leaves = mid.children;
const l3Names = tree.filter((r) => r.country === countryName && r.l2 === target).map((r) => r.l3);
for (let j = 0; j < l3Names.length; j++) {
const want = norm(l3Names[j]);
const code = norm(codeOf(l3Names[j]));
const leaf =
leaves.find((l) => norm(l.categoryName) === want) ??
(code ? leaves.find((l) => norm(l.categoryName).startsWith(code)) : undefined);
if (!leaf) {
unmatched.push(`L3 MISS: ${countryName} / ${target} / ${l3Names[j]}`);
continue;
}
await prisma.category.update({ where: { id: leaf.id }, data: { sortOrder: j + 1 } });
}
}
}
if (unmatched.length) {
console.error(`UNMATCHED (${unmatched.length}):\n` + unmatched.join('\n'));
process.exit(1);
}
console.log('backfill done');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,36 @@
/**
* 整理脚本(幂等 / 显式人工触发)——解析去运行时化后的唯一解析入口:
* 1. 回填结构化解析列(只补 NULL,不覆盖存量);
* 2. 派生链接标签(未人工接管的 SDS 链接按名称刷新,人工接管不动)+ 商品镜像;
* 3. 自动建族(auto-group);
* 4. 全量族重算(并集尺码表/包装 + 五维价格矩阵)。
*
* 运行:pnpm --filter @inkreach/api organize
* 后台等价入口:POST /product-families/organize(「整理」按钮)。
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { PublicCacheService } from '../src/public/public-cache.service';
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
import { ProductFamiliesService } from '../src/product-families/product-families.service';
import { OrganizeService } from '../src/product-families/organize.service';
async function main() {
const prisma = new PrismaService();
await prisma.onModuleInit();
const recompute = new FamilyRecomputeService(prisma, new PublicCacheService());
const families = new ProductFamiliesService(prisma, recompute);
const organize = new OrganizeService(prisma, recompute, families, new PublicCacheService());
const result = await organize.organize();
console.log(
`[organize] labels parsed=${result.labelsParsed} (unparsable=${result.labelsUnparsable}) | ` +
`tags links=${result.linksUpdated} goods=${result.goodsUpdated} | ` +
`families created=${result.familiesCreated} recomputed=${result.familiesRecomputed}`,
);
await prisma.onModuleDestroy();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+51
View File
@@ -0,0 +1,51 @@
/**
* 存量修复脚本(幂等):分类名去括号 —— 删除所有括号段(型号限定词/内容性括号,
* 整段删除)。分类名每小时被 SDS 同步覆盖,清洗已落在 syncCategories 入库路径;
* 本脚本用于部署后立即生效(不等下一轮同步)或修人工建的历史分类。
*
* 运行:
* pnpm --filter @inkreach/api fix:category-names # dry-run,只打印清单
* pnpm --filter @inkreach/api fix:category-names -- --apply # 实际写库
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { cleanCategoryDisplayName } from '../src/sync/sync.service';
async function main() {
const apply = process.argv.includes('--apply');
const prisma = new PrismaService();
await prisma.onModuleInit();
const rows = await prisma.category.findMany({
select: { id: true, categoryName: true },
orderBy: { id: 'asc' },
});
const renames = rows
.map((row) => ({
id: row.id,
from: row.categoryName,
to: cleanCategoryDisplayName(row.categoryName) ?? row.categoryName,
}))
.filter((r) => r.from !== r.to);
if (apply && renames.length > 0) {
await prisma.$transaction(
renames.map((r) =>
prisma.category.update({
where: { id: r.id },
data: { categoryName: r.to },
}),
),
);
}
const mode = apply ? 'APPLIED' : 'DRY-RUN(未写库,加 --apply 执行)';
console.log(`[fix:category-names] ${mode} renamed=${renames.length}`);
for (const r of renames) {
console.log(` #${r.id} ${r.from} -> ${r.to}`);
}
await prisma.onModuleDestroy();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,44 @@
/**
* 存量修复脚本(幂等):把纯款号商品名(如 `PLTK016`)按主链接 SDS 分类名
* 补成「描述名 款号」(如 `童装纯色插肩短袖T恤 PLTK016`)。
*
* 运行:
* pnpm --filter @inkreach/api fix:good-names # dry-run,只打印清单
* pnpm --filter @inkreach/api fix:good-names -- --apply # 实际写库
* origin_goods.good_name 为 SDS 纯镜像(每小时同步覆盖),本脚本只改 goods.good_name。
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { PublicCacheService } from '../src/public/public-cache.service';
import { SyncService } from '../src/sync/sync.service';
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
import { GoodsService } from '../src/goods/goods.service';
async function main() {
const dryRun = !process.argv.includes('--apply');
const prisma = new PrismaService();
await prisma.onModuleInit();
const recompute = new FamilyRecomputeService(prisma, new PublicCacheService());
// 回填路径不触发详情同步,SyncService 仅作占位依赖
const goods = new GoodsService(
prisma,
{ queueProductDetailSync: async () => undefined } as unknown as SyncService,
recompute,
new PublicCacheService(),
);
const result = await goods.backfillPureSkuGoodNames({ dryRun });
const mode = dryRun ? 'DRY-RUN(未写库,加 --apply 执行)' : 'APPLIED';
console.log(`[fix:good-names] ${mode} renamed=${result.renamed} unmapped=${result.unmapped.length}`);
for (const r of result.renames) {
console.log(` #${r.id} ${r.from} -> ${r.to}`);
}
for (const u of result.unmapped) {
console.log(` #${u.id} ${u.from} -> (无分类映射,保留原名)`);
}
await prisma.onModuleDestroy();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,79 @@
-- AlterTable
ALTER TABLE "origin_goods" ADD COLUMN "craft_label" TEXT,
ADD COLUMN "family_id" BIGINT,
ADD COLUMN "logistics_label" TEXT,
ADD COLUMN "sku_code" TEXT,
ADD COLUMN "warehouse_label" TEXT;
-- CreateTable
CREATE TABLE "product_families" (
"family_id" BIGSERIAL NOT NULL,
"family_code" TEXT,
"family_name" TEXT NOT NULL,
"family_image" TEXT,
"country_id" BIGINT,
"category_id" BIGINT,
"primary_origin_good_id" BIGINT,
"detail" JSONB,
"size_chart" JSONB,
"package_specs" JSONB,
"price_matrix" JSONB,
"auto_managed" BOOLEAN NOT NULL DEFAULT true,
"stale" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "product_families_pkey" PRIMARY KEY ("family_id")
);
-- CreateTable
CREATE TABLE "family_price_overrides" (
"family_price_override_id" BIGSERIAL NOT NULL,
"family_id" BIGINT NOT NULL,
"size_id" TEXT NOT NULL,
"color_id" TEXT NOT NULL,
"craft" TEXT NOT NULL,
"logistics" TEXT NOT NULL,
"price" DECIMAL(12,2) NOT NULL,
"note" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "family_price_overrides_pkey" PRIMARY KEY ("family_price_override_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "product_families_family_code_key" ON "product_families"("family_code");
-- CreateIndex
CREATE INDEX "product_families_country_id_idx" ON "product_families"("country_id");
-- CreateIndex
CREATE INDEX "product_families_category_id_idx" ON "product_families"("category_id");
-- CreateIndex
CREATE UNIQUE INDEX "family_price_overrides_family_id_size_id_color_id_craft_log_key" ON "family_price_overrides"("family_id", "size_id", "color_id", "craft", "logistics");
-- CreateIndex
CREATE INDEX "origin_goods_family_id_idx" ON "origin_goods"("family_id");
-- CreateIndex
CREATE INDEX "origin_goods_sku_code_idx" ON "origin_goods"("sku_code");
-- CreateIndex
CREATE INDEX "origin_goods_logistics_label_idx" ON "origin_goods"("logistics_label");
-- CreateIndex
CREATE INDEX "origin_goods_craft_label_idx" ON "origin_goods"("craft_label");
-- AddForeignKey
ALTER TABLE "origin_goods" ADD CONSTRAINT "origin_goods_family_id_fkey" FOREIGN KEY ("family_id") REFERENCES "product_families"("family_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "product_families" ADD CONSTRAINT "product_families_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("country_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "product_families" ADD CONSTRAINT "product_families_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("category_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "family_price_overrides" ADD CONSTRAINT "family_price_overrides_family_id_fkey" FOREIGN KEY ("family_id") REFERENCES "product_families"("family_id") ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "goods" ADD COLUMN "family_id" BIGINT;
-- CreateIndex
CREATE INDEX "goods_family_id_idx" ON "goods"("family_id");
-- AddForeignKey
ALTER TABLE "goods" ADD CONSTRAINT "goods_family_id_fkey" FOREIGN KEY ("family_id") REFERENCES "product_families"("family_id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- Backfill: Good 的族 = 其主链接所属族(派生数据,后续由服务层联动维护)
UPDATE "goods" SET "family_id" = (
SELECT "o"."family_id" FROM "origin_goods" "o"
WHERE "o"."origin_good_id" = "goods"."origin_good_id"
);
@@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "origin_goods" ADD COLUMN "tags_manual" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "origin_good_tags" (
"id" BIGSERIAL NOT NULL,
"origin_good_id" BIGINT NOT NULL,
"tag_id" BIGINT NOT NULL,
"manual" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "origin_good_tags_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "origin_good_tags_tag_id_idx" ON "origin_good_tags"("tag_id");
-- CreateIndex
CREATE UNIQUE INDEX "origin_good_tags_origin_good_id_tag_id_key" ON "origin_good_tags"("origin_good_id", "tag_id");
-- AddForeignKey
ALTER TABLE "origin_good_tags" ADD CONSTRAINT "origin_good_tags_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "origin_good_tags" ADD CONSTRAINT "origin_good_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags"("tag_id") ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1,6 @@
-- 价格矩阵五维化:覆盖表补 印花数量 维度(表当前 0 行,无需数据回填)
ALTER TABLE "family_price_overrides" ADD COLUMN "print_count" TEXT NOT NULL DEFAULT '单面印花';
DROP INDEX "family_price_overrides_family_id_size_id_color_id_craft_log_key";
CREATE UNIQUE INDEX "family_price_overrides_family_id_size_id_color_id_print_count_craft_log_key"
ON "family_price_overrides"("family_id", "size_id", "color_id", "print_count", "craft", "logistics");
@@ -0,0 +1,2 @@
-- 国家自定义排序:默认 0 保持既有 id 序,后台可拖拽调整
ALTER TABLE "countries" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "categories" ADD COLUMN "sort_order" INTEGER NOT NULL DEFAULT 0;
+41
View File
@@ -0,0 +1,41 @@
/**
* 全量族重算(一次性维护脚本):
* 逐族调用 FamilyRecomputeService.recomputeFamily,刷新物化字段
* (并集尺码表/包装规则 + 五维价格矩阵,含选项组词表序排序)。
* autoManaged=false 的族内部只置 stale,不会覆盖人工物化字段。
*
* 运行:pnpm --filter @inkreach/api recompute:families
*/
import { PrismaService } from '../src/prisma/prisma.service';
import { PublicCacheService } from '../src/public/public-cache.service';
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
async function main() {
const prisma = new PrismaService();
await prisma.onModuleInit();
const recompute = new FamilyRecomputeService(prisma, new PublicCacheService());
const families = await prisma.productFamily.findMany({
select: { id: true },
orderBy: { id: 'asc' },
});
let ok = 0;
let failed = 0;
for (const { id } of families) {
try {
await recompute.recomputeFamily(id);
ok++;
} catch (error) {
failed++;
console.error(`family ${id} recompute failed: ${String(error)}`);
}
}
console.log(`[recompute:families] total=${families.length} ok=${ok} failed=${failed}`);
await prisma.onModuleDestroy();
if (failed > 0) process.exit(1);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+90
View File
@@ -30,19 +30,49 @@ model OriginGood {
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2) goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
delisted Boolean @default(false) delisted Boolean @default(false)
source OriginGoodSource @default(SDS) source OriginGoodSource @default(SDS)
familyId BigInt? @map("family_id")
skuCode String? @map("sku_code")
logisticsLabel String? @map("logistics_label")
craftLabel String? @map("craft_label")
warehouseLabel String? @map("warehouse_label")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
family ProductFamily? @relation(fields: [familyId], references: [id], onDelete: SetNull, onUpdate: NoAction)
goods Good[] goods Good[]
detail OriginGoodDetail? detail OriginGoodDetail?
variants OriginGoodVariant[] variants OriginGoodVariant[]
mergedIntoGoods GoodOriginGood[] mergedIntoGoods GoodOriginGood[]
// 链接级标签:自动派生(manual=false)或人工接管(tagsManual=true 后全部 manual
tagsManual Boolean @default(false) @map("tags_manual")
originGoodTags OriginGoodTag[]
@@index([sdsCategoryId]) @@index([sdsCategoryId])
@@index([source]) @@index([source])
@@index([familyId])
@@index([skuCode])
@@index([logisticsLabel])
@@index([craftLabel])
@@map("origin_goods") @@map("origin_goods")
} }
// ---------- OriginGood-Tag Junction(链接级标签:自动派生 + 人工修正) ----------
model OriginGoodTag {
id BigInt @id @default(autoincrement())
originGoodId BigInt @map("origin_good_id")
tagId BigInt @map("tag_id")
// true = 人工配置(自动同步永不覆盖);false = 按链接名称自动派生
manual Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([originGoodId, tagId])
@@index([tagId])
@@map("origin_good_tags")
}
// ---------- Origin Good Details (cached from SDS /products/{id}) ---------- // ---------- Origin Good Details (cached from SDS /products/{id}) ----------
model OriginGoodDetail { model OriginGoodDetail {
originGoodId BigInt @id @map("origin_good_id") originGoodId BigInt @id @map("origin_good_id")
@@ -113,11 +143,13 @@ model Country {
id BigInt @id @default(autoincrement()) @map("country_id") id BigInt @id @default(autoincrement()) @map("country_id")
countryName String @unique @map("country_name") countryName String @unique @map("country_name")
countryIcon String? @map("country_icon") countryIcon String? @map("country_icon")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
goods Good[] goods Good[]
positions Position[] positions Position[]
families ProductFamily[]
@@map("countries") @@map("countries")
} }
@@ -129,6 +161,7 @@ model Category {
categoryName String @map("category_name") categoryName String @map("category_name")
categoryIcon String? @map("category_icon") categoryIcon String? @map("category_icon")
sdsCategoryId String? @unique @map("sds_category_id") sdsCategoryId String? @unique @map("sds_category_id")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@ -136,6 +169,7 @@ model Category {
children Category[] @relation("CategoryToCategory") children Category[] @relation("CategoryToCategory")
goods Good[] goods Good[]
positions Position[] positions Position[]
families ProductFamily[]
@@index([parentCategoryId]) @@index([parentCategoryId])
@@map("categories") @@map("categories")
@@ -170,6 +204,7 @@ model Tag {
goods Good[] goods Good[]
goodTags GoodTag[] goodTags GoodTag[]
originGoodTags OriginGoodTag[]
tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction) tagGroup TagGroup? @relation(fields: [tagGroupId], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@index([tagGroupId]) @@index([tagGroupId])
@@ -200,6 +235,7 @@ model Position {
model Good { model Good {
id BigInt @id @default(autoincrement()) @map("good_id") id BigInt @id @default(autoincrement()) @map("good_id")
originGoodId BigInt @map("origin_good_id") originGoodId BigInt @map("origin_good_id")
familyId BigInt? @map("family_id")
countryId BigInt @map("country_id") countryId BigInt @map("country_id")
categoryId BigInt @map("category_id") categoryId BigInt @map("category_id")
tagId BigInt? @map("tag_id") tagId BigInt? @map("tag_id")
@@ -211,6 +247,7 @@ model Good {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction) originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction)
family ProductFamily? @relation(fields: [familyId], references: [id], onDelete: SetNull, onUpdate: NoAction)
country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction) country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction) category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction) tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@ -219,6 +256,7 @@ model Good {
mergedOriginGoods GoodOriginGood[] mergedOriginGoods GoodOriginGood[]
@@index([originGoodId]) @@index([originGoodId])
@@index([familyId])
@@index([countryId]) @@index([countryId])
@@index([categoryId]) @@index([categoryId])
@@index([tagId]) @@index([tagId])
@@ -258,6 +296,58 @@ model GoodOriginGood {
@@map("good_origin_goods") @@map("good_origin_goods")
} }
// ---------- Product Families (SPU layer over origin goods) ----------
// 设计文档:docs/superpowers/specs/2026-08-28-product-family-merge-design.md
// primaryOriginGoodId 刻意不建 FK:避免与 origin_goods 相互依赖,主链接被同步删除时由服务层清理。
model ProductFamily {
id BigInt @id @default(autoincrement()) @map("family_id")
familyCode String? @unique @map("family_code")
familyName String @map("family_name")
familyImage String? @map("family_image")
countryId BigInt? @map("country_id")
categoryId BigInt? @map("category_id")
primaryOriginGoodId BigInt? @map("primary_origin_good_id")
detail Json?
sizeChart Json? @map("size_chart")
packageSpecs Json? @map("package_specs")
priceMatrix Json? @map("price_matrix")
autoManaged Boolean @default(true) @map("auto_managed")
stale Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
originGoods OriginGood[]
priceOverrides FamilyPriceOverride[]
goods Good[]
country Country? @relation(fields: [countryId], references: [id], onDelete: SetNull, onUpdate: NoAction)
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@index([countryId])
@@index([categoryId])
@@map("product_families")
}
// ---------- Family Price Overrides (manual per-cell price) ----------
// 独立于推导矩阵:重算只重建推导部分,本表永不被动覆盖。
model FamilyPriceOverride {
id BigInt @id @default(autoincrement()) @map("family_price_override_id")
familyId BigInt @map("family_id")
sizeId String @map("size_id")
colorId String @map("color_id")
printCount String @default("单面印花") @map("print_count")
craft String
logistics String
price Decimal @db.Decimal(12, 2)
note String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
family ProductFamily @relation(fields: [familyId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([familyId, sizeId, colorId, printCount, craft, logistics])
@@map("family_price_overrides")
}
// ---------- Users (admin authentication) ---------- // ---------- Users (admin authentication) ----------
enum Role { enum Role {
ADMIN ADMIN
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+5
View File
@@ -11,9 +11,11 @@ import { TagsModule } from './tags/tags.module';
import { TagGroupsModule } from './tag-groups/tag-groups.module'; import { TagGroupsModule } from './tag-groups/tag-groups.module';
import { PositionsModule } from './positions/positions.module'; import { PositionsModule } from './positions/positions.module';
import { OriginGoodsModule } from './origin-goods/origin-goods.module'; import { OriginGoodsModule } from './origin-goods/origin-goods.module';
import { ProductFamiliesModule } from './product-families/product-families.module';
import { GoodsModule } from './goods/goods.module'; import { GoodsModule } from './goods/goods.module';
import { SyncModule } from './sync/sync.module'; import { SyncModule } from './sync/sync.module';
import { PublicModule } from './public/public.module'; import { PublicModule } from './public/public.module';
import { PublicCacheModule } from './public/public-cache.module';
import { UploadModule } from './upload/upload.module'; import { UploadModule } from './upload/upload.module';
@Module({ @Module({
@@ -30,6 +32,8 @@ import { UploadModule } from './upload/upload.module';
}, },
]), ]),
PrismaModule, PrismaModule,
// public 读路径缓存(分域版本失效,@Global 供各写路径注入 bump 入口)
PublicCacheModule,
AuthModule, AuthModule,
CountriesModule, CountriesModule,
CategoriesModule, CategoriesModule,
@@ -37,6 +41,7 @@ import { UploadModule } from './upload/upload.module';
TagGroupsModule, TagGroupsModule,
PositionsModule, PositionsModule,
OriginGoodsModule, OriginGoodsModule,
ProductFamiliesModule,
GoodsModule, GoodsModule,
SyncModule, SyncModule,
PublicModule, PublicModule,
+6 -5
View File
@@ -64,8 +64,9 @@ export class AuthController {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure: isProd, secure: isProd,
// Only ever sent to /auth/refresh and /auth/logout // 浏览器实际请求路径带代理前缀(/api/auth/*、/v2-api/auth/*),cookie path
path: '/auth', // 必须用 '/' 才能命中;否则 refresh cookie 永远带不上 → 访问令牌一过期就掉线
path: '/',
}); });
// The refresh token deliberately stays HttpOnly-only. // The refresh token deliberately stays HttpOnly-only.
return { return {
@@ -86,7 +87,7 @@ export class AuthController {
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const token = req.cookies?.[REFRESH_TOKEN_COOKIE]; const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
if (!token) { if (!token) {
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' }); res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/' });
throw new UnauthorizedException('Missing refresh token'); throw new UnauthorizedException('Missing refresh token');
} }
const result = await this.authService.refresh(token); const result = await this.authService.refresh(token);
@@ -100,7 +101,7 @@ export class AuthController {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure: isProd, secure: isProd,
path: '/auth', path: '/',
}); });
return { return {
accessToken: result.accessToken, accessToken: result.accessToken,
@@ -126,7 +127,7 @@ export class AuthController {
): Promise<{ success: true }> { ): Promise<{ success: true }> {
await this.authService.logout(req.user.id); await this.authService.logout(req.user.id);
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' }); res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' }); res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/' });
return { success: true }; return { success: true };
} }
} }
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { import {
BadRequestException, BadRequestException,
@@ -13,7 +14,7 @@ describe('CategoriesService', () => {
beforeAll(async () => { beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
providers: [CategoriesService, PrismaService], providers: [CategoriesService, PrismaService, PublicCacheService],
}).compile(); }).compile();
service = moduleRef.get(CategoriesService); service = moduleRef.get(CategoriesService);
prisma = moduleRef.get(PrismaService); prisma = moduleRef.get(PrismaService);
+14 -4
View File
@@ -5,13 +5,17 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from '../public/public-cache.service';
import { CreateCategoryDto } from './dto/create-category.dto'; import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto'; import { UpdateCategoryDto } from './dto/update-category.dto';
import { CategoryNodeDto } from './dto/category-node.dto'; import { CategoryNodeDto } from './dto/category-node.dto';
@Injectable() @Injectable()
export class CategoriesService { export class CategoriesService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly publicCache: PublicCacheService,
) {}
async findAll(): Promise<CategoryNodeDto[]> { async findAll(): Promise<CategoryNodeDto[]> {
const all = await this.prisma.category.findMany({ const all = await this.prisma.category.findMany({
@@ -37,7 +41,7 @@ export class CategoriesService {
// Validate the parent exists to produce a clean 404 instead of FK error. // Validate the parent exists to produce a clean 404 instead of FK error.
await this.findOne(BigInt(dto.parentCategoryId)); await this.findOne(BigInt(dto.parentCategoryId));
} }
return this.prisma.category.create({ const created = await this.prisma.category.create({
data: { data: {
categoryName: dto.categoryName, categoryName: dto.categoryName,
categoryIcon: dto.categoryIcon ?? null, categoryIcon: dto.categoryIcon ?? null,
@@ -47,6 +51,8 @@ export class CategoriesService {
: BigInt(dto.parentCategoryId), : BigInt(dto.parentCategoryId),
}, },
}); });
this.publicCache.bump('meta');
return created;
} }
async update(id: bigint, dto: UpdateCategoryDto) { async update(id: bigint, dto: UpdateCategoryDto) {
@@ -66,7 +72,9 @@ export class CategoriesService {
? { disconnect: true } ? { disconnect: true }
: { connect: { id: BigInt(dto.parentCategoryId) } }; : { connect: { id: BigInt(dto.parentCategoryId) } };
} }
return this.prisma.category.update({ where: { id }, data }); const updated = await this.prisma.category.update({ where: { id }, data });
this.publicCache.bump('meta');
return updated;
} }
async remove(id: bigint) { async remove(id: bigint) {
@@ -80,7 +88,9 @@ export class CategoriesService {
); );
} }
try { try {
return await this.prisma.category.delete({ where: { id } }); const removed = await this.prisma.category.delete({ where: { id } });
this.publicCache.bump('meta');
return removed;
} catch (err) { } catch (err) {
if (this.isForeignKeyViolation(err)) { if (this.isForeignKeyViolation(err)) {
throw new BadRequestException( throw new BadRequestException(
@@ -18,6 +18,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CountriesService } from './countries.service'; import { CountriesService } from './countries.service';
import { CreateCountryDto } from './dto/create-country.dto'; import { CreateCountryDto } from './dto/create-country.dto';
import { UpdateCountryDto } from './dto/update-country.dto'; import { UpdateCountryDto } from './dto/update-country.dto';
import { ReorderCountriesDto } from './dto/reorder-countries.dto';
@ApiTags('countries') @ApiTags('countries')
@ApiBearerAuth() @ApiBearerAuth()
@@ -32,6 +33,13 @@ export class CountriesController {
return this.service.findAll(); return this.service.findAll();
} }
// 注意:'sort' 静态路由必须声明在 ':id' 之前,否则会被参数路由吞掉
@Patch('sort')
@ApiOperation({ summary: 'Batch update country sort order (drag reorder)' })
reorder(@Body() dto: ReorderCountriesDto) {
return this.service.reorder(dto);
}
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get one country' }) @ApiOperation({ summary: 'Get one country' })
findOne(@Param('id', ParseIntPipe) id: string) { findOne(@Param('id', ParseIntPipe) id: string) {
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { import {
BadRequestException, BadRequestException,
@@ -14,7 +15,7 @@ describe('CountriesService', () => {
beforeAll(async () => { beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
providers: [CountriesService, PrismaService], providers: [CountriesService, PrismaService, PublicCacheService],
}).compile(); }).compile();
service = moduleRef.get(CountriesService); service = moduleRef.get(CountriesService);
prisma = moduleRef.get(PrismaService); prisma = moduleRef.get(PrismaService);
@@ -112,4 +113,30 @@ describe('CountriesService', () => {
const idx = created.indexOf(`${name}-v2`); const idx = created.indexOf(`${name}-v2`);
if (idx !== -1) created.splice(idx, 1); if (idx !== -1) created.splice(idx, 1);
}); });
it('reorder persists sortOrder and findAll honors it', async () => {
const stamp = Date.now();
const a = await service.create({ countryName: `Sort A ${stamp}` });
const b = await service.create({ countryName: `Sort B ${stamp}` });
const c = await service.create({ countryName: `Sort C ${stamp}` });
created.push(a.countryName, b.countryName, c.countryName);
// 新顺序 C, A, Bid 序与 sortOrder 无关时也能稳定验证)
const reordered = await service.reorder({
items: [
{ id: Number(c.id), sortOrder: 0 },
{ id: Number(a.id), sortOrder: 1 },
{ id: Number(b.id), sortOrder: 2 },
],
});
const ours = reordered.filter((r) =>
[a.id, b.id, c.id].some((id) => id.toString() === r.id.toString()),
);
expect(ours.map((r) => r.id.toString())).toEqual([c.id.toString(), a.id.toString(), b.id.toString()]);
const list = await service.findAll();
const pos = (id: bigint) => list.findIndex((r) => r.id.toString() === id.toString());
expect(pos(c.id)).toBeLessThan(pos(a.id));
expect(pos(a.id)).toBeLessThan(pos(b.id));
});
}); });
+32 -5
View File
@@ -6,15 +6,36 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from '../public/public-cache.service';
import { CreateCountryDto } from './dto/create-country.dto'; import { CreateCountryDto } from './dto/create-country.dto';
import { UpdateCountryDto } from './dto/update-country.dto'; import { UpdateCountryDto } from './dto/update-country.dto';
import { ReorderCountriesDto } from './dto/reorder-countries.dto';
@Injectable() @Injectable()
export class CountriesService { export class CountriesService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly publicCache: PublicCacheService,
) {}
findAll() { findAll() {
return this.prisma.country.findMany({ orderBy: { id: 'asc' } }); return this.prisma.country.findMany({
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
}
/** 批量保存拖拽后的顺序(全量提交,sortOrder = 目标下标) */
async reorder(dto: ReorderCountriesDto) {
await this.prisma.$transaction(
dto.items.map((item) =>
this.prisma.country.update({
where: { id: BigInt(item.id) },
data: { sortOrder: item.sortOrder },
}),
),
);
this.publicCache.bump('meta');
return this.findAll();
} }
async findOne(id: bigint) { async findOne(id: bigint) {
@@ -27,12 +48,14 @@ export class CountriesService {
async create(dto: CreateCountryDto) { async create(dto: CreateCountryDto) {
try { try {
return await this.prisma.country.create({ const created = await this.prisma.country.create({
data: { data: {
countryName: dto.countryName, countryName: dto.countryName,
countryIcon: dto.countryIcon ?? null, countryIcon: dto.countryIcon ?? null,
}, },
}); });
this.publicCache.bump('meta');
return created;
} catch (err) { } catch (err) {
if ( if (
err instanceof Prisma.PrismaClientKnownRequestError && err instanceof Prisma.PrismaClientKnownRequestError &&
@@ -47,13 +70,15 @@ export class CountriesService {
async update(id: bigint, dto: UpdateCountryDto) { async update(id: bigint, dto: UpdateCountryDto) {
await this.findOne(id); await this.findOne(id);
try { try {
return await this.prisma.country.update({ const updated = await this.prisma.country.update({
where: { id }, where: { id },
data: { data: {
countryName: dto.countryName, countryName: dto.countryName,
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon, countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
}, },
}); });
this.publicCache.bump('meta');
return updated;
} catch (err) { } catch (err) {
if ( if (
err instanceof Prisma.PrismaClientKnownRequestError && err instanceof Prisma.PrismaClientKnownRequestError &&
@@ -68,7 +93,9 @@ export class CountriesService {
async remove(id: bigint) { async remove(id: bigint) {
await this.findOne(id); await this.findOne(id);
try { try {
return await this.prisma.country.delete({ where: { id } }); const removed = await this.prisma.country.delete({ where: { id } });
this.publicCache.bump('meta');
return removed;
} catch (err) { } catch (err) {
if (this.isForeignKeyViolation(err)) { if (this.isForeignKeyViolation(err)) {
throw new BadRequestException( throw new BadRequestException(
@@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsInt,
Min,
ValidateNested,
} from 'class-validator';
export class CountryOrderItem {
@ApiProperty()
@IsInt()
@Min(1)
id!: number;
@ApiProperty()
@IsInt()
@Min(0)
sortOrder!: number;
}
export class ReorderCountriesDto {
@ApiProperty({ type: [CountryOrderItem] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CountryOrderItem)
items!: CountryOrderItem[];
}
+25
View File
@@ -211,6 +211,31 @@ export class CreateCustomGoodDto extends OmitType(CreateGoodDto, [
@IsNumberString() @IsNumberString()
goodPrice?: string | null; goodPrice?: string | null;
@ApiProperty({ required: false, description: '物流归因(入族后参与价格矩阵)', example: '包邮' })
@IsOptional()
@IsString()
logisticsLabel?: string;
@ApiProperty({ required: false, description: '工艺/印花数量归因', example: '双面印花' })
@IsOptional()
@IsString()
craftLabel?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
skuCode?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
warehouseLabel?: string;
@ApiProperty({ required: false, description: '挂入的产品族 id(缺省为独立商品)', example: '12' })
@IsOptional()
@IsNumberString()
familyId?: string;
@ApiProperty({ required: false, type: CustomGoodDetailDto }) @ApiProperty({ required: false, type: CustomGoodDetailDto })
@IsOptional() @IsOptional()
@ValidateNested() @ValidateNested()
+12 -1
View File
@@ -13,6 +13,7 @@ export interface GoodRelations {
goodName: string | null; goodName: string | null;
goodImage: string | null; goodImage: string | null;
goodPrice: unknown; goodPrice: unknown;
family?: { id: bigint; familyCode: string | null; familyName: string; _count?: { originGoods: number } } | null;
detail?: { detail?: {
productCode: string | null; productCode: string | null;
syncedAt: Date; syncedAt: Date;
@@ -32,7 +33,7 @@ export interface GoodRelations {
}>; }>;
_count?: { variants: number }; _count?: { variants: number };
} | null; } | null;
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[]; goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: bigint | null } }[];
mergedOriginGoods?: Array<{ mergedOriginGoods?: Array<{
originGood: { originGood: {
id: bigint; id: bigint;
@@ -127,6 +128,7 @@ export class GoodDto {
sizeRowCount: number; sizeRowCount: number;
packageRowCount: number; packageRowCount: number;
productCode: string | null; productCode: string | null;
family?: { familyId: string; familyCode: string | null; familyName: string; memberCount?: number } | null;
} | null; } | null;
static from( static from(
@@ -173,6 +175,7 @@ export class GoodDto {
tagName: gt.tag.tagName, tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor, tagColor: gt.tag.tagColor,
tagFontColor: gt.tag.tagFontColor, tagFontColor: gt.tag.tagFontColor,
tagGroupId: gt.tag.tagGroupId?.toString() ?? null,
})) }))
: [], : [],
mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({ mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({
@@ -215,6 +218,14 @@ export class GoodDto {
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart), sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs), packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
productCode: rel.originGood.detail?.productCode ?? null, productCode: rel.originGood.detail?.productCode ?? null,
family: rel.originGood.family
? {
familyId: rel.originGood.family.id.toString(),
familyCode: rel.originGood.family.familyCode,
familyName: rel.originGood.family.familyName,
memberCount: rel.originGood.family._count?.originGoods,
}
: null,
} }
: null, : null,
}; };
+11
View File
@@ -61,4 +61,15 @@ export class UpdateGoodDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
goodImage?: string | null; goodImage?: string | null;
@ApiProperty({
required: false,
nullable: true,
type: Number,
description: '所属产品族 idnull = 脱离族)',
})
@IsOptional()
@IsInt()
@Min(1)
familyId?: number | null;
} }
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { GoodsController } from './goods.controller'; import { GoodsController } from './goods.controller';
import { GoodsService } from './goods.service'; import { GoodsService } from './goods.service';
import { SyncModule } from '../sync/sync.module'; import { SyncModule } from '../sync/sync.module';
import { ProductFamiliesModule } from '../product-families/product-families.module';
@Module({ @Module({
imports: [SyncModule], imports: [SyncModule, ProductFamiliesModule],
controllers: [GoodsController], controllers: [GoodsController],
providers: [GoodsService], providers: [GoodsService],
exports: [GoodsService], exports: [GoodsService],
+276 -1
View File
@@ -1,11 +1,18 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { import {
BadRequestException, BadRequestException,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { GoodsService } from './goods.service'; import {
cleanGoodDisplayName,
describePureSkuGoodName,
GoodsService,
isPureSkuGoodName,
} from './goods.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
describe('GoodsService', () => { describe('GoodsService', () => {
let service: GoodsService; let service: GoodsService;
@@ -24,12 +31,25 @@ describe('GoodsService', () => {
beforeAll(async () => { beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
providers: [ providers: [
PublicCacheService,
GoodsService, GoodsService,
PrismaService, PrismaService,
{ {
provide: SyncService, provide: SyncService,
useValue: { queueProductDetailSync: jest.fn() }, useValue: { queueProductDetailSync: jest.fn() },
}, },
{
provide: FamilyRecomputeService,
// 全量并行跑时其他套件的扫名归族可能把本套件夹具链接收进族,
// create/update 会调用 syncFamilyTags —— mock 必须覆盖全部被调方法
useValue: {
enqueue: jest.fn(),
recomputeFamily: jest.fn().mockResolvedValue(undefined),
syncFamilyTags: jest.fn().mockResolvedValue({ goodsUpdated: 0, linksUpdated: 0 }),
refreshLinkTags: jest.fn().mockResolvedValue(undefined),
mirrorLinkTagsToGoods: jest.fn().mockResolvedValue(undefined),
},
},
], ],
}).compile(); }).compile();
service = moduleRef.get(GoodsService); service = moduleRef.get(GoodsService);
@@ -261,6 +281,261 @@ describe('GoodsService', () => {
expect(after.total).toBe(before.total); expect(after.total).toBe(before.total);
}); });
describe('good name normalization', () => {
it('create/update 时把结构完整的原始链接名规范化为 品名+型号', async () => {
const created = await service.create({
goodName: '德国(不包邮)230g水洗T恤-DETM002-双面印花',
originGoodId: Number(originGoodIds[0]),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
try {
expect(created.goodName).toBe('230g水洗T恤 DETM002');
const updated = await service.update(BigInt(created.id), {
goodName: '加拿大(不包邮)180g纯棉T恤-CATM001-单面印花',
});
expect(updated.goodName).toBe('180g纯棉T恤 CATM001');
} finally {
await prisma.good.delete({ where: { id: BigInt(created.id) } });
}
});
it('非链接结构的名称原样保留(已解析名/自定义名)', async () => {
const created = await service.create({
goodName: '230g水洗T恤 DETM002',
originGoodId: Number(originGoodIds[0]),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
try {
expect(created.goodName).toBe('230g水洗T恤 DETM002');
const updated = await service.update(BigInt(created.id), {
goodName: '自定义商品 ABC',
});
expect(updated.goodName).toBe('自定义商品 ABC');
} finally {
await prisma.good.delete({ where: { id: BigInt(created.id) } });
}
});
});
describe('pure sku good name(纯款号补描述)', () => {
const pskuCode = `PSKU${stamp}`;
const pskuDesc = `童装纯色测试插肩T恤${stamp}`;
let pskuOriginGoodId: bigint;
let bareOriginGoodId: bigint;
beforeAll(async () => {
await prisma.category.create({
data: {
categoryName: `${pskuCode} ${pskuDesc}`,
sdsCategoryId: `psku-cat-${stamp}`,
},
});
const pskuOg = await prisma.originGood.create({
data: {
sdsGoodId: `sds-psku-${stamp}`,
goodName: pskuCode,
sdsCategoryId: `psku-cat-${stamp}`,
},
});
pskuOriginGoodId = pskuOg.id;
const bareOg = await prisma.originGood.create({
data: {
sdsGoodId: `sds-psku-bare-${stamp}`,
goodName: `PSKB${stamp}`,
},
});
bareOriginGoodId = bareOg.id;
});
afterAll(async () => {
await prisma.good.deleteMany({
where: { OR: [{ goodName: { contains: pskuCode } }, { goodName: { contains: `PSKB${stamp}` } }] },
});
await prisma.originGood.deleteMany({
where: { id: { in: [pskuOriginGoodId, bareOriginGoodId] } },
});
await prisma.category.deleteMany({
where: { sdsCategoryId: `psku-cat-${stamp}` },
});
});
it('isPureSkuGoodName:仅纯字母数字为真', () => {
expect(isPureSkuGoodName('PLTK016')).toBe(true);
expect(isPureSkuGoodName(' pltk016 ')).toBe(true);
expect(isPureSkuGoodName('PSKU1')).toBe(true);
expect(isPureSkuGoodName('童装纯色插肩短袖T恤 PLTK016')).toBe(false);
expect(isPureSkuGoodName('德国(不包邮)T恤-DG001-烫画')).toBe(false);
expect(isPureSkuGoodName('230g水洗T恤 DETM002')).toBe(false);
expect(isPureSkuGoodName('P')).toBe(false);
expect(isPureSkuGoodName('')).toBe(false);
});
it('cleanGoodDisplayName:品名内的 ASCII 型号限定词剥离,中文括号内容保留', () => {
expect(
cleanGoodDisplayName('波兰(包邮)童装纯色插肩短袖T恤(DTG180-PLTK016-双面印花'),
).toBe('童装纯色插肩短袖T恤 PLTK016');
expect(cleanGoodDisplayName('童装纯色插肩短袖T恤(DTG180 PLTK016')).toBe(
'童装纯色插肩短袖T恤 PLTK016',
);
expect(cleanGoodDisplayName('180G纯棉T恤 JSA002 DG001')).toBe('180G纯棉T恤 DG001');
expect(cleanGoodDisplayName('牛奶丝T恤(女款) DG601')).toBe('牛奶丝T恤(女款) DG601');
expect(cleanGoodDisplayName('230g水洗T恤 DETM002')).toBe('230g水洗T恤 DETM002');
});
it('describePureSkuGoodName:标准分类名剥款号补描述', () => {
expect(
describePureSkuGoodName('PLTK016', 'PLTK016 童装纯色插肩短袖T恤'),
).toBe('童装纯色插肩短袖T恤 PLTK016');
});
it('describePureSkuGoodName:分类名无款号前缀时用全名', () => {
expect(
describePureSkuGoodName('PLTK016', '童装纯色插肩短袖T恤'),
).toBe('童装纯色插肩短袖T恤 PLTK016');
});
it('describePureSkuGoodName:分类名即款号/空分类名/非纯款号名原样返回', () => {
expect(describePureSkuGoodName('PLTK016', 'PLTK016')).toBe('PLTK016');
expect(describePureSkuGoodName('PLTK016', null)).toBe('PLTK016');
expect(describePureSkuGoodName('PLTK016', undefined)).toBe('PLTK016');
expect(
describePureSkuGoodName('童装T恤 PLTK016', 'PLTK016 童装T恤'),
).toBe('童装T恤 PLTK016');
expect(describePureSkuGoodName('230g水洗T恤 DETM002', 'PLTK016 童装T恤')).toBe(
'230g水洗T恤 DETM002',
);
});
it('describePureSkuGoodName:分类名中的 ASCII 型号限定词一并剥离', () => {
expect(
describePureSkuGoodName('PLTK016', 'PLTK016 童装纯色插肩短袖T恤(DTG180'),
).toBe('童装纯色插肩短袖T恤 PLTK016');
});
it('create:纯款号名按主链接 SDS 分类名补描述', async () => {
const created = await service.create({
goodName: pskuCode,
originGoodId: Number(pskuOriginGoodId),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
try {
expect(created.goodName).toBe(`${pskuDesc} ${pskuCode}`);
} finally {
await prisma.good.delete({ where: { id: BigInt(created.id) } });
}
});
it('create:主链接无 sdsCategoryId 映射时纯款号原样保留', async () => {
const created = await service.create({
goodName: `PSKB${stamp}`,
originGoodId: Number(bareOriginGoodId),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
try {
expect(created.goodName).toBe(`PSKB${stamp}`);
} finally {
await prisma.good.delete({ where: { id: BigInt(created.id) } });
}
});
it('update:改成纯款号名按商品主链接分类名补描述', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} psku-rename`,
originGoodId: Number(pskuOriginGoodId),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
try {
const updated = await service.update(BigInt(created.id), {
goodName: pskuCode,
});
expect(updated.goodName).toBe(`${pskuDesc} ${pskuCode}`);
} finally {
await prisma.good.delete({ where: { id: BigInt(created.id) } });
}
});
it('batchCreate:纯款号链接名补描述、完整链接名规范化', async () => {
const created = await service.batchCreate({
countryId: Number(countryId),
categoryId: Number(categoryId),
items: [{ originGoodId: Number(pskuOriginGoodId) }],
});
try {
expect(created[0]?.goodName).toBe(`${pskuDesc} ${pskuCode}`);
} finally {
await prisma.good.deleteMany({
where: { id: { in: created.map((g) => BigInt(g.id)) } },
});
}
});
it('backfillPureSkuGoodNamesdry-run 只列不改,apply 改名且幂等', async () => {
// 绕过写路径直写库,模拟存量纯款号名
const row = await prisma.good.create({
data: {
goodName: pskuCode,
originGoodId: pskuOriginGoodId,
countryId,
categoryId,
},
});
try {
// dry-run / apply 均用 goodId 限定在本夹具,避免测试副作用波及共享库真实数据
const dry = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id });
expect(dry.renames).toEqual([
{ id: row.id.toString(), from: pskuCode, to: `${pskuDesc} ${pskuCode}` },
]);
expect(
(await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName,
).toBe(pskuCode);
const applied = await service.backfillPureSkuGoodNames({ dryRun: false, goodId: row.id });
expect(applied.renamed).toBe(1);
expect(
(await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName,
).toBe(`${pskuDesc} ${pskuCode}`);
// 幂等:已改名的行不再出现在后续清单
const again = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id });
expect(again.renames).toEqual([]);
} finally {
await prisma.good.delete({ where: { id: row.id } });
}
});
it('backfillPureSkuGoodNames:剥离「品名(型号限定词) SKU」存量名中的限定词', async () => {
const row = await prisma.good.create({
data: {
goodName: `${pskuDesc}DTG180 ${pskuCode}`,
originGoodId: pskuOriginGoodId,
countryId,
categoryId,
},
});
try {
const dry = await service.backfillPureSkuGoodNames({ dryRun: true, goodId: row.id });
expect(dry.renames).toEqual([
{
id: row.id.toString(),
from: `${pskuDesc}DTG180 ${pskuCode}`,
to: `${pskuDesc} ${pskuCode}`,
},
]);
await service.backfillPureSkuGoodNames({ dryRun: false, goodId: row.id });
expect(
(await prisma.good.findUniqueOrThrow({ where: { id: row.id } })).goodName,
).toBe(`${pskuDesc} ${pskuCode}`);
} finally {
await prisma.good.delete({ where: { id: row.id } });
}
});
});
describe('merged origin goods', () => { describe('merged origin goods', () => {
it('creates a good with merged origin goods and reads them back', async () => { it('creates a good with merged origin goods and reads them back', async () => {
const created = await service.create({ const created = await service.create({
+310 -12
View File
@@ -5,6 +5,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { parseOriginName } from '../product-families/origin-name.parser';
import { CreateGoodDto } from './dto/create-good.dto'; import { CreateGoodDto } from './dto/create-good.dto';
import { UpdateGoodDto } from './dto/update-good.dto'; import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto'; import { QueryGoodDto } from './dto/query-good.dto';
@@ -12,6 +13,9 @@ import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto'; import { BatchPriorityDto } from './dto/batch-priority.dto';
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto'; import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
import { SyncService } from '../sync/sync.service'; import { SyncService } from '../sync/sync.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { PublicCacheService } from '../public/public-cache.service';
import { isAutoTagGroupName } from '../product-families/auto-tag-rules';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { import {
CreateCustomGoodDto, CreateCustomGoodDto,
@@ -30,6 +34,15 @@ const GOOD_INCLUDE = {
detail: true, detail: true,
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }, variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
_count: { select: { variants: true } }, _count: { select: { variants: true } },
family: {
select: {
id: true,
familyCode: true,
familyName: true,
stale: true,
_count: { select: { originGoods: true } },
},
},
}, },
}, },
goodTags: { include: { tag: true } }, goodTags: { include: { tag: true } },
@@ -47,11 +60,68 @@ const GOOD_INCLUDE = {
}, },
} satisfies Prisma.GoodInclude; } satisfies Prisma.GoodInclude;
/**
* 商品名(公开展示名)规范化:结构完整的原始链接名(`国家(物流)品名-型号-…`,
* 含半角/全角括号混用)一律存为「品名 型号」;已解析名与自由命名的自定义名原样保留。
* 防的是绕过前端弹窗(预填解析名)的写入路径——API 直调、批量工具、旧客户端。
*/
export function normalizeGoodName(name: string): string {
const parsed = parseOriginName(name);
if (parsed.productName && parsed.skuCode) {
return `${parsed.productName} ${parsed.skuCode}`;
}
return name;
}
/** ASCII 型号限定词括号组(SDS 品名内的供应商/工艺型号,如(DTG180)(JSA002)) */
const ASCII_PAREN_GROUP = /(?:|\()[A-Za-z0-9]+(?:|\))/g;
/** 展示名剥离 ASCII 型号限定词;剥空(名称仅剩限定词)时原样返回 */
export function stripAsciiParenQualifiers(name: string): string {
const stripped = name.replace(ASCII_PAREN_GROUP, ' ').replace(/\s+/g, ' ').trim();
return stripped || name;
}
/**
* 商品展示名清洗:规范化 + 剥离 ASCII 型号限定词。
* SDS 品名常带型号限定词(如 `童装…(DTG180)`),展示名统一剥成「品名 SKU」。
*/
export function cleanGoodDisplayName(name: string): string {
return stripAsciiParenQualifiers(normalizeGoodName(name));
}
/** 纯款号名:仅字母/数字(如 `PLTK016`),链接名解析不出品名即此类 */
export function isPureSkuGoodName(name: string): boolean {
return /^[A-Za-z0-9]{2,}$/.test(name.trim());
}
/**
* 纯款号展示名兜底:用 SDS 分类名补描述。
* `PLTK016` + 分类名 `PLTK016 童装纯色插肩短袖T恤` → `童装纯色插肩短袖T恤 PLTK016`。
* 分类名首 token 仅当为纯字母数字时视为款号剥离(与 auto-group 的
* codeFromCategoryName 语义一致),描述中的 ASCII 型号限定词一并剥离;
* 非纯款号名、无分类名或剥后为空时原样返回。
*/
export function describePureSkuGoodName(
name: string,
categoryName: string | null | undefined,
): string {
if (!isPureSkuGoodName(name) || !categoryName?.trim()) return name;
const sku = name.trim();
const tokens = categoryName.trim().split(/\s+/);
const desc = stripAsciiParenQualifiers(
(/^[A-Za-z0-9]+$/.test(tokens[0] ?? '') ? tokens.slice(1) : tokens).join(' '),
);
return desc ? `${desc} ${sku}` : name;
}
@Injectable() @Injectable()
export class GoodsService { export class GoodsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly syncService: SyncService, private readonly syncService: SyncService,
private readonly familyRecompute: FamilyRecomputeService,
private readonly publicCache: PublicCacheService,
) {} ) {}
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> { async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
@@ -120,20 +190,27 @@ export class GoodsService {
); );
await this.ensureMergedOriginGoods(mergedIds); await this.ensureMergedOriginGoods(mergedIds);
const result = await this.prisma.$transaction(async (tx) => { const result = await this.prisma.$transaction(async (tx) => {
// Good 的族是派生数据:主链接所属族
const primary = await tx.originGood.findUnique({
where: { id: BigInt(dto.originGoodId) },
select: { familyId: true, sdsCategoryId: true },
});
const tagIds = await this.stripAutoGroupTags(dto.tagIds ?? [], primary?.familyId ?? null);
const created = await tx.good.create({ const created = await tx.good.create({
data: { data: {
goodName: dto.goodName, goodName: await this.resolveGoodName(tx, dto.goodName, primary?.sdsCategoryId),
goodImage: dto.goodImage, goodImage: dto.goodImage,
originGoodId: BigInt(dto.originGoodId), originGoodId: BigInt(dto.originGoodId),
familyId: primary?.familyId ?? null,
countryId: BigInt(dto.countryId), countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId), categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId), positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
goodPriority: dto.goodPriority ?? 0, goodPriority: dto.goodPriority ?? 0,
}, },
}); });
if (dto.tagIds && dto.tagIds.length > 0) { if (tagIds.length > 0) {
await tx.goodTag.createMany({ await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({ data: tagIds.map((tagId) => ({
goodId: created.id, goodId: created.id,
tagId: BigInt(tagId), tagId: BigInt(tagId),
})), })),
@@ -161,6 +238,11 @@ export class GoodsService {
mergedOriginGoods: result.mergedOriginGoods, mergedOriginGoods: result.mergedOriginGoods,
}); });
}); });
if (result.originGood) {
// 建商品后把链接有效标签镜像到该商品(纯聚合,不派生——派生在整理流程)
await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId));
}
this.publicCache.bump('goods');
if ( if (
result.originGood?.source === 'SDS' && result.originGood?.source === 'SDS' &&
result.originGood.sdsGoodId && result.originGood.sdsGoodId &&
@@ -176,6 +258,14 @@ export class GoodsService {
await this.ensureCategory(dto.categoryId); await this.ensureCategory(dto.categoryId);
if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId); if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId);
for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId); for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId);
let family: { id: bigint } | null = null;
if (dto.familyId !== undefined) {
family = await this.prisma.productFamily.findUnique({
where: { id: BigInt(dto.familyId) },
select: { id: true },
});
if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`);
}
const goodId = await this.prisma.$transaction(async (tx) => { const goodId = await this.prisma.$transaction(async (tx) => {
const originGood = await tx.originGood.create({ const originGood = await tx.originGood.create({
@@ -185,6 +275,15 @@ export class GoodsService {
goodName: dto.goodName, goodName: dto.goodName,
goodImage: dto.goodImage ?? null, goodImage: dto.goodImage ?? null,
goodPrice: this.decimal(dto.goodPrice), goodPrice: this.decimal(dto.goodPrice),
...(family ? { familyId: family.id } : {}),
...(dto.logisticsLabel !== undefined || dto.craftLabel !== undefined
? {
logisticsLabel: dto.logisticsLabel ?? null,
craftLabel: dto.craftLabel ?? null,
skuCode: dto.skuCode ?? null,
warehouseLabel: dto.warehouseLabel ?? null,
}
: {}),
detail: { detail: {
create: this.customDetailData( create: this.customDetailData(
dto.detail ?? {}, dto.detail ?? {},
@@ -198,11 +297,12 @@ export class GoodsService {
const good = await tx.good.create({ const good = await tx.good.create({
data: { data: {
originGoodId: originGood.id, originGoodId: originGood.id,
familyId: family?.id ?? null,
countryId: BigInt(dto.countryId), countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId), categoryId: BigInt(dto.categoryId),
positionId: positionId:
dto.positionId === undefined ? null : BigInt(dto.positionId), dto.positionId === undefined ? null : BigInt(dto.positionId),
goodName: dto.goodName, goodName: normalizeGoodName(dto.goodName),
goodImage: dto.goodImage ?? null, goodImage: dto.goodImage ?? null,
goodPriority: dto.goodPriority ?? 0, goodPriority: dto.goodPriority ?? 0,
}, },
@@ -217,6 +317,8 @@ export class GoodsService {
} }
return good.id; return good.id;
}); });
this.publicCache.bump('goods');
if (family) this.familyRecompute.enqueue(family.id);
return this.findOne(goodId); return this.findOne(goodId);
} }
@@ -263,12 +365,15 @@ export class GoodsService {
); );
} }
const goodData: Prisma.GoodUpdateInput = {}; const goodData: Prisma.GoodUpdateInput = {};
if (dto.goodName !== undefined) goodData.goodName = dto.goodName; if (dto.goodName !== undefined) {
goodData.goodName = normalizeGoodName(dto.goodName);
}
if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage; if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage;
if (Object.keys(goodData).length) { if (Object.keys(goodData).length) {
await tx.good.update({ where: { id }, data: goodData }); await tx.good.update({ where: { id }, data: goodData });
} }
}); });
this.publicCache.bump('goods');
return this.findOne(id); return this.findOne(id);
} }
@@ -286,7 +391,13 @@ export class GoodsService {
await this.ensureMergedOriginGoods(mergedIds); await this.ensureMergedOriginGoods(mergedIds);
} }
const data: Prisma.GoodUpdateInput = {}; const data: Prisma.GoodUpdateInput = {};
if (dto.goodName !== undefined) data.goodName = dto.goodName; if (dto.goodName !== undefined) {
data.goodName = await this.resolveGoodName(
this.prisma,
dto.goodName,
await this.sdsCategoryIdForGood(id, dto.originGoodId),
);
}
if (dto.originGoodId !== undefined) { if (dto.originGoodId !== undefined) {
await this.ensureOriginGood(dto.originGoodId); await this.ensureOriginGood(dto.originGoodId);
data.originGood = { connect: { id: BigInt(dto.originGoodId) } }; data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
@@ -307,18 +418,48 @@ export class GoodsService {
} }
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority; if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage; if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
if (dto.familyId !== undefined) {
if (dto.familyId !== null) {
const family = await this.prisma.productFamily.findUnique({
where: { id: BigInt(dto.familyId) },
select: { id: true },
});
if (!family) throw new NotFoundException(`product family ${dto.familyId} not found`);
data.family = { connect: { id: family.id } };
} else {
data.family = { disconnect: true };
}
}
if (dto.tagIds !== undefined) { if (dto.tagIds !== undefined) {
for (const tagId of dto.tagIds) { for (const tagId of dto.tagIds) {
await this.ensureTag(tagId); await this.ensureTag(tagId);
} }
} }
// 有族商品的自动组(物流/工艺/位置)标签为族派生数据,不接受手动写入
let familyIdForTags: bigint | null;
if (dto.familyId !== undefined) {
familyIdForTags = dto.familyId === null ? null : BigInt(dto.familyId);
} else {
familyIdForTags =
(
await this.prisma.good.findUnique({
where: { id },
select: { familyId: true },
})
)?.familyId ?? null;
}
const updateTagIds =
dto.tagIds !== undefined
? await this.stripAutoGroupTags(dto.tagIds, familyIdForTags)
: undefined;
const result = await this.prisma.$transaction(async (tx) => { const result = await this.prisma.$transaction(async (tx) => {
if (dto.tagIds !== undefined) { if (updateTagIds !== undefined) {
await tx.goodTag.deleteMany({ where: { goodId: id } }); await tx.goodTag.deleteMany({ where: { goodId: id } });
if (dto.tagIds.length > 0) { if (updateTagIds.length > 0) {
await tx.goodTag.createMany({ await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({ data: updateTagIds.map((tagId) => ({
goodId: id, goodId: id,
tagId: BigInt(tagId), tagId: BigInt(tagId),
})), })),
@@ -351,6 +492,15 @@ export class GoodsService {
mergedOriginGoods: updated.mergedOriginGoods, mergedOriginGoods: updated.mergedOriginGoods,
}); });
}); });
if (result.originGood) {
// 更新后把链接有效标签镜像到该商品(纯聚合,不派生)
await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId));
}
if (familyIdForTags) {
// 归族联动后重算矩阵(成员变化 → 自动重算,结构化聚合)
await this.familyRecompute.recomputeFamily(familyIdForTags);
}
this.publicCache.bump('goods');
if ( if (
result.originGood?.source === 'SDS' && result.originGood?.source === 'SDS' &&
result.originGood.sdsGoodId && result.originGood.sdsGoodId &&
@@ -378,6 +528,7 @@ export class GoodsService {
} }
} }
}); });
this.publicCache.bump('goods');
return { id: id.toString() }; return { id: id.toString() };
} }
@@ -395,6 +546,7 @@ export class GoodsService {
} }
return { count: dto.items.length }; return { count: dto.items.length };
}); });
this.publicCache.bump('goods');
return result; return result;
} }
@@ -422,9 +574,14 @@ export class GoodsService {
} }
const row = await tx.good.create({ const row = await tx.good.create({
data: { data: {
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`, goodName: await this.resolveGoodName(
tx,
og.goodName ?? `Origin Good ${og.sdsGoodId}`,
og.sdsCategoryId,
),
goodImage: og.goodImage, goodImage: og.goodImage,
originGoodId: og.id, originGoodId: og.id,
familyId: og.familyId,
countryId: BigInt(dto.countryId), countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId), categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId), positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
@@ -485,9 +642,85 @@ export class GoodsService {
)) { )) {
this.syncService.queueProductDetailSync(goodId); this.syncService.queueProductDetailSync(goodId);
} }
this.publicCache.bump('goods');
return result; return result;
} }
/**
* 存量修复(幂等):商品展示名统一为「品名 SKU」——
* 1. 剥离 ASCII 型号限定词(如 `童装…(DTG180 PLTK016` → `童装… PLTK016`),
* 顺带把结构完整的原始链接名规范化;
* 2. 清洗后仍为纯款号(如 `PLTK016`)的,按主链接 SDS 分类名补描述。
* dryRun 只返回清单不写库;goodId 可将范围限定到单个商品(测试用)。
* 纯款号但无分类映射(补不出描述名)的行保持原样并计入 unmapped。
*/
async backfillPureSkuGoodNames(opts: {
dryRun: boolean;
goodId?: bigint;
}): Promise<{
applied: boolean;
renamed: number;
renames: { id: string; from: string; to: string }[];
unmapped: { id: string; from: string }[];
}> {
const rows = await this.prisma.good.findMany({
where: opts.goodId !== undefined ? { id: opts.goodId } : {},
select: {
id: true,
goodName: true,
originGood: { select: { sdsCategoryId: true } },
},
});
const cleaned = rows.map((row) => {
const name = cleanGoodDisplayName(row.goodName);
return { row, name, pure: isPureSkuGoodName(name) };
});
const sdsCategoryIds = [
...new Set(
cleaned
.filter((c) => c.pure)
.map((c) => c.row.originGood?.sdsCategoryId)
.filter((v): v is string => Boolean(v)),
),
];
const categories = sdsCategoryIds.length
? await this.prisma.category.findMany({
where: { sdsCategoryId: { in: sdsCategoryIds } },
select: { sdsCategoryId: true, categoryName: true },
})
: [];
const categoryNameBySdsId = new Map(
categories.map((c) => [c.sdsCategoryId, c.categoryName]),
);
const renames: { id: string; from: string; to: string }[] = [];
const unmapped: { id: string; from: string }[] = [];
for (const { row, name, pure } of cleaned) {
const categoryName =
pure && row.originGood?.sdsCategoryId
? categoryNameBySdsId.get(row.originGood.sdsCategoryId)
: undefined;
const to = describePureSkuGoodName(name, categoryName);
if (to === row.goodName) {
if (pure) unmapped.push({ id: row.id.toString(), from: row.goodName });
} else {
renames.push({ id: row.id.toString(), from: row.goodName, to });
}
}
if (!opts.dryRun && renames.length > 0) {
await this.prisma.$transaction(
renames.map((r) =>
this.prisma.good.update({
where: { id: BigInt(r.id) },
data: { goodName: r.to },
}),
),
);
}
return { applied: !opts.dryRun, renamed: renames.length, renames, unmapped };
}
/** /**
* Walk the category tree and return the requested id + all of its * Walk the category tree and return the requested id + all of its
* descendants. We use a level-by-level BFS to keep the queries small * descendants. We use a level-by-level BFS to keep the queries small
@@ -509,6 +742,48 @@ export class GoodsService {
return ids; return ids;
} }
/**
* 写路径名称兜底:规范化后仍为纯款号(如 `PLTK016`,SDS 部分链接名只有款号)
* 时,用主链接 SDS 分类名(形如 `PLTK016 童装纯色插肩短袖T恤`)补成「描述名 款号」。
* 非纯款号名不做任何额外查询。
*/
private async resolveGoodName(
db: Pick<PrismaService, 'category'>,
rawName: string,
sdsCategoryId: string | null | undefined,
): Promise<string> {
const normalized = cleanGoodDisplayName(rawName);
if (!isPureSkuGoodName(normalized) || !sdsCategoryId) return normalized;
const category = await db.category.findUnique({
where: { sdsCategoryId },
select: { categoryName: true },
});
return describePureSkuGoodName(normalized, category?.categoryName);
}
/** 商品主链接(可为本次切换后的)的 sdsCategoryId;无链接或未分类返回 null */
private async sdsCategoryIdForGood(
goodId: bigint,
overrideOriginGoodId?: number,
): Promise<string | null> {
let originGoodId: bigint | null = null;
if (overrideOriginGoodId !== undefined) {
originGoodId = BigInt(overrideOriginGoodId);
} else {
const good = await this.prisma.good.findUnique({
where: { id: goodId },
select: { originGoodId: true },
});
originGoodId = good?.originGoodId ?? null;
}
if (originGoodId === null) return null;
const og = await this.prisma.originGood.findUnique({
where: { id: originGoodId },
select: { sdsCategoryId: true },
});
return og?.sdsCategoryId ?? null;
}
private async ensureOriginGood(id: number) { private async ensureOriginGood(id: number) {
const og = await this.prisma.originGood.findUnique({ const og = await this.prisma.originGood.findUnique({
where: { id: BigInt(id) }, where: { id: BigInt(id) },
@@ -551,8 +826,31 @@ export class GoodsService {
if (!c) throw new BadRequestException(`Category ${id} not found`); if (!c) throw new BadRequestException(`Category ${id} not found`);
} }
private async ensureTag(id: number) { /** 有族商品:剔除自动组(物流/工艺/印花数量等)标签——它们由族按链接名称派生 */
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } }); private async stripAutoGroupTags(
tagIds: number[],
familyId: bigint | null,
): Promise<number[]> {
if (!familyId || !tagIds.length) return tagIds;
const groups = await this.prisma.tagGroup.findMany({
select: { id: true, groupName: true },
});
const autoIds = new Set(
groups.filter((g) => isAutoTagGroupName(g.groupName)).map((g) => g.id.toString()),
);
const tags = await this.prisma.tag.findMany({
where: { id: { in: tagIds.map((id) => BigInt(id)) } },
select: { id: true, tagGroupId: true },
});
const blocked = new Set(
tags
.filter((t) => t.tagGroupId && autoIds.has(t.tagGroupId.toString()))
.map((t) => t.id.toString()),
);
return blocked.size ? tagIds.filter((id) => !blocked.has(String(id))) : tagIds;
}
private async ensureTag(id: number) { const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
if (!t) throw new BadRequestException(`Tag ${id} not found`); if (!t) throw new BadRequestException(`Tag ${id} not found`);
} }
+15 -3
View File
@@ -30,10 +30,22 @@ async function bootstrap() {
}), }),
); );
// Security headers (X-Content-Type-Options, X-Frame-Options, CSP, HSTS, ...) // Security headers (X-Content-Type-Options, X-Frame-Options, HSTS, ...)
// Static assets (/uploads, /assets) are embedded cross-origin by other // Static assets (/uploads, /assets) are embedded cross-origin by other
// sites, so CORP must allow cross-origin reads. // sites, so CORP must allow cross-origin reads. CSP 去掉默认的
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } })); // upgrade-insecure-requestsSwagger UI 经纯 http 访问时该指令会把
// 自身子资源升级为 https,导致 ERR_SSL_PROTOCOL_ERROR。
app.use(
helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
contentSecurityPolicy: {
useDefaults: true,
directives: {
'upgrade-insecure-requests': null,
},
},
}),
);
// Parse auth cookies (HttpOnly access/refresh tokens). Resolve both the // Parse auth cookies (HttpOnly access/refresh tokens). Resolve both the
// namespace and its `default` interop shape so it works regardless of // namespace and its `default` interop shape so it works regardless of
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsNumber,
} from 'class-validator';
export class UpdateOriginGoodTagsDto {
@ApiProperty({ description: '链接标签 id 全量集合(人工接管)', type: [Number] })
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(50)
@IsNumber({}, { each: true })
@Type(() => Number)
tagIds: number[];
}
@@ -1,8 +1,9 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OriginGoodsService } from './origin-goods.service'; import { OriginGoodsService } from './origin-goods.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto'; import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
import { UpdateOriginGoodTagsDto } from './dto/update-origin-good-tags.dto';
@ApiTags('origin-goods') @ApiTags('origin-goods')
@ApiBearerAuth() @ApiBearerAuth()
@@ -24,4 +25,28 @@ export class OriginGoodsController {
findAll(@Query() query: QueryOriginGoodDto) { findAll(@Query() query: QueryOriginGoodDto) {
return this.service.findAll(query); return this.service.findAll(query);
} }
@Get(':id')
@ApiOperation({ summary: '单链接详情(detail + variants,成员展开面板用)' })
findOne(@Param('id') id: string) {
return this.service.findOne(BigInt(id));
}
@Get(':id/tags')
@ApiOperation({ summary: '链接当前标签(含 manual 标记)' })
getTags(@Param('id') id: string) {
return this.service.getTags(BigInt(id));
}
@Put(':id/tags')
@ApiOperation({ summary: '人工接管链接标签(全量替换,自动同步不再覆盖)' })
updateTags(@Param('id') id: string, @Body() dto: UpdateOriginGoodTagsDto) {
return this.service.updateTags(BigInt(id), dto.tagIds);
}
@Delete(':id/tags')
@ApiOperation({ summary: '恢复自动派生(清掉人工标签)' })
resetTags(@Param('id') id: string) {
return this.service.resetTags(BigInt(id));
}
} }
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { OriginGoodsController } from './origin-goods.controller'; import { OriginGoodsController } from './origin-goods.controller';
import { OriginGoodsService } from './origin-goods.service'; import { OriginGoodsService } from './origin-goods.service';
import { ProductFamiliesModule } from '../product-families/product-families.module';
@Module({ @Module({
imports: [ProductFamiliesModule],
controllers: [OriginGoodsController], controllers: [OriginGoodsController],
providers: [OriginGoodsService], providers: [OriginGoodsService],
}) })
@@ -1,18 +1,26 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { OriginGoodsService } from './origin-goods.service'; import { OriginGoodsService } from './origin-goods.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { ProductFamiliesService } from '../product-families/product-families.service';
import { OrganizeService } from '../product-families/organize.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
describe('OriginGoodsService', () => { describe('OriginGoodsService', () => {
let service: OriginGoodsService; let service: OriginGoodsService;
let organize: OrganizeService;
let families: ProductFamiliesService;
let prisma: PrismaService; let prisma: PrismaService;
const stamp = Date.now(); const stamp = Date.now();
const createdSds: string[] = []; const createdSds: string[] = [];
beforeAll(async () => { beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
providers: [OriginGoodsService, PrismaService], providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService, PublicCacheService],
}).compile(); }).compile();
service = moduleRef.get(OriginGoodsService); service = moduleRef.get(OriginGoodsService);
organize = moduleRef.get(OrganizeService);
families = moduleRef.get(ProductFamiliesService);
prisma = moduleRef.get(PrismaService); prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit(); await prisma.onModuleInit();
@@ -39,6 +47,20 @@ describe('OriginGoodsService', () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
it('findOne returns og with detail/variants payload', async () => {
const og = await prisma.originGood.findFirstOrThrow({
where: { sdsGoodId: `sds-${stamp}-3` },
});
const result = await service.findOne(og.id);
expect(result.id).toBe(og.id.toString());
expect(result.sdsGoodId).toBe(`sds-${stamp}-3`);
expect(result.variantCount).toBe(0);
expect(result.hasDetail).toBe(false);
expect(result.detail).toBeNull();
expect(result.variants).toEqual([]);
await expect(service.findOne(og.id + 10_000_000n)).rejects.toThrow();
});
it('returns paginated results', async () => { it('returns paginated results', async () => {
const page1 = await service.findAll({ const page1 = await service.findAll({
page: 1, page: 1,
@@ -136,10 +158,104 @@ describe('OriginGoodsService', () => {
expect(nodeB.configuredCount).toBeGreaterThanOrEqual(1); expect(nodeB.configuredCount).toBeGreaterThanOrEqual(1);
expect(nodeB.configuredCountries).toContain(`Tree Country ${stamp}`); expect(nodeB.configuredCountries).toContain(`Tree Country ${stamp}`);
} finally { } finally {
// 先删引用行,否则 good.delete 被外键限制挡住(错误被吞后分类/商品/国家全残留)
await prisma.goodOriginGood.deleteMany({ where: { goodId: good.id } }).catch(() => undefined);
await prisma.good.delete({ where: { id: good.id } }).catch(() => undefined); await prisma.good.delete({ where: { id: good.id } }).catch(() => undefined);
await prisma.country.delete({ where: { id: country.id } }).catch(() => undefined); await prisma.country.delete({ where: { id: country.id } }).catch(() => undefined);
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined); await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
} }
}); });
}); });
describe('updateTags:缺啥补啥 + 自动归族', () => {
const tagIdByNames = async (names: string[]) => {
const rows = await prisma.tag.findMany({ where: { tagName: { in: names } } });
return rows.map((r) => r.id);
};
const tagNamesOf = async (ogId: bigint) => {
const rows = await prisma.originGoodTag.findMany({
where: { originGoodId: ogId },
include: { tag: true },
});
return rows.map((r) => r.tag.tagName).sort();
};
let ogFill: any;
beforeAll(async () => {
ogFill = await prisma.originGood.create({
data: { sdsGoodId: `ogt-fill-${stamp}`, goodName: '美国(包邮)测试K-OG1-单面印花' },
});
createdSds.push(ogFill.sdsGoodId);
// 派生一次,确保三个维度组的标签字典存在
await organize.deriveTagsForOg(ogFill.id);
});
it('缺维度自动补齐:只勾「不包邮」→ 补 单面印花+烫画,全部人工行', async () => {
const [nfs] = await tagIdByNames(['不包邮']);
const res = await service.updateTags(ogFill.id, [Number(nfs)]);
expect(res.tagsManual).toBe(true);
expect(res.tags.map((t: any) => t.tagName).sort()).toEqual(
['不包邮', '单面印花', '烫画'].sort(),
);
expect((res as any).filledDimensionTags?.sort()).toEqual(['单面印花', '烫画'].sort());
const rows = await prisma.originGoodTag.findMany({ where: { originGoodId: ogFill.id } });
expect(rows.every((r) => r.manual)).toBe(true);
});
it('人工勾齐不补;旧词「热转印」经别名视为工艺维度已齐', async () => {
const full = await tagIdByNames(['包邮', '单面印花', '烫画']);
const res1 = await service.updateTags(ogFill.id, full.map(Number));
expect((res1 as any).filledDimensionTags ?? []).toEqual([]);
// 热转印(旧词)满足工艺维度,不再补 烫画
const alias = await tagIdByNames(['包邮', '单面印花', '热转印']);
// 旧词可能不存在于字典:ensureDerivedTagMap 只建封闭词;此处兜底创建
if (alias.length === 3) {
const res2 = await service.updateTags(ogFill.id, alias.map(Number));
expect((res2 as any).filledDimensionTags ?? []).toEqual([]);
} else {
const g = await prisma.tagGroup.upsert({
where: { groupName: '印刷工艺' },
create: { groupName: '印刷工艺' },
update: {},
});
const t = await prisma.tag.upsert({
where: { tagName: '热转印' },
create: { tagName: '热转印', tagGroupId: g.id },
update: {},
});
const [baoyou, danmian] = await tagIdByNames(['包邮', '单面印花']);
const res2 = await service.updateTags(ogFill.id, [Number(baoyou), Number(danmian), Number(t.id)]);
expect((res2 as any).filledDimensionTags ?? []).toEqual([]);
}
expect(await tagNamesOf(ogFill.id)).toEqual(['包邮', '单面印花', '热转印'].sort());
});
it('无族链接保存标签后自动并入匹配族(同分类优先),响应带 attachedFamilyId', async () => {
const cat = `cat-ogt-${stamp}`;
const a = await prisma.originGood.create({
data: { sdsGoodId: `ogt-fam-${stamp}-a`, goodName: '美国(包邮)测试K-OG2-双面印花', sdsCategoryId: cat },
});
const b = await prisma.originGood.create({
data: { sdsGoodId: `ogt-fam-${stamp}-b`, goodName: '美国(不包邮)测试K-OG2-双面印花', sdsCategoryId: cat },
});
createdSds.push(a.sdsGoodId, b.sdsGoodId);
const family = await families.create({
familyName: `测试K族${stamp}`,
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
});
try {
await organize.deriveTagsForOg(b.id);
const tagIds = (await prisma.originGoodTag.findMany({ where: { originGoodId: b.id } })).map((r) => Number(r.tagId));
const res = await service.updateTags(b.id, tagIds);
expect((res as any).attachedFamilyId).toBe(String(family.id));
const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } });
expect(after.familyId).toBe(BigInt(family.id));
} finally {
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined);
}
});
});
}); });
@@ -1,8 +1,34 @@
import { Injectable } from '@nestjs/common'; import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { OrganizeService } from '../product-families/organize.service';
import { ProductFamiliesService } from '../product-families/product-families.service';
import { PublicCacheService } from '../public/public-cache.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto'; import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
export interface OriginGoodTagItem {
id: string;
tagName: string;
tagColor: string | null;
tagFontColor: string | null;
manual: boolean;
}
export interface OriginGoodTagsResult {
tagsManual: boolean;
tags: OriginGoodTagItem[];
/** 人工勾选缺定价维度时按链接名自动补齐的标签名(缺啥补啥) */
filledDimensionTags?: string[];
/** 保存时链接无族且自动并入成功的目标族 id */
attachedFamilyId?: string;
}
export interface PaginatedOriginGoods { export interface PaginatedOriginGoods {
items: Array<{ items: Array<{
id: string; id: string;
@@ -41,6 +67,10 @@ export interface OriginGoodsTreeNode {
variantCount: number; variantCount: number;
sizeRowCount: number; sizeRowCount: number;
packageRowCount: number; packageRowCount: number;
familyId: string | null;
familyName: string | null;
familyCode: string | null;
familyStale: boolean | null;
} }
/** A category node in the hierarchical tree, with origin goods as leaves. */ /** A category node in the hierarchical tree, with origin goods as leaves. */
@@ -63,7 +93,175 @@ export interface OriginGoodsTreeResponse {
@Injectable() @Injectable()
export class OriginGoodsService { export class OriginGoodsService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly familyRecompute: FamilyRecomputeService,
private readonly organize: OrganizeService,
private readonly families: ProductFamiliesService,
private readonly publicCache: PublicCacheService,
) {}
/** 链接当前标签(含 manual 标记) */
async getTags(id: bigint): Promise<OriginGoodTagsResult> {
const og = await this.prisma.originGood.findUnique({
where: { id },
include: {
originGoodTags: {
include: {
tag: { select: { id: true, tagName: true, tagColor: true, tagFontColor: true } },
},
orderBy: { tagId: 'asc' },
},
},
});
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
return {
tagsManual: og.tagsManual,
tags: og.originGoodTags.map((r) => ({
id: r.tag.id.toString(),
tagName: r.tag.tagName,
tagColor: r.tag.tagColor,
tagFontColor: r.tag.tagFontColor,
manual: r.manual,
})),
};
}
/**
* 人工接管链接标签:人工勾选全量保存为 manual 行(自动同步永不覆盖);
* 三个定价维度组(印花数量/工艺/物流)缺哪组按链接名自动补哪组(缺啥补啥),
* 保证链接 SKU 不掉出族价格矩阵;未归族链接随后自动并入匹配族。
*/
async updateTags(id: bigint, tagIds: number[]): Promise<OriginGoodTagsResult> {
const og = await this.prisma.originGood.findUnique({
where: { id },
select: { id: true, goodName: true, source: true },
});
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
let uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) =>
Number(a - b),
);
if (uniqueIds.length) {
const count = await this.prisma.tag.count({ where: { id: { in: uniqueIds } } });
if (count !== uniqueIds.length) {
throw new BadRequestException('存在无效标签');
}
}
// CUSTOM 成员的归因走管理员显式 craftLabel/logisticsLabel,名称不解析、不补齐
const fill =
og.source === 'SDS'
? await this.organize.fillMissingDimTags(og.goodName, uniqueIds)
: { tagIds: [] as bigint[], names: [] as string[] };
if (fill.tagIds.length) {
uniqueIds = [...new Set([...uniqueIds, ...fill.tagIds])].sort((a, b) =>
Number(a - b),
);
}
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
...(!uniqueIds.length
? []
: [
this.prisma.originGoodTag.createMany({
data: uniqueIds.map((tagId) => ({
originGoodId: id,
tagId,
manual: true,
})),
}),
]),
this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }),
]);
await this.familyRecompute.mirrorLinkTagsToGoods(id);
// 标签镜像改变 good_tags(meta 域的标签组过滤);无族链接挂靠失败时
// 后续 recompute 不会执行,此处显式 bump 兜住全部路径(含 matrix 归因)
this.publicCache.bump('meta', 'goods', 'matrix');
// 人工改标签 → 归因维度可能变化,自动重算族矩阵(有族才重算)
const ogFull = await this.prisma.originGood.findUnique({
where: { id },
select: { familyId: true },
});
let attachedFamilyId: string | undefined;
if (ogFull?.familyId) {
await this.familyRecompute.recomputeFamily(ogFull.familyId);
} else {
// 无族链接:自动并入匹配的同款族(同 SDS 分类优先,回退族语义名称键)
const fid = await this.families.attachToMatchingFamily(id);
if (fid) attachedFamilyId = fid.toString();
}
const base = await this.getTags(id);
return {
...base,
filledDimensionTags: fill.names,
...(attachedFamilyId ? { attachedFamilyId } : {}),
};
}
/** 恢复自动:清掉全部标签行(含人工行),按链接名称重新派生(显式人工动作) */
async resetTags(id: bigint): Promise<OriginGoodTagsResult> {
const og = await this.prisma.originGood.findUnique({
where: { id },
select: { id: true, familyId: true },
});
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
this.prisma.originGood.update({ where: { id }, data: { tagsManual: false } }),
]);
// 派生集中在整理服务(解析去运行时化);「恢复自动」本身是显式人工动作
await this.organize.deriveTagsForOg(id);
this.publicCache.bump('meta', 'goods', 'matrix');
if (og.familyId) await this.familyRecompute.recomputeFamily(og.familyId);
return this.getTags(id);
}
/** 单链接详情(含 detail 与 variants,成员展开面板用) */
async findOne(id: bigint) {
const og = await this.prisma.originGood.findUnique({
where: { id },
include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
_count: { select: { variants: true } },
},
});
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
const detail = og.detail;
return {
id: og.id.toString(),
sdsGoodId: og.sdsGoodId,
goodName: og.goodName,
goodImage: og.goodImage,
goodPrice: og.goodPrice === null || og.goodPrice === undefined ? null : og.goodPrice.toString(),
source: og.source,
delisted: og.delisted,
hasDetail: Boolean(detail),
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
variantCount: og._count.variants,
detail: detail
? {
productCode: detail.productCode,
englishName: detail.englishName,
productionCycleHours: detail.productionCycleHours,
minWeightG: detail.minWeightG === null ? null : detail.minWeightG.toString(),
productionProcess: detail.productionProcess,
materialDescription: detail.materialDescription,
sizeChart: (detail.sizeChart ?? null) as unknown,
packageSpecs: (detail.packageSpecs ?? null) as unknown,
}
: null,
variants: og.variants.map((v) => ({
sdsVariantId: v.sdsVariantId,
sku: v.sku,
sizeId: v.sizeId,
sizeName: v.sizeName,
colorId: v.colorId,
colorName: v.colorName,
price: v.price === null ? null : v.price.toString(),
enabled: v.enabled,
})),
};
}
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> { async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
const { page, pageSize, keyword } = query; const { page, pageSize, keyword } = query;
@@ -129,7 +327,11 @@ export class OriginGoodsService {
this.prisma.originGood.findMany({ this.prisma.originGood.findMany({
where: { delisted: false, source: 'SDS' }, where: { delisted: false, source: 'SDS' },
orderBy: { goodName: 'asc' }, orderBy: { goodName: 'asc' },
include: { detail: true, _count: { select: { variants: true } } }, include: {
detail: true,
_count: { select: { variants: true } },
family: { select: { id: true, familyName: true, familyCode: true, stale: true } },
},
}), }),
this.prisma.good.groupBy({ this.prisma.good.groupBy({
by: ['originGoodId'], by: ['originGoodId'],
@@ -271,6 +473,10 @@ export class OriginGoodsService {
variantCount: og._count.variants, variantCount: og._count.variants,
sizeRowCount: this.jsonRows(og.detail?.sizeChart), sizeRowCount: this.jsonRows(og.detail?.sizeChart),
packageRowCount: this.jsonRows(og.detail?.packageSpecs), packageRowCount: this.jsonRows(og.detail?.packageSpecs),
familyId: og.family?.id.toString() ?? null,
familyName: og.family?.familyName ?? null,
familyCode: og.family?.familyCode ?? null,
familyStale: og.family?.stale ?? null,
})); }));
const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0); const childTotal = childNodes.reduce((s, n) => s + n.totalCount, 0);
@@ -322,6 +528,10 @@ export class OriginGoodsService {
variantCount: og._count.variants, variantCount: og._count.variants,
sizeRowCount: this.jsonRows(og.detail?.sizeChart), sizeRowCount: this.jsonRows(og.detail?.sizeChart),
packageRowCount: this.jsonRows(og.detail?.packageSpecs), packageRowCount: this.jsonRows(og.detail?.packageSpecs),
familyId: og.family?.id.toString() ?? null,
familyName: og.family?.familyName ?? null,
familyCode: og.family?.familyCode ?? null,
familyStale: og.family?.stale ?? null,
})), })),
}); });
} }
@@ -1,3 +1,4 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common';
import { PositionsService } from './positions.service'; import { PositionsService } from './positions.service';
@@ -12,7 +13,7 @@ describe('PositionsService', () => {
beforeAll(async () => { beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
providers: [PositionsService, PrismaService], providers: [PositionsService, PrismaService, PublicCacheService],
}).compile(); }).compile();
service = moduleRef.get(PositionsService); service = moduleRef.get(PositionsService);
prisma = moduleRef.get(PrismaService); prisma = moduleRef.get(PrismaService);
+14 -4
View File
@@ -3,12 +3,16 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from '../public/public-cache.service';
import { CreatePositionDto } from './dto/create-position.dto'; import { CreatePositionDto } from './dto/create-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto';
@Injectable() @Injectable()
export class PositionsService { export class PositionsService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly publicCache: PublicCacheService,
) {}
findAll(filters?: { countryId?: bigint; categoryId?: bigint }) { findAll(filters?: { countryId?: bigint; categoryId?: bigint }) {
const where: { countryId?: bigint; categoryId?: bigint } = {}; const where: { countryId?: bigint; categoryId?: bigint } = {};
@@ -37,7 +41,7 @@ export class PositionsService {
if (dto.categoryId !== undefined) { if (dto.categoryId !== undefined) {
await this.ensureCategory(dto.categoryId); await this.ensureCategory(dto.categoryId);
} }
return this.prisma.position.create({ const created = await this.prisma.position.create({
data: { data: {
indexVal: dto.indexVal, indexVal: dto.indexVal,
countryId: dto.countryId === undefined ? null : BigInt(dto.countryId), countryId: dto.countryId === undefined ? null : BigInt(dto.countryId),
@@ -45,6 +49,8 @@ export class PositionsService {
}, },
include: { country: true, category: true }, include: { country: true, category: true },
}); });
this.publicCache.bump('goods');
return created;
} }
async update(id: bigint, dto: UpdatePositionDto) { async update(id: bigint, dto: UpdatePositionDto) {
@@ -55,7 +61,7 @@ export class PositionsService {
if (dto.categoryId !== undefined && dto.categoryId !== null) { if (dto.categoryId !== undefined && dto.categoryId !== null) {
await this.ensureCategory(dto.categoryId); await this.ensureCategory(dto.categoryId);
} }
return this.prisma.position.update({ const updated = await this.prisma.position.update({
where: { id }, where: { id },
data: { data: {
indexVal: dto.indexVal, indexVal: dto.indexVal,
@@ -74,11 +80,15 @@ export class PositionsService {
}, },
include: { country: true, category: true }, include: { country: true, category: true },
}); });
this.publicCache.bump('goods');
return updated;
} }
async remove(id: bigint) { async remove(id: bigint) {
await this.findOne(id); await this.findOne(id);
return this.prisma.position.delete({ where: { id } }); const removed = await this.prisma.position.delete({ where: { id } });
this.publicCache.bump('goods');
return removed;
} }
private async ensureCountry(id: number) { private async ensureCountry(id: number) {
@@ -0,0 +1,75 @@
import { deriveLinkTagNames, isAutoTagGroupName } from './auto-tag-rules';
describe('auto-tag-rules / deriveLinkTagNames', () => {
it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => {
expect(
deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花'),
).toEqual(['单面印花', '烫画', '包邮']);
});
it('双面印花 + 不包邮 → 双面印花/烫画/不包邮', () => {
expect(
deriveLinkTagNames('美国(不包邮)180g纯棉T恤成人款-DG001-双面印花·美东新泽西仓'),
).toEqual(['双面印花', '烫画', '不包邮']);
});
it('工艺段写法「直喷双面/直喷单面」→ 裸 双面/单面 也归入印花数量', () => {
expect(
deriveLinkTagNames('日本(包邮)200g纯棉T恤-JPTM001-直喷双面'),
).toEqual(['双面印花', '直喷', '包邮']);
expect(
deriveLinkTagNames('日本(包邮)200g纯棉T恤-JPTM001-直喷单面'),
).toEqual(['单面印花', '直喷', '包邮']);
});
it('不打印 + 光板(物流备注)+ 不包邮 → 归并为单个 不打印 工艺标签(光板即不打印)', () => {
expect(
deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'),
).toEqual(['不打印', '不包邮']);
});
it('仅光板(无不打印字样)同样归为 不打印', () => {
expect(deriveLinkTagNames('美国(包邮光板)T恤-DG001')).toEqual(['不打印', '包邮']);
});
it('直喷命中时不给默认烫画;名称未写单/双面时印花数量默认单面印花', () => {
expect(deriveLinkTagNames('美国(包邮)卫衣-DG002-直喷')).toEqual(['单面印花', '直喷', '包邮']);
});
it('不打印/光板(无印花面)不补默认印花数量', () => {
expect(deriveLinkTagNames('美国(不包邮光板)T恤-DG001-不打印')).toEqual(['不打印', '不包邮']);
});
it('物流备注既无包邮也无不包邮时不下发物流标签', () => {
const names = deriveLinkTagNames('美国(快递)卫衣-DG002-单面印花');
expect(names).toContain('单面印花');
expect(names).toContain('烫画');
expect(names).not.toContain('包邮');
expect(names).not.toContain('不包邮');
});
it('空名称返回空数组', () => {
expect(deriveLinkTagNames(null)).toEqual([]);
expect(deriveLinkTagNames('')).toEqual([]);
});
it('先判「不包邮」再判「包邮」,避免子串误命中', () => {
const names = deriveLinkTagNames('美国(不包邮)T恤-DG001-单面印花');
expect(names).toContain('不包邮');
expect(names).not.toContain('包邮');
});
});
describe('auto-tag-rules / isAutoTagGroupName', () => {
it('命中真实派生组:物流渠道 / 印刷工艺 / 印花数量 / 印刷位置', () => {
expect(isAutoTagGroupName('物流渠道')).toBe(true);
expect(isAutoTagGroupName('印刷工艺')).toBe(true);
expect(isAutoTagGroupName('印花数量')).toBe(true);
expect(isAutoTagGroupName('印刷位置')).toBe(true);
});
it('其他分组不视为自动组', () => {
expect(isAutoTagGroupName('潮流')).toBe(false);
expect(isAutoTagGroupName('场景')).toBe(false);
});
});
@@ -0,0 +1,80 @@
/**
* 链接标签自动派生规则(产品确认版,2026-08-28):
*
* 标签与「产品链接」一一对应 —— 每条链接因 印花数量/工艺/物流 不同而价格不同
* (价格矩阵维度 = 印花数量·工艺 × 物流 × 尺码 × 颜色),因此标签按链接名称解析:
*
* - 印花数量:名称含「双面印花」→ 双面印花;否则含「单面印花」→ 单面印花;
* - 工艺:名称含「直喷」「不打印」「光板」→ 对应标签(可多个);都不含 → 默认「烫画」;
* - 物流:名称含「不包邮」→ 不包邮;否则含「包邮」→ 包邮(先判「不包邮」,避免子串误命中)。
*
* 这些分组(印花数量 / 印刷工艺 / 物流渠道)下的标签为派生数据,不接受手动写入;
* 其他分组保持人工管理。
*/
/** 自动(派生)标签组名匹配:命中即视为族托管,手输的这类标签会被剔除 */
export function isAutoTagGroupName(name: string): boolean {
return /物流|工艺|位置|印花数量/.test(name);
}
/**
* 工艺旧词 → 封闭词表别名:人工标签历史上建过「热转印」(= 烫画,热转印工艺),
* 归一后才能进价格矩阵 —— 别名只影响矩阵归因,不改标签字典本身。
*/
export const CRAFT_TAG_ALIASES: Record<string, string> = {
: '烫画',
};
export function normalizeCraftTag(name: string): string {
return CRAFT_TAG_ALIASES[name] ?? name;
}
export interface DerivedTagGroupSpec {
/** 标签组名(查找用 includes 匹配,缺失时自动建组) */
group: string;
/** 组内规则标签(缺失时自动建标) */
tags: string[];
}
/** 派生标签所属组及其全部合法取值(光板 = 不打印,不单独设标签) */
export const DERIVED_TAG_GROUP_SPECS: DerivedTagGroupSpec[] = [
{ group: '物流渠道', tags: ['包邮', '不包邮'] },
{ group: '印花数量', tags: ['单面印花', '双面印花'] },
{ group: '印刷工艺', tags: ['烫画', '直喷', '不打印'] },
];
/** 工艺关键字 → 标签名(光板即为不打印) */
const CRAFT_KEYWORD_MAP: Record<string, string> = {
: '直喷',
: '不打印',
: '不打印',
};
const CRAFT_DEFAULT = '烫画';
/**
* 由链接名称解析标签名集合(不含组信息)。
* 确定性、纯函数 —— 同名链接在任何环境派生结果一致。
*/
export function deriveLinkTagNames(name: string | null | undefined): string[] {
if (!name) return [];
const names: string[] = [];
// 「双面印花/单面印花」显式命中,或工艺段写作「直喷双面/直喷单面」的裸「双面/单面」;
// 双面优先判断,避免「单面」误吞。名称完全没写且工艺非「不打印」时默认单面印花
// (解析假设集中在整理脚本层,可审查可重跑;不打印/光板 无印花面,不补)
const craftTags = new Set<string>();
for (const [keyword, tagName] of Object.entries(CRAFT_KEYWORD_MAP)) {
if (name.includes(keyword)) craftTags.add(tagName);
}
const noPrint = craftTags.has('不打印');
if (name.includes('双面')) names.push('双面印花');
else if (name.includes('单面')) names.push('单面印花');
else if (!noPrint) names.push('单面印花');
if (craftTags.size > 0) names.push(...craftTags);
else names.push(CRAFT_DEFAULT);
if (name.includes('不包邮')) names.push('不包邮');
else if (name.includes('包邮')) names.push('包邮');
return names;
}
@@ -0,0 +1,280 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
IsNumberString,
IsObject,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export class CreateProductFamilyDto {
@ApiProperty({ example: '180g纯棉T恤(成人款)' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
familyName!: string;
@ApiProperty({ required: false, example: 'DG001' })
@IsOptional()
@IsString()
@MaxLength(100)
familyCode?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
familyImage?: string | null;
@ApiProperty({ required: false, example: '1' })
@IsOptional()
@IsNumberString()
countryId?: string;
@ApiProperty({ required: false, example: '2' })
@IsOptional()
@IsNumberString()
categoryId?: string;
@ApiProperty({
required: false,
description: '主链接 origin_good_idcanonical 详情与跳转兜底)',
example: '123',
})
@IsOptional()
@IsNumberString()
primaryOriginGoodId?: string;
@ApiProperty({ required: false, type: [String], example: ['123', '124'] })
@IsOptional()
@IsArray()
@IsNumberString({}, { each: true })
originGoodIds?: string[];
}
export class PatchProductFamilyDto extends PartialType(CreateProductFamilyDto) {
@ApiProperty({ required: false, description: 'false = 人工锁定,重算只置 stale' })
@IsOptional()
@IsBoolean()
autoManaged?: boolean;
}
export class QueryProductFamilyDto {
@ApiProperty({ required: false, description: '匹配 familyName / familyCode' })
@IsOptional()
@IsString()
keyword?: string;
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
pageSize?: number;
}
export class AutoGroupDto {
@ApiProperty({ required: false, default: false, description: 'true = 实际建族' })
@IsOptional()
@IsBoolean()
apply?: boolean;
}
export class UpdateFamilyMembersDto {
@ApiProperty({ required: false, type: [String] })
@IsOptional()
@IsArray()
@IsNumberString({}, { each: true })
addOriginGoodIds?: string[];
@ApiProperty({ required: false, type: [String] })
@IsOptional()
@IsArray()
@IsNumberString({}, { each: true })
removeOriginGoodIds?: string[];
}
export class CustomMemberVariantDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
sku!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
sizeId?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
sizeName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorId?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorName?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
colorHex?: string | null;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
imageUrl?: string | null;
@ApiProperty({ example: '28.00' })
@IsNumber()
@Min(0.01)
price!: number;
}
export class CreateCustomMemberDto {
@ApiProperty({ example: '美国(包邮)180g纯棉T恤-DG001-双面印花(自建)' })
@IsString()
@IsNotEmpty()
@MaxLength(300)
goodName!: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string | null;
@ApiProperty({ description: '物流归因(价格矩阵维度,必填)', example: '包邮' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
logisticsLabel!: string;
@ApiProperty({ description: '工艺/印花数量归因(价格矩阵维度,必填)', example: '双面印花' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
craftLabel!: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
skuCode?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
warehouseLabel?: string;
@ApiProperty({ type: [CustomMemberVariantDto], minItems: 1 })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CustomMemberVariantDto)
variants!: CustomMemberVariantDto[];
@ApiProperty({
required: false,
type: Object,
description: '可选人工详情(尺码表/包装规则参与并集)',
})
@IsOptional()
@IsObject()
detail?: { sizeChart?: Record<string, unknown>; packageSpecs?: Record<string, unknown> };
}
export class PriceOverrideItemDto {
@ApiProperty({ example: 'size_S' })
@IsString()
@IsNotEmpty()
sizeId!: string;
@ApiProperty({ example: 'color_blk' })
@IsString()
@IsNotEmpty()
colorId!: string;
@ApiProperty({ example: '单面印花', description: '印花数量维:单面印花 / 双面印花' })
@IsString()
@IsNotEmpty()
printCount!: string;
@ApiProperty({ example: '烫画', description: '工艺维:烫画 / 直喷 / 不打印' })
@IsString()
@IsNotEmpty()
craft!: string;
@ApiProperty({ example: '包邮' })
@IsString()
@IsNotEmpty()
logistics!: string;
@ApiProperty({ example: '23.00' })
@IsNumber()
@Min(0.01)
price!: number;
@ApiProperty({ required: false, description: '改价原因(审计)' })
@IsOptional()
@IsString()
note?: string;
}
export class PutPriceOverridesDto {
@ApiProperty({ type: [PriceOverrideItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PriceOverrideItemDto)
items!: PriceOverrideItemDto[];
}
export class PriceOverrideCellDto {
@ApiProperty({ example: 'size_S' })
@IsString()
@IsNotEmpty()
sizeId!: string;
@ApiProperty({ example: 'color_blk' })
@IsString()
@IsNotEmpty()
colorId!: string;
@ApiProperty({ example: '单面印花', description: '印花数量维:单面印花 / 双面印花' })
@IsString()
@IsNotEmpty()
printCount!: string;
@ApiProperty({ example: '烫画', description: '工艺维:烫画 / 直喷 / 不打印' })
@IsString()
@IsNotEmpty()
craft!: string;
@ApiProperty({ example: '包邮' })
@IsString()
@IsNotEmpty()
logistics!: string;
}
export class DeletePriceOverridesDto {
@ApiProperty({ type: [PriceOverrideCellDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PriceOverrideCellDto)
cells!: PriceOverrideCellDto[];
}
@@ -0,0 +1,503 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import {
FamilyRecomputeService,
derivePriceMatrix,
memberMatrixCombos,
} from './family-recompute.service';
import { ProductFamiliesService } from './product-families.service';
import { OrganizeService } from './organize.service';
import { PrismaService } from '../prisma/prisma.service';
/**
* 集成测试(连真实库,与 goods.service.spec.ts 同模式):
* 验证并集尺码表/包装规则裁决、五维价格矩阵推导(最低价/来源累积/维度过滤)、
* 覆盖合并、autoManaged 锁定语义与 canonical detail 初始化。
*/
describe('FamilyRecomputeService', () => {
let service: FamilyRecomputeService;
let organize!: OrganizeService;
let prisma: PrismaService;
const stamp = Date.now();
const createdOriginGoodIds: bigint[] = [];
const createdFamilyIds: bigint[] = [];
const createdTagIds: bigint[] = [];
const createdTagGroupIds: bigint[] = [];
const mkOriginGood = async (over: {
goodName?: string;
source?: 'SDS' | 'CUSTOM';
craftLabel?: string | null;
logisticsLabel?: string | null;
variants?: Array<{
sdsVariantId: string;
sku: string;
sizeId?: string;
sizeName?: string;
colorId?: string;
colorName?: string;
price: number;
enabled?: boolean;
}>;
sizeChart?: object;
packageSpecs?: object;
}) => {
const og = await prisma.originGood.create({
data: {
sdsGoodId: `recompute-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`,
goodName: over.goodName ?? `测试链接-${stamp}`,
source: over.source ?? 'CUSTOM',
craftLabel: over.craftLabel ?? null,
logisticsLabel: over.logisticsLabel ?? null,
},
});
createdOriginGoodIds.push(og.id);
if (over.variants?.length) {
await prisma.originGoodVariant.createMany({
data: over.variants.map((v) => ({
originGoodId: og.id,
sdsVariantId: v.sdsVariantId,
sku: v.sku,
sizeId: v.sizeId ?? null,
sizeName: v.sizeName ?? null,
colorId: v.colorId ?? null,
colorName: v.colorName ?? null,
price: new Prisma.Decimal(v.price),
enabled: v.enabled ?? true,
})),
});
}
if (over.sizeChart || over.packageSpecs) {
await prisma.originGoodDetail.create({
data: {
originGoodId: og.id,
sizeChart: (over.sizeChart ?? undefined) as Prisma.InputJsonValue,
packageSpecs: (over.packageSpecs ?? undefined) as Prisma.InputJsonValue,
},
});
}
return og;
};
const mkFamily = async (over: {
primaryOriginGoodId?: bigint | null;
autoManaged?: boolean;
memberIds?: bigint[];
}) => {
const family = await prisma.productFamily.create({
data: {
familyName: `测试族-${stamp}-${createdFamilyIds.length}`,
autoManaged: over.autoManaged ?? true,
primaryOriginGoodId: over.primaryOriginGoodId ?? null,
},
});
createdFamilyIds.push(family.id);
if (over.memberIds?.length) {
await prisma.originGood.updateMany({
where: { id: { in: over.memberIds } },
data: { familyId: family.id },
});
}
return family;
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [FamilyRecomputeService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(FamilyRecomputeService);
organize = new OrganizeService(
moduleRef.get(PrismaService),
service,
new ProductFamiliesService(moduleRef.get(PrismaService), service),
moduleRef.get(PublicCacheService),
);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
await prisma.tag.deleteMany({ where: { id: { in: createdTagIds } } });
await prisma.tagGroup.deleteMany({ where: { id: { in: createdTagGroupIds } } });
await prisma.$disconnect();
});
it('并集尺码表:覆盖最全的成员优先、主链接次之、按首现顺序合并', async () => {
// A:主链接,2 行;C:非主链接,3 行 → C 的 S 行胜出
const a = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮',
sizeChart: { columns: [{ key: 'chest', name: '胸围' }], rows: [
{ sizeId: 'size_S', sizeName: 'S', chest: 100 },
{ sizeId: 'size_XL', sizeName: 'XL', chest: 108 },
] },
});
const c = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮',
sizeChart: { columns: [{ key: 'chest', name: '胸围' }], rows: [
{ sizeId: 'size_S', sizeName: 'S', chest: 98 },
{ sizeId: 'size_XXL', sizeName: 'XXL', chest: 112 },
{ sizeId: 'size_XXXL', sizeName: 'XXXL', chest: 116 },
] },
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, c.id] });
await service.recomputeFamily(family.id);
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
const chart = after.sizeChart as { rows: Array<{ sizeName: string; chest: number }> };
const names = chart.rows.map((r) => r.sizeName);
expect(names).toEqual(['S', 'XXL', 'XXXL', 'XL']); // C(3行) 优先遍历,A 补充 XL
expect(chart.rows.find((r) => r.sizeName === 'S')?.chest).toBe(98); // 冲突取覆盖最全成员
expect(after.stale).toBe(false);
});
it('价格矩阵:五维格子取最低价并累积来源;维度取整理派生的链接标签;停用变体不参与', async () => {
const a = await mkOriginGood({
source: 'SDS',
goodName: '美国(包邮)测试A-PA1-单面印花',
variants: [
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
{ sdsVariantId: 'v2', sku: 'A-XL-WHT', sizeId: 'size_XL', sizeName: 'XL', colorId: 'color_wht', colorName: '白色', price: 27 },
{ sdsVariantId: 'v3', sku: 'A-S-BLK-OFF', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 20, enabled: false },
],
});
const b = await mkOriginGood({
source: 'SDS',
goodName: '美国(包邮)测试B-PB1-单面印花', // 同格子(不同仓库)
variants: [
{ sdsVariantId: 'v4', sku: 'B-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 24.5 },
{ sdsVariantId: 'v5', sku: 'B-XXXL-BLK', sizeId: 'size_XXXL', sizeName: 'XXXL', colorId: 'color_blk', colorName: '黑色', price: 29 },
],
});
const d = await mkOriginGood({
source: 'SDS',
goodName: '美国(包邮)测试D-PD1-直喷双面',
variants: [
{ sdsVariantId: 'v6', sku: 'D-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 30 },
],
});
const e = await mkOriginGood({
source: 'SDS',
goodName: '美国(不包邮)测试E-PE1-光板', // 整理派生:不打印 + 不包邮(无印花数量标签 → 结构化占位单面)
variants: [
{ sdsVariantId: 'v7', sku: 'E-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 22 },
],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id, d.id, e.id] });
// 整理(显式派生标签)→ 重算(纯聚合)。运行时不解析名称,未派生的成员不进矩阵
await organize.deriveFamilyTags(family.id);
await service.recomputeFamily(family.id);
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
const matrix = after.priceMatrix as any;
const cell = matrix.rows.find(
(r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk'
&& r.printCount === '单面印花' && r.craft === '烫画' && r.logistics === '包邮',
);
expect(cell.price).toBe('24.5');
expect(cell.manual).toBe(false);
expect(cell.sources).toHaveLength(2); // a.v1 + b.v4;停用 v3 排除
// 直喷双面 → 印花数量/工艺两维正确拆分
const dg = matrix.rows.find((r: any) => r.printCount === '双面印花' && r.craft === '直喷' && r.price === '30');
expect(dg).toBeTruthy();
// 光板 → 工艺=不打印、印花数量占位单面、物流=不包邮
const blank = matrix.rows.find((r: any) => r.craft === '不打印' && r.logistics === '不包邮');
expect(blank).toBeTruthy();
expect(blank.printCount).toBe('单面印花');
expect(matrix.printCounts.sort()).toEqual(['单面印花', '双面印花']);
expect(matrix.crafts.sort()).toEqual(['不打印', '烫画', '直喷']);
expect(matrix.logistics.sort()).toEqual(['不包邮', '包邮']);
expect(matrix.sizes.map((s: any) => s.name).sort()).toEqual(['S', 'XL', 'XXXL']);
});
it('价格矩阵:未整理(无标签)的 SDS 成员不进矩阵', async () => {
const a = await mkOriginGood({
source: 'SDS',
goodName: '美国(包邮)测试U-PU1-单面印花',
variants: [
{ sdsVariantId: 'v1', sku: 'U-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
// 只重算、不整理 → 无标签 → 空矩阵
await service.recomputeFamily(family.id);
let after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((after.priceMatrix as any).rows).toHaveLength(0);
// 整理后进入矩阵
await organize.deriveFamilyTags(family.id);
await service.recomputeFamily(family.id);
after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((after.priceMatrix as any).rows).toHaveLength(1);
});
it('价格矩阵:人工接管标签即权威维度(人工标签 > 一切)', async () => {
const a = await mkOriginGood({
goodName: '美国(包邮)测试M-PM1-单面印花', // 名称派生是 单面/烫画/包邮
variants: [
{ sdsVariantId: 'v1', sku: 'M-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
],
});
// 人工把该链接标签改成 双面印花/直喷/不包邮(手工造标签行,模拟 updateTags 结果)
const tagIds: bigint[] = [];
for (const groupName of ['印花数量', '印刷工艺', '物流渠道']) {
const group = await prisma.tagGroup.findFirst({ where: { groupName } });
const g = group ?? (await prisma.tagGroup.create({ data: { groupName } }));
if (!group) createdTagGroupIds.push(g.id);
for (const tagName of groupName === '印花数量' ? ['双面印花'] : groupName === '印刷工艺' ? ['直喷'] : ['不包邮']) {
const existing = await prisma.tag.findFirst({ where: { tagName } });
const t = existing ?? (await prisma.tag.create({ data: { tagName, tagGroupId: g.id } }));
if (!existing) createdTagIds.push(t.id);
tagIds.push(t.id);
}
}
await prisma.originGoodTag.createMany({
data: tagIds.map((tagId) => ({ originGoodId: a.id, tagId, manual: true })),
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
await service.recomputeFamily(family.id);
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
const matrix = after.priceMatrix as any;
expect(matrix.rows).toHaveLength(1);
expect(matrix.rows[0].printCount).toBe('双面印花');
expect(matrix.rows[0].craft).toBe('直喷');
expect(matrix.rows[0].logistics).toBe('不包邮');
});
it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => {
const a = await mkOriginGood({
goodName: '美国(包邮)测试O-PO1-单面印花',
variants: [
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
await prisma.familyPriceOverride.createMany({
data: [
{ familyId: family.id, sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮', price: new Prisma.Decimal(23) },
{ familyId: family.id, sizeId: 'size_M', colorId: 'color_red', printCount: '四面印花', craft: '丝印', logistics: '海运', price: new Prisma.Decimal(40) },
],
});
await service.recomputeFamily(family.id);
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
const matrix = after.priceMatrix as any;
const hit = matrix.rows.find(
(r: any) => r.sizeId === 'size_S' && r.colorId === 'color_blk' && r.printCount === '单面印花' && r.craft === '烫画' && r.logistics === '包邮',
);
expect(hit.price).toBe('23');
expect(hit.manual).toBe(true);
const added = matrix.rows.find((r: any) => r.sizeId === 'size_M' && r.craft === '丝印');
expect(added).toBeTruthy();
expect(added.manual).toBe(true);
expect(added.printCount).toBe('四面印花');
expect(added.sources).toEqual([]);
expect(matrix.crafts).toContain('丝印');
expect(matrix.printCounts).toContain('四面印花');
expect(matrix.logistics).toContain('海运');
expect(matrix.sizes.some((s: any) => s.key === 'size_M')).toBe(true);
});
it('autoManaged=false:只置 stale,不覆盖物化字段', async () => {
const a = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮',
variants: [
{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 },
],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id], autoManaged: false });
await prisma.productFamily.update({
where: { id: family.id },
data: { priceMatrix: { version: 'locked' } as Prisma.InputJsonValue },
});
await service.recomputeFamily(family.id);
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect(after.stale).toBe(true);
expect((after.priceMatrix as any).version).toBe('locked'); // 未被覆盖
});
it('canonical detail:为空时从主链接初始化一次,非空不动', async () => {
const a = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮' });
await prisma.originGoodDetail.create({
data: { originGoodId: a.id, englishName: 'Cotton Tee', materialDescription: '100% 棉' },
});
const b = await mkOriginGood({ craftLabel: '单面印花', logisticsLabel: '包邮' });
await prisma.originGoodDetail.create({
data: { originGoodId: b.id, englishName: 'Should Not Win' },
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id, b.id] });
await service.recomputeFamily(family.id);
let after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((after.detail as any).englishName).toBe('Cotton Tee');
expect((after.detail as any).materialDescription).toBe('100% 棉');
await prisma.productFamily.update({
where: { id: family.id },
data: { detail: { englishName: '人工已改' } as Prisma.InputJsonValue },
});
await service.recomputeFamily(family.id);
after = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((after.detail as any).englishName).toBe('人工已改');
});
it('族不存在时 no-op 不抛错', async () => {
await expect(service.recomputeFamily(999999n)).resolves.toBeUndefined();
});
it('enqueue 去重且串行执行', async () => {
const a = await mkOriginGood({
craftLabel: '单面印花',
logisticsLabel: '包邮',
variants: [{ sdsVariantId: 'v1', sku: 'A-S-BLK', sizeId: 'size_S', sizeName: 'S', colorId: 'color_blk', colorName: '黑色', price: 25 }],
});
const family = await mkFamily({ primaryOriginGoodId: a.id, memberIds: [a.id] });
const spy = jest.spyOn(service, 'recomputeFamily').mockResolvedValue();
service.enqueue(family.id);
service.enqueue(family.id);
service.enqueue(family.id);
await new Promise((r) => setTimeout(r, 50));
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
});
/**
* derivePriceMatrix 纯函数单测:选项组排序(词表序 + 未知值沉底)。
* 业务确认的展示顺序:printCount 单面→双面;craft 烫画→直喷→不打印;logistics 不包邮→包邮。
* 词表外自由文本(CUSTOM craftLabel/logisticsLabel,如"海运")沉底,相互间保持首次遇到序。
*/
describe('derivePriceMatrix 选项组排序', () => {
const mkMember = (
id: number,
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
) =>
({
id: BigInt(id),
source: over.source ?? 'SDS',
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
variants: [],
craftLabel: over.craftLabel ?? null,
logisticsLabel: over.logisticsLabel ?? null,
}) as any;
it('成员顺序无关:三个维度恒为词表序', () => {
// 故意让"遇到序"与目标词表序全部相反(双面在前/直喷在前/不包邮在前)
const members = [
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
];
const m = derivePriceMatrix(members, []);
expect(m.printCounts).toEqual(['单面印花', '双面印花']);
expect(m.crafts).toEqual(['烫画', '直喷']);
expect(m.logistics).toEqual(['不包邮', '包邮']);
});
it('子集保序:只出现部分取值时相对顺序不变', () => {
const m = derivePriceMatrix([mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] })], []);
expect(m.printCounts).toEqual(['双面印花']);
expect(m.crafts).toEqual(['直喷']);
expect(m.logistics).toEqual(['不包邮']);
});
it('未知自由文本沉底且相互保持首次遇到序', () => {
const members = [
// CUSTOM:自由文本标签 + tags 提供印花数量(无印花维度不进矩阵的既有语义)
mkMember(1, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '水洗', logisticsLabel: '空运' }),
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
];
const m = derivePriceMatrix(members, []);
expect(m.printCounts).toEqual(['单面印花', '双面印花']);
expect(m.crafts).toEqual(['烫画', '丝印', '水洗']);
expect(m.logistics).toEqual(['包邮', '海运', '空运']);
});
it('人工覆盖新增取值参与同一排序', () => {
const members = [mkMember(1, { tags: ['烫画', '单面印花', '包邮'] })];
const overrides = [
{
sizeId: 'size_M',
colorId: 'color_red',
printCount: '四面印花',
craft: '丝印',
logistics: '海运',
price: '40',
},
] as any;
const m = derivePriceMatrix(members, overrides);
expect(m.printCounts).toEqual(['单面印花', '四面印花']);
expect(m.crafts).toEqual(['烫画', '丝印']);
expect(m.logistics).toEqual(['包邮', '海运']);
});
it('幂等:同输入多跑两遍输出全等(含顺序),与成员遍历顺序无关', () => {
const members = [
mkMember(1, { tags: ['直喷', '双面印花', '不包邮'] }),
mkMember(2, { source: 'CUSTOM', tags: ['双面印花'], craftLabel: '丝印', logisticsLabel: '海运' }),
mkMember(3, { tags: ['烫画', '单面印花', '包邮'] }),
];
const first = derivePriceMatrix(members, []);
const second = derivePriceMatrix(members, []);
const reversed = derivePriceMatrix([...members].reverse(), []);
expect(JSON.stringify(second)).toBe(JSON.stringify(first));
expect(JSON.stringify(reversed)).toBe(JSON.stringify(first));
});
});
/**
* 印刷工艺新增"热转印":矩阵维度词表放开准入 + 展示序追加末尾。
* 自动派生规则不改 —— SDS 链接只能人工打标;不打标的链接/族零影响。
*/
describe('热转印工艺', () => {
const mkMember = (
id: number,
over: { tags?: string[]; source?: 'SDS' | 'CUSTOM'; craftLabel?: string | null; logisticsLabel?: string | null },
) =>
({
id: BigInt(id),
source: over.source ?? 'SDS',
originGoodTags: (over.tags ?? []).map((t) => ({ tag: { tagName: t } })),
variants: [],
craftLabel: over.craftLabel ?? null,
logisticsLabel: over.logisticsLabel ?? null,
}) as any;
it('memberMatrixCombos:打标热转印的链接产出矩阵组合(此前被词表过滤为空)', () => {
const combos = memberMatrixCombos({ tagNames: ['单面印花', '热转印', '包邮'] });
expect(combos).toEqual([{ printCount: '单面印花', craft: '热转印', logistics: '包邮' }]);
});
it('derivePriceMatrixcrafts 展示序为 烫画→直喷→不打印→热转印(词表内,不再沉底)', () => {
const members = [
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
mkMember(2, { tags: ['烫画', '单面印花', '包邮'] }),
mkMember(3, { tags: ['直喷', '单面印花', '不包邮'] }),
mkMember(4, { tags: ['不打印', '单面印花', '包邮'] }),
];
const m = derivePriceMatrix(members, []);
expect(m.crafts).toEqual(['烫画', '直喷', '不打印', '热转印']);
});
it('词表外自由文本(丝印/水洗)仍然沉底,与热转印区分', () => {
const members = [
mkMember(1, { tags: ['热转印', '单面印花', '包邮'] }),
mkMember(2, { source: 'CUSTOM', tags: ['单面印花'], craftLabel: '丝印', logisticsLabel: '包邮' }),
];
const m = derivePriceMatrix(members, []);
expect(m.crafts).toEqual(['热转印', '丝印']);
});
});
@@ -0,0 +1,461 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from '../public/public-cache.service';
import { isAutoTagGroupName } from './auto-tag-rules';
/**
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
* 设计:docs/superpowers/specs/2026-08-28-product-family-merge-design.md §7-§9
*
* 关键不变量:
* - 推导输入全部为确定性数据(成员镜像、覆盖表),重算幂等;
* - autoManaged=false 的族只置 stale,绝不覆盖人工物化字段;
* - 覆盖表与自定义成员数据只读,永不写回。
*/
export interface PriceMatrixSource {
sdsGoodId: string;
sdsVariantId: string;
price: string;
}
export interface PriceMatrixRow {
sizeId: string;
sizeName: string | null;
colorId: string;
colorName: string | null;
/** 印花数量维:单面印花 / 双面印花 */
printCount: string;
/** 工艺维:烫画 / 直喷 / 不打印 */
craft: string;
logistics: string;
price: string;
manual: boolean;
sources: PriceMatrixSource[];
}
export interface PriceMatrix {
sizes: Array<{ key: string; name: string | null }>;
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
printCounts: string[];
crafts: string[];
logistics: string[];
rows: PriceMatrixRow[];
}
export interface ChartSizeRow {
sizeId?: string | null;
sizeName?: string | null;
[key: string]: unknown;
}
export interface ChartLike {
columns?: unknown;
rows?: ChartSizeRow[];
}
type Member = Prisma.OriginGoodGetPayload<{
include: {
detail: true;
variants: true;
originGoodTags: { select: { tag: { select: { tagName: true } } } };
};
}>;
type OverrideRow = Prisma.FamilyPriceOverrideGetPayload<{}>;
/** 矩阵三个归因维度的合法取值(封闭集合,与派生标签组一致) */
const DIM_VALUES = {
printCount: ['单面印花', '双面印花'],
craft: ['烫画', '直喷', '不打印', '热转印'],
logistics: ['包邮', '不包邮'],
} as const satisfies Record<string, readonly string[]>;
type DimKey = keyof typeof DIM_VALUES;
/**
* 选项组展示顺序(业务确认):printCount 单面→双面;craft 烫画→直喷→不打印→热转印;logistics 不包邮→包邮。
* 与 DIM_VALUES 的"组合生成序"是两回事,物化前对聚合数组按此稳定排序;
* 词表外自由文本(CUSTOM 标签,如"海运")沉底,相互间保持首次遇到序。
*/
const OPTION_DISPLAY_ORDER = {
printCount: ['单面印花', '双面印花'],
craft: ['烫画', '直喷', '不打印', '热转印'],
logistics: ['不包邮', '包邮'],
} as const;
function optionRank(value: string, canon: readonly string[]): number {
const i = canon.indexOf(value);
return i === -1 ? canon.length : i;
}
function sortOptions(values: string[], canon: readonly string[]): string[] {
return [...values].sort((a, b) => optionRank(a, canon) - optionRank(b, canon));
}
export interface MatrixCombo {
printCount: string;
craft: string;
logistics: string;
}
/**
* 成员在矩阵中的归因维度组合(纯函数),取值只有两个来源——
* 1. 链接有效标签(人工接管后仍准确,仅认封闭词表内的标签);
* 2. CUSTOM 成员的管理员显式标签(craftLabel/logisticsLabel,自由文本)。
* 【不解析名称】:SDS 成员无标签 → 返回空(不进矩阵),由整理(OrganizeService)显式补标签。
* 多值时取笛卡尔积 —— 一个链接理论上只属一个组合,此处只是容错。
*/
export function memberMatrixCombos(input: {
tagNames: string[];
customLabels?: { craft?: string | null; logistics?: string | null };
}): MatrixCombo[] {
const values = (dim: DimKey): string[] =>
DIM_VALUES[dim].filter((v) => input.tagNames.includes(v));
let printCounts = values('printCount');
let crafts = values('craft');
let logistics = values('logistics');
// CUSTOM 成员:管理员显式标签是唯一来源(craftLabel/logisticsLabel,自由文本)
const craftLabel = input.customLabels?.craft ?? '';
const logisticsLabel = input.customLabels?.logistics ?? '';
if (!crafts.length && craftLabel) crafts = [craftLabel];
if (!logistics.length && logisticsLabel) logistics = [logisticsLabel];
// 结构化占位规则(非名称解析):工艺=不打印 时印花面不存在,印花数量固定单面占位,
// 保证光板链接的价格进矩阵;工艺=烫画/直喷 而缺印花数量标签 → 缺维度不进矩阵(等整理补标签)
const noPrintCraft =
crafts.includes('不打印') || craftLabel.includes('不打印') || craftLabel.includes('光板');
if (!printCounts.length && noPrintCraft) {
printCounts = [DIM_VALUES.printCount[0]];
} else if (!printCounts.length && craftLabel.includes('双面')) {
printCounts = ['双面印花'];
}
if (!printCounts.length || !crafts.length || !logistics.length) return [];
const combos: MatrixCombo[] = [];
for (const printCount of printCounts) {
for (const craft of crafts) {
for (const logistic of logistics) {
combos.push({ printCount, craft, logistics: logistic });
}
}
}
return combos;
}
function chartRowKey(row: ChartSizeRow): string {
return String(row.sizeName ?? row.sizeId ?? '');
}
/**
* 按优先级顺序(已排序的成员 → 各自的 chart)合并尺码行并集。
* 同键冲突时先到先得 —— 调用方负责把"覆盖最全的成员 / 主链接"排在前面。
*/
export function unionChartRows(charts: Array<ChartLike | null | undefined>): ChartSizeRow[] {
const out: ChartSizeRow[] = [];
const seen = new Set<string>();
for (const chart of charts) {
for (const row of chart?.rows ?? []) {
const key = chartRowKey(row);
if (!key || seen.has(key)) continue;
seen.add(key);
out.push(row);
}
}
return out;
}
function firstColumns(charts: Array<ChartLike | null | undefined>): unknown {
for (const chart of charts) {
if (chart?.columns !== undefined && chart?.columns !== null) return chart.columns;
}
return undefined;
}
/**
* 推导五维价格矩阵:(sizeKey, colorKey, printCount, craft, logistics) → 价格。
* 尺寸键 = variant.sizeId ?? variant.sizeName;颜色键 = variant.colorId ?? variant.colorName
* 归因维度取成员链接标签(见 memberMatrixCombos)。
* 同格子多来源取最低价,sources 全保留;随后合并人工覆盖(manual=true)。
*/
export function derivePriceMatrix(members: Member[], overrides: OverrideRow[]): PriceMatrix {
const sizes = new Map<string, string | null>();
const colors = new Map<string, { name: string | null; hex: string | null; imageUrl: string | null }>();
const cellMap = new Map<string, PriceMatrixRow>();
const memberCombos = members.map((member) => ({
member,
combos: memberMatrixCombos({
tagNames: member.originGoodTags.map((r) => r.tag.tagName),
// CUSTOM 成员无同步标签,管理员显式填写的标签字段是其唯一归因来源
customLabels:
member.source === 'CUSTOM'
? { craft: member.craftLabel, logistics: member.logisticsLabel }
: undefined,
}),
}));
const noteVariant = (variant: Member['variants'][number]) => {
const sizeKey = String(variant.sizeId ?? variant.sizeName ?? '');
const colorKey = String(variant.colorId ?? variant.colorName ?? '');
if (sizeKey && !sizes.has(sizeKey)) sizes.set(sizeKey, variant.sizeName);
if (colorKey) {
const prev = colors.get(colorKey);
colors.set(colorKey, {
name: prev?.name ?? variant.colorName,
hex: prev?.hex ?? variant.colorHex,
imageUrl: prev?.imageUrl ?? variant.imageUrl,
});
}
return { sizeKey, colorKey };
};
for (const { member, combos } of memberCombos) {
for (const combo of combos) {
for (const variant of member.variants) {
if (variant.price === null) continue;
const { sizeKey, colorKey } = noteVariant(variant);
if (!sizeKey && !colorKey) continue;
const key = `${sizeKey}|${colorKey}|${combo.printCount}|${combo.craft}|${combo.logistics}`;
const priceStr = variant.price.toString();
const source: PriceMatrixSource = {
sdsGoodId: member.sdsGoodId,
sdsVariantId: variant.sdsVariantId,
price: priceStr,
};
const existing = cellMap.get(key);
if (!existing) {
cellMap.set(key, {
sizeId: sizeKey,
sizeName: variant.sizeName,
colorId: colorKey,
colorName: variant.colorName,
printCount: combo.printCount,
craft: combo.craft,
logistics: combo.logistics,
price: priceStr,
manual: false,
sources: [source],
});
} else {
existing.sources.push(source);
if (Number(variant.price) < Number(existing.price)) existing.price = priceStr;
}
}
}
}
const printCounts = [
...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.printCount))),
];
const crafts = [...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.craft)))];
const logisticsOptions = [
...new Set(memberCombos.flatMap(({ combos }) => combos.map((c) => c.logistics))),
];
const rows = [...cellMap.values()];
// 人工覆盖:命中改价,未命中新增行(人工补组合),选项并入覆盖用到的取值
for (const override of overrides) {
if (!printCounts.includes(override.printCount)) printCounts.push(override.printCount);
if (!crafts.includes(override.craft)) crafts.push(override.craft);
if (!logisticsOptions.includes(override.logistics)) logisticsOptions.push(override.logistics);
if (override.sizeId && !sizes.has(override.sizeId)) sizes.set(override.sizeId, null);
if (override.colorId && !colors.has(override.colorId)) {
colors.set(override.colorId, { name: null, hex: null, imageUrl: null });
}
const key = `${override.sizeId}|${override.colorId}|${override.printCount}|${override.craft}|${override.logistics}`;
const row = cellMap.get(key);
if (row) {
row.price = override.price.toString();
row.manual = true;
} else {
const added: PriceMatrixRow = {
sizeId: override.sizeId,
sizeName: sizes.get(override.sizeId) ?? null,
colorId: override.colorId,
colorName: colors.get(override.colorId)?.name ?? null,
printCount: override.printCount,
craft: override.craft,
logistics: override.logistics,
price: override.price.toString(),
manual: true,
sources: [],
};
cellMap.set(key, added);
rows.push(added);
}
}
return {
sizes: [...sizes.entries()].map(([key, name]) => ({ key, name })),
colors: [...colors.entries()].map(([key, v]) => ({ key, ...v })),
printCounts: sortOptions(printCounts, OPTION_DISPLAY_ORDER.printCount),
crafts: sortOptions(crafts, OPTION_DISPLAY_ORDER.craft),
logistics: sortOptions(logisticsOptions, OPTION_DISPLAY_ORDER.logistics),
rows,
};
}
/** canonical detail 的文本字段(从主链接 detail 摘取,不含 JSON 物化与时间戳) */
function pickDetailText(detail: Prisma.OriginGoodDetailGetPayload<{}>): Prisma.InputJsonValue {
return {
productCode: detail.productCode,
englishName: detail.englishName,
blankDesignUrl: detail.blankDesignUrl,
detailsPageVideoUrl: detail.detailsPageVideoUrl,
textureName: detail.textureName,
productionCycleHours: detail.productionCycleHours,
minWeightG: detail.minWeightG ? detail.minWeightG.toString() : null,
reminder: detail.reminder,
productionProcess: detail.productionProcess,
materialDescription: detail.materialDescription,
productPerformance: detail.productPerformance,
applicableScenarios: detail.applicableScenarios,
washingInstructions: detail.washingInstructions,
specialDescription: detail.specialDescription,
designExplanation: detail.designExplanation,
designArea: detail.designArea,
pictureRequest: detail.pictureRequest,
};
}
const json = (value: unknown): Prisma.InputJsonValue | typeof Prisma.DbNull =>
(value === undefined || value === null ? Prisma.DbNull : value) as Prisma.InputJsonValue;
@Injectable()
export class FamilyRecomputeService {
private readonly logger = new Logger(FamilyRecomputeService.name);
private readonly pending = new Map<string, Promise<void>>();
constructor(
private readonly prisma: PrismaService,
private readonly publicCache: PublicCacheService,
) {}
/** 进程内去重的异步重算入口(同步钩子用) */
enqueue(familyId: bigint): void {
const key = familyId.toString();
if (this.pending.has(key)) return;
const run = this.recomputeFamily(familyId)
.catch((error) => {
this.logger.error(`family ${key} recompute failed: ${String(error)}`);
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, run);
}
async recomputeFamily(familyId: bigint): Promise<void> {
const family = await this.prisma.productFamily.findUnique({
where: { id: familyId },
include: {
originGoods: {
where: { delisted: false },
// 固定遍历序:保证"首次遇到序"(未知自由文本取值的相对顺序)确定性,重算幂等
orderBy: { id: 'asc' },
include: {
detail: true,
variants: { where: { enabled: true }, orderBy: { sortOrder: 'asc' } },
originGoodTags: { select: { tag: { select: { tagName: true } } } },
},
},
priceOverrides: true,
},
});
if (!family) return;
const members = family.originGoods;
const primary = members.find((m) => m.id === family.primaryOriginGoodId) ?? members[0] ?? null;
// 冲突裁决优先级:尺码覆盖最全 → 主链接 → id 升序(与设计 §7 一致)
const priority = [...members].sort((a, b) => {
const aRows = a.detail?.sizeChart ? ((a.detail.sizeChart as ChartLike).rows?.length ?? 0) : 0;
const bRows = b.detail?.sizeChart ? ((b.detail.sizeChart as ChartLike).rows?.length ?? 0) : 0;
if (bRows !== aRows) return bRows - aRows;
const aPrimary = a.id === primary?.id ? 1 : 0;
const bPrimary = b.id === primary?.id ? 1 : 0;
if (aPrimary !== bPrimary) return bPrimary - aPrimary;
return Number(a.id - b.id);
});
const sizeCharts = priority.map((m) => m.detail?.sizeChart as ChartLike | undefined);
const packageCharts = priority.map((m) => m.detail?.packageSpecs as ChartLike | undefined);
const sizeChart = unionChartRows(sizeCharts).length
? { columns: firstColumns(sizeCharts), rows: unionChartRows(sizeCharts) }
: null;
const packageSpecs = unionChartRows(packageCharts).length
? { columns: firstColumns(packageCharts), rows: unionChartRows(packageCharts) }
: null;
const matrix = derivePriceMatrix(members, family.priceOverrides);
if (family.autoManaged) {
await this.prisma.productFamily.update({
where: { id: family.id },
data: {
sizeChart: json(sizeChart),
packageSpecs: json(packageSpecs),
priceMatrix: json(matrix),
stale: false,
// canonical detail 仅在为空时从主链接初始化一次,人工编辑后永不被覆盖
...(family.detail === null && primary?.detail
? { detail: pickDetailText(primary.detail) }
: {}),
},
});
} else {
await this.prisma.productFamily.update({
where: { id: family.id },
data: { stale: true },
});
}
// 族物化 JSON(矩阵/尺码表)与成员口径变化 → public 列表价/详情族块失效。
// 人工接管族(autoManaged=false)虽未重写矩阵,但其成员/详情已变,goods 域同样失效。
this.publicCache.bump('goods', 'matrix');
}
/** 把链接的有效标签镜像到其名下商品(good 标签 = 链接标签 ∪ 非自动组既有标签) */
async mirrorLinkTagsToGoods(ogId: bigint): Promise<void> {
const rows = await this.prisma.originGoodTag.findMany({
where: { originGoodId: ogId },
select: { tagId: true },
});
const linkTagIds = rows.map((r) => r.tagId);
const goods = await this.prisma.good.findMany({
where: { originGoodId: ogId },
include: {
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
},
});
if (!goods.length) return;
const groups = await this.prisma.tagGroup.findMany({
select: { id: true, groupName: true },
});
const autoGroupIds = new Set(
groups.filter((g) => isAutoTagGroupName(g.groupName)).map((g) => g.id.toString()),
);
for (const good of goods) {
const keep = good.goodTags
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
.map((gt) => gt.tagId);
const target = [...new Set([...keep, ...linkTagIds])].sort((a, b) =>
Number(a - b),
);
const current = good.goodTags.map((gt) => gt.tagId).sort((a, b) => Number(a - b));
const same =
target.length === current.length && target.every((id, i) => id === current[i]);
if (same) continue;
await this.prisma.$transaction([
this.prisma.goodTag.deleteMany({ where: { goodId: good.id } }),
...(!target.length
? []
: [
this.prisma.goodTag.createMany({
data: target.map((tagId) => ({ goodId: good.id, tagId })),
}),
]),
]);
}
}
}
@@ -0,0 +1,353 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { FamilyRecomputeService } from './family-recompute.service';
import { ProductFamiliesService } from './product-families.service';
import { OrganizeService } from './organize.service';
import { OriginGoodsService } from '../origin-goods/origin-goods.service';
import { PrismaService } from '../prisma/prisma.service';
/**
* 整理服务(解析去运行时化后的唯一派生入口):
* 1) 链接级:未人工接管的 SDS 链接按名称派生 origin_good_tagsmanual=false);
* 2) 商品级:good 标签镜像其链接的有效标签(保留非自动组人工标签);
* 3) 人工接管(updateTags)后自动同步不再覆盖,恢复自动(resetTags)回到派生。
*/
describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
let recompute: FamilyRecomputeService;
let organize: OrganizeService;
let originGoods: OriginGoodsService;
let prisma: PrismaService;
const stamp = Date.now();
const ids = {
originGood: [] as bigint[],
family: [] as bigint[],
good: [] as bigint[],
country: [] as bigint[],
category: [] as bigint[],
group: [] as bigint[],
tag: [] as bigint[],
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [FamilyRecomputeService, ProductFamiliesService, OrganizeService, OriginGoodsService, PrismaService, PublicCacheService],
}).compile();
recompute = moduleRef.get(FamilyRecomputeService);
organize = moduleRef.get(OrganizeService);
originGoods = moduleRef.get(OriginGoodsService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
ids.country.push((await prisma.country.create({ data: { countryName: `标签同步国家-${stamp}` } })).id);
ids.category.push((await prisma.category.create({ data: { categoryName: `标签同步分类-${stamp}` } })).id);
// 人工分组(派生规则之外,标签应保留)
ids.group.push((await prisma.tagGroup.create({ data: { groupName: `风格${stamp}`, sortOrder: 9 } })).id);
});
afterAll(async () => {
await prisma.goodTag.deleteMany({ where: { goodId: { in: ids.good } } });
await prisma.good.deleteMany({ where: { id: { in: ids.good } } });
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: ids.originGood } } });
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } });
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } });
await prisma.tag.deleteMany({ where: { id: { in: ids.tag } } });
await prisma.tagGroup.deleteMany({ where: { id: { in: ids.group } } });
await prisma.country.deleteMany({ where: { id: { in: ids.country } } });
await prisma.category.deleteMany({ where: { id: { in: ids.category } } });
await prisma.$disconnect();
});
async function goodTagNames(goodId: bigint): Promise<string[]> {
const rows = await prisma.goodTag.findMany({ where: { goodId }, include: { tag: true } });
return rows.map((r) => r.tag.tagName).sort();
}
async function linkTagNames(ogId: bigint): Promise<string[]> {
const rows = await prisma.originGoodTag.findMany({ where: { originGoodId: ogId }, include: { tag: true } });
return rows.map((r) => r.tag.tagName).sort();
}
it('链接派生标签落在链接上,商品镜像链接标签;旧自动组标签剔除、人工分组保留', async () => {
const styleTag = await prisma.tag.create({ data: { tagName: `潮流${stamp}`, tagGroupId: ids.group[0] } });
ids.tag.push(styleTag.id);
const legacyPositionTag = await prisma.tag.findFirst({
where: { tagName: '单面印', tagGroup: { groupName: { contains: '印刷位置' } } },
});
const og1 = await prisma.originGood.create({
data: {
sdsGoodId: `tagsync-${stamp}-1`,
goodName: `美国(包邮)180g纯棉T恤成人款-DG${stamp}-单面印花`,
logisticsLabel: '包邮',
craftLabel: '单面印花',
},
});
const og2 = await prisma.originGood.create({
data: {
sdsGoodId: `tagsync-${stamp}-2`,
goodName: `美国(不包邮光板)180GT恤成人款-JS${stamp}-不打印·美西洛杉矶二仓`,
logisticsLabel: '不包邮光板',
craftLabel: '不打印',
},
});
ids.originGood.push(og1.id, og2.id);
const family = await prisma.productFamily.create({
data: { familyName: `标签同步族-${stamp}`, primaryOriginGoodId: og1.id },
});
ids.family.push(family.id);
await prisma.originGood.updateMany({
where: { id: { in: [og1.id, og2.id] } },
data: { familyId: family.id },
});
const good1 = await prisma.good.create({
data: {
originGoodId: og1.id,
familyId: family.id,
countryId: ids.country[0],
categoryId: ids.category[0],
goodName: `标签同步商品1-${stamp}`,
},
});
const good2 = await prisma.good.create({
data: {
originGoodId: og2.id,
familyId: family.id,
countryId: ids.country[0],
categoryId: ids.category[0],
goodName: `标签同步商品2-${stamp}`,
},
});
ids.good.push(good1.id, good2.id);
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: styleTag.id } });
if (legacyPositionTag) {
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: legacyPositionTag.id } });
}
const r1 = await organize.deriveFamilyTags(family.id);
expect(r1.linksUpdated).toBe(2);
expect(r1.goodsUpdated).toBe(2);
// 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印(光板归并为不打印)/不包邮
expect(await linkTagNames(og1.id)).toEqual(['包邮', '单面印花', '烫画']);
expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印']);
// 商品级:镜像各自链接(+人工分组保留,旧自动组剔除)
const names1 = await goodTagNames(good1.id);
expect(names1).toEqual(expect.arrayContaining(['包邮', '烫画', '单面印花', `潮流${stamp}`]));
expect(names1).not.toContain('单面印');
const names2 = await goodTagNames(good2.id);
expect(names2).toEqual(['不包邮', '不打印']);
expect(names2).not.toContain('光板');
expect(names2).not.toContain('烫画');
// 幂等
const r2 = await organize.deriveFamilyTags(family.id);
expect(r2.linksUpdated).toBe(0);
expect(r2.goodsUpdated).toBe(0);
});
it('人工接管链接标签后自动同步不覆盖,商品跟随;恢复自动回到派生', async () => {
const og = await prisma.originGood.create({
data: {
sdsGoodId: `tagsync-manual-${stamp}`,
goodName: `美国(包邮)卫衣-DGM${stamp}-单面印花`,
logisticsLabel: '包邮',
craftLabel: '单面印花',
},
});
ids.originGood.push(og.id);
const family = await prisma.productFamily.create({
data: { familyName: `人工标签族-${stamp}`, primaryOriginGoodId: og.id },
});
ids.family.push(family.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
const good = await prisma.good.create({
data: {
originGoodId: og.id,
familyId: family.id,
countryId: ids.country[0],
categoryId: ids.category[0],
goodName: `人工标签商品-${stamp}`,
},
});
ids.good.push(good.id);
await organize.deriveFamilyTags(family.id);
expect(await linkTagNames(og.id)).toEqual(['包邮', '单面印花', '烫画']);
// 人工接管:解析错了(实际是直喷)→ 改成 直喷
const zhpena = await prisma.tag.findFirst({ where: { tagName: '直喷' } });
const baoyou = await prisma.tag.findFirst({ where: { tagName: '包邮' } });
const result = await originGoods.updateTags(og.id, [Number(zhpena!.id), Number(baoyou!.id)]);
expect(result.tagsManual).toBe(true);
// 人工勾了 直喷/包邮,缺印花数量维度 → 按名称自动补「单面印花」(缺啥补啥)
expect(result.tags.map((t) => t.tagName).sort()).toEqual(['包邮', '单面印花', '直喷']);
expect(result.filledDimensionTags).toEqual(['单面印花']);
expect(result.tags.every((t) => t.manual)).toBe(true);
// 商品镜像跟随人工修正(含补齐维度)
expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '直喷']);
// 再次族同步:人工行(含补齐行)不被覆盖
await organize.deriveFamilyTags(family.id);
expect(await linkTagNames(og.id)).toEqual(['包邮', '单面印花', '直喷']);
expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '直喷']);
// 恢复自动 → 回到名称派生结果
const reset = await originGoods.resetTags(og.id);
expect(reset.tagsManual).toBe(false);
expect(reset.tags.map((t) => t.tagName)).toEqual(['包邮', '单面印花', '烫画']);
expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '烫画']);
});
it('自定义来源链接不派生标签,仅镜像人工配置', async () => {
const ogCustom = await prisma.originGood.create({
data: {
source: 'CUSTOM',
sdsGoodId: `tagsync-custom-${stamp}`,
goodName: `自定义包邮T恤${stamp}`,
},
});
ids.originGood.push(ogCustom.id);
const family = await prisma.productFamily.create({
data: { familyName: `自定义标签族-${stamp}`, primaryOriginGoodId: ogCustom.id },
});
ids.family.push(family.id);
await prisma.originGood.update({ where: { id: ogCustom.id }, data: { familyId: family.id } });
const good = await prisma.good.create({
data: {
originGoodId: ogCustom.id,
familyId: family.id,
countryId: ids.country[0],
categoryId: ids.category[0],
goodName: `自定义标签商品-${stamp}`,
},
});
ids.good.push(good.id);
const r = await organize.deriveFamilyTags(family.id);
expect(r.linksUpdated).toBe(0);
expect(await goodTagNames(good.id)).toEqual([]);
// 自定义链接同样可人工配置标签
const baoyou = await prisma.tag.findFirst({ where: { tagName: '包邮' } });
await originGoods.updateTags(ogCustom.id, [Number(baoyou!.id)]);
expect(await goodTagNames(good.id)).toEqual(['包邮']);
});
it('族重算不解析不触碰标签:上游改名后重算,标签/矩阵维度保持不变(防倒灌)', async () => {
const og = await prisma.originGood.create({
data: {
sdsGoodId: `tagsync-nodrift-${stamp}`,
goodName: `美国(包邮)防倒灌T恤-ND${stamp}-单面印花`,
},
});
ids.originGood.push(og.id);
await prisma.originGoodVariant.create({
data: {
originGoodId: og.id,
sdsVariantId: `nd-v-${stamp}`,
sku: `ND${stamp}`,
sizeName: 'S',
price: 21,
},
});
const family = await prisma.productFamily.create({
data: { familyName: `防倒灌族-${stamp}`, primaryOriginGoodId: og.id },
});
ids.family.push(family.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
// 整理派生一次 → 标签就位,矩阵有格子
await organize.deriveFamilyTags(family.id);
await recompute.recomputeFamily(family.id);
const tagsBefore = await linkTagNames(og.id);
expect(tagsBefore).toEqual(['包邮', '单面印花', '烫画']);
let fam = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
expect((fam.priceMatrix as any).rows).toHaveLength(1);
// 模拟上游改名(同步镜像只更新 goodName)→ 仅重算、不整理
await prisma.originGood.update({
where: { id: og.id },
data: { goodName: '美国(包邮)防倒灌T恤-ND0000-直喷双面' },
});
await recompute.recomputeFamily(family.id);
// 标签与矩阵维度保持派生时的快照,不随名称漂移
expect(await linkTagNames(og.id)).toEqual(tagsBefore);
fam = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
const row = (fam.priceMatrix as any).rows[0];
expect(row.printCount).toBe('单面印花');
expect(row.craft).toBe('烫画');
// 显式整理后才按新名称刷新
await organize.deriveFamilyTags(family.id);
const refreshed = await linkTagNames(og.id);
expect(refreshed).toHaveLength(3);
expect(refreshed).toEqual(expect.arrayContaining(['包邮', '双面印花', '直喷']));
});
it('整理碎片合并:纯碎片族并入带商品族(保公开 goodId 稳定)并删除;多族带商品进人工复审', async () => {
const cat = `cat-frag-${stamp}`;
const mkFrag = async (suffix: string) => {
const og = await prisma.originGood.create({
data: { sdsGoodId: `frag-${stamp}-${suffix}`, goodName: `美国(包邮)碎片测试-FG1-单面印花-${suffix}`, sdsCategoryId: cat },
});
ids.originGood.push(og.id);
const f = await prisma.productFamily.create({
data: { familyName: `碎片族${stamp}-${suffix}`, primaryOriginGoodId: og.id },
});
ids.family.push(f.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: f.id } });
return { og, f };
};
const loose = await mkFrag('a'); // 纯碎片(无商品/无覆盖价)
const curated = await mkFrag('b'); // 带商品 → keeper(公开 goodId=族ID
const good = await prisma.good.create({
data: {
goodName: `碎片商品${stamp}`,
originGoodId: curated.og.id,
familyId: curated.f.id,
countryId: ids.country[0],
categoryId: ids.category[0],
},
});
ids.good.push(good.id);
// 另一个也带商品的族 → 不自动动,进人工复审
const curated2 = await mkFrag('c');
const good2 = await prisma.good.create({
data: {
goodName: `碎片商品2${stamp}`,
originGoodId: curated2.og.id,
familyId: curated2.f.id,
countryId: ids.country[0],
categoryId: ids.category[0],
},
});
ids.good.push(good2.id);
try {
const result = await organize.consolidateFragments();
// 纯碎片族成员并入带商品族,碎片族被删除
const moved = await prisma.originGood.findUniqueOrThrow({ where: { id: loose.og.id } });
expect(moved.familyId).toBe(curated.f.id);
const gone = await prisma.productFamily.findUnique({ where: { id: loose.f.id } });
expect(gone).toBeNull();
// 带商品的族保留;两个带商品族互为碎片 → 较新的进人工复审
const kept = await prisma.productFamily.findUniqueOrThrow({ where: { id: curated.f.id } });
expect(kept).toBeTruthy();
const review = (result.manualReview ?? []).find((r: any) => r.fragmentFamilyId === String(curated2.f.id));
expect(review).toBeTruthy();
expect(review?.keeperFamilyId).toBe(String(curated.f.id));
expect(review?.reason).toContain('has-goods');
} finally {
await prisma.goodTag.deleteMany({ where: { goodId: { in: ids.good } } }).catch(() => undefined);
await prisma.good.deleteMany({ where: { id: { in: ids.good } } }).catch(() => undefined);
ids.good = [];
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } }).catch(() => undefined);
ids.family = [];
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } }).catch(() => undefined);
ids.originGood = [];
}
});
});
@@ -0,0 +1,493 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService } from './family-recompute.service';
import { ProductFamiliesService } from './product-families.service';
import { PublicCacheService } from '../public/public-cache.service';
import { parseOriginName } from './origin-name.parser';
import {
DERIVED_TAG_GROUP_SPECS,
deriveLinkTagNames,
isAutoTagGroupName,
normalizeCraftTag,
} from './auto-tag-rules';
/**
* 整理服务(解析去运行时化的唯一解析入口):
* 所有"按上游链接名解析/派生"的逻辑集中于此,由显式人工动作触发——
* CLIpnpm --filter @inkreach/api organize)或后台「整理」按钮(POST /product-families/organize)。
*
* 运行时(同步/重算/公开读)永不解析名称:
* - 同步 = 纯镜像;族重算 = 结构化聚合(成员变体 + 链接标签 + 覆盖价)。
*
* 人工接管(tagsManual=true)的链接派生永不触碰;幂等可重跑。
*/
@Injectable()
export class OrganizeService {
private readonly logger = new Logger(OrganizeService.name);
constructor(
private readonly prisma: PrismaService,
private readonly recompute: FamilyRecomputeService,
private readonly families: ProductFamiliesService,
private readonly publicCache: PublicCacheService,
) {}
async organize() {
const labels = await this.backfillLabels();
const tags = await this.deriveAllTags();
const grouped = await this.families.autoGroup(true);
const fragments = await this.consolidateFragments();
const families = await this.prisma.productFamily.findMany({ select: { id: true } });
for (const f of families) {
await this.recompute.recomputeFamily(f.id);
}
const result = {
labelsParsed: labels.parsed,
labelsUnparsable: labels.unparsable,
linksUpdated: tags.linksUpdated,
goodsUpdated: tags.goodsUpdated,
familiesCreated: grouped.applied,
familiesMerged: grouped.merged,
familiesSkipped: grouped.skipped?.length ?? 0,
fragmentsConsolidated: fragments.consolidated,
fragmentsManualReview: fragments.manualReview.length,
familiesRecomputed: families.length,
};
this.logger.log(`organize done: ${JSON.stringify(result)}`);
// 整理是全量标签/归族/矩阵重写动作,三域全失效(末尾逐族重算只覆盖 goods+matrix
// 标签镜像与标签组创建落在 meta 域)
this.publicCache.bump('meta', 'goods', 'matrix');
return result;
}
/**
* 碎片族合并:同一 SDS 分类出现多个族时(历史 autoGroup 只建不并所致),
* 把「纯碎片族」(autoManaged、无商品、无覆盖价)的成员并入最老族并删除空族;
* 带商品/覆盖价或人工锁定的碎片族保留,写进人工复审报告(由后台手动移动成员)。
* keeper 选择:同簇中最早带商品的族,否则最老的 autoManaged 族。
*/
async consolidateFragments(): Promise<{
consolidated: number;
manualReview: Array<{ keeperFamilyId: string; fragmentFamilyId: string; reason: string }>;
}> {
const families = await this.prisma.productFamily.findMany({
select: { id: true, autoManaged: true },
orderBy: { id: 'asc' },
});
const members = await this.prisma.originGood.findMany({
where: { familyId: { not: null }, delisted: false },
select: { familyId: true, sdsCategoryId: true },
});
const goodCounts = await this.prisma.good.groupBy({
by: ['familyId'],
_count: { _all: true },
where: { familyId: { not: null } },
});
const goodsByFamily = new Map(goodCounts.map((g) => [g.familyId!.toString(), g._count._all]));
const overrideCounts = await this.prisma.familyPriceOverride.groupBy({
by: ['familyId'],
_count: { _all: true },
});
const overridesByFamily = new Map(
overrideCounts.map((o) => [o.familyId.toString(), o._count._all]),
);
const autoManagedById = new Map(families.map((f) => [f.id.toString(), f.autoManaged]));
const familyCatIds = new Map<string, Set<string>>();
for (const m of members) {
if (!m.sdsCategoryId) continue;
const key = m.familyId!.toString();
familyCatIds.set(key, (familyCatIds.get(key) ?? new Set()).add(m.sdsCategoryId));
}
// 按 SDS 分类聚簇:同分类的多个族互为碎片
const clusterByFamily = new Map<string, Set<string>>();
const catOwners = new Map<string, string[]>();
for (const f of families) {
const cats = familyCatIds.get(f.id.toString()) ?? new Set();
for (const cat of cats) {
const owners = catOwners.get(cat) ?? [];
owners.push(f.id.toString());
catOwners.set(cat, owners);
}
}
for (const owners of catOwners.values()) {
if (owners.length < 2) continue;
for (const id of owners) {
const cluster = clusterByFamily.get(id) ?? new Set();
owners.forEach((o) => cluster.add(o));
clusterByFamily.set(id, cluster);
}
}
let consolidated = 0;
const manualReview: Array<{ keeperFamilyId: string; fragmentFamilyId: string; reason: string }> = [];
const processedClusters = new Set<string>();
for (const [familyIdStr, cluster] of clusterByFamily) {
const sorted = [...cluster].sort((a, b) => Number(BigInt(a) - BigInt(b)));
const clusterKey = sorted.join(',');
if (processedClusters.has(clusterKey)) continue;
processedClusters.add(clusterKey);
// keeper:最早带商品的族,否则最老的 autoManaged 族
const withGoods = sorted.find((id) => (goodsByFamily.get(id) ?? 0) > 0);
const keeper =
withGoods ??
sorted.find((id) => autoManagedById.get(id) === true) ??
sorted[0];
for (const id of sorted) {
if (id === keeper) continue;
const reasons: string[] = [];
if ((goodsByFamily.get(id) ?? 0) > 0) reasons.push('has-goods');
if ((overridesByFamily.get(id) ?? 0) > 0) reasons.push('has-overrides');
if (autoManagedById.get(id) !== true) reasons.push('manual-locked');
if (reasons.length) {
manualReview.push({
keeperFamilyId: keeper,
fragmentFamilyId: id,
reason: reasons.join(','),
});
continue;
}
// 纯碎片:成员并入 keeper,删除空族
const keeperId = BigInt(keeper);
await this.prisma.originGood.updateMany({
where: { familyId: BigInt(id) },
data: { familyId: keeperId },
});
await this.prisma.good.updateMany({
where: { familyId: BigInt(id) },
data: { familyId: keeperId },
});
await this.prisma.productFamily.delete({ where: { id: BigInt(id) } });
consolidated += 1;
}
await this.recompute.recomputeFamily(BigInt(keeper));
}
return { consolidated, manualReview };
}
/**
* 回填结构化解析列(skuCode/logisticsLabel/craftLabel/warehouseLabel)。
* 只补 NULL 列——存量正确数据不覆盖(同步已不再写入这些列)。
*/
async backfillLabels(): Promise<{ parsed: number; unparsable: number }> {
const ogs = await this.prisma.originGood.findMany({
where: {
OR: [
{ skuCode: null },
{ logisticsLabel: null },
{ craftLabel: null },
{ warehouseLabel: null },
],
},
select: { id: true, goodName: true, skuCode: true, logisticsLabel: true, craftLabel: true, warehouseLabel: true },
});
let parsed = 0;
let unparsable = 0;
for (const og of ogs) {
const p = parseOriginName(og.goodName);
const next = {
skuCode: og.skuCode ?? p.skuCode,
logisticsLabel: og.logisticsLabel ?? p.logisticsLabel,
craftLabel: og.craftLabel ?? p.craftLabel,
warehouseLabel: og.warehouseLabel ?? p.warehouseLabel,
};
if (!p.skuCode && !p.craftLabel) unparsable += 1;
await this.prisma.originGood.update({ where: { id: og.id }, data: next });
parsed += 1;
}
// 结构化标签列是 CUSTOM 成员的矩阵归因来源(下次重算生效),保守失效 matrix+goods
this.publicCache.bump('goods', 'matrix');
return { parsed, unparsable };
}
/** 全量派生:未人工接管的 SDS 链接按名称刷新自动标签,人工接管不动;随后镜像商品并重算受影响族 */
async deriveAllTags(): Promise<{ linksUpdated: number; goodsUpdated: number; familiesRecomputed: number }> {
const families = await this.prisma.productFamily.findMany({
select: { id: true },
orderBy: { id: 'asc' },
});
let linksUpdated = 0;
let goodsUpdated = 0;
const touchedFamilies: bigint[] = [];
for (const f of families) {
const r = await this.deriveFamilyTags(f.id);
linksUpdated += r.linksUpdated;
goodsUpdated += r.goodsUpdated;
if (r.linksUpdated > 0 || r.goodsUpdated > 0) touchedFamilies.push(f.id);
}
// 散链接(无族)也派生并镜像,保证族化之前标签已就绪
const loose = await this.prisma.originGood.findMany({
where: { familyId: null, delisted: false, source: 'SDS', tagsManual: false },
select: { id: true },
});
for (const og of loose) {
const r = await this.deriveTagsForOg(og.id);
linksUpdated += r.linksUpdated;
}
for (const fid of touchedFamilies) {
await this.recompute.recomputeFamily(fid);
}
// 标签镜像(good_tags)落 meta 域;散链接无族不会被重算覆盖,入口统一失效
this.publicCache.bump('meta', 'goods', 'matrix');
return { linksUpdated, goodsUpdated, familiesRecomputed: touchedFamilies.length };
}
/**
* 族 → 标签派生(标签与「产品链接」一一对应):
* 1) 链接级:未人工接管(tagsManual=false)的 SDS 链接,按链接名称刷新
* origin_good_tags 的派生行(manual=false);人工行(manual=true)永远保留;
* 2) 商品级镜像:good 标签 = 自身链接的有效标签 ∪ 非自动组的既有标签。
* 仅更新发生变化的行,幂等。
*/
async deriveFamilyTags(
familyId: bigint,
): Promise<{ goodsUpdated: number; linksUpdated: number }> {
const tagMap = await this.ensureDerivedTagMap();
const family = await this.prisma.productFamily.findUnique({
where: { id: familyId },
include: {
originGoods: {
where: { delisted: false },
include: { originGoodTags: true },
},
},
});
if (!family) return { goodsUpdated: 0, linksUpdated: 0 };
let linksUpdated = 0;
for (const og of family.originGoods) {
if (og.tagsManual || og.source !== 'SDS') continue;
const derivedIds = this.resolveDerivedIds(og.goodName, tagMap);
const autoRows = og.originGoodTags.filter((r) => !r.manual);
const currentIds = autoRows.map((r) => r.tagId).sort((a, b) => Number(a - b));
const same =
derivedIds.length === currentIds.length &&
derivedIds.every((id, i) => id === currentIds[i]);
if (same) continue;
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({
where: { originGoodId: og.id, manual: false },
}),
...(!derivedIds.length
? []
: [
this.prisma.originGoodTag.createMany({
data: derivedIds.map((tagId) => ({
originGoodId: og.id,
tagId,
manual: false,
})),
}),
]),
]);
linksUpdated += 1;
}
// 商品级镜像:派生写入后重新读取链接标签(上面的 include 是派生前快照)
const familyOgIds = family.originGoods.map((o) => o.id);
const freshRows = familyOgIds.length
? await this.prisma.originGoodTag.findMany({
where: { originGoodId: { in: familyOgIds } },
})
: [];
const tagsByOg = new Map<string, bigint[]>();
for (const r of freshRows) {
const key = r.originGoodId.toString();
tagsByOg.set(key, [...(tagsByOg.get(key) ?? []), r.tagId]);
}
const goods = await this.prisma.good.findMany({
where: { familyId },
include: {
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
originGood: { select: { id: true } },
},
});
const strayOgIds = [
...new Set(
goods
.map((g) => g.originGood?.id.toString())
.filter((id): id is string => !!id && !tagsByOg.has(id)),
),
];
if (strayOgIds.length) {
const rows = await this.prisma.originGoodTag.findMany({
where: { originGoodId: { in: strayOgIds.map((v) => BigInt(v)) } },
});
for (const r of rows) {
const key = r.originGoodId.toString();
tagsByOg.set(key, [...(tagsByOg.get(key) ?? []), r.tagId]);
}
}
const { autoGroupIds } = await this.autoGroupIds();
let goodsUpdated = 0;
for (const good of goods) {
const linkTagIds = good.originGood
? (tagsByOg.get(good.originGood.id.toString()) ?? [])
: [];
const keep = good.goodTags
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
.map((gt) => gt.tagId);
const target = [...new Set([...keep, ...linkTagIds])].sort((a, b) =>
Number(a - b),
);
const current = good.goodTags.map((gt) => gt.tagId).sort((a, b) => Number(a - b));
const same =
target.length === current.length && target.every((id, i) => id === current[i]);
if (same) continue;
await this.prisma.$transaction([
this.prisma.goodTag.deleteMany({ where: { goodId: good.id } }),
...(!target.length
? []
: [
this.prisma.goodTag.createMany({
data: target.map((tagId) => ({ goodId: good.id, tagId })),
}),
]),
]);
goodsUpdated += 1;
}
this.publicCache.bump('meta', 'goods');
return { goodsUpdated, linksUpdated };
}
/**
* 单链接派生(供「恢复自动」等显式人工动作复用):
* 未人工接管时按名称刷新派生标签,并把有效标签镜像到其名下商品。
*/
async deriveTagsForOg(ogId: bigint): Promise<{ linksUpdated: number }> {
const og = await this.prisma.originGood.findUnique({
where: { id: ogId },
include: { originGoodTags: true },
});
if (!og) return { linksUpdated: 0 };
let linksUpdated = 0;
if (!og.tagsManual && og.source === 'SDS') {
const tagMap = await this.ensureDerivedTagMap();
const derivedIds = this.resolveDerivedIds(og.goodName, tagMap);
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({
where: { originGoodId: og.id, manual: false },
}),
...(!derivedIds.length
? []
: [
this.prisma.originGoodTag.createMany({
data: derivedIds.map((tagId) => ({
originGoodId: og.id,
tagId,
manual: false,
})),
}),
]),
]);
linksUpdated = 1;
}
await this.recompute.mirrorLinkTagsToGoods(og.id);
this.publicCache.bump('meta', 'goods');
return { linksUpdated };
}
/**
* 人工标签缺维补齐:三个定价维度组(印花数量/工艺/物流)任一组在当前标签里
* 没有封闭词表取值时,按链接名称派生补上;工艺维度经别名归一后判断(热转印≈烫画)。
* 人工已勾的维度永不覆盖;名称派生不出该维度时不补(由调用方提示)。
*/
async fillMissingDimTags(
goodName: string | null,
currentTagIds: bigint[],
): Promise<{ tagIds: bigint[]; names: string[] }> {
const tagMap = await this.ensureDerivedTagMap();
const current = currentTagIds.length
? await this.prisma.tag.findMany({
where: { id: { in: currentTagIds } },
select: { tagName: true },
})
: [];
const currentNames = new Set(current.map((t) => normalizeCraftTag(t.tagName)));
const derivedNames = deriveLinkTagNames(goodName);
const missingNames: string[] = [];
for (const spec of DERIVED_TAG_GROUP_SPECS) {
if (spec.tags.some((name) => currentNames.has(name))) continue;
const fromName = spec.tags.find((name) => derivedNames.includes(name));
if (fromName) missingNames.push(fromName);
}
const tagIds = missingNames
.map((name) => tagMap.get(name))
.filter((id): id is bigint => id !== undefined);
return { tagIds, names: missingNames };
}
private async autoGroupIds(): Promise<{ autoGroupIds: Set<string> }> {
const groups = await this.prisma.tagGroup.findMany({
select: { id: true, groupName: true },
});
return {
autoGroupIds: new Set(
groups
.filter((g) => isAutoTagGroupName(g.groupName))
.map((g) => g.id.toString()),
),
};
}
private resolveDerivedIds(
name: string | null | undefined,
tagMap: Map<string, bigint>,
): bigint[] {
return [
...new Set(
deriveLinkTagNames(name)
.map((n) => tagMap.get(n))
.filter((id): id is bigint => id !== undefined),
),
].sort((a, b) => Number(a - b));
}
/** 确保派生标签组与标签存在,返回「标签名 → 标签 id」映射(并发下取最小 id,天然去重) */
private async ensureDerivedTagMap(): Promise<Map<string, bigint>> {
const map = new Map<string, bigint>();
for (const spec of DERIVED_TAG_GROUP_SPECS) {
const findGroups = () =>
this.prisma.tagGroup.findMany({
where: { groupName: { contains: spec.group } },
orderBy: { id: 'asc' },
});
let group = (await findGroups())[0] ?? null;
if (!group) {
try {
group = await this.prisma.tagGroup.create({ data: { groupName: spec.group } });
} catch {
group = (await findGroups())[0] ?? null;
}
}
if (!group) continue;
const tags = await this.prisma.tag.findMany({
where: { tagGroupId: group.id, tagName: { in: spec.tags } },
orderBy: { id: 'asc' },
});
for (const tagName of spec.tags) {
const existing = tags.find((t) => t.tagName === tagName);
if (existing) {
map.set(tagName, existing.id);
continue;
}
try {
const created = await this.prisma.tag.create({
data: { tagGroupId: group.id, tagName },
});
map.set(tagName, created.id);
} catch {
const fallback = await this.prisma.tag.findFirst({
where: { tagGroupId: group.id, tagName },
orderBy: { id: 'asc' },
});
if (fallback) map.set(tagName, fallback.id);
}
}
}
return map;
}
}
@@ -0,0 +1,142 @@
import { parseOriginName, originGroupKey, familyNameKey } from './origin-name.parser';
describe('parseOriginName', () => {
it('解析完整四段名(含仓库)', () => {
expect(
parseOriginName('美国(不包邮)240g涤纶休闲短裤-DG206-单面印花-美西洛杉矶一仓'),
).toEqual({
country: '美国',
logisticsLabel: '不包邮',
productName: '240g涤纶休闲短裤',
skuCode: 'DG206',
craftLabel: '单面印花',
warehouseLabel: '美西洛杉矶一仓',
});
});
it('解析三段名(无仓库)', () => {
expect(
parseOriginName('墨西哥(不包邮)180g纯棉女装修身T恤-METP001-单面印花'),
).toEqual({
country: '墨西哥',
logisticsLabel: '不包邮',
productName: '180g纯棉女装修身T恤',
skuCode: 'METP001',
craftLabel: '单面印花',
warehouseLabel: null,
});
});
it('物流备注含星号等符号原样保留', () => {
const r = parseOriginName(
'波兰(包邮*运费订单结算时支付)250g男女同款抓毛圆领卫衣-PLHM002-双面印花',
);
expect(r.logisticsLabel).toBe('包邮*运费订单结算时支付');
expect(r.skuCode).toBe('PLHM002');
});
it('半角括号也能解析', () => {
const r = parseOriginName('美国(包邮)T恤-DG001-单面印花');
expect(r.country).toBe('美国');
expect(r.logisticsLabel).toBe('包邮');
expect(r.productName).toBe('T恤');
});
it('段1无括号时 country=整段、物流为空', () => {
const r = parseOriginName('美国T恤-DG001-单面印花');
expect(r.country).toBe('美国T恤');
expect(r.logisticsLabel).toBeNull();
expect(r.productName).toBeNull();
});
it('两段名:只解析国家/物流/品名/SKU', () => {
const r = parseOriginName('美国(包邮)T恤-DG001');
expect(r.skuCode).toBe('DG001');
expect(r.craftLabel).toBeNull();
expect(r.warehouseLabel).toBeNull();
});
it('半角/全角括号混用(美国(不包邮)…)按混合对解析', () => {
const r = parseOriginName('美国(不包邮)女士高弹中长款瑜伽运动裤-JSD004-单面印花-美西洛杉矶一仓');
expect(r.country).toBe('美国');
expect(r.logisticsLabel).toBe('不包邮');
expect(r.productName).toBe('女士高弹中长款瑜伽运动裤');
expect(r.skuCode).toBe('JSD004');
});
it('一段名/空值/null 安全', () => {
expect(parseOriginName('随便一个名字').skuCode).toBeNull();
expect(parseOriginName('随便一个名字').country).toBe('随便一个名字');
expect(parseOriginName(null).country).toBeNull();
expect(parseOriginName('').country).toBeNull();
});
it('段前后空格被 trim', () => {
const r = parseOriginName('美国(包邮) T恤 - DG001 - 单面印花');
expect(r.skuCode).toBe('DG001');
expect(r.craftLabel).toBe('单面印花');
expect(r.productName).toBe('T恤');
});
it('仓库段含连字符时整体保留', () => {
const r = parseOriginName('美国(包邮)T恤-DG001-单面印花-某仓-二期');
expect(r.warehouseLabel).toBe('某仓-二期');
});
});
describe('originGroupKey', () => {
it('与 admin truncateToProcess 语义一致:前3段', () => {
expect(originGroupKey('美国(包邮)T恤-DG001-单面印花-美西一仓')).toBe(
'美国(包邮)T恤-DG001-单面印花',
);
});
it('物流差异导致不同组', () => {
expect(originGroupKey('美国(不包邮)T恤-DG001-单面印花')).not.toBe(
originGroupKey('美国(包邮)T恤-DG001-单面印花'),
);
});
it('空名返回空串', () => {
expect(originGroupKey(null)).toBe('');
expect(originGroupKey('')).toBe('');
});
});
describe('familyNameKey(族语义分组键)', () => {
it('物流差异同键:包邮/不包邮归一族', () => {
expect(familyNameKey('英国(不包邮)鼠标垫-GBIU017-单面印花')).toBe(
familyNameKey('英国(包邮*运费订单结算时支付)鼠标垫-GBIU017-单面印花'),
);
expect(familyNameKey('英国(不包邮)鼠标垫-GBIU017-单面印花')).toBe('英国鼠标垫-GBIU017');
});
it('工艺/仓库段不参与:跨工艺归一族', () => {
expect(familyNameKey('美国(包邮)T恤-DG001-单面印花-美西一仓')).toBe(
familyNameKey('美国(不包邮)T恤-DG001-双面印花'),
);
});
it('不同 SKU / 不同国家不同键', () => {
expect(familyNameKey('美国(包邮)T恤-DG001-单面印花')).not.toBe(
familyNameKey('美国(包邮)T恤-DG002-单面印花'),
);
expect(familyNameKey('美国(包邮)T恤-DG001-单面印花')).not.toBe(
familyNameKey('英国(包邮)T恤-DG001-单面印花'),
);
});
it('半角/全角括号混用与无括号都能出键', () => {
expect(familyNameKey('美国(不包邮)T恤-DG001-单面印花')).toBe('美国T恤-DG001');
expect(familyNameKey('美国T恤-DG001-单面印花')).toBe('美国T恤-DG001');
});
it('无 SKU 段时仅用品名段', () => {
expect(familyNameKey('美国(包邮)T恤')).toBe('美国T恤');
});
it('空名返回空串', () => {
expect(familyNameKey(null)).toBe('');
expect(familyNameKey('')).toBe('');
});
});
@@ -0,0 +1,91 @@
/**
* 原产品链接名结构化解析。
*
* 名称格式(与 admin 端 utils/origin-name.ts 的调查结论一致):
* `国家(物流备注)品名-SKU-工艺位置[-仓库名]`
*
* 仓库段为可选;段与段之间以 `-` 分隔,段 1 内国家与物流备注以全角(或半角)括号分隔。
* 解析失败的字段置 null,不抛异常 —— 上游名称是自由文本,解析器必须容错。
*/
export interface ParsedOriginName {
country: string | null;
logisticsLabel: string | null;
productName: string | null;
skuCode: string | null;
craftLabel: string | null;
warehouseLabel: string | null;
}
const EMPTY: ParsedOriginName = {
country: null,
logisticsLabel: null,
productName: null,
skuCode: null,
craftLabel: null,
warehouseLabel: null,
};
function splitSegment1(seg: string): Pick<ParsedOriginName, 'country' | 'logisticsLabel' | 'productName'> {
// SDS 上游名称存在半角/全角括号混用(如 `美国(不包邮)…`),先归一成全角再配对
const normalized = seg.replace(/\(/g, '').replace(/\)/g, '');
const openAt = normalized.indexOf('');
if (openAt >= 0) {
const closeAt = normalized.indexOf('', openAt);
if (closeAt > openAt) {
return {
country: normalized.slice(0, openAt).trim() || null,
logisticsLabel: normalized.slice(openAt + 1, closeAt).trim() || null,
productName: normalized.slice(closeAt + 1).trim() || null,
};
}
}
return { country: normalized.trim() || null, logisticsLabel: null, productName: null };
}
export function parseOriginName(name: string | null | undefined): ParsedOriginName {
if (!name) return EMPTY;
const segs = name.split('-').map((s) => s.trim());
if (segs.length === 0 || segs[0] === '') return EMPTY;
return {
...splitSegment1(segs[0]),
skuCode: segs[1] || null,
craftLabel: segs[2] || null,
// 仓库名本身可能含连字符,第 4 段起整体保留
warehouseLabel: segs.length > 3 ? segs.slice(3).join('-') : null,
};
}
/**
* 分组键:与 admin 端 truncateToProcessKEEP_SEGMENTS = 3)语义一致,
* 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。
*/
export function originGroupKey(name: string | null | undefined): string {
if (!name) return '';
return name.split('-').slice(0, 3).join('-');
}
/**
* 族语义分组键(自动建族/归族的回退键):第 1 段剥离(物流备注)括号内容,
* 保留 国家+品名,拼接 SKU 段,丢弃工艺/仓库段 —— 物流、工艺、印花是价格矩阵
* 维度而非分族维度,同款链接(同国家+品名+SKU)必须归一族。
*/
export function familyNameKey(name: string | null | undefined): string {
if (!name) return '';
const segs = name.split('-').map((s) => s.trim());
const head = segs[0] ?? '';
if (!head) return '';
// 半角/全角括号混用先归一(与 splitSegment1 一致)
const normalized = head.replace(/\(/g, '').replace(/\)/g, '');
const openAt = normalized.indexOf('');
let stripped = normalized;
if (openAt >= 0) {
const closeAt = normalized.indexOf('', openAt);
if (closeAt > openAt) {
stripped = normalized.slice(0, openAt) + normalized.slice(closeAt + 1);
}
}
stripped = stripped.trim();
const sku = segs[1] ?? '';
if (!stripped) return sku || '';
return sku ? `${stripped}-${sku}` : stripped;
}
@@ -0,0 +1,101 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Put, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProductFamiliesService } from './product-families.service';
import { OrganizeService } from './organize.service';
import {
AutoGroupDto,
CreateCustomMemberDto,
CreateProductFamilyDto,
DeletePriceOverridesDto,
PatchProductFamilyDto,
PutPriceOverridesDto,
QueryProductFamilyDto,
UpdateFamilyMembersDto,
} from './dto/product-family.dto';
@ApiTags('product-families')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('product-families')
export class ProductFamiliesController {
constructor(
private readonly service: ProductFamiliesService,
private readonly organize: OrganizeService,
) {}
@Get()
@ApiOperation({ summary: 'List product families with keyword & pagination' })
list(@Query() query: QueryProductFamilyDto) {
return this.service.list(query);
}
@Post('organize')
@ApiOperation({
summary:
'整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族 → 全量重算矩阵',
})
organizeAll() {
return this.organize.organize();
}
@Post('auto-group')
@ApiOperation({ summary: 'Auto-group unassigned origin goods by 3-segment name key' })
autoGroup(@Body() dto: AutoGroupDto) {
return this.service.autoGroup(dto.apply ?? false);
}
@Post()
@ApiOperation({ summary: 'Create a product family (optionally attach members)' })
create(@Body() dto: CreateProductFamilyDto) {
return this.service.create(dto);
}
@Get(':id')
@ApiOperation({ summary: 'Family detail with members & overrides' })
detail(@Param('id', ParseIntPipe) id: string) {
return this.service.detail(BigInt(id));
}
@Patch(':id')
@ApiOperation({ summary: 'Patch canonical fields / autoManaged / primary link' })
patch(@Param('id', ParseIntPipe) id: string, @Body() dto: PatchProductFamilyDto) {
return this.service.patch(BigInt(id), dto);
}
@Post(':id/recompute')
@ApiOperation({ summary: 'Manually recompute union & price matrix' })
recompute(@Param('id', ParseIntPipe) id: string) {
return this.service.recomputeNow(BigInt(id));
}
@Post(':id/members')
@ApiOperation({ summary: 'Add / remove SDS member links' })
updateMembers(@Param('id', ParseIntPipe) id: string, @Body() dto: UpdateFamilyMembersDto) {
return this.service.updateMembers(BigInt(id), dto);
}
@Post(':id/members/custom')
@ApiOperation({ summary: 'Create a custom (manual) member inside the family' })
createCustomMember(@Param('id', ParseIntPipe) id: string, @Body() dto: CreateCustomMemberDto) {
return this.service.createCustomMember(BigInt(id), dto);
}
@Get(':id/price-overrides')
@ApiOperation({ summary: 'List manual price overrides with derived-price comparison' })
listOverrides(@Param('id', ParseIntPipe) id: string) {
return this.service.listPriceOverrides(BigInt(id));
}
@Put(':id/price-overrides')
@ApiOperation({ summary: 'Batch upsert manual price overrides' })
putOverrides(@Param('id', ParseIntPipe) id: string, @Body() dto: PutPriceOverridesDto) {
return this.service.putPriceOverrides(BigInt(id), dto.items);
}
@Delete(':id/price-overrides')
@ApiOperation({ summary: 'Remove overrides to restore derived prices' })
deleteOverrides(@Param('id', ParseIntPipe) id: string, @Body() dto: DeletePriceOverridesDto) {
return this.service.deletePriceOverrides(BigInt(id), dto);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { FamilyRecomputeService } from './family-recompute.service';
import { OrganizeService } from './organize.service';
import { ProductFamiliesController } from './product-families.controller';
import { ProductFamiliesService } from './product-families.service';
@Module({
imports: [PrismaModule],
controllers: [ProductFamiliesController],
providers: [ProductFamiliesService, FamilyRecomputeService, OrganizeService],
exports: [ProductFamiliesService, FamilyRecomputeService, OrganizeService],
})
export class ProductFamiliesModule {}
@@ -0,0 +1,340 @@
import { PublicCacheService } from '../public/public-cache.service';
import { Test } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ProductFamiliesService } from './product-families.service';
import { FamilyRecomputeService } from './family-recompute.service';
import { OrganizeService } from './organize.service';
import { PrismaService } from '../prisma/prisma.service';
describe('ProductFamiliesService', () => {
let service: ProductFamiliesService;
let organize!: OrganizeService;
let prisma: PrismaService;
const stamp = Date.now();
const createdOriginGoodIds: bigint[] = [];
const createdFamilyIds: bigint[] = [];
const mkOriginGood = async (name: string, over: Record<string, unknown> = {}) => {
const og = await prisma.originGood.create({
data: {
sdsGoodId: `famsvc-${stamp}-${createdOriginGoodIds.length}-${Math.random().toString(36).slice(2, 7)}`,
goodName: name,
...over,
},
});
createdOriginGoodIds.push(og.id);
return og;
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(ProductFamiliesService);
organize = new OrganizeService(
moduleRef.get(PrismaService),
moduleRef.get(FamilyRecomputeService),
service,
moduleRef.get(PublicCacheService),
);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
await prisma.$disconnect();
});
it('create:挂成员、重算、familyCode 冲突自动加后缀', async () => {
const code = `DG${stamp}T`;
const a = await mkOriginGood('美国(包邮)T恤-DGTEST-单面印花', {
craftLabel: '单面印花',
logisticsLabel: '包邮',
});
await prisma.originGoodVariant.create({
data: {
originGoodId: a.id,
sdsVariantId: 'svc-v1',
sku: 'T-S',
sizeId: 'size_S',
sizeName: 'S',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(25),
},
});
await organize.deriveTagsForOg(a.id);
const f1 = (await service.create({
familyName: '测试T恤',
familyCode: code,
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
})) as any;
createdFamilyIds.push(BigInt(f1.id));
expect(f1.familyCode).toBe(code);
expect(f1._count.originGoods).toBe(1);
expect((f1.priceMatrix as any).rows).toHaveLength(1);
const f2 = (await service.create({ familyName: '测试T恤二号', familyCode: code })) as any;
createdFamilyIds.push(BigInt(f2.id));
expect(f2.familyCode).toBe(`${code}-2`);
});
it('detail:不存在 404', async () => {
await expect(service.detail(999999n)).rejects.toThrow(NotFoundException);
});
it('listkeyword 过滤 familyName/familyCode + 分页字段', async () => {
const code = `DG${stamp}T`;
const res = (await service.list({ keyword: `测试T恤`, page: 1, pageSize: 10 })) as any;
expect(res.total).toBeGreaterThanOrEqual(2);
expect(res.items.length).toBeGreaterThanOrEqual(2);
expect(res.page).toBe(1);
const byCode = (await service.list({ keyword: `${code}-2` })) as any;
expect(byCode.total).toBe(1);
});
it('patch:改 canonical 字段并触发重算,不丢成员', async () => {
const f = (await service.create({ familyName: `补丁族-${stamp}` })) as any;
createdFamilyIds.push(BigInt(f.id));
const patched = (await service.patch(BigInt(f.id), {
familyName: `补丁族改-${stamp}`,
autoManaged: false,
})) as any;
expect(patched.familyName).toBe(`补丁族改-${stamp}`);
expect(patched.autoManaged).toBe(false);
// 改回,避免影响后续用例
await service.patch(BigInt(f.id), { autoManaged: true });
});
it('auto-group:无分类时按族语义键分组(物流/工艺不分族);预览不写库;apply 幂等', async () => {
const g1a = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花`);
const g1b = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花-某仓`);
const g2 = await mkOriginGood(`自动组${stamp}(不包邮)卫衣-ZZ${stamp}-单面印花`);
const preview = (await service.autoGroup(false)) as any;
expect(preview.applied).toBe(0);
const hit = preview.groups.find((g: any) => g.groupKey.includes(`ZZ${stamp}`) && g.memberCount === 3);
expect(hit).toBeTruthy();
expect(hit.familyName).toContain('卫衣');
expect(hit.action).toBe('create');
const applied = (await service.autoGroup(true)) as any;
expect(applied.applied).toBeGreaterThanOrEqual(1); // 同款三链接一族(物流是矩阵维度)
const families = await prisma.productFamily.findMany({
where: { familyName: { contains: `自动组${stamp}` } },
});
for (const f of families) createdFamilyIds.push(f.id);
const grouped = await prisma.originGood.findMany({
where: { id: { in: [g1a.id, g1b.id, g2.id] } },
select: { familyId: true },
});
expect(grouped.every((g) => g.familyId !== null)).toBe(true);
// 物流/工艺差异不拆族:三条链接同一族
expect(grouped[0].familyId).toBe(grouped[1].familyId);
expect(grouped[2].familyId).toBe(grouped[0].familyId);
// 幂等:候选已清空
const again = (await service.autoGroup(true)) as any;
const hitAgain = again.groups.filter((g: any) => g.groupKey.includes(`ZZ${stamp}`));
expect(hitAgain).toHaveLength(0);
});
it('auto-group:已有同款族时并入而非新建(不再产生 -2 碎片族)', async () => {
const cat = `cat-merge-${stamp}`;
const a = await mkOriginGood(`美国(包邮)测试M-MG1-单面印花`, { sdsCategoryId: cat });
const b = await mkOriginGood(`美国(不包邮)测试M-MG1-双面印花`, { sdsCategoryId: cat });
// 先给 a 建族(模拟早先整理只见到 a)
const family = await service.create({
familyName: `测试M族${stamp}`,
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
});
createdFamilyIds.push(BigInt(family.id));
try {
const result = (await service.autoGroup(true)) as any;
const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } });
expect(after.familyId).toBe(BigInt(family.id)); // b 并入已有族
expect(result.merged).toBeGreaterThanOrEqual(1);
// 该分类没有产生第二个族
const dup = await prisma.productFamily.findMany({
where: { familyName: { contains: `测试M族${stamp}` } },
});
expect(dup).toHaveLength(1);
} finally {
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined);
}
});
it('auto-group:同款已有族但为人工锁定(autoManaged=false)时不并入也不新建', async () => {
const cat = `cat-manual-${stamp}`;
const a = await mkOriginGood(`美国(包邮)测试M-MG2-单面印花`, { sdsCategoryId: cat });
const b = await mkOriginGood(`美国(不包邮)测试M-MG2-单面印花`, { sdsCategoryId: cat });
const family = await service.create({
familyName: `测试M锁定族${stamp}`,
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
});
createdFamilyIds.push(BigInt(family.id));
try {
await prisma.productFamily.update({ where: { id: BigInt(family.id) }, data: { autoManaged: false } });
const result = (await service.autoGroup(true)) as any;
const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } });
expect(after.familyId).toBeNull(); // 不并入人工族
const sameCat = await prisma.productFamily.findMany({ where: { familyName: { contains: `测试M锁定族${stamp}` } } });
expect(sameCat).toHaveLength(1); // 也不新建平行族
expect((result.skipped ?? []).length).toBeGreaterThanOrEqual(0); // 报告字段存在
} finally {
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined);
await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined);
}
});
it('auto-group:同 SDS 分类(产品模型)跨工艺/编码归一族,族名取分类名', async () => {
const cat = await prisma.category.create({
data: { categoryName: `ZZF${stamp} 180G纯棉T恤(ZZA${stamp}`, sdsCategoryId: `cat-hook-${stamp}` },
});
try {
const a = await mkOriginGood(`美国(包邮)180g纯棉T恤-DGZ${stamp}-单面印花`, {
sdsCategoryId: `cat-hook-${stamp}`,
craftLabel: '单面印花',
logisticsLabel: '包邮',
});
const b = await mkOriginGood(`美国(不包邮)180GT恤-ZZA${stamp}-双面印花-某仓`, {
sdsCategoryId: `cat-hook-${stamp}`,
craftLabel: '双面印花',
logisticsLabel: '不包邮',
});
const result = (await service.autoGroup(true)) as any;
const grouped = await prisma.originGood.findMany({
where: { id: { in: [a.id, b.id] } },
select: { familyId: true },
});
expect(grouped[0].familyId).not.toBeNull();
expect(grouped[0].familyId).toBe(grouped[1].familyId); // 跨工艺/编码/物流同族
const family = await prisma.productFamily.findUniqueOrThrow({
where: { id: grouped[0].familyId! },
});
createdFamilyIds.push(family.id);
expect(family.familyName).toBe(`ZZF${stamp} 180G纯棉T恤(ZZA${stamp}`);
expect(family.familyCode).toBe(`ZZF${stamp}`);
expect(result.applied).toBeGreaterThanOrEqual(1);
} finally {
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
}
});
it('members:增删成员、移除主链接后 primary 落到剩余成员', async () => {
const a = await mkOriginGood(`成员${stamp}A`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
const b = await mkOriginGood(`成员${stamp}B`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
const c = await mkOriginGood(`成员${stamp}C`, { craftLabel: '单面印花', logisticsLabel: '包邮' });
const f = (await service.create({
familyName: `成员族-${stamp}`,
originGoodIds: [a.id.toString(), b.id.toString(), c.id.toString()],
primaryOriginGoodId: a.id.toString(),
})) as any;
createdFamilyIds.push(BigInt(f.id));
const removed = (await service.updateMembers(BigInt(f.id), {
removeOriginGoodIds: [a.id.toString()],
})) as any;
expect(removed._count.originGoods).toBe(2);
expect(BigInt(removed.primaryOriginGoodId)).toBe(b.id); // 落到剩余最小 id
const added = (await service.updateMembers(BigInt(f.id), {
addOriginGoodIds: [a.id.toString()],
})) as any;
expect(added._count.originGoods).toBe(3);
await expect(
service.updateMembers(BigInt(f.id), { addOriginGoodIds: ['999999'] }),
).rejects.toThrow(BadRequestException);
});
it('custom member:创建带归因的 CUSTOM 成员并入矩阵', async () => {
const f = (await service.create({ familyName: `自建族-${stamp}` })) as any;
createdFamilyIds.push(BigInt(f.id));
const member = (await service.createCustomMember(BigInt(f.id), {
goodName: `自建商品-${stamp}`,
logisticsLabel: '海运',
craftLabel: '双面印花',
skuCode: `ZZC${stamp}`,
variants: [{ sku: 'C-M', sizeId: 'size_M', sizeName: 'M', colorId: 'color_red', colorName: '红色', price: 33 }],
detail: { sizeChart: { rows: [{ sizeId: 'size_M', sizeName: 'M', chest: 100 }] } },
})) as any;
expect(member.sdsGoodId.startsWith('custom-')).toBe(true);
expect(member.logisticsLabel).toBe('海运');
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: BigInt(f.id) } });
const matrix = after.priceMatrix as any;
expect(matrix.rows).toHaveLength(1);
expect(matrix.rows[0].price).toBe('33');
expect(matrix.logistics).toContain('海运');
expect((after.sizeChart as any).rows).toHaveLength(1); // 自定义成员尺码参与并集
expect(BigInt(after.primaryOriginGoodId!)).toBe(BigInt(member.id)); // 空族首个成员成为主链接
});
it('price overrides:非法维度 400;合法覆盖生效;删除恢复推导价', async () => {
const a = await mkOriginGood(`美国(包邮)覆盖测试-OV${stamp}-单面印花`);
await prisma.originGoodVariant.create({
data: {
originGoodId: a.id,
sdsVariantId: 'ov-v1',
sku: 'O-S',
sizeId: 'size_S',
sizeName: 'S',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(25),
},
});
await organize.deriveTagsForOg(a.id);
const f = (await service.create({
familyName: `覆盖族-${stamp}`,
originGoodIds: [a.id.toString()],
primaryOriginGoodId: a.id.toString(),
})) as any;
createdFamilyIds.push(BigInt(f.id));
const fid = BigInt(f.id);
await expect(
service.putPriceOverrides(fid, [
{ sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '不存在的工艺', logistics: '包邮', price: 1 },
]),
).rejects.toThrow(BadRequestException);
const result = (await service.putPriceOverrides(fid, [
{ sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮', price: 23, note: '促销' },
])) as any;
expect(result.items).toHaveLength(1);
expect(result.items[0].derivedPrice).toBe('25');
expect(result.items[0].diff).toBe('-2.00');
const after = await prisma.productFamily.findUniqueOrThrow({ where: { id: fid } });
const row = (after.priceMatrix as any).rows[0];
expect(row.price).toBe('23');
expect(row.manual).toBe(true);
const restored = (await service.deletePriceOverrides(fid, {
cells: [{ sizeId: 'size_S', colorId: 'color_blk', printCount: '单面印花', craft: '烫画', logistics: '包邮' }],
})) as any;
expect(restored.items).toHaveLength(0);
const after2 = await prisma.productFamily.findUniqueOrThrow({ where: { id: fid } });
expect((after2.priceMatrix as any).rows[0].price).toBe('25');
expect((after2.priceMatrix as any).rows[0].manual).toBe(false);
});
it('recomputeNow:手动重算返回最新族详情', async () => {
const f = (await service.create({ familyName: `手动重算族-${stamp}` })) as any;
createdFamilyIds.push(BigInt(f.id));
const res = (await service.recomputeNow(BigInt(f.id))) as any;
expect(BigInt(res.id)).toBe(BigInt(f.id));
await expect(service.recomputeNow(999999n)).rejects.toThrow(NotFoundException);
});
});
@@ -0,0 +1,625 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService, PriceMatrix } from './family-recompute.service';
import { originGroupKey, parseOriginName, familyNameKey } from './origin-name.parser';
import {
CreateCustomMemberDto,
CreateProductFamilyDto,
DeletePriceOverridesDto,
PatchProductFamilyDto,
PriceOverrideItemDto,
QueryProductFamilyDto,
UpdateFamilyMembersDto,
} from './dto/product-family.dto';
/** 从分类名提取模型编码:`DG001 180G纯棉T恤(JSA002` → `DG001` */
function codeFromCategoryName(categoryName: string | null | undefined): string | null {
if (!categoryName) return null;
const token = categoryName.trim().split(/\s+/)[0] ?? '';
return /^[A-Za-z0-9]+$/.test(token) ? token : null;
}
const FAMILY_INCLUDE = { originGoods: {
orderBy: { id: 'asc' as const },
select: {
id: true,
sdsGoodId: true,
goodName: true,
goodImage: true,
goodPrice: true,
source: true,
delisted: true,
skuCode: true,
logisticsLabel: true,
craftLabel: true,
warehouseLabel: true,
tagsManual: true,
originGoodTags: {
orderBy: { tagId: 'asc' as const },
select: {
id: true,
manual: true,
tag: { select: { id: true, tagName: true, tagColor: true, tagFontColor: true } },
},
},
_count: { select: { variants: true } },
},
},
priceOverrides: true,
_count: { select: { originGoods: true, priceOverrides: true } },
} satisfies Prisma.ProductFamilyInclude;
@Injectable()
export class ProductFamiliesService {
constructor(
private readonly prisma: PrismaService,
private readonly recompute: FamilyRecomputeService,
) {}
async list(query: QueryProductFamilyDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.ProductFamilyWhereInput = query.keyword
? {
OR: [
{ familyName: { contains: query.keyword, mode: 'insensitive' } },
{ familyCode: { contains: query.keyword, mode: 'insensitive' } },
],
}
: {};
const [items, total] = await this.prisma.$transaction([
this.prisma.productFamily.findMany({
where,
include: { _count: { select: { originGoods: true, priceOverrides: true } } },
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.productFamily.count({ where }),
]);
return { items, total, page, pageSize };
}
async detail(id: bigint) {
const family = await this.prisma.productFamily.findUnique({
where: { id },
include: FAMILY_INCLUDE,
});
if (!family) throw new NotFoundException(`product family ${id} not found`);
return family;
}
async create(dto: CreateProductFamilyDto) {
const family = await this.prisma.productFamily.create({
data: {
familyName: dto.familyName,
familyCode: dto.familyCode ? await this.ensureUniqueCode(dto.familyCode) : null,
familyImage: dto.familyImage ?? null,
countryId: dto.countryId ? BigInt(dto.countryId) : null,
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
primaryOriginGoodId: dto.primaryOriginGoodId ? BigInt(dto.primaryOriginGoodId) : null,
},
});
if (dto.originGoodIds?.length) {
await this.attachMembers(
family.id,
dto.originGoodIds.map((v) => BigInt(v)),
);
}
await this.recompute.recomputeFamily(family.id);
return this.detail(family.id);
}
async patch(id: bigint, dto: PatchProductFamilyDto) {
const existing = await this.prisma.productFamily.findUnique({ where: { id } });
if (!existing) throw new NotFoundException(`product family ${id} not found`);
const data: Prisma.ProductFamilyUpdateInput = {};
if (dto.familyName !== undefined) data.familyName = dto.familyName;
if (dto.familyImage !== undefined) data.familyImage = dto.familyImage;
if (dto.autoManaged !== undefined) data.autoManaged = dto.autoManaged;
if (dto.countryId !== undefined) {
data.country = dto.countryId
? { connect: { id: BigInt(dto.countryId) } }
: { disconnect: true };
}
if (dto.categoryId !== undefined) {
data.category = dto.categoryId
? { connect: { id: BigInt(dto.categoryId) } }
: { disconnect: true };
}
if (dto.familyCode !== undefined) {
data.familyCode =
dto.familyCode === ''
? null
: dto.familyCode !== existing.familyCode
? await this.ensureUniqueCode(dto.familyCode, id)
: existing.familyCode;
}
if (dto.primaryOriginGoodId !== undefined) {
data.primaryOriginGoodId = dto.primaryOriginGoodId ? BigInt(dto.primaryOriginGoodId) : null;
}
await this.prisma.productFamily.update({ where: { id }, data });
await this.recompute.recomputeFamily(id);
return this.detail(id);
}
/** 自动成族:SDS 叶子分类即产品模型(如 "DG001 180G纯棉T恤(JSA002"),
* 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退族语义名称键
* (国家+品名+SKU,物流/工艺是矩阵维度不分族)。
* 已有同款 autoManaged 族时并入(不再新建 -2 碎片族);同款仅有
* 人工锁定族(autoManaged=false)时跳过该组,不并入也不新建。
* apply=false 仅预览(action 标注 create/merge/skip)。 */
async autoGroup(apply: boolean) {
const candidates = await this.prisma.originGood.findMany({
where: { familyId: null, delisted: false },
select: { id: true, goodName: true, goodImage: true, source: true, sdsCategoryId: true },
orderBy: { id: 'asc' },
});
const categories = await this.prisma.category.findMany({
where: { sdsCategoryId: { not: null } },
select: { sdsCategoryId: true, categoryName: true },
});
const catName = new Map(categories.map((c) => [c.sdsCategoryId as string, c.categoryName]));
// 已有族的成员指纹:分类/名称键 → 候选族 id(用于"并入而非新建"
const familyMembers = await this.prisma.originGood.findMany({
where: { familyId: { not: null }, delisted: false },
select: { familyId: true, sdsCategoryId: true, goodName: true },
});
const catFamilies = new Map<string, bigint[]>();
const nameFamilies = new Map<string, bigint[]>();
const push = (map: Map<string, bigint[]>, key: string, id: bigint) => {
map.set(key, [...(map.get(key) ?? []), id]);
};
for (const m of familyMembers) {
if (m.sdsCategoryId) push(catFamilies, m.sdsCategoryId, m.familyId!);
const nk = familyNameKey(m.goodName);
if (nk) push(nameFamilies, nk, m.familyId!);
}
const autoFamilyIds = new Set(
(
await this.prisma.productFamily.findMany({
where: { autoManaged: true },
select: { id: true },
})
).map((f) => f.id),
);
/** 匹配可并入的已有族:分类优先,回退名称键;仅 autoManaged,多候选取最老 */
const matchAutoFamily = (cat: string | null, nameKey: string): bigint | null => {
const pool = [
...new Set([...(cat ? (catFamilies.get(cat) ?? []) : []), ...(nameKey ? (nameFamilies.get(nameKey) ?? []) : [])]),
]
.filter((id) => autoFamilyIds.has(id))
.sort((a, b) => Number(a - b));
return pool[0] ?? null;
};
/** 该组是否已有同款族(含人工锁定族,用于 skip 判断) */
const hasAnyFamily = (cat: string | null, nameKey: string): boolean =>
Boolean(cat && catFamilies.has(cat)) || Boolean(nameKey && nameFamilies.has(nameKey));
const groups = new Map<string, typeof candidates>();
const groupKeys = new Map<string, { cat: string | null; nameKey: string }>();
for (const og of candidates) {
const nameKey = familyNameKey(og.goodName);
const key = og.sdsCategoryId ? `cat:${og.sdsCategoryId}` : nameKey ? `name:${nameKey}` : '';
if (!key) continue;
groupKeys.set(key, { cat: og.sdsCategoryId ?? null, nameKey });
const arr = groups.get(key);
if (arr) arr.push(og);
else groups.set(key, [og]);
}
const resolveGroup = (key: string) => {
const members = groups.get(key)!;
const { cat, nameKey } = groupKeys.get(key)!;
const parsed = parseOriginName(members[0].goodName);
const categoryName = cat ? (catName.get(cat) ?? null) : null;
return {
members,
cat,
nameKey,
parsed,
categoryName,
familyName: categoryName ?? parsed.productName ?? nameKey,
};
};
const preview = [...groups.keys()].map((key) => {
const g = resolveGroup(key);
const target = g.cat || g.nameKey ? matchAutoFamily(g.cat, g.nameKey) : null;
const skip = !target && hasAnyFamily(g.cat, g.nameKey);
return {
groupKey: key,
familyName: g.familyName,
familyCode: codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? null,
memberCount: g.members.length,
sampleNames: g.members.slice(0, 3).map((m) => m.goodName ?? ''),
action: target ? ('merge' as const) : skip ? ('skip' as const) : ('create' as const),
targetFamilyId: target ? target.toString() : null,
};
});
if (!apply) return { applied: 0, merged: 0, skipped: [] as string[], groups: preview };
let applied = 0;
let merged = 0;
const skipped: string[] = [];
for (const key of groups.keys()) {
const g = resolveGroup(key);
const memberIds = g.members.map((m) => m.id);
const target = g.cat || g.nameKey ? matchAutoFamily(g.cat, g.nameKey) : null;
if (target) {
// 并入已有 autoManaged 族:不再新建 -2 碎片族
await this.attachMembers(target, memberIds, { strict: false });
await this.prisma.good.updateMany({
where: { originGoodId: { in: memberIds } },
data: { familyId: target },
});
await this.recompute.recomputeFamily(target);
merged += memberIds.length;
continue;
}
if (hasAnyFamily(g.cat, g.nameKey)) {
// 同款仅有人工锁定族:不并入也不新建,留在候选里等人工处理
skipped.push(key);
continue;
}
const fallbackCode =
g.members[0].source === 'CUSTOM' ? `CUSTOM-${g.members[0].id}` : null;
const family = await this.prisma.productFamily.create({
data: {
familyName: g.familyName,
familyCode:
codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? fallbackCode
? await this.ensureUniqueCode(
(codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? fallbackCode)!,
)
: null,
familyImage: g.members[0].goodImage ?? null,
primaryOriginGoodId: g.members[0].id,
},
});
// 宽容挂载:并行环境下成员可能在候选查询后消失(如测试清理),跳过即可
await this.attachMembers(
family.id,
memberIds,
{ strict: false },
);
await this.prisma.good.updateMany({
where: { originGoodId: { in: memberIds } },
data: { familyId: family.id },
});
await this.recompute.recomputeFamily(family.id);
applied += 1;
}
return { applied, merged, skipped, groups: preview };
}
/**
* 单链接自动归族:同 SDS 分类优先,回退族语义名称键(国家+品名+SKU,物流/工艺
* 是矩阵维度不分族);只并入 autoManaged 族(人工锁定族不动),多候选取最老
* (id 最小)。返回并入的族 id,无匹配返回 null。
*/
async attachToMatchingFamily(originGoodId: bigint): Promise<bigint | null> {
const og = await this.prisma.originGood.findUnique({
where: { id: originGoodId },
select: { id: true, sdsCategoryId: true, goodName: true, delisted: true, familyId: true },
});
if (!og || og.familyId || og.delisted) return null;
const members = await this.prisma.originGood.findMany({
where: { familyId: { not: null }, delisted: false },
select: { familyId: true, sdsCategoryId: true, goodName: true },
});
let candidates: bigint[] = [];
if (og.sdsCategoryId) {
candidates = members
.filter((m) => m.sdsCategoryId === og.sdsCategoryId)
.map((m) => m.familyId!);
}
if (!candidates.length) {
const key = familyNameKey(og.goodName);
if (key) {
candidates = members
.filter((m) => familyNameKey(m.goodName) === key)
.map((m) => m.familyId!);
}
}
if (!candidates.length) return null;
const families = await this.prisma.productFamily.findMany({
where: { id: { in: [...new Set(candidates)] }, autoManaged: true },
orderBy: { id: 'asc' },
select: { id: true },
});
if (!families.length) return null;
const target = families[0];
await this.attachMembers(target.id, [og.id]);
await this.prisma.good.updateMany({
where: { originGoodId: og.id },
data: { familyId: target.id },
});
await this.recompute.recomputeFamily(target.id);
return target.id;
}
async updateMembers(id: bigint, dto: UpdateFamilyMembersDto) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
if (dto.removeOriginGoodIds?.length) {
const removeIds = dto.removeOriginGoodIds.map((v) => BigInt(v));
const remaining = await this.prisma.originGood.count({
where: { familyId: id, id: { notIn: removeIds } },
});
await this.prisma.originGood.updateMany({
where: { id: { in: removeIds }, familyId: id },
data: { familyId: null },
});
await this.prisma.good.updateMany({
where: { originGoodId: { in: removeIds }, familyId: id },
data: { familyId: null },
});
// 移除的是主链接(或主链接已不在族内)→ 落到剩余第一个成员
if (remaining > 0) {
const stillPrimary = await this.prisma.originGood.count({
where: { familyId: id, id: family.primaryOriginGoodId ?? -1n },
});
if (!stillPrimary) {
const next = await this.prisma.originGood.findFirst({
where: { familyId: id },
orderBy: { id: 'asc' },
select: { id: true },
});
if (next) {
await this.prisma.productFamily.update({
where: { id },
data: { primaryOriginGoodId: next.id },
});
}
}
} else {
await this.prisma.productFamily.update({
where: { id },
data: { primaryOriginGoodId: null },
});
}
}
if (dto.addOriginGoodIds?.length) {
const addIds = dto.addOriginGoodIds.map((v) => BigInt(v));
await this.attachMembers(id, addIds);
await this.prisma.good.updateMany({
where: { originGoodId: { in: addIds } },
data: { familyId: id },
});
}
await this.recompute.recomputeFamily(id);
return this.detail(id);
}
/** 在族内创建自定义成员(人工商品),成功后重算 */
async createCustomMember(familyId: bigint, dto: CreateCustomMemberDto) {
const family = await this.prisma.productFamily.findUnique({ where: { id: familyId } });
if (!family) throw new NotFoundException(`product family ${familyId} not found`);
const { randomUUID } = await import('node:crypto');
const originGood = await this.prisma.originGood.create({
data: {
sdsGoodId: `custom-${randomUUID()}`,
goodName: dto.goodName,
goodImage: dto.goodImage ?? null,
source: 'CUSTOM',
familyId,
skuCode: dto.skuCode ?? null,
logisticsLabel: dto.logisticsLabel,
craftLabel: dto.craftLabel,
warehouseLabel: dto.warehouseLabel ?? null,
},
});
await this.prisma.originGoodVariant.createMany({
data: dto.variants.map((v) => ({
originGoodId: originGood.id,
sdsVariantId: `custom-${randomUUID()}`,
sku: v.sku,
sizeId: v.sizeId ?? null,
sizeName: v.sizeName ?? null,
colorId: v.colorId ?? null,
colorName: v.colorName ?? null,
colorHex: v.colorHex ?? null,
imageUrl: v.imageUrl ?? null,
price: new Prisma.Decimal(v.price),
})),
});
if (dto.detail?.sizeChart || dto.detail?.packageSpecs) {
await this.prisma.originGoodDetail.create({
data: {
originGoodId: originGood.id,
sizeChart: (dto.detail?.sizeChart ?? undefined) as Prisma.InputJsonValue,
packageSpecs: (dto.detail?.packageSpecs ?? undefined) as Prisma.InputJsonValue,
},
});
}
if (!family.primaryOriginGoodId) {
await this.prisma.productFamily.update({
where: { id: familyId },
data: { primaryOriginGoodId: originGood.id },
});
}
await this.recompute.recomputeFamily(familyId);
return originGood;
}
async listPriceOverrides(id: bigint) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
const overrides = await this.prisma.familyPriceOverride.findMany({
where: { familyId: id },
orderBy: { updatedAt: 'desc' },
});
const matrix = (family.priceMatrix as PriceMatrix | null) ?? null;
const rows = matrix?.rows ?? [];
return {
items: overrides.map((o) => {
// 推导价 = 该格子全部来源中的最低价(覆盖生效前的推导结果,保留在 sources 里)
const row = rows.find(
(r) =>
r.sizeId === o.sizeId &&
r.colorId === o.colorId &&
r.printCount === o.printCount &&
r.craft === o.craft &&
r.logistics === o.logistics,
);
const derived =
row?.sources.length && !row.manual
? row.price
: row?.sources.length
? String(Math.min(...row.sources.map((s) => Number(s.price))))
: null;
return {
...o,
derivedPrice: derived,
diff: derived !== null ? (Number(o.price) - Number(derived)).toFixed(2) : null,
};
}),
};
}
async putPriceOverrides(id: bigint, items: PriceOverrideItemDto[]) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
// 矩阵未物化(如刚建族)时先重算,保证维度校验有依据
let matrix = family.priceMatrix as PriceMatrix | null;
if (!matrix) {
await this.recompute.recomputeFamily(id);
const refreshed = await this.prisma.productFamily.findUnique({ where: { id } });
matrix = (refreshed?.priceMatrix as PriceMatrix | null) ?? null;
}
const allowed = {
sizes: new Set((matrix?.sizes ?? []).map((s) => s.key)),
colors: new Set((matrix?.colors ?? []).map((c) => c.key)),
printCounts: new Set(matrix?.printCounts ?? []),
crafts: new Set(matrix?.crafts ?? []),
logistics: new Set(matrix?.logistics ?? []),
};
const invalid = items.filter(
(i) =>
!allowed.sizes.has(i.sizeId) ||
!allowed.colors.has(i.colorId) ||
!allowed.printCounts.has(i.printCount) ||
!allowed.crafts.has(i.craft) ||
!allowed.logistics.has(i.logistics),
);
if (invalid.length) {
throw new BadRequestException({
message: 'price override dimensions must exist in the family matrix',
invalidCells: invalid.map((i) => ({
sizeId: i.sizeId,
colorId: i.colorId,
printCount: i.printCount,
craft: i.craft,
logistics: i.logistics,
})),
});
}
for (const item of items) {
await this.prisma.familyPriceOverride.upsert({
where: {
familyId_sizeId_colorId_printCount_craft_logistics: {
familyId: id,
sizeId: item.sizeId,
colorId: item.colorId,
printCount: item.printCount,
craft: item.craft,
logistics: item.logistics,
},
},
create: {
familyId: id,
sizeId: item.sizeId,
colorId: item.colorId,
printCount: item.printCount,
craft: item.craft,
logistics: item.logistics,
price: new Prisma.Decimal(item.price),
note: item.note ?? null,
},
update: {
price: new Prisma.Decimal(item.price),
note: item.note ?? null,
},
});
}
await this.recompute.recomputeFamily(id);
return this.listPriceOverrides(id);
}
async deletePriceOverrides(id: bigint, dto: DeletePriceOverridesDto) {
for (const cell of dto.cells) {
await this.prisma.familyPriceOverride.deleteMany({
where: {
familyId: id,
sizeId: cell.sizeId,
colorId: cell.colorId,
printCount: cell.printCount,
craft: cell.craft,
logistics: cell.logistics,
},
});
}
await this.recompute.recomputeFamily(id);
return this.listPriceOverrides(id);
}
async recomputeNow(id: bigint) {
const family = await this.prisma.productFamily.findUnique({ where: { id } });
if (!family) throw new NotFoundException(`product family ${id} not found`);
await this.recompute.recomputeFamily(id);
return this.detail(id);
}
private async attachMembers(
familyId: bigint,
originGoodIds: bigint[],
opts: { strict?: boolean } = {},
) {
if (!originGoodIds.length) return;
const existings = await this.prisma.originGood.findMany({
where: { id: { in: originGoodIds } },
select: { id: true },
});
const existingIds = new Set(existings.map((e) => e.id.toString()));
const missing = originGoodIds.filter((v) => !existingIds.has(v.toString()));
if (missing.length && opts.strict !== false) {
throw new BadRequestException({
message: 'origin goods not found',
ids: missing.map(String),
});
}
const attachable = originGoodIds.filter((v) => existingIds.has(v.toString()));
if (!attachable.length) return;
await this.prisma.originGood.updateMany({
where: { id: { in: attachable } },
data: { familyId },
});
}
/** familyCode 全局唯一:冲突时追加 -2/-3… 后缀(不同国家同 SKU 常见) */
private async ensureUniqueCode(code: string, selfId?: bigint): Promise<string> {
let candidate = code;
let seq = 2;
// eslint-disable-next-line no-constant-condition
while (true) {
const clash = await this.prisma.productFamily.findFirst({
where: { familyCode: candidate, ...(selfId ? { id: { not: selfId } } : {}) },
select: { id: true },
});
if (!clash) return candidate;
candidate = `${code}-${seq++}`;
}
}
}
@@ -59,6 +59,28 @@ export class PublicGoodDetailDto extends PublicGoodDto {
@ApiProperty({ nullable: true }) @ApiProperty({ nullable: true })
detailSyncedAt!: string | null; detailSyncedAt!: string | null;
/** 灰度字段:PUBLIC_DETAIL_FROM_FAMILY=true 且主链接有所属族时输出,否则完全不含该键 */
@ApiProperty({
required: false,
nullable: true,
type: Object,
description: '产品族块:并集尺码表/包装规则 + 五维价格矩阵(尺码×颜色×印花数量×工艺×物流)',
})
family?: {
familyId: string;
familyCode: string | null;
familyName: string;
sizes: Array<{ key: string; name: string | null }>;
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
printCounts: string[];
crafts: string[];
logistics: string[];
sizeChart: Record<string, unknown> | null;
packageSpecs: Record<string, unknown> | null;
priceMatrix: Record<string, unknown>;
minPrice: string | null;
} | null;
} }
export class PublicTagGroupFilterDto { export class PublicTagGroupFilterDto {
@@ -0,0 +1,283 @@
import { Test } from '@nestjs/testing';
import { PrismaService } from '../prisma/prisma.service';
import { PublicCacheService } from './public-cache.service';
import { PublicService } from './public.service';
import { CategoriesService } from '../categories/categories.service';
import { CountriesService } from '../countries/countries.service';
import { TagsService } from '../tags/tags.service';
import { TagGroupsService } from '../tag-groups/tag-groups.service';
import { PositionsService } from '../positions/positions.service';
import { GoodsService } from '../goods/goods.service';
import { SyncService } from '../sync/sync.service';
import { SdsClientService } from '../sync/sds-client.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { ProductFamiliesService } from '../product-families/product-families.service';
import { OrganizeService } from '../product-families/organize.service';
import { OriginGoodsService } from '../origin-goods/origin-goods.service';
/**
* public 缓存失效链路(集成):
* 1. 读端点命中缓存(同筛选翻页/重复详情不再查库);
* 2. 「TTL 未到但写已发生 → 前台立即可见」端到端;
* 3. 每条写路径 bump 断言(全局约束 §2:等待 TTL 过期不是失效手段)。
* 夹具自包含:全部天然键带运行时间戳,不依赖库内既有数据。
*/
describe('public 缓存失效链路', () => {
const stamp = Date.now();
let prisma: PrismaService;
let cache: PublicCacheService;
let service: PublicService;
let categoriesService: CategoriesService;
let countriesService: CountriesService;
let tagsService: TagsService;
let tagGroupsService: TagGroupsService;
let positionsService: PositionsService;
let goodsService: GoodsService;
let syncService: SyncService;
let countryId: bigint;
let categoryId: bigint;
let tagId: bigint;
let tagGroupId: bigint;
let positionId: bigint;
let originGoodId: bigint;
let familyId: bigint;
let goodId: bigint;
let sdsGoodId: string;
let sdsCategoryId: string;
beforeAll(async () => {
const sdsMock: Partial<SdsClientService> = {
// 返回库内全部 SDS 分类 + 逻辑:保证 syncCategories 不误删并行套件的夹具
fetchCategoryTree: async () => {
const rows = await prisma.category.findMany({
where: { sdsCategoryId: { not: null } },
select: { sdsCategoryId: true, categoryName: true },
});
return rows.map((r) => ({ id: r.sdsCategoryId!, name: r.categoryName }));
},
fetchProductsPage: async () => ({ items: [] }),
fetchProductDetail: async (goodId: string | number) => ({ id: goodId }),
};
const moduleRef = await Test.createTestingModule({
providers: [
PublicService,
CategoriesService,
CountriesService,
TagsService,
TagGroupsService,
PositionsService,
GoodsService,
SyncService,
FamilyRecomputeService,
ProductFamiliesService,
OrganizeService,
OriginGoodsService,
{ provide: SdsClientService, useValue: sdsMock },
PrismaService,
PublicCacheService,
],
}).compile();
service = moduleRef.get(PublicService);
cache = moduleRef.get(PublicCacheService);
categoriesService = moduleRef.get(CategoriesService);
countriesService = moduleRef.get(CountriesService);
tagsService = moduleRef.get(TagsService);
tagGroupsService = moduleRef.get(TagGroupsService);
positionsService = moduleRef.get(PositionsService);
goodsService = moduleRef.get(GoodsService);
syncService = moduleRef.get(SyncService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
const country = await prisma.country.create({
data: { countryName: `失效测试国家 ${stamp}` },
});
countryId = country.id;
sdsCategoryId = `inv-sds-cat-${stamp}`;
const category = await prisma.category.create({
data: { categoryName: `失效测试分类 ${stamp}`, sdsCategoryId },
});
categoryId = category.id;
const tagGroup = await prisma.tagGroup.create({
data: { groupName: `失效测试标签组 ${stamp}` },
});
tagGroupId = tagGroup.id;
const tag = await prisma.tag.create({
data: { tagName: `失效测试标签 ${stamp}`, tagGroupId },
});
tagId = tag.id;
const position = await prisma.position.create({
data: { indexVal: 1, countryId, categoryId },
});
positionId = position.id;
sdsGoodId = `inv-sds-good-${stamp}`;
const og = await prisma.originGood.create({
data: { sdsGoodId, goodName: `失效测试链接 ${stamp}`, goodImage: 'http://img' },
});
originGoodId = og.id;
const family = await prisma.productFamily.create({
data: { familyName: `失效测试族 ${stamp}`, primaryOriginGoodId: og.id },
});
familyId = family.id;
await prisma.originGood.update({
where: { id: og.id },
data: { familyId },
});
const good = await prisma.good.create({
data: {
goodName: `失效测试商品 ${stamp}`,
originGoodId,
familyId,
countryId,
categoryId,
goodPriority: 5,
},
});
goodId = good.id;
}, 30_000);
afterAll(async () => {
await prisma.good.deleteMany({ where: { id: goodId } });
await prisma.originGoodTag.deleteMany({ where: { originGoodId } });
await prisma.originGoodVariant.deleteMany({ where: { originGoodId } });
await prisma.originGoodDetail.deleteMany({ where: { originGoodId } });
await prisma.originGood.deleteMany({ where: { id: originGoodId } });
await prisma.familyPriceOverride.deleteMany({ where: { familyId } });
await prisma.productFamily.deleteMany({ where: { id: familyId } });
await prisma.position.deleteMany({ where: { id: positionId } });
await prisma.tag.deleteMany({ where: { id: tagId } });
await prisma.tagGroup.deleteMany({ where: { id: tagGroupId } });
await prisma.category.deleteMany({
where: { OR: [{ id: categoryId }, { sdsCategoryId: { startsWith: `inv-sds-cat-${stamp}` } }] },
});
await prisma.country.deleteMany({ where: { id: countryId } });
await prisma.$disconnect();
});
it('列表读缓存:同筛选重复请求与翻页共享一份缓存,只查一次库', async () => {
const findManySpy = jest.spyOn(prisma.good, 'findMany');
const query = {
page: 1,
pageSize: 1,
countryId: countryId.toString(),
keyword: `失效测试商品 ${stamp}`,
sort: 'DEFAULT' as const,
};
const first = await service.getGoods(query);
const second = await service.getGoods(query);
const page2 = await service.getGoods({ ...query, page: 2 });
expect(first.items).toHaveLength(1);
expect(first.items[0].goodId).toBe(familyId.toString());
expect(second.items).toEqual(first.items);
expect(page2.items).toHaveLength(0); // total=1,第二页为空,但仍命中同一份缓存
expect(findManySpy).toHaveBeenCalledTimes(1);
findManySpy.mockRestore();
});
it('详情读缓存:重复请求不再查族表', async () => {
const familySpy = jest.spyOn(prisma.productFamily, 'findUnique');
const first = await service.getGood(familyId.toString());
const second = await service.getGood(familyId.toString());
expect(first.goodId).toBe(familyId.toString());
expect(second).toEqual(first);
expect(familySpy).toHaveBeenCalledTimes(1);
familySpy.mockRestore();
});
it('TTL 未到但分类改名 → 分类树立即返回新名(写后即失效,端到端)', async () => {
const oldName = `失效测试分类 ${stamp}`;
const newName = `失效后分类 ${stamp}`;
const categorySpy = jest.spyOn(prisma.category, 'findMany');
const before = await service.getCategoriesTree();
const flatBefore = JSON.stringify(before);
expect(flatBefore).toContain(oldName);
const callsAfterWarm = categorySpy.mock.calls.length;
await categoriesService.update(categoryId, { categoryName: newName });
const after = await service.getCategoriesTree();
expect(JSON.stringify(after)).toContain(newName);
expect(JSON.stringify(after)).not.toContain(oldName);
// 旧值来自缓存则不会再查库;失效正确时应产生新的分类查询
expect(categorySpy.mock.calls.length).toBeGreaterThan(callsAfterWarm);
categorySpy.mockRestore();
});
it('分类写路径 bump metaCountries/Tags/TagGroups', async () => {
const meta0 = cache.version('meta');
const goods0 = cache.version('goods');
await countriesService.update(countryId, {
countryName: `失效测试国家改 ${stamp}`,
countryIcon: null,
});
expect(cache.version('meta')).toBe(meta0 + 1);
await tagsService.update(tagId, { tagName: `失效测试标签改 ${stamp}` });
expect(cache.version('meta')).toBe(meta0 + 2);
await tagGroupsService.update(tagGroupId, { groupName: `失效测试标签组改 ${stamp}` });
expect(cache.version('meta')).toBe(meta0 + 3);
expect(cache.version('goods')).toBe(goods0); // meta 写路径不误伤 goods 域
});
it('positions/goods 写路径 bump goods', async () => {
const goods0 = cache.version('goods');
const meta0 = cache.version('meta');
await positionsService.update(positionId, { indexVal: 9 });
expect(cache.version('goods')).toBe(goods0 + 1);
await goodsService.update(goodId, { goodName: `失效测试商品改 ${stamp}` });
// update 内部联动 recomputeFamilygoods+matrix)后再显式 bump goods
expect(cache.version('goods')).toBeGreaterThanOrEqual(goods0 + 2);
expect(cache.version('meta')).toBe(meta0);
});
it('族重算 recomputeFamily bump goods+matrix', async () => {
const goods0 = cache.version('goods');
const matrix0 = cache.version('matrix');
const recompute = new FamilyRecomputeService(prisma, cache);
await recompute.recomputeFamily(familyId);
// goods 用 ≥:goods.update 等前置操作可能触发异步详情同步(fire-and-forget
// persistProductDetail 也会 bump goods),不与本断言强耦合;matrix 仅重算写
expect(cache.version('goods')).toBeGreaterThanOrEqual(goods0 + 1);
expect(cache.version('matrix')).toBe(matrix0 + 1);
});
it('链接人工改标签 bump meta+goods+matrix(含无族路径兜底)', async () => {
const meta0 = cache.version('meta');
const goods0 = cache.version('goods');
const matrix0 = cache.version('matrix');
const originGoodsService = new OriginGoodsService(
prisma,
new FamilyRecomputeService(prisma, cache),
new OrganizeService(prisma, new FamilyRecomputeService(prisma, cache), new ProductFamiliesService(prisma, new FamilyRecomputeService(prisma, cache)), cache),
new ProductFamiliesService(prisma, new FamilyRecomputeService(prisma, cache)),
cache,
);
await originGoodsService.updateTags(originGoodId, []);
expect(cache.version('meta')).toBeGreaterThanOrEqual(meta0 + 1);
expect(cache.version('goods')).toBeGreaterThanOrEqual(goods0 + 1);
expect(cache.version('matrix')).toBeGreaterThanOrEqual(matrix0 + 1);
});
it('同步写路径:syncCategories bump meta+goodssyncProducts bump goodspersistProductDetail bump goods', async () => {
const meta0 = cache.version('meta');
const goods0 = cache.version('goods');
await syncService.syncCategories();
expect(cache.version('meta')).toBe(meta0 + 1);
expect(cache.version('goods')).toBe(goods0 + 1);
await syncService.syncProducts();
expect(cache.version('goods')).toBe(goods0 + 2);
const goods1 = cache.version('goods');
// persistProductDetail 是私有方法,直调以断言挂钩(上游最小对象,mapper 全容忍)
await (syncService as unknown as { persistProductDetail: (id: bigint, up: unknown) => Promise<void> }).persistProductDetail(
originGoodId,
{ id: sdsGoodId },
);
expect(cache.version('goods')).toBeGreaterThanOrEqual(goods1 + 1);
}, 60_000);
});
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { PublicCacheService } from './public-cache.service';
/**
* Global public 缓存模块,导出 {@link PublicCacheService}。
*
* 与 PrismaModule 同款 @Global 模式:public 读路径与各 admin/sync 写路径
* 都要注入失效入口(bump),逐一 import 太啰嗦。单进程部署下进程内即全局;
* 扩容多副本时缓存需换共享存储(见整改计划 P1-3 扩展阶梯)。
*/
@Global()
@Module({
providers: [PublicCacheService],
exports: [PublicCacheService],
})
export class PublicCacheModule {}
@@ -0,0 +1,165 @@
import { PublicCacheDomain, PublicCacheService } from './public-cache.service';
describe('PublicCacheService', () => {
let cache: PublicCacheService;
beforeEach(() => {
cache = new PublicCacheService();
delete process.env.PUBLIC_CACHE_DISABLED;
delete process.env.PUBLIC_CACHE_TTL_MS;
});
afterAll(() => {
delete process.env.PUBLIC_CACHE_DISABLED;
delete process.env.PUBLIC_CACHE_TTL_MS;
});
it('命中:相同 key 第二次调用不再执行 loader', async () => {
const loader = jest.fn(async () => ({ value: 1 }));
const first = await cache.wrap('k', ['goods'], loader);
const second = await cache.wrap('k', ['goods'], loader);
expect(first).toEqual({ value: 1 });
expect(second).toEqual({ value: 1 });
expect(loader).toHaveBeenCalledTimes(1);
});
it('不同依赖域组合的相同 key 互不串缓存', async () => {
const a = await cache.wrap('k', ['goods'], async () => 'A');
const b = await cache.wrap('k', ['goods', 'matrix'], async () => 'B');
expect(a).toBe('A');
expect(b).toBe('B');
});
it('TTL 过期后重新执行 loader(TTL 只是兜底,不是失效手段)', async () => {
jest.useFakeTimers();
jest.setSystemTime(1_700_000_000_000);
try {
process.env.PUBLIC_CACHE_TTL_MS = '1000';
const loader = jest.fn(async () => ({ v: 1 }));
await cache.wrap('k', ['goods'], loader);
jest.setSystemTime(1_700_000_000_000 + 999);
await cache.wrap('k', ['goods'], loader);
expect(loader).toHaveBeenCalledTimes(1);
jest.setSystemTime(1_700_000_000_000 + 1001);
await cache.wrap('k', ['goods'], loader);
expect(loader).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});
it('bump 域版本后,依赖该域的缓存立即作废', async () => {
const loader = jest.fn(async () => ({ v: 1 }));
await cache.wrap('list', ['goods', 'matrix'], loader);
cache.bump('goods');
await cache.wrap('list', ['goods', 'matrix'], loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it('bump 某域不影响不依赖该域的条目(域间隔离)', async () => {
const metaLoader = jest.fn(async () => 'meta-data');
const goodsLoader = jest.fn(async () => 'goods-data');
await cache.wrap('meta-key', ['meta'], metaLoader);
cache.bump('goods');
await cache.wrap('meta-key', ['meta'], metaLoader);
expect(metaLoader).toHaveBeenCalledTimes(1);
await cache.wrap('goods-key', ['goods'], goodsLoader);
expect(goodsLoader).toHaveBeenCalledTimes(1);
});
it('跨域条目:任一依赖域 bump 都使其作废', async () => {
const loader = jest.fn(async () => 'x');
await cache.wrap('combo', ['goods', 'matrix', 'meta'], loader);
cache.bump('matrix');
await cache.wrap('combo', ['goods', 'matrix', 'meta'], loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it('竞态防护:loader 执行期间发生 bump → 结果照常返回但不入缓存', async () => {
let releaseLoader!: (v: string) => void;
const loader = jest.fn(
() =>
new Promise<string>((resolve) => {
releaseLoader = resolve;
}),
);
const inflight = cache.wrap('k', ['goods'], loader);
cache.bump('goods'); // 加载期间数据变了
releaseLoader('stale-value');
await expect(inflight).resolves.toBe('stale-value');
const loader2 = jest.fn(async () => 'fresh-value');
await expect(cache.wrap('k', ['goods'], loader2)).resolves.toBe('fresh-value');
expect(loader2).toHaveBeenCalledTimes(1); // 旧值没有写回缓存
});
it('并发合并:并发 miss 只触发一次 loader(防击穿)', async () => {
let release!: () => void;
const loader = jest.fn(
() =>
new Promise<number>((resolve) => {
release = () => resolve(42);
}),
);
const p1 = cache.wrap('hot', ['goods'], loader);
const p2 = cache.wrap('hot', ['goods'], loader);
const p3 = cache.wrap('hot', ['goods'], loader);
release();
expect(await Promise.all([p1, p2, p3])).toEqual([42, 42, 42]);
expect(loader).toHaveBeenCalledTimes(1);
});
it('PUBLIC_CACHE_DISABLED=true 时直通 loader、不缓存', async () => {
process.env.PUBLIC_CACHE_DISABLED = 'true';
const loader = jest.fn(async () => ({ v: 1 }));
await cache.wrap('k', ['goods'], loader);
await cache.wrap('k', ['goods'], loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it('loader 抛错不缓存、不残留 pending', async () => {
const failing = jest.fn(async () => {
throw new Error('boom');
});
await expect(cache.wrap('k', ['goods'], failing)).rejects.toThrow('boom');
const ok = jest.fn(async () => 'good');
await expect(cache.wrap('k', ['goods'], ok)).resolves.toBe('good');
});
it('条目上限:先清过期条目,仍超限则按插入序淘汰最旧的', async () => {
jest.useFakeTimers();
jest.setSystemTime(1_700_000_000_000);
try {
process.env.PUBLIC_CACHE_TTL_MS = '1000';
process.env.PUBLIC_CACHE_MAX_ENTRIES = '3';
await cache.wrap('old', ['goods'], async () => 'old');
jest.setSystemTime(1_700_000_000_000 + 2000);
// old 已过期;再写 4 条活跃条目,第一条应顺带清掉过期的 old
for (let i = 0; i < 4; i++) {
await cache.wrap(`k${i}`, ['goods'], async () => i);
}
const loader = jest.fn(async () => 'reloaded');
// k0 应已被容量淘汰,重新加载;k3 仍命中
await cache.wrap('k0', ['goods'], loader);
expect(loader).toHaveBeenCalledTimes(1);
const hit = jest.fn(async () => -1);
await cache.wrap('k3', ['goods'], hit);
expect(hit).toHaveBeenCalledTimes(0);
} finally {
delete process.env.PUBLIC_CACHE_MAX_ENTRIES;
jest.useRealTimers();
}
});
it('version() 暴露域版本用于诊断,bump 递增', () => {
const before = cache.version('goods');
cache.bump('goods');
expect(cache.version('goods')).toBe(before + 1);
expect(cache.version('matrix')).toBe(before === 0 ? 0 : cache.version('matrix'));
});
it('未知域名直接抛错(编程错误早暴露)', async () => {
expect(() => cache.bump('nope' as PublicCacheDomain)).toThrow();
await expect(cache.wrap('k', ['nope' as PublicCacheDomain], async () => 1)).rejects.toThrow();
});
});
+171
View File
@@ -0,0 +1,171 @@
import { Injectable, Logger } from '@nestjs/common';
/**
* public 读路径的进程内缓存(性能整改 P0-1,详见
* docs/references/performance-review-public-port.md 与
* plans/refactor/public-capacity-10k-refactor.md)。
*
* 语义要点(全局约束 §2 的落地):
* - 分域版本号失效:写路径在事务提交成功后 bump 依赖域,依赖该域的缓存
* 条目立即作废——「等 TTL 自然过期」只是内存回收兜底,不是失效手段;
* - 条目记录写入时全部依赖域的版本快照,读时逐一比对,跨域依赖
* (列表同时依赖 goods+matrix+meta)天然联动失效;
* - loader 完成后复核版本:bump 发生在加载期间 → 结果照常返回但不入缓存,
* 杜绝「失效瞬间的并发读把旧值写回」竞态;
* - in-flight 合并:并发 miss 只触发一次 loader,防瞬时洪峰击穿缓存;
* - 单进程部署下进程内即全局缓存;扩容多副本时需换共享存储(计划 P1-3 阶梯 c)。
*
* 环境开关:PUBLIC_CACHE_DISABLED=true 全直通(应急);
* PUBLIC_CACHE_TTL_MS / PUBLIC_CACHE_MAX_ENTRIES 可调(默认 10 分钟 / 512 条)。
*/
export const PUBLIC_CACHE_DOMAINS = ['meta', 'goods', 'matrix'] as const;
export type PublicCacheDomain = (typeof PUBLIC_CACHE_DOMAINS)[number];
const DEFAULT_TTL_MS = 10 * 60 * 1000;
const DEFAULT_MAX_ENTRIES = 512;
interface CacheEntry {
value: unknown;
expiresAt: number;
/** 写入时各依赖域的版本快照——任一域 bump 即作废 */
versions: ReadonlyMap<PublicCacheDomain, number>;
}
@Injectable()
export class PublicCacheService {
private readonly logger = new Logger(PublicCacheService.name);
private readonly versions = new Map<PublicCacheDomain, number>(
PUBLIC_CACHE_DOMAINS.map((domain) => [domain, 0]),
);
private readonly entries = new Map<string, CacheEntry>();
private readonly pending = new Map<string, Promise<unknown>>();
/**
* 读穿透封装:命中(未过期且全部依赖域版本未变)直接返回缓存值,
* 否则执行 loader 并缓存。domains 声明该值依赖的数据域——
* 写路径 bump 其中任一域都会让本条目作废。
*/
async wrap<T>(
key: string,
domains: readonly PublicCacheDomain[],
loader: () => Promise<T>,
): Promise<T> {
this.assertDomains(domains);
if (this.disabled()) return loader();
const storeKey = `${[...domains].sort().join('+')}|${key}`;
const hit = this.readHit(storeKey);
if (hit !== undefined) return hit as T;
const inflight = this.pending.get(storeKey);
if (inflight) return inflight as Promise<T>;
const snapshot = this.snapshotVersions(domains);
const run: Promise<T> = (async () => {
const value = await loader();
if (this.versionsMatch(snapshot)) {
this.writeEntry(storeKey, value, snapshot);
} else {
this.logger.debug(`cache skip (version bumped during load): ${storeKey}`);
}
return value;
})();
this.pending.set(storeKey, run as Promise<unknown>);
// 失败与成功都要摘掉 pending,异常不得残留在途槽位
void run.catch(() => undefined).finally(() => this.pending.delete(storeKey));
return run;
}
/**
* 失效入口:写事务提交成功后调用。递增域版本并清除依赖该域的条目
* (版本号本身已保证正确性,清条目只为及时回收内存)。
*/
bump(...domains: PublicCacheDomain[]): void {
this.assertDomains(domains);
for (const domain of domains) {
this.versions.set(domain, (this.versions.get(domain) ?? 0) + 1);
}
if (this.entries.size === 0) return;
for (const [key, entry] of this.entries) {
if (domains.some((domain) => entry.versions.has(domain))) {
this.entries.delete(key);
}
}
}
/** 当前域版本(诊断/测试用) */
version(domain: PublicCacheDomain): number {
this.assertDomains([domain]);
return this.versions.get(domain) ?? 0;
}
private readHit(storeKey: string): unknown {
const entry = this.entries.get(storeKey);
if (!entry) return undefined;
if (Date.now() >= entry.expiresAt) {
this.entries.delete(storeKey);
return undefined;
}
if (!this.versionsMatch(entry.versions)) {
this.entries.delete(storeKey);
return undefined;
}
return entry.value;
}
private versionsMatch(snapshot: ReadonlyMap<PublicCacheDomain, number>): boolean {
for (const [domain, version] of snapshot) {
if ((this.versions.get(domain) ?? 0) !== version) return false;
}
return true;
}
private snapshotVersions(
domains: readonly PublicCacheDomain[],
): Map<PublicCacheDomain, number> {
return new Map(domains.map((domain) => [domain, this.versions.get(domain) ?? 0]));
}
private writeEntry(
storeKey: string,
value: unknown,
versions: ReadonlyMap<PublicCacheDomain, number>,
): void {
const maxEntries = this.maxEntries();
if (this.entries.size >= maxEntries) {
const now = Date.now();
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key);
}
while (this.entries.size >= maxEntries) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.entries.delete(oldest);
}
}
this.entries.set(storeKey, { value, expiresAt: Date.now() + this.ttlMs(), versions });
}
private ttlMs(): number {
const parsed = Number(process.env.PUBLIC_CACHE_TTL_MS);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_MS;
}
private maxEntries(): number {
const parsed = Number(process.env.PUBLIC_CACHE_MAX_ENTRIES);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_ENTRIES;
}
private disabled(): boolean {
return process.env.PUBLIC_CACHE_DISABLED === 'true';
}
private assertDomains(domains: readonly PublicCacheDomain[]): void {
for (const domain of domains) {
if (!this.versions.has(domain)) {
throw new Error(`未知的 public 缓存域: ${String(domain)}`);
}
}
}
}
@@ -0,0 +1,261 @@
import { PublicCacheService } from './public-cache.service';
import { Test } from '@nestjs/testing';
import { Prisma } from '@prisma/client';
import { PublicService } from './public.service';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { ProductFamiliesService } from '../product-families/product-families.service';
import { OrganizeService } from '../product-families/organize.service';
/**
* 三期灰度族块集成测试:PUBLIC_DETAIL_FROM_FAMILY 开关两态行为、
* 族块字段内容、Good.familyId 派生与成员移动联动。
*/
describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
let service: PublicService;
let prisma: PrismaService;
const stamp = Date.now();
const createdOriginGoodIds: bigint[] = [];
const createdFamilyIds: bigint[] = [];
const createdGoodIds: bigint[] = [];
const createdCountryIds: bigint[] = [];
const createdCategoryIds: bigint[] = [];
const prevFlag = process.env.PUBLIC_DETAIL_FROM_FAMILY;
let sdsGoodId = '';
let familyId = 0n;
beforeAll(async () => {
// 本套件测数据语义(裸 prisma 改数后立即读公开端点),不走带 bump 的写路径——
// 禁用 public 缓存;缓存命中/失效语义由 public-cache.invalidation.spec.ts 覆盖
process.env.PUBLIC_CACHE_DISABLED = 'true';
const moduleRef = await Test.createTestingModule({
providers: [PublicService, PrismaService, PublicCacheService],
}).compile();
service = moduleRef.get(PublicService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
const country = await prisma.country.create({
data: { countryName: `公开族国家-${stamp}` },
});
createdCountryIds.push(country.id);
const category = await prisma.category.create({
data: { categoryName: `公开族分类-${stamp}` },
});
createdCategoryIds.push(category.id);
// 族 + 主链接(带变体)+ Good
const og = await prisma.originGood.create({
data: {
sdsGoodId: `pubfam-${stamp}`,
goodName: `美国(包邮)测试T恤-PF${stamp}-单面印花`,
goodPrice: new Prisma.Decimal(25),
craftLabel: '单面印花',
logisticsLabel: '包邮',
},
});
sdsGoodId = og.sdsGoodId;
createdOriginGoodIds.push(og.id);
await prisma.originGoodVariant.create({
data: {
originGoodId: og.id,
sdsVariantId: 'pf-v1',
sku: `PF-${stamp}-S`,
sizeId: 'size_S',
sizeName: 'S',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(25),
},
});
const family = await prisma.productFamily.create({
data: { familyName: `公开族-${stamp}`, familyCode: `PF${stamp}`, primaryOriginGoodId: og.id },
});
familyId = family.id;
createdFamilyIds.push(family.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
// 解析去运行时化:先整理派生标签,再重算(重算只聚合不解析)
const recomputeSvc = new FamilyRecomputeService(prisma, new PublicCacheService());
const organizeSvc = new OrganizeService(
prisma,
recomputeSvc,
new ProductFamiliesService(prisma, recomputeSvc),
new PublicCacheService(),
);
await organizeSvc.deriveFamilyTags(family.id);
await recomputeSvc.recomputeFamily(family.id);
const good = await prisma.good.create({
data: {
originGoodId: og.id,
familyId: family.id,
countryId: country.id,
categoryId: category.id,
goodName: `公开族商品-${stamp}`,
},
});
createdGoodIds.push(good.id);
});
afterAll(async () => {
process.env.PUBLIC_DETAIL_FROM_FAMILY = prevFlag;
await prisma.good.deleteMany({ where: { id: { in: createdGoodIds } } });
await prisma.originGood.deleteMany({ where: { id: { in: createdOriginGoodIds } } });
await prisma.productFamily.deleteMany({ where: { id: { in: createdFamilyIds } } });
await prisma.country.deleteMany({ where: { id: { in: createdCountryIds } } });
await prisma.category.deleteMany({ where: { id: { in: createdCategoryIds } } });
await prisma.$disconnect();
});
it('默认(未设开关):输出族块', async () => {
delete process.env.PUBLIC_DETAIL_FROM_FAMILY
const detail = await service.getGood(familyId.toString());
expect(detail.goodId).toBe(familyId.toString());
expect(detail.family).toBeTruthy();
expect(detail.family!.familyCode).toBe(`PF${stamp}`);
expect(detail.family!.minPrice).toBe('25');
// 旧字段保留(向后兼容)
expect(detail.variants.length).toBe(1);
});
it('显式关闭(false):响应完全不含 family 键(应急回退)', async () => {
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'false';
const detail = await service.getGood(familyId.toString());
expect(detail.goodId).toBe(familyId.toString());
expect('family' in detail).toBe(false);
});
it('族变体并集:族ID 命中商品且变体含全体成员;成员 sdsGoodId 不再可寻址', async () => {
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
// 再加一个同族成员(不同仓库段)
const og2 = await prisma.originGood.create({
data: {
sdsGoodId: `pubfam-m-${stamp}`,
goodName: `美国(包邮)测试T恤-PF${stamp}-单面印花-二仓`,
craftLabel: '单面印花',
logisticsLabel: '包邮',
familyId,
},
});
createdOriginGoodIds.push(og2.id);
await prisma.originGoodVariant.create({
data: {
originGoodId: og2.id,
sdsVariantId: 'pf-m-v1',
sku: `PF-${stamp}-M`,
sizeId: 'size_M',
sizeName: 'M',
colorId: 'color_blk',
colorName: '黑色',
price: new Prisma.Decimal(26),
},
});
// 族 ID 是唯一公开键:成员变体并入同一详情
const detail = await service.getGood(familyId.toString());
expect(detail.goodId).toBe(familyId.toString());
const skus = detail.variants.map((v) => v.sku);
expect(skus).toContain(`PF-${stamp}-S`);
expect(skus).toContain(`PF-${stamp}-M`); // 族成员变体并集
// 成员链接的 sdsGoodId 不再可寻址
await expect(service.getGood(`pubfam-m-${stamp}`)).rejects.toThrow();
// 清理:把成员移出族避免影响其他用例
await prisma.originGoodVariant.deleteMany({ where: { originGoodId: og2.id } });
await prisma.originGood.update({ where: { id: og2.id }, data: { familyId: null } });
});
it('无族商品:公开端点不可见(列表不含 / 详情 404)', async () => {
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
const og2 = await prisma.originGood.create({
data: {
sdsGoodId: `pubfam-2-${stamp}`,
goodName: `无族链接-${stamp}`,
goodPrice: new Prisma.Decimal(10),
},
});
createdOriginGoodIds.push(og2.id);
const good2 = await prisma.good.create({
data: {
originGoodId: og2.id,
countryId: createdCountryIds[0],
categoryId: createdCategoryIds[0],
goodName: `无族商品-${stamp}`,
},
});
createdGoodIds.push(good2.id);
const list = await service.getGoods({
page: 1,
pageSize: 50,
countryId: createdCountryIds[0].toString(),
});
expect(list.items.map((i) => i.goodName)).not.toContain(`无族商品-${stamp}`);
await expect(service.getGood(`pubfam-2-${stamp}`)).rejects.toThrow();
});
});
describe('Good familyId derivation', () => {
let prisma: PrismaService;
let familiesService: import('../product-families/product-families.service').ProductFamiliesService;
const stamp = Date.now();
const ids = { originGood: [] as bigint[], family: [] as bigint[], good: [] as bigint[], country: [] as bigint[], category: [] as bigint[] };
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaService, PublicCacheService],
}).compile();
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
familiesService = new (require('../product-families/product-families.service').ProductFamiliesService)(
prisma,
new FamilyRecomputeService(prisma, new PublicCacheService()),
new PublicCacheService(),
);
});
afterAll(async () => {
await prisma.good.deleteMany({ where: { id: { in: ids.good } } });
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } });
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } });
await prisma.country.deleteMany({ where: { id: { in: ids.country } } });
await prisma.category.deleteMany({ where: { id: { in: ids.category } } });
await prisma.$disconnect();
});
it('updateMembers 移除/添加成员时,Good.familyId 联动', async () => {
const country = await prisma.country.create({ data: { countryName: `联动国家-${stamp}` } });
ids.country.push(country.id);
const category = await prisma.category.create({ data: { categoryName: `联动分类-${stamp}` } });
ids.category.push(category.id);
const og = await prisma.originGood.create({
data: { sdsGoodId: `derive-${stamp}`, goodName: `联动链接-${stamp}` },
});
ids.originGood.push(og.id);
const family = await prisma.productFamily.create({
data: { familyName: `联动族-${stamp}`, primaryOriginGoodId: og.id },
});
ids.family.push(family.id);
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } });
const good = await prisma.good.create({
data: {
originGoodId: og.id,
familyId: family.id,
countryId: country.id,
categoryId: category.id,
goodName: `联动商品-${stamp}`,
},
});
ids.good.push(good.id);
// 移出族 → Good.familyId 置空
await familiesService.updateMembers(family.id, { removeOriginGoodIds: [og.id.toString()] });
expect(
(await prisma.good.findUniqueOrThrow({ where: { id: good.id } })).familyId,
).toBeNull();
// 重新挂回 → Good.familyId 回填
await familiesService.updateMembers(family.id, { addOriginGoodIds: [og.id.toString()] });
expect(
(await prisma.good.findUniqueOrThrow({ where: { id: good.id } })).familyId,
).toBe(family.id);
});
});

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