Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84b3b5daa | ||
|
|
1ffd73a1af | ||
|
|
ae880ff346 | ||
|
|
26a5b08b56 | ||
|
|
63820a3e6f | ||
|
|
b32a5575df | ||
|
|
a0ad6da9c7 | ||
|
|
2bec7822f0 | ||
|
|
76eb849b49 | ||
|
|
bea0bf14d8 | ||
|
|
3ccddc5982 | ||
|
|
dc90712c4f | ||
|
|
d01c1e1b97 | ||
|
|
3d431f8375 | ||
|
|
6973f863b5 | ||
|
|
88f60ba2e2 | ||
|
|
01fe444f93 | ||
|
|
318f5c2a64 | ||
|
|
007ad2c831 | ||
|
|
60992cb3d9 | ||
|
|
62f968c1c1 | ||
|
|
f55e7ba4bb | ||
|
|
a5884c638c | ||
|
|
674d38a9a7 | ||
|
|
102d61c181 | ||
|
|
12882e7d5a | ||
|
|
a756c8c3c8 | ||
|
|
ab42cb49a3 | ||
|
|
5d089bdb43 | ||
|
|
49d28c8d4f | ||
|
|
ab51052d26 | ||
|
|
7ff12fc69b | ||
|
|
fa4a95af98 | ||
|
|
ff8aad171c | ||
|
|
b6c19ae859 | ||
|
|
93ac525c55 | ||
|
|
976f308e3c | ||
|
|
64a6fa7004 | ||
|
|
b5875d33b2 | ||
|
|
ce283dff78 | ||
|
|
6498a198ef | ||
|
|
7c46f15967 | ||
|
|
d625b40873 | ||
|
|
6f2d836ef5 | ||
|
|
5e8183c220 | ||
|
|
4ddbf486bd | ||
|
|
0e451d07e6 | ||
|
|
aba90cb011 | ||
|
|
fdfe435ecd | ||
|
|
a14b8ecf4a | ||
|
|
1e07c62fc8 | ||
|
|
77f9c05346 | ||
|
|
fe6ab3825d | ||
|
|
8a6db1bdee | ||
|
|
d0102ec4e8 | ||
|
|
63b436fe41 | ||
|
|
5a7a2841de | ||
|
|
8a052773cd | ||
|
|
e5ec022834 | ||
|
|
e018f16d0b | ||
|
|
b055aedd3e | ||
|
|
1ef82c4c14 | ||
|
|
f07e843130 | ||
|
|
6054cb556a | ||
|
|
d4515631b1 | ||
|
|
c7e6c35fbf | ||
|
|
6d9305b878 | ||
|
|
7c85f7fe9f | ||
|
|
f280b1daf5 | ||
|
|
d67858c69b | ||
|
|
c2d50c9fa3 | ||
|
|
9863346c6d | ||
|
|
f28f51155f | ||
|
|
753b554413 | ||
|
|
10197c47a8 | ||
|
|
34d84a7a7c | ||
|
|
6f22a6fd1f | ||
|
|
08201f18a4 | ||
|
|
0a2302c4e2 | ||
|
|
3ddc4c87e8 | ||
|
|
37b52c5ebb | ||
|
|
1128167aef | ||
|
|
ee336968a0 | ||
|
|
bbd925070a | ||
|
|
f5a3b8c840 | ||
|
|
6395d9e6ab | ||
|
|
eedfbb344e | ||
|
|
a13931c84c | ||
|
|
353e51e871 | ||
|
|
7b17fff86f | ||
|
|
f919577ddd | ||
|
|
4eadb67c36 | ||
|
|
14c53504f0 | ||
|
|
6418aa7302 | ||
|
|
b4b9fbbe60 | ||
|
|
ba3c6d2d4f | ||
|
|
d70a03f6fa | ||
|
|
fd7ca5d53f | ||
|
|
07f3134f79 | ||
|
|
dcd2b235b9 | ||
|
|
c492469d70 | ||
|
|
8fad8de73e | ||
|
|
e49eebcedd | ||
|
|
ab450c350e | ||
|
|
f7c4252a91 | ||
|
|
502b7eba6e | ||
|
|
c8531bfe08 | ||
|
|
d63f1a6f35 | ||
|
|
d20a933e34 | ||
|
|
f68898dea4 | ||
|
|
de0f5509b1 | ||
|
|
c07b28def7 | ||
|
|
60946b5f64 | ||
|
|
37b45a4679 | ||
|
|
573549eb15 | ||
|
|
14d77fbab2 | ||
|
|
1186d615e2 | ||
|
|
270e516f94 | ||
|
|
d826c5bdd4 | ||
|
|
457792b729 | ||
|
|
8233dda07a | ||
|
|
cbdb507be9 | ||
|
|
b3bd10de06 | ||
|
|
e7582bbd6e | ||
|
|
4c50189538 | ||
|
|
4bebb9e4b2 | ||
|
|
ac29a0c7bf | ||
|
|
ac3a39f40d | ||
|
|
491e74b905 | ||
|
|
0aa6cbf1d9 | ||
|
|
697b82a64b | ||
|
|
f06dfffbda | ||
|
|
d9ecd04747 | ||
|
|
9c279ae393 | ||
|
|
848eed0b6f | ||
|
|
043d2463a6 |
@@ -0,0 +1,26 @@
|
|||||||
|
# 依赖与包管理缓存(容器内会重新安装,禁止从宿主机带入)
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
.pnpm-store
|
||||||
|
.pnpm-debug.log
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# Git 与本地文件
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# 测试与文档
|
||||||
|
coverage
|
||||||
|
**/*.spec.ts
|
||||||
|
**/*.test.ts
|
||||||
|
|
||||||
|
# 运行数据与本地产物
|
||||||
|
data-cleaning
|
||||||
|
deploy/backups
|
||||||
|
apps/api/uploads
|
||||||
|
*.log
|
||||||
@@ -8,7 +8,6 @@ dist/
|
|||||||
deploy/certbot/
|
deploy/certbot/
|
||||||
deploy/data-dump.json
|
deploy/data-dump.json
|
||||||
uploads/
|
uploads/
|
||||||
|
.pnpm-store/
|
||||||
# Data cleaning runtime artifacts (production snapshots, exports, reports)
|
data-cleaning/
|
||||||
data-cleaning/runs/
|
backups/
|
||||||
deploy/backups/
|
|
||||||
|
|||||||
@@ -89,3 +89,17 @@ 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 加载失败,直接复用项目自身的运行镜像(如 deploy-v2-api)跑 prisma/jest 最稳。
|
||||||
|
- **Prisma 唯一查询用字段名而非列名**:`where: { id }` 而非 `@map("category_id")` 映射后的 `categoryId`;schema `@map` 只影响 SQL 列名,Prisma Client 的唯一输入类型永远用 model 字段名。
|
||||||
|
- **跨套件分页断言要圈定夹具**:真实库上测"列表排序"时全库数据可能远超 pageSize,夹具根本进不了第一页;给夹具商品名加唯一前缀 + `keyword` 过滤圈定,断言既稳定又能看到完整顺序。
|
||||||
|
- **"绝对排序键 + 子集过滤"模式**:需要"任意筛选组合下顺序都正确"时,给每条数据算好一组绝对排序键(如 国家→二级→款→priority,缺失沉底),筛选只做子集过滤不做特殊排序分支——比每个筛选组合写一套 orderBy 逻辑可靠得多。
|
||||||
|
- **"代表行选取收敛为单一方法"**:多端点共享同一实体视图(如族化商品对外的代表行)时,代表行选取规则必须收敛为一个私有方法供所有端点复用,禁止各端点各自 `goods[0]`——否则排序参数不同会导致 goodName/主图等字段在列表与详情间漂移。
|
||||||
|
|||||||
@@ -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 # 开发
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^21.0.0",
|
||||||
"unplugin-vue-components": "^32.1.0",
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
"vue-tsc": "^3.2.8"
|
"vue-tsc": "^3.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -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`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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
|
||||||
|
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 },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ declare module 'vue' {
|
|||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ export interface Good {
|
|||||||
position?: Position
|
position?: Position
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
mergedOriginGoods?: MergedOriginGoodSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergedOriginGoodSummary {
|
||||||
|
id: string
|
||||||
|
sdsGoodId: string
|
||||||
|
goodName: string | null
|
||||||
|
goodImage: string | null
|
||||||
|
goodPrice: string | null
|
||||||
|
hasDetail: boolean
|
||||||
|
variantCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OriginGoodDetail {
|
export interface OriginGoodDetail {
|
||||||
@@ -125,6 +136,7 @@ export interface CreateGoodRequest {
|
|||||||
tagIds?: number[]
|
tagIds?: number[]
|
||||||
positionId?: number
|
positionId?: number
|
||||||
goodPriority?: number
|
goodPriority?: number
|
||||||
|
mergedOriginGoodIds?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCustomGoodRequest {
|
export interface CreateCustomGoodRequest {
|
||||||
@@ -157,6 +169,7 @@ export interface UpdateGoodRequest {
|
|||||||
tagIds?: number[]
|
tagIds?: number[]
|
||||||
positionId?: number | null
|
positionId?: number | null
|
||||||
goodPriority?: number
|
goodPriority?: number
|
||||||
|
mergedOriginGoodIds?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchCreateItem {
|
export interface BatchCreateItem {
|
||||||
@@ -347,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
|
||||||
@@ -365,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 {
|
||||||
@@ -383,6 +401,107 @@ 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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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 为空 → 回退名称 3 段规则', () => {
|
||||||
|
const a = node(null, '美国(包邮)T恤-DG001-单面印花')
|
||||||
|
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG001-单面印花-某仓'))).toBe(true)
|
||||||
|
expect(sameFamily(a, node('12', '美国(包邮)T恤-DG001-单面印花'))).toBe(true)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { sameOriginGroup } from './origin-name'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两个原产品节点是否属于同一产品族:
|
||||||
|
* 优先用服务端返回的 familyId 精确匹配;无族归属时回退名称 3 段规则
|
||||||
|
* (老数据/未回填场景兜底)。
|
||||||
|
*/
|
||||||
|
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 sameOriginGroup(a.goodName, b.goodName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
cleanLinkName,
|
||||||
|
deriveLinkTagNames,
|
||||||
|
linkDims,
|
||||||
|
parseLinkName,
|
||||||
|
sameOriginGroup,
|
||||||
|
truncateToProcess,
|
||||||
|
} from './origin-name';
|
||||||
|
|
||||||
|
describe('truncateToProcess', () => {
|
||||||
|
it('keeps first 3 dash segments (drops warehouse)', () => {
|
||||||
|
expect(truncateToProcess('美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓'))
|
||||||
|
.toBe('美国(包邮)180g纯棉T恤成人款-DG001-单面印花');
|
||||||
|
});
|
||||||
|
it('is identity for 3-segment names', () => {
|
||||||
|
expect(truncateToProcess('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面'))
|
||||||
|
.toBe('美国(不包邮)230g水洗炒雪花T恤-KRHM003-直喷双面');
|
||||||
|
});
|
||||||
|
it('handles malformed names without country prefix', () => {
|
||||||
|
expect(truncateToProcess('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花'))
|
||||||
|
.toBe('(不包邮)230g水洗炒雪花T恤-FRTM001-双面印花');
|
||||||
|
});
|
||||||
|
it('returns empty for null/empty', () => {
|
||||||
|
expect(truncateToProcess(null)).toBe('');
|
||||||
|
expect(truncateToProcess('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sameOriginGroup', () => {
|
||||||
|
it('matches same product across warehouses', () => {
|
||||||
|
expect(sameOriginGroup(
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美西洛杉矶一仓',
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花-美中亚特兰大仓',
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
it('does not match different SKU', () => {
|
||||||
|
expect(sameOriginGroup(
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG001-单面印花',
|
||||||
|
'美国(包邮)180g纯棉T恤成人款-DG002-单面印花',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
it('does not match different country', () => {
|
||||||
|
expect(sameOriginGroup(
|
||||||
|
'美国(包邮)T恤-DG001-单面印花',
|
||||||
|
'德国(不包邮)T恤-DG001-单面印花',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
it('does not match empty names', () => {
|
||||||
|
expect(sameOriginGroup(null, 'x-y-z')).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('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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* 原产品名匹配规则(见 data-cleaning/README.md 命名调查):
|
||||||
|
* `国家(物流备注)品名-SKU-工艺位置[-仓库名]`
|
||||||
|
* 截断到「工艺位置」= 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。
|
||||||
|
* 国家前缀在第 1 段内,天然参与比较(不同国家不合并)。
|
||||||
|
*/
|
||||||
|
const KEEP_SEGMENTS = 3;
|
||||||
|
|
||||||
|
export function truncateToProcess(name: string | null | undefined): string {
|
||||||
|
if (!name) return '';
|
||||||
|
return name.split('-').slice(0, KEEP_SEGMENTS).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameOriginGroup(
|
||||||
|
a: string | null | undefined,
|
||||||
|
b: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const ka = truncateToProcess(a);
|
||||||
|
return ka !== '' && ka === truncateToProcess(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 链接名结构化解析结果:品名 + 型号(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 closeIdx = Math.max(head.lastIndexOf(')'), head.lastIndexOf(')'));
|
||||||
|
if (closeIdx <= 0 || closeIdx === head.length - 1) return null;
|
||||||
|
const productName = head.slice(closeIdx + 1).trim();
|
||||||
|
if (!productName) return null;
|
||||||
|
const skuCode = segs[1] || null;
|
||||||
|
return { productName, skuCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名 */
|
||||||
|
export function cleanLinkName(name: string | null | undefined): string {
|
||||||
|
if (!name) return '';
|
||||||
|
const parsed = parseLinkName(name);
|
||||||
|
if (!parsed) return name;
|
||||||
|
return parsed.skuCode ? `${parsed.productName} ${parsed.skuCode}` : parsed.productName;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -52,7 +52,6 @@ const dialogLoading = ref(false)
|
|||||||
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
const dialogForm = reactive<CreateCategoryRequest & { id?: string }>({
|
||||||
categoryName: '',
|
categoryName: '',
|
||||||
categoryIcon: '',
|
categoryIcon: '',
|
||||||
parentCategoryId: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
@@ -146,7 +145,7 @@ onMounted(fetchTree)
|
|||||||
>
|
>
|
||||||
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
<el-table-column prop="categoryName" label="名称" min-width="220" />
|
||||||
<el-table-column label="图标" width="100">
|
<el-table-column label="图标" width="100">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: any">
|
||||||
<el-image
|
<el-image
|
||||||
v-if="row.categoryIcon"
|
v-if="row.categoryIcon"
|
||||||
:src="row.categoryIcon"
|
:src="row.categoryIcon"
|
||||||
@@ -158,17 +157,17 @@ onMounted(fetchTree)
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="父级">
|
<el-table-column label="父级">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: any">
|
||||||
{{ row.parent?.categoryName || '-' }}
|
{{ row.parent?.categoryName || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="子级数" width="100">
|
<el-table-column label="子级数" width="100">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: any">
|
||||||
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
{{ row._count?.children ?? row.children?.length ?? 0 }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<template #default="{ row }: { row: CategoryTree }">
|
<template #default="{ row }: any">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -23,7 +23,7 @@ const filter = reactive<Required<CountryFilter>>({
|
|||||||
async function fetchList() {
|
async function fetchList() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await countriesApi.getCountriesList(filter)
|
await countriesApi.getCountriesList(filter)
|
||||||
const data = await countriesApi.getCountriesList(filter) as any
|
const data = await countriesApi.getCountriesList(filter) as any
|
||||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
list.value = arr
|
list.value = arr
|
||||||
@@ -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,27 +177,30 @@ 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 }: { row: Country }">
|
v-for="(row, index) in list"
|
||||||
|
:key="row.id"
|
||||||
|
class="country-row"
|
||||||
|
:class="{ 'is-dragging': dragIndex === index }"
|
||||||
|
draggable="true"
|
||||||
|
title="拖拽调整顺序,松开即保存"
|
||||||
|
@dragstart="onRowDragStart(index)"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="onRowDrop(index)"
|
||||||
|
@dragend="dragIndex = null"
|
||||||
|
>
|
||||||
|
<el-icon class="drag-handle"><Rank /></el-icon>
|
||||||
<el-image
|
<el-image
|
||||||
v-if="row.countryIcon"
|
v-if="row.countryIcon"
|
||||||
:src="row.countryIcon"
|
:src="row.countryIcon"
|
||||||
:preview-src-list="[row.countryIcon]"
|
:preview-src-list="[row.countryIcon]"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
style="width: 32px; height: 32px; border-radius: 4px;"
|
class="row-icon"
|
||||||
/>
|
/>
|
||||||
<span v-else>-</span>
|
<span v-else class="row-icon row-icon-placeholder">-</span>
|
||||||
</template>
|
<span class="row-name">{{ row.countryName }}</span>
|
||||||
</el-table-column>
|
<span class="row-time">{{ new Date(row.createdAt).toLocaleString() }}</span>
|
||||||
<el-table-column prop="countryName" label="名称" min-width="200" />
|
|
||||||
<el-table-column label="创建时间" width="180">
|
|
||||||
<template #default="{ row }: { row: Country }">
|
|
||||||
{{ new Date(row.createdAt).toLocaleString() }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
|
||||||
<template #default="{ row }: { row: Country }">
|
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
@@ -176,23 +211,9 @@ onMounted(fetchList)
|
|||||||
<span>删除</span>
|
<span>删除</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</li>
|
||||||
</el-table-column>
|
<li v-if="!loading && !list.length" class="country-empty">暂无国家</li>
|
||||||
<template #empty>
|
</ul>
|
||||||
<el-empty description="暂无国家" />
|
|
||||||
</template>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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,927 @@
|
|||||||
|
<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 { 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 {
|
||||||
|
await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
|
||||||
|
ElMessage.success('标签已保存')
|
||||||
|
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>
|
||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
UpdatePositionRequest,
|
UpdatePositionRequest,
|
||||||
PositionFilter,
|
PositionFilter,
|
||||||
Country,
|
Country,
|
||||||
Category,
|
|
||||||
CategoryTree,
|
CategoryTree,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { positionsApi } from '@/api/positions'
|
import { positionsApi } from '@/api/positions'
|
||||||
@@ -41,7 +40,7 @@ async function loadLookups() {
|
|||||||
async function fetchList() {
|
async function fetchList() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await positionsApi.getPositionsList(filter)
|
await positionsApi.getPositionsList(filter)
|
||||||
const data = await positionsApi.getPositionsList(filter) as any
|
const data = await positionsApi.getPositionsList(filter) as any
|
||||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
list.value = arr
|
list.value = arr
|
||||||
@@ -88,8 +87,6 @@ const dialogLoading = ref(false)
|
|||||||
|
|
||||||
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
const dialogForm = reactive<CreatePositionRequest & { id?: string }>({
|
||||||
indexVal: 0,
|
indexVal: 0,
|
||||||
countryId: '',
|
|
||||||
categoryId: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
@@ -218,17 +215,17 @@ onMounted(async () => {
|
|||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
<el-table-column label="排序值" prop="indexVal" width="100" sortable />
|
||||||
<el-table-column label="国家" min-width="160">
|
<el-table-column label="国家" min-width="160">
|
||||||
<template #default="{ row }: { row: Position }">
|
<template #default="{ row }: any">
|
||||||
{{ row.country?.countryName || '-' }}
|
{{ row.country?.countryName || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="分类" min-width="200">
|
<el-table-column label="分类" min-width="200">
|
||||||
<template #default="{ row }: { row: Position }">
|
<template #default="{ row }: any">
|
||||||
{{ getCategoryName(row) }}
|
{{ getCategoryName(row) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<template #default="{ row }: { row: Position }">
|
<template #default="{ row }: any">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const filter = reactive<Required<TagFilter>>({
|
|||||||
async function fetchList() {
|
async function fetchList() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await tagsApi.getTagsList(filter)
|
await tagsApi.getTagsList(filter)
|
||||||
const data = await tagsApi.getTagsList(filter) as any
|
const data = await tagsApi.getTagsList(filter) as any
|
||||||
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
const arr = Array.isArray(data) ? data : (data.items ?? [])
|
||||||
list.value = arr
|
list.value = arr
|
||||||
@@ -62,7 +62,6 @@ const dialogForm = reactive<CreateTagRequest & { id?: string; tagGroupId?: numbe
|
|||||||
tagColor: '#ff6800',
|
tagColor: '#ff6800',
|
||||||
tagFontColor: '#ffffff',
|
tagFontColor: '#ffffff',
|
||||||
timing: '',
|
timing: '',
|
||||||
tagGroupId: null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dialogRules = {
|
const dialogRules = {
|
||||||
@@ -171,7 +170,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
<el-table-column label="预览" width="120">
|
<el-table-column label="预览" width="120">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: any">
|
||||||
<span
|
<span
|
||||||
v-if="row.tagColor"
|
v-if="row.tagColor"
|
||||||
class="tag-preview"
|
class="tag-preview"
|
||||||
@@ -182,7 +181,7 @@ onMounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="tagName" label="名称" min-width="200" />
|
<el-table-column prop="tagName" label="名称" min-width="200" />
|
||||||
<el-table-column label="背景色" width="140">
|
<el-table-column label="背景色" width="140">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: any">
|
||||||
<div class="color-cell">
|
<div class="color-cell">
|
||||||
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
<span class="color-swatch" :style="{ background: row.tagColor || '#d1d5db' }" />
|
||||||
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
<span class="color-hex">{{ row.tagColor || '-' }}</span>
|
||||||
@@ -190,7 +189,7 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="字体色" width="140">
|
<el-table-column label="字体色" width="140">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: any">
|
||||||
<div class="color-cell">
|
<div class="color-cell">
|
||||||
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
<span class="color-swatch" :style="{ background: row.tagFontColor || '#d1d5db' }" />
|
||||||
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
<span class="color-hex">{{ row.tagFontColor || '-' }}</span>
|
||||||
@@ -198,14 +197,14 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="所属分组" min-width="140">
|
<el-table-column label="所属分组" min-width="140">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: any">
|
||||||
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
<span v-if="row.tagGroup">{{ row.tagGroup.groupName }}</span>
|
||||||
<span v-else style="color: #999;">未分组</span>
|
<span v-else style="color: #999;">未分组</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="timing" label="定时" min-width="120" />
|
<el-table-column prop="timing" label="定时" min-width="120" />
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
<template #default="{ row }: { row: Tag }">
|
<template #default="{ row }: any">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
<el-button size="small" type="primary" plain @click="openEditDialog(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -22,7 +22,10 @@
|
|||||||
"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",
|
||||||
|
"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,35 @@
|
|||||||
|
/**
|
||||||
|
* 整理脚本(幂等 / 显式人工触发)——解析去运行时化后的唯一解析入口:
|
||||||
|
* 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 { 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);
|
||||||
|
const families = new ProductFamiliesService(prisma, recompute);
|
||||||
|
const organize = new OrganizeService(prisma, recompute, families);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "good_origin_goods" (
|
||||||
|
"good_id" BIGINT NOT NULL,
|
||||||
|
"origin_good_id" BIGINT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "good_origin_goods_pkey" PRIMARY KEY ("good_id","origin_good_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "good_origin_goods_origin_good_id_idx" ON "good_origin_goods"("origin_good_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_good_id_fkey" FOREIGN KEY ("good_id") REFERENCES "goods"("good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "good_origin_goods" ADD CONSTRAINT "good_origin_goods_origin_good_id_fkey" FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id") ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 全量族重算(一次性维护脚本):
|
||||||
|
* 逐族调用 FamilyRecomputeService.recomputeFamily,刷新物化字段
|
||||||
|
* (并集尺码表/包装规则 + 五维价格矩阵,含选项组词表序排序)。
|
||||||
|
* autoManaged=false 的族内部只置 stale,不会覆盖人工物化字段。
|
||||||
|
*
|
||||||
|
* 运行:pnpm --filter @inkreach/api recompute:families
|
||||||
|
*/
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -30,18 +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[]
|
||||||
|
// 链接级标签:自动派生(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")
|
||||||
@@ -112,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")
|
||||||
}
|
}
|
||||||
@@ -128,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)
|
||||||
|
|
||||||
@@ -135,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")
|
||||||
@@ -169,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])
|
||||||
@@ -199,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")
|
||||||
@@ -210,13 +247,16 @@ 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)
|
||||||
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
position Position? @relation(fields: [positionId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
goodTags GoodTag[]
|
goodTags GoodTag[]
|
||||||
|
mergedOriginGoods GoodOriginGood[]
|
||||||
|
|
||||||
@@index([originGoodId])
|
@@index([originGoodId])
|
||||||
|
@@index([familyId])
|
||||||
@@index([countryId])
|
@@index([countryId])
|
||||||
@@index([categoryId])
|
@@index([categoryId])
|
||||||
@@index([tagId])
|
@@index([tagId])
|
||||||
@@ -241,6 +281,73 @@ model GoodTag {
|
|||||||
@@map("good_tags")
|
@@map("good_tags")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Good-OriginGood Junction (merged secondary sources, M:N) ----------
|
||||||
|
// Primary source stays on goods.origin_good_id and is NOT stored here.
|
||||||
|
model GoodOriginGood {
|
||||||
|
goodId BigInt @map("good_id")
|
||||||
|
originGoodId BigInt @map("origin_good_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
good Good @relation(fields: [goodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@id([goodId, originGoodId])
|
||||||
|
@@index([originGoodId])
|
||||||
|
@@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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.9 KiB |
@@ -11,6 +11,7 @@ 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';
|
||||||
@@ -37,6 +38,7 @@ import { UploadModule } from './upload/upload.module';
|
|||||||
TagGroupsModule,
|
TagGroupsModule,
|
||||||
PositionsModule,
|
PositionsModule,
|
||||||
OriginGoodsModule,
|
OriginGoodsModule,
|
||||||
|
ProductFamiliesModule,
|
||||||
GoodsModule,
|
GoodsModule,
|
||||||
SyncModule,
|
SyncModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -112,4 +112,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, B(id 序与 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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,13 +8,29 @@ import {
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.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) {}
|
||||||
|
|
||||||
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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return this.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: bigint) {
|
async findOne(id: bigint) {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@ export class BatchCreateItemDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
originGoodId!: number;
|
originGoodId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
|
mergedOriginGoodIds?: number[];
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export class CreateGoodDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
originGoodId!: number;
|
originGoodId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
|
mergedOriginGoodIds?: number[];
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,32 @@ 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<{
|
||||||
|
originGood: {
|
||||||
|
id: bigint;
|
||||||
|
sdsGoodId: string;
|
||||||
|
source: 'SDS' | 'CUSTOM';
|
||||||
|
goodName: string | null;
|
||||||
|
goodImage: string | null;
|
||||||
|
goodPrice: unknown;
|
||||||
|
detail?: { syncedAt: Date } | Record<string, unknown> | null;
|
||||||
|
variants?: Array<{ [key: string]: unknown }>;
|
||||||
|
_count?: { variants: number };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergedOriginGoodSummary {
|
||||||
|
id: string;
|
||||||
|
sdsGoodId: string;
|
||||||
|
source: 'SDS' | 'CUSTOM';
|
||||||
|
isCustom: boolean;
|
||||||
|
goodName: string | null;
|
||||||
|
goodImage: string | null;
|
||||||
|
goodPrice: string | null;
|
||||||
|
hasDetail: boolean;
|
||||||
|
variantCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GoodDto {
|
export class GoodDto {
|
||||||
@@ -81,6 +107,9 @@ export class GoodDto {
|
|||||||
@ApiProperty({ required: false, type: Array })
|
@ApiProperty({ required: false, type: Array })
|
||||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: Array })
|
||||||
|
mergedOriginGoods!: MergedOriginGoodSummary[];
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
position?: { id: string; indexVal: number } | null;
|
position?: { id: string; indexVal: number } | null;
|
||||||
|
|
||||||
@@ -99,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(
|
||||||
@@ -145,8 +175,24 @@ 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) => ({
|
||||||
|
id: m.originGood.id.toString(),
|
||||||
|
sdsGoodId: m.originGood.sdsGoodId,
|
||||||
|
source: m.originGood.source,
|
||||||
|
isCustom: m.originGood.source === 'CUSTOM',
|
||||||
|
goodName: m.originGood.goodName,
|
||||||
|
goodImage: m.originGood.goodImage,
|
||||||
|
goodPrice:
|
||||||
|
m.originGood.goodPrice === null || m.originGood.goodPrice === undefined
|
||||||
|
? null
|
||||||
|
: (m.originGood.goodPrice as { toString(): string }).toString(),
|
||||||
|
hasDetail: Boolean(m.originGood.detail),
|
||||||
|
variantCount:
|
||||||
|
m.originGood._count?.variants ?? m.originGood.variants?.length ?? 0,
|
||||||
|
})),
|
||||||
position: rel.position
|
position: rel.position
|
||||||
? {
|
? {
|
||||||
id: rel.position.id.toString(),
|
id: rel.position.id.toString(),
|
||||||
@@ -172,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,
|
||||||
};
|
};
|
||||||
@@ -194,16 +248,39 @@ export class GoodDetailDto extends GoodDto {
|
|||||||
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
||||||
const base = GoodDto.from(good, rel);
|
const base = GoodDto.from(good, rel);
|
||||||
const detail = rel.originGood?.detail;
|
const detail = rel.originGood?.detail;
|
||||||
return {
|
const toAnnotated = (
|
||||||
...base,
|
variant: Record<string, unknown>,
|
||||||
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
originGoodId: string,
|
||||||
variants: (rel.originGood?.variants ?? []).map((variant) => ({
|
originGoodName: string | null,
|
||||||
|
) => ({
|
||||||
...variant,
|
...variant,
|
||||||
price:
|
price:
|
||||||
variant.price === null || variant.price === undefined
|
variant.price === null || variant.price === undefined
|
||||||
? null
|
? null
|
||||||
: (variant.price as { toString(): string }).toString(),
|
: (variant.price as unknown as { toString(): string }).toString(),
|
||||||
})),
|
originGoodId,
|
||||||
|
originGoodName,
|
||||||
|
});
|
||||||
|
const primaryId = good.originGoodId.toString();
|
||||||
|
const primaryName = rel.originGood?.goodName ?? null;
|
||||||
|
const mergedVariants = [
|
||||||
|
...(rel.originGood?.variants ?? []).map((variant) =>
|
||||||
|
toAnnotated(variant as Record<string, unknown>, primaryId, primaryName),
|
||||||
|
),
|
||||||
|
...(rel.mergedOriginGoods ?? []).flatMap((m) =>
|
||||||
|
(m.originGood.variants ?? []).map((variant) =>
|
||||||
|
toAnnotated(
|
||||||
|
variant,
|
||||||
|
m.originGood.id.toString(),
|
||||||
|
m.originGood.goodName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
||||||
|
variants: mergedVariants,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class QueryGoodDto {
|
|||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(200)
|
@Max(1000)
|
||||||
pageSize: number = 20;
|
pageSize: number = 20;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export class UpdateGoodDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
originGoodId?: number;
|
originGoodId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID 全量覆盖,不含主源' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
|
mergedOriginGoodIds?: number[];
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -54,4 +61,15 @@ export class UpdateGoodDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
goodImage?: string | null;
|
goodImage?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
nullable: true,
|
||||||
|
type: Number,
|
||||||
|
description: '所属产品族 id(null = 脱离族)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
familyId?: number | null;
|
||||||
}
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { GoodsService } from './goods.service';
|
import { GoodsService } 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;
|
||||||
@@ -30,6 +31,18 @@ describe('GoodsService', () => {
|
|||||||
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 +274,173 @@ 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('merged origin goods', () => {
|
||||||
|
it('creates a good with merged origin goods and reads them back', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} merged`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[2]), Number(originGoodIds[3])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
expect(created.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||||||
|
[originGoodIds[2].toString(), originGoodIds[3].toString()].sort(),
|
||||||
|
);
|
||||||
|
const fetched = await service.findOne(BigInt(created.id));
|
||||||
|
expect(fetched.mergedOriginGoods.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mergedOriginGoodIds containing the primary', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
goodName: `Goods Test ${stamp} bad-primary`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[1])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mergedOriginGoodIds that do not exist', async () => {
|
||||||
|
await expect(
|
||||||
|
service.create({
|
||||||
|
goodName: `Goods Test ${stamp} bad-missing`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [999999999],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces merged origin goods on update', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} replace`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
const updated = await service.update(BigInt(created.id), {
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[3]), Number(originGoodIds[4])],
|
||||||
|
});
|
||||||
|
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||||||
|
[originGoodIds[3].toString(), originGoodIds[4].toString()].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves old primary into merged list when switching primary', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} switch`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
const updated = await service.update(BigInt(created.id), {
|
||||||
|
originGoodId: Number(originGoodIds[2]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[1]), Number(originGoodIds[3])],
|
||||||
|
});
|
||||||
|
expect(updated.originGoodId).toBe(originGoodIds[2].toString());
|
||||||
|
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||||||
|
[originGoodIds[1].toString(), originGoodIds[3].toString()].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cascades merged rows on good removal', async () => {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} cascade`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
await service.remove(BigInt(created.id));
|
||||||
|
const rows = await prisma.goodOriginGood.count({
|
||||||
|
where: { goodId: BigInt(created.id) },
|
||||||
|
});
|
||||||
|
expect(rows).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns merged variants with source annotation in detail', async () => {
|
||||||
|
const v1 = await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: originGoodIds[1],
|
||||||
|
sdsVariantId: `mv-pri-${stamp}`,
|
||||||
|
sku: `MV-PRI-${stamp}`,
|
||||||
|
colorName: '黑色',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const v2 = await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: originGoodIds[2],
|
||||||
|
sdsVariantId: `mv-sec-${stamp}`,
|
||||||
|
sku: `MV-SEC-${stamp}`,
|
||||||
|
colorName: '白色',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const created = await service.create({
|
||||||
|
goodName: `Goods Test ${stamp} variants`,
|
||||||
|
originGoodId: Number(originGoodIds[1]),
|
||||||
|
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||||
|
countryId: Number(countryId),
|
||||||
|
categoryId: Number(categoryId),
|
||||||
|
});
|
||||||
|
const detail = await service.findOne(BigInt(created.id));
|
||||||
|
const sources = new Set(
|
||||||
|
detail.variants.map((v) => v['originGoodId'] as string),
|
||||||
|
);
|
||||||
|
expect(sources.has(originGoodIds[1].toString())).toBe(true);
|
||||||
|
expect(sources.has(originGoodIds[2].toString())).toBe(true);
|
||||||
|
expect(detail.variants).toHaveLength(2);
|
||||||
|
expect(detail.mergedOriginGoods.find((m) => m.id === originGoodIds[2].toString())?.variantCount).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await prisma.originGoodVariant.delete({ where: { id: v1.id } });
|
||||||
|
await prisma.originGoodVariant.delete({ where: { id: v2.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('throws NotFoundException for unknown id', async () => {
|
it('throws NotFoundException for unknown id', async () => {
|
||||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
@@ -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,8 @@ 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 { isAutoTagGroupName } from '../product-families/auto-tag-rules';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import {
|
import {
|
||||||
CreateCustomGoodDto,
|
CreateCustomGoodDto,
|
||||||
@@ -30,16 +33,51 @@ 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 } },
|
||||||
|
mergedOriginGoods: {
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
include: {
|
||||||
|
originGood: {
|
||||||
|
include: {
|
||||||
|
detail: true,
|
||||||
|
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
_count: { select: { variants: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
@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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||||
@@ -75,6 +113,7 @@ export class GoodsService {
|
|||||||
position: g.position,
|
position: g.position,
|
||||||
originGood: g.originGood,
|
originGood: g.originGood,
|
||||||
goodTags: g.goodTags,
|
goodTags: g.goodTags,
|
||||||
|
mergedOriginGoods: g.mergedOriginGoods,
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
@@ -95,31 +134,52 @@ export class GoodsService {
|
|||||||
position: good.position,
|
position: good.position,
|
||||||
originGood: good.originGood,
|
originGood: good.originGood,
|
||||||
goodTags: good.goodTags,
|
goodTags: good.goodTags,
|
||||||
|
mergedOriginGoods: good.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||||
await this.ensureReferences(dto);
|
await this.ensureReferences(dto);
|
||||||
|
const mergedIds = this.dedupeMergedIds(
|
||||||
|
BigInt(dto.originGoodId),
|
||||||
|
dto.mergedOriginGoodIds,
|
||||||
|
);
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
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: normalizeGoodName(dto.goodName),
|
||||||
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),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (mergedIds.length > 0) {
|
||||||
|
await tx.goodOriginGood.createMany({
|
||||||
|
data: mergedIds.map((originGoodId) => ({
|
||||||
|
goodId: created.id,
|
||||||
|
originGoodId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
const result = await tx.good.findUniqueOrThrow({
|
const result = await tx.good.findUniqueOrThrow({
|
||||||
where: { id: created.id },
|
where: { id: created.id },
|
||||||
include: GOOD_INCLUDE,
|
include: GOOD_INCLUDE,
|
||||||
@@ -131,8 +191,13 @@ export class GoodsService {
|
|||||||
position: result.position,
|
position: result.position,
|
||||||
originGood: result.originGood,
|
originGood: result.originGood,
|
||||||
goodTags: result.goodTags,
|
goodTags: result.goodTags,
|
||||||
|
mergedOriginGoods: result.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (result.originGood) {
|
||||||
|
// 建商品后把链接有效标签镜像到该商品(纯聚合,不派生——派生在整理流程)
|
||||||
|
await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId));
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
result.originGood?.source === 'SDS' &&
|
result.originGood?.source === 'SDS' &&
|
||||||
result.originGood.sdsGoodId &&
|
result.originGood.sdsGoodId &&
|
||||||
@@ -148,6 +213,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({
|
||||||
@@ -157,6 +230,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 ?? {},
|
||||||
@@ -170,11 +252,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,
|
||||||
},
|
},
|
||||||
@@ -189,6 +272,7 @@ export class GoodsService {
|
|||||||
}
|
}
|
||||||
return good.id;
|
return good.id;
|
||||||
});
|
});
|
||||||
|
if (family) this.familyRecompute.enqueue(family.id);
|
||||||
return this.findOne(goodId);
|
return this.findOne(goodId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +319,9 @@ 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 });
|
||||||
@@ -246,8 +332,19 @@ export class GoodsService {
|
|||||||
|
|
||||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
|
let mergedIds: bigint[] | undefined;
|
||||||
|
if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) {
|
||||||
|
const current = await this.prisma.good.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: { originGoodId: true },
|
||||||
|
});
|
||||||
|
const primaryId =
|
||||||
|
dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId;
|
||||||
|
mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds);
|
||||||
|
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 = normalizeGoodName(dto.goodName);
|
||||||
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) } };
|
||||||
@@ -268,24 +365,65 @@ 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),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (mergedIds !== undefined) {
|
||||||
|
await tx.goodOriginGood.deleteMany({ where: { goodId: id } });
|
||||||
|
if (mergedIds.length > 0) {
|
||||||
|
await tx.goodOriginGood.createMany({
|
||||||
|
data: mergedIds.map((originGoodId) => ({
|
||||||
|
goodId: id,
|
||||||
|
originGoodId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const updated = await tx.good.update({
|
const updated = await tx.good.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@@ -298,8 +436,17 @@ export class GoodsService {
|
|||||||
position: updated.position,
|
position: updated.position,
|
||||||
originGood: updated.originGood,
|
originGood: updated.originGood,
|
||||||
goodTags: updated.goodTags,
|
goodTags: updated.goodTags,
|
||||||
|
mergedOriginGoods: updated.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if (result.originGood) {
|
||||||
|
// 更新后把链接有效标签镜像到该商品(纯聚合,不派生)
|
||||||
|
await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId));
|
||||||
|
}
|
||||||
|
if (familyIdForTags) {
|
||||||
|
// 归族联动后重算矩阵(成员变化 → 自动重算,结构化聚合)
|
||||||
|
await this.familyRecompute.recomputeFamily(familyIdForTags);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
result.originGood?.source === 'SDS' &&
|
result.originGood?.source === 'SDS' &&
|
||||||
result.originGood.sdsGoodId &&
|
result.originGood.sdsGoodId &&
|
||||||
@@ -374,6 +521,7 @@ export class GoodsService {
|
|||||||
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
|
||||||
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),
|
||||||
@@ -388,6 +536,24 @@ export class GoodsService {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds);
|
||||||
|
if (itemMerged.length > 0) {
|
||||||
|
const existRows = await tx.originGood.findMany({
|
||||||
|
where: { id: { in: itemMerged } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existRows.length !== itemMerged.length) {
|
||||||
|
const found = new Set(existRows.map((r) => r.id.toString()));
|
||||||
|
const missing = itemMerged.find((mid) => !found.has(mid.toString()));
|
||||||
|
throw new BadRequestException(`Origin good ${missing} not found`);
|
||||||
|
}
|
||||||
|
await tx.goodOriginGood.createMany({
|
||||||
|
data: itemMerged.map((originGoodId) => ({
|
||||||
|
goodId: row.id,
|
||||||
|
originGoodId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
const result = await tx.good.findUniqueOrThrow({
|
const result = await tx.good.findUniqueOrThrow({
|
||||||
where: { id: row.id },
|
where: { id: row.id },
|
||||||
include: GOOD_INCLUDE,
|
include: GOOD_INCLUDE,
|
||||||
@@ -399,6 +565,7 @@ export class GoodsService {
|
|||||||
position: result.position,
|
position: result.position,
|
||||||
originGood: result.originGood,
|
originGood: result.originGood,
|
||||||
goodTags: result.goodTags,
|
goodTags: result.goodTags,
|
||||||
|
mergedOriginGoods: result.mergedOriginGoods,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
@@ -446,6 +613,31 @@ export class GoodsService {
|
|||||||
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dedupe merged ids and reject any that equals the primary source. */
|
||||||
|
private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] {
|
||||||
|
if (!ids || ids.length === 0) return [];
|
||||||
|
const unique = [...new Set(ids.map((id) => BigInt(id)))];
|
||||||
|
if (unique.includes(primaryId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'mergedOriginGoodIds 不能包含主源 originGoodId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureMergedOriginGoods(ids: bigint[]) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const rows = await this.prisma.originGood.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (rows.length !== ids.length) {
|
||||||
|
const found = new Set(rows.map((r) => r.id.toString()));
|
||||||
|
const missing = ids.find((id) => !found.has(id.toString()));
|
||||||
|
throw new BadRequestException(`Origin good ${missing} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureCountry(id: number) {
|
private async ensureCountry(id: number) {
|
||||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||||
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
||||||
@@ -456,8 +648,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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-requests:Swagger 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,5 +1,8 @@
|
|||||||
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', () => {
|
||||||
@@ -10,7 +13,7 @@ describe('OriginGoodsService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [OriginGoodsService, PrismaService],
|
providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(OriginGoodsService);
|
service = moduleRef.get(OriginGoodsService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
@@ -39,6 +42,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,
|
||||||
@@ -77,4 +94,71 @@ describe('OriginGoodsService', () => {
|
|||||||
expect(result.total).toBe(0);
|
expect(result.total).toBe(0);
|
||||||
expect(result.items.length).toBe(0);
|
expect(result.items.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getTree merged references', () => {
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
function findOgNode(
|
||||||
|
treeResponse: { tree: any[] },
|
||||||
|
ogId: string,
|
||||||
|
): { configuredCount: number; configuredCountries: string[] } {
|
||||||
|
let found: { configuredCount: number; configuredCountries: string[] } | null = null;
|
||||||
|
const walk = (nodes: any[]) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
const hit = (n.originGoods ?? []).find((o: any) => o.id === ogId);
|
||||||
|
if (hit) {
|
||||||
|
found = hit;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (n.children?.length) walk(n.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(treeResponse.tree);
|
||||||
|
if (!found) throw new Error(`og node ${ogId} not found in tree`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('counts secondary references as configured', async () => {
|
||||||
|
const sdsCat = `tree-cat-${stamp}`;
|
||||||
|
await prisma.originGood.createMany({
|
||||||
|
data: [
|
||||||
|
{ sdsGoodId: `tree-a-${stamp}`, goodName: `Tree A ${stamp}`, sdsCategoryId: sdsCat },
|
||||||
|
{ sdsGoodId: `tree-b-${stamp}`, goodName: `Tree B ${stamp}`, sdsCategoryId: sdsCat },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
createdSds.push(`tree-a-${stamp}`, `tree-b-${stamp}`);
|
||||||
|
const originA = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: `tree-a-${stamp}` } });
|
||||||
|
const originB = await prisma.originGood.findUniqueOrThrow({ where: { sdsGoodId: `tree-b-${stamp}` } });
|
||||||
|
|
||||||
|
const cat = await prisma.category.create({
|
||||||
|
data: { categoryName: `Tree Cat ${stamp}`, sdsCategoryId: sdsCat },
|
||||||
|
});
|
||||||
|
const country = await prisma.country.create({
|
||||||
|
data: { countryName: `Tree Country ${stamp}` },
|
||||||
|
});
|
||||||
|
const good = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: `Tree Good ${stamp}`,
|
||||||
|
originGoodId: originA.id,
|
||||||
|
countryId: country.id,
|
||||||
|
categoryId: cat.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.goodOriginGood.create({
|
||||||
|
data: { goodId: good.id, originGoodId: originB.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tree = await service.getTree();
|
||||||
|
const nodeB = findOgNode(tree, originB.id.toString());
|
||||||
|
expect(nodeB.configuredCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(nodeB.configuredCountries).toContain(`Tree Country ${stamp}`);
|
||||||
|
} 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.country.delete({ where: { id: country.id } }).catch(() => undefined);
|
||||||
|
await prisma.category.delete({ where: { id: cat.id } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
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 { 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[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedOriginGoods {
|
export interface PaginatedOriginGoods {
|
||||||
items: Array<{
|
items: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,6 +61,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 +87,146 @@ 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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 链接当前标签(含 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 行(自动同步永不覆盖),
|
||||||
|
* 并把有效标签镜像到该链接名下的商品。
|
||||||
|
*/
|
||||||
|
async updateTags(id: bigint, tagIds: number[]): Promise<OriginGoodTagsResult> {
|
||||||
|
const og = await this.prisma.originGood.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
||||||
|
const 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('存在无效标签');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
// 人工改标签 → 归因维度可能变化,自动重算族矩阵(有族才重算)
|
||||||
|
const ogFull = await this.prisma.originGood.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { familyId: true },
|
||||||
|
});
|
||||||
|
if (ogFull?.familyId) await this.familyRecompute.recomputeFamily(ogFull.familyId);
|
||||||
|
return this.getTags(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复自动:清掉全部标签行(含人工行),按链接名称重新派生(显式人工动作) */
|
||||||
|
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);
|
||||||
|
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;
|
||||||
@@ -114,7 +277,7 @@ export class OriginGoodsService {
|
|||||||
* under a synthetic "未分类" root node.
|
* under a synthetic "未分类" root node.
|
||||||
*/
|
*/
|
||||||
async getTree(): Promise<OriginGoodsTreeResponse> {
|
async getTree(): Promise<OriginGoodsTreeResponse> {
|
||||||
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags] =
|
const [allCategories, allOriginGoods, configCounts, goodsWithCountries, goodsWithTags, mergedCounts, mergedWithCountries] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.prisma.category.findMany({
|
this.prisma.category.findMany({
|
||||||
where: { sdsCategoryId: { not: null } },
|
where: { sdsCategoryId: { not: null } },
|
||||||
@@ -129,7 +292,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'],
|
||||||
@@ -144,7 +311,12 @@ export class OriginGoodsService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.goodTag.findMany({
|
this.prisma.goodTag.findMany({
|
||||||
select: {
|
select: {
|
||||||
good: { select: { originGoodId: true } },
|
good: {
|
||||||
|
select: {
|
||||||
|
originGoodId: true,
|
||||||
|
mergedOriginGoods: { select: { originGoodId: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
tag: {
|
tag: {
|
||||||
select: {
|
select: {
|
||||||
tagName: true,
|
tagName: true,
|
||||||
@@ -157,26 +329,57 @@ export class OriginGoodsService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// Secondary-source references (good_origin_goods)
|
||||||
|
this.prisma.goodOriginGood.groupBy({
|
||||||
|
by: ['originGoodId'],
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.goodOriginGood.findMany({
|
||||||
|
select: {
|
||||||
|
originGoodId: true,
|
||||||
|
good: { select: { country: { select: { countryName: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const countMap = new Map<string, number>();
|
const countMap = new Map<string, number>();
|
||||||
configCounts.forEach((c) =>
|
configCounts.forEach((c) =>
|
||||||
countMap.set(c.originGoodId.toString(), c._count._all),
|
countMap.set(c.originGoodId.toString(), c._count._all),
|
||||||
);
|
);
|
||||||
|
// Secondary (merged) references count towards configured status too.
|
||||||
const countryMap = new Map<string, string[]>();
|
mergedCounts.forEach((c) => {
|
||||||
goodsWithCountries.forEach((g) => {
|
const key = c.originGoodId.toString();
|
||||||
const key = g.originGoodId.toString();
|
countMap.set(key, (countMap.get(key) ?? 0) + c._count._all);
|
||||||
const name = g.country?.countryName;
|
|
||||||
if (!name) return;
|
|
||||||
const arr = countryMap.get(key);
|
|
||||||
if (arr) arr.push(name);
|
|
||||||
else countryMap.set(key, [name]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const countryMap = new Map<string, string[]>();
|
||||||
|
const addCountry = (key: string, name?: string | null) => {
|
||||||
|
if (!name) return;
|
||||||
|
const arr = countryMap.get(key);
|
||||||
|
if (!arr?.includes(name)) {
|
||||||
|
countryMap.set(key, [...(arr ?? []), name]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
goodsWithCountries.forEach((g) =>
|
||||||
|
addCountry(g.originGoodId.toString(), g.country?.countryName),
|
||||||
|
);
|
||||||
|
mergedWithCountries.forEach((m) =>
|
||||||
|
addCountry(m.originGoodId.toString(), m.good.country?.countryName),
|
||||||
|
);
|
||||||
|
|
||||||
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
|
const tagMap = new Map<string, { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number }[]>();
|
||||||
|
const addTag = (
|
||||||
|
key: string,
|
||||||
|
tagInfo: { tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroupId: string | null; tagGroupName: string | null; sortOrder: number },
|
||||||
|
) => {
|
||||||
|
const arr = tagMap.get(key);
|
||||||
|
if (arr) {
|
||||||
|
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
||||||
|
} else {
|
||||||
|
tagMap.set(key, [tagInfo]);
|
||||||
|
}
|
||||||
|
};
|
||||||
goodsWithTags.forEach((gt) => {
|
goodsWithTags.forEach((gt) => {
|
||||||
const key = gt.good.originGoodId.toString();
|
|
||||||
const tagInfo = {
|
const tagInfo = {
|
||||||
tagName: gt.tag.tagName,
|
tagName: gt.tag.tagName,
|
||||||
tagColor: gt.tag.tagColor,
|
tagColor: gt.tag.tagColor,
|
||||||
@@ -185,11 +388,10 @@ export class OriginGoodsService {
|
|||||||
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
|
tagGroupName: gt.tag.tagGroup?.groupName ?? null,
|
||||||
sortOrder: gt.tag.sortOrder,
|
sortOrder: gt.tag.sortOrder,
|
||||||
};
|
};
|
||||||
const arr = tagMap.get(key);
|
addTag(gt.good.originGoodId.toString(), tagInfo);
|
||||||
if (arr) {
|
// A good's tags also mark its secondary origin goods as configured.
|
||||||
if (!arr.some((t) => t.tagName === tagInfo.tagName)) arr.push(tagInfo);
|
for (const m of gt.good.mergedOriginGoods) {
|
||||||
} else {
|
addTag(m.originGoodId.toString(), tagInfo);
|
||||||
tagMap.set(key, [tagInfo]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,6 +438,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);
|
||||||
@@ -287,6 +493,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,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,68 @@
|
|||||||
|
/**
|
||||||
|
* 链接标签自动派生规则(产品确认版,2026-08-28):
|
||||||
|
*
|
||||||
|
* 标签与「产品链接」一一对应 —— 每条链接因 印花数量/工艺/物流 不同而价格不同
|
||||||
|
* (价格矩阵维度 = 印花数量·工艺 × 物流 × 尺码 × 颜色),因此标签按链接名称解析:
|
||||||
|
*
|
||||||
|
* - 印花数量:名称含「双面印花」→ 双面印花;否则含「单面印花」→ 单面印花;
|
||||||
|
* - 工艺:名称含「直喷」「不打印」「光板」→ 对应标签(可多个);都不含 → 默认「烫画」;
|
||||||
|
* - 物流:名称含「不包邮」→ 不包邮;否则含「包邮」→ 包邮(先判「不包邮」,避免子串误命中)。
|
||||||
|
*
|
||||||
|
* 这些分组(印花数量 / 印刷工艺 / 物流渠道)下的标签为派生数据,不接受手动写入;
|
||||||
|
* 其他分组保持人工管理。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 自动(派生)标签组名匹配:命中即视为族托管,手输的这类标签会被剔除 */
|
||||||
|
export function isAutoTagGroupName(name: string): boolean {
|
||||||
|
return /物流|工艺|位置|印花数量/.test(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_id(canonical 详情与跳转兜底)',
|
||||||
|
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,453 @@
|
|||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { FamilyRecomputeService, derivePriceMatrix } 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],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(FamilyRecomputeService);
|
||||||
|
organize = new OrganizeService(
|
||||||
|
moduleRef.get(PrismaService),
|
||||||
|
service,
|
||||||
|
new ProductFamiliesService(moduleRef.get(PrismaService), service),
|
||||||
|
);
|
||||||
|
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', 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.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) {}
|
||||||
|
|
||||||
|
/** 进程内去重的异步重算入口(同步钩子用) */
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
/** 把链接的有效标签镜像到其名下商品(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,286 @@
|
|||||||
|
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_tags(manual=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],
|
||||||
|
}).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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)).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(['包邮', '双面印花', '直喷']));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
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 { parseOriginName } from './origin-name.parser';
|
||||||
|
import {
|
||||||
|
DERIVED_TAG_GROUP_SPECS,
|
||||||
|
deriveLinkTagNames,
|
||||||
|
isAutoTagGroupName,
|
||||||
|
} from './auto-tag-rules';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整理服务(解析去运行时化的唯一解析入口):
|
||||||
|
* 所有"按上游链接名解析/派生"的逻辑集中于此,由显式人工动作触发——
|
||||||
|
* CLI(pnpm --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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async organize() {
|
||||||
|
const labels = await this.backfillLabels();
|
||||||
|
const tags = await this.deriveAllTags();
|
||||||
|
const grouped = await this.families.autoGroup(true);
|
||||||
|
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,
|
||||||
|
familiesRecomputed: families.length,
|
||||||
|
};
|
||||||
|
this.logger.log(`organize done: ${JSON.stringify(result)}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回填结构化解析列(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;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
return { linksUpdated };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,104 @@
|
|||||||
|
import { parseOriginName, originGroupKey } 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 原产品链接名结构化解析。
|
||||||
|
*
|
||||||
|
* 名称格式(与 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 端 truncateToProcess(KEEP_SEGMENTS = 3)语义一致,
|
||||||
|
* 保留按 `-` 分段的前 3 段,丢弃可选的仓库名段。
|
||||||
|
*/
|
||||||
|
export function originGroupKey(name: string | null | undefined): string {
|
||||||
|
if (!name) return '';
|
||||||
|
return name.split('-').slice(0, 3).join('-');
|
||||||
|
}
|
||||||
@@ -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,283 @@
|
|||||||
|
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],
|
||||||
|
}).compile();
|
||||||
|
service = moduleRef.get(ProductFamiliesService);
|
||||||
|
organize = new OrganizeService(
|
||||||
|
moduleRef.get(PrismaService),
|
||||||
|
moduleRef.get(FamilyRecomputeService),
|
||||||
|
service,
|
||||||
|
);
|
||||||
|
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('list:keyword 过滤 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 === 2);
|
||||||
|
expect(hit).toBeTruthy();
|
||||||
|
expect(hit.familyName).toContain('卫衣');
|
||||||
|
|
||||||
|
const applied = (await service.autoGroup(true)) as any;
|
||||||
|
expect(applied.applied).toBeGreaterThanOrEqual(2); // 包邮组 + 不包邮组(物流不同不同组)
|
||||||
|
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).not.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:同 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,501 @@
|
|||||||
|
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 } 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)"),
|
||||||
|
* 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退名称 3 段键;apply=false 仅预览 */
|
||||||
|
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]));
|
||||||
|
|
||||||
|
const groups = new Map<string, typeof candidates>();
|
||||||
|
for (const og of candidates) {
|
||||||
|
const nameKey = originGroupKey(og.goodName);
|
||||||
|
const key = og.sdsCategoryId ? `cat:${og.sdsCategoryId}` : nameKey ? `name:${nameKey}` : '';
|
||||||
|
if (!key) continue;
|
||||||
|
const arr = groups.get(key);
|
||||||
|
if (arr) arr.push(og);
|
||||||
|
else groups.set(key, [og]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = [...groups.values()].map((members) => {
|
||||||
|
const parsed = parseOriginName(members[0].goodName);
|
||||||
|
const categoryName = members[0].sdsCategoryId
|
||||||
|
? (catName.get(members[0].sdsCategoryId) ?? null)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
groupKey: members[0].sdsCategoryId ? `cat:${members[0].sdsCategoryId}` : `name:${originGroupKey(members[0].goodName)}`,
|
||||||
|
familyName:
|
||||||
|
categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName),
|
||||||
|
familyCode: codeFromCategoryName(categoryName) ?? parsed.skuCode ?? null,
|
||||||
|
memberCount: members.length,
|
||||||
|
sampleNames: members.slice(0, 3).map((m) => m.goodName ?? ''),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apply) return { applied: 0, groups: preview };
|
||||||
|
|
||||||
|
let applied = 0;
|
||||||
|
for (const members of groups.values()) {
|
||||||
|
const parsed = parseOriginName(members[0].goodName);
|
||||||
|
const categoryName = members[0].sdsCategoryId
|
||||||
|
? (catName.get(members[0].sdsCategoryId) ?? null)
|
||||||
|
: null;
|
||||||
|
const fallbackCode =
|
||||||
|
members[0].source === 'CUSTOM' ? `CUSTOM-${members[0].id}` : null;
|
||||||
|
const family = await this.prisma.productFamily.create({
|
||||||
|
data: {
|
||||||
|
familyName: categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName),
|
||||||
|
familyCode:
|
||||||
|
codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode
|
||||||
|
? await this.ensureUniqueCode(
|
||||||
|
(codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode)!,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
familyImage: members[0].goodImage ?? null,
|
||||||
|
primaryOriginGoodId: members[0].id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 宽容挂载:并行环境下成员可能在候选查询后消失(如测试清理),跳过即可
|
||||||
|
await this.attachMembers(
|
||||||
|
family.id,
|
||||||
|
members.map((m) => m.id),
|
||||||
|
{ strict: false },
|
||||||
|
);
|
||||||
|
await this.recompute.recomputeFamily(family.id);
|
||||||
|
applied += 1;
|
||||||
|
}
|
||||||
|
return { applied, groups: preview };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,255 @@
|
|||||||
|
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 () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [PublicService, PrismaService],
|
||||||
|
}).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);
|
||||||
|
const organizeSvc = new OrganizeService(
|
||||||
|
prisma,
|
||||||
|
recomputeSvc,
|
||||||
|
new ProductFamiliesService(prisma, recomputeSvc),
|
||||||
|
);
|
||||||
|
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],
|
||||||
|
}).compile();
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
await prisma.onModuleInit();
|
||||||
|
familiesService = new (require('../product-families/product-families.service').ProductFamiliesService)(
|
||||||
|
prisma,
|
||||||
|
new FamilyRecomputeService(prisma),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,7 +56,7 @@ export class PublicController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('goods')
|
@Get('goods')
|
||||||
@ApiOperation({ summary: '分页获取商品' })
|
@ApiOperation({ summary: '分页获取商品(族化契约:一族一条,goodId=族ID;无族商品不返回)' })
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: 'tags',
|
name: 'tags',
|
||||||
required: false,
|
required: false,
|
||||||
@@ -80,8 +80,8 @@ export class PublicController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('goods/:goodId')
|
@Get('goods/:goodId')
|
||||||
@ApiOperation({ summary: '获取商品完整详情' })
|
@ApiOperation({ summary: '获取商品完整详情(goodId = 族 ID)' })
|
||||||
@ApiParam({ name: 'goodId', type: String, example: '168746' })
|
@ApiParam({ name: 'goodId', type: String, example: '758', description: '产品族 ID' })
|
||||||
@ApiOkResponse({ type: PublicGoodDetailDto })
|
@ApiOkResponse({ type: PublicGoodDetailDto })
|
||||||
getGood(@Param('goodId') goodId: string): Promise<PublicGoodDetailDto> {
|
getGood(@Param('goodId') goodId: string): Promise<PublicGoodDetailDto> {
|
||||||
return this.service.getGood(goodId);
|
return this.service.getGood(goodId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { PublicService } from './public.service';
|
import { PublicService } from './public.service';
|
||||||
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
describe('PublicService', () => {
|
describe('PublicService', () => {
|
||||||
@@ -15,6 +16,7 @@ describe('PublicService', () => {
|
|||||||
let filterGroupIds: bigint[] = [];
|
let filterGroupIds: bigint[] = [];
|
||||||
let filterTagIds: bigint[] = [];
|
let filterTagIds: bigint[] = [];
|
||||||
let originGoodId: bigint;
|
let originGoodId: bigint;
|
||||||
|
let familyId: bigint;
|
||||||
let goodIds: bigint[] = [];
|
let goodIds: bigint[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -57,6 +59,16 @@ describe('PublicService', () => {
|
|||||||
});
|
});
|
||||||
originGoodId = og.id;
|
originGoodId = og.id;
|
||||||
|
|
||||||
|
// 公开契约族化:商品必须挂族才对外可见
|
||||||
|
const family = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `Pub Family ${stamp}`, primaryOriginGoodId: og.id },
|
||||||
|
});
|
||||||
|
familyId = family.id;
|
||||||
|
await prisma.originGood.update({
|
||||||
|
where: { id: og.id },
|
||||||
|
data: { familyId: family.id },
|
||||||
|
});
|
||||||
|
|
||||||
// Seed 3 goods:
|
// Seed 3 goods:
|
||||||
// high priority + position.indexVal=1
|
// high priority + position.indexVal=1
|
||||||
// mid priority + position.indexVal=5
|
// mid priority + position.indexVal=5
|
||||||
@@ -72,6 +84,7 @@ describe('PublicService', () => {
|
|||||||
data: {
|
data: {
|
||||||
goodName: `Pub High ${stamp}`,
|
goodName: `Pub High ${stamp}`,
|
||||||
originGoodId,
|
originGoodId,
|
||||||
|
familyId: family.id,
|
||||||
countryId,
|
countryId,
|
||||||
categoryId,
|
categoryId,
|
||||||
goodPriority: 10,
|
goodPriority: 10,
|
||||||
@@ -82,6 +95,7 @@ describe('PublicService', () => {
|
|||||||
data: {
|
data: {
|
||||||
goodName: `Pub Mid ${stamp}`,
|
goodName: `Pub Mid ${stamp}`,
|
||||||
originGoodId,
|
originGoodId,
|
||||||
|
familyId: family.id,
|
||||||
countryId,
|
countryId,
|
||||||
categoryId,
|
categoryId,
|
||||||
goodPriority: 5,
|
goodPriority: 5,
|
||||||
@@ -92,6 +106,7 @@ describe('PublicService', () => {
|
|||||||
data: {
|
data: {
|
||||||
goodName: `Pub NoPos ${stamp}`,
|
goodName: `Pub NoPos ${stamp}`,
|
||||||
originGoodId,
|
originGoodId,
|
||||||
|
familyId: family.id,
|
||||||
countryId,
|
countryId,
|
||||||
categoryId,
|
categoryId,
|
||||||
tagId,
|
tagId,
|
||||||
@@ -143,6 +158,8 @@ describe('PublicService', () => {
|
|||||||
price: 38,
|
price: 38,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// 物化族矩阵(公开详情 family 块依赖 priceMatrix 已重算)
|
||||||
|
await new FamilyRecomputeService(prisma).recomputeFamily(family.id);
|
||||||
|
|
||||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||||
// returns more than one category.
|
// returns more than one category.
|
||||||
@@ -150,6 +167,7 @@ describe('PublicService', () => {
|
|||||||
data: {
|
data: {
|
||||||
goodName: `Pub Other ${stamp}`,
|
goodName: `Pub Other ${stamp}`,
|
||||||
originGoodId,
|
originGoodId,
|
||||||
|
familyId: family.id,
|
||||||
countryId,
|
countryId,
|
||||||
categoryId: otherCategoryId,
|
categoryId: otherCategoryId,
|
||||||
goodPriority: 1,
|
goodPriority: 1,
|
||||||
@@ -162,6 +180,7 @@ describe('PublicService', () => {
|
|||||||
data: {
|
data: {
|
||||||
goodName: `Pub ChildGood ${stamp}`,
|
goodName: `Pub ChildGood ${stamp}`,
|
||||||
originGoodId,
|
originGoodId,
|
||||||
|
familyId: family.id,
|
||||||
countryId,
|
countryId,
|
||||||
categoryId: childCategoryId,
|
categoryId: childCategoryId,
|
||||||
goodPriority: 0,
|
goodPriority: 0,
|
||||||
@@ -176,6 +195,8 @@ describe('PublicService', () => {
|
|||||||
await prisma.good.deleteMany({
|
await prisma.good.deleteMany({
|
||||||
where: { goodName: { contains: `Pub ` } },
|
where: { goodName: { contains: `Pub ` } },
|
||||||
});
|
});
|
||||||
|
// Good.familyId / OriginGood.familyId 均为 SetNull,先删商品再删族
|
||||||
|
await prisma.productFamily.deleteMany({ where: { id: familyId } });
|
||||||
await prisma.position.deleteMany({
|
await prisma.position.deleteMany({
|
||||||
where: { countryId },
|
where: { countryId },
|
||||||
});
|
});
|
||||||
@@ -224,7 +245,7 @@ describe('PublicService', () => {
|
|||||||
categoryId: categoryId.toString(), // includes child
|
categoryId: categoryId.toString(), // includes child
|
||||||
keyword: `Pub `,
|
keyword: `Pub `,
|
||||||
});
|
});
|
||||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
expect(filtered.total).toBe(1); // 族化后:同族 4 条在售 Good(High/Mid/NoPos/Child)= 1 个款
|
||||||
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true);
|
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,9 +275,8 @@ describe('PublicService', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
|
// 族化后命中族内多条 Good 仍只出代表行(High 优先级最高)
|
||||||
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
|
expect(sameGroup.items.map((item) => item.goodName)).toEqual([`Pub High ${stamp}`]);
|
||||||
);
|
|
||||||
|
|
||||||
const acrossGroups = await service.getGoods({
|
const acrossGroups = await service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -293,20 +313,90 @@ describe('PublicService', () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the SDS product id as the public product id', async () => {
|
it('returns the family id as the public product id (一族多条 Good 只出一条)', async () => {
|
||||||
const result = await service.getGoods({
|
const result = await service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 1,
|
pageSize: 50,
|
||||||
countryId: countryId.toString(),
|
countryId: countryId.toString(),
|
||||||
keyword: `Pub High ${stamp}`,
|
keyword: `Pub `,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 该族下 5 条 Good(High/Mid/NoPos/Other/Child)→ 列表仅 1 条,goodId=族ID
|
||||||
expect(result.items).toHaveLength(1);
|
expect(result.items).toHaveLength(1);
|
||||||
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
|
expect(result.items[0].goodId).toBe(familyId.toString());
|
||||||
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
||||||
|
expect(result.items[0].goodName).toBe(`Pub High ${stamp}`); // 代表行 = 排序第一条
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns custom goods through the same public product contract', async () => {
|
it('list price is the family price matrix minimum (SQL aggregate)', async () => {
|
||||||
|
// 自包含 fixture:CUSTOM 光板成员(craftLabel/logisticsLabel 是矩阵归因来源)
|
||||||
|
// + 单变体 38 元。SDS 成员无标签时矩阵为空(不解析名称,等整理补标签),
|
||||||
|
// 共享 fixture 族 therefore 无矩阵,无法覆盖该路径。
|
||||||
|
const og = await prisma.originGood.create({
|
||||||
|
data: {
|
||||||
|
source: 'CUSTOM',
|
||||||
|
sdsGoodId: `pub-matrix-${stamp}`,
|
||||||
|
goodName: `Pub Matrix ${stamp}`,
|
||||||
|
craftLabel: '不打印',
|
||||||
|
logisticsLabel: '包邮',
|
||||||
|
goodPrice: 50, // 无矩阵时的回退链接价
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const family = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `Pub Matrix Family ${stamp}`, primaryOriginGoodId: og.id },
|
||||||
|
});
|
||||||
|
await prisma.originGood.update({
|
||||||
|
where: { id: og.id },
|
||||||
|
data: { familyId: family.id },
|
||||||
|
});
|
||||||
|
await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: og.id,
|
||||||
|
sdsVariantId: `pub-matrix-v-${stamp}`,
|
||||||
|
sku: `PM${stamp}`,
|
||||||
|
sizeName: 'S',
|
||||||
|
price: 38,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const good = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: `Pub Matrix Good ${stamp}`,
|
||||||
|
originGoodId: og.id,
|
||||||
|
familyId: family.id,
|
||||||
|
countryId,
|
||||||
|
categoryId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// 重算前:族无矩阵 → 回退链接价
|
||||||
|
const before = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
keyword: `Pub Matrix Good`,
|
||||||
|
});
|
||||||
|
expect(before.items).toHaveLength(1);
|
||||||
|
expect(before.items[0].price).toBe('50');
|
||||||
|
|
||||||
|
await new FamilyRecomputeService(prisma).recomputeFamily(family.id);
|
||||||
|
const after = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
keyword: `Pub Matrix Good`,
|
||||||
|
});
|
||||||
|
expect(after.items).toHaveLength(1);
|
||||||
|
// 重算后:矩阵最低价 38 生效,不再是回退链接价
|
||||||
|
expect(after.items[0].price).toBe('38');
|
||||||
|
} finally {
|
||||||
|
await prisma.good.delete({ where: { id: good.id } });
|
||||||
|
await prisma.originGoodVariant.deleteMany({
|
||||||
|
where: { sdsVariantId: `pub-matrix-v-${stamp}` },
|
||||||
|
});
|
||||||
|
await prisma.productFamily.delete({ where: { id: family.id } });
|
||||||
|
await prisma.originGood.delete({ where: { id: og.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('custom goods (无族) are not visible on public endpoints', async () => {
|
||||||
const customPublicId = `custom-public-${stamp}`;
|
const customPublicId = `custom-public-${stamp}`;
|
||||||
const origin = await prisma.originGood.create({
|
const origin = await prisma.originGood.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -326,17 +416,22 @@ describe('PublicService', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const detail = await service.getGood(customPublicId);
|
const list = await service.getGoods({
|
||||||
expect(detail.goodId).toBe(customPublicId);
|
page: 1,
|
||||||
expect(detail.goodName).toBe(`Pub Custom ${stamp}`);
|
pageSize: 50,
|
||||||
expect(detail.productCode).toBe(`CUSTOM-${stamp}`);
|
countryId: countryId.toString(),
|
||||||
|
keyword: `Pub Custom`,
|
||||||
|
});
|
||||||
|
expect(list.items).toHaveLength(0);
|
||||||
|
// sdsGoodId 不再是公开寻址键:非数字直接 404
|
||||||
|
await expect(service.getGood(customPublicId)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.good.delete({ where: { id: good.id } });
|
await prisma.good.delete({ where: { id: good.id } });
|
||||||
await prisma.originGood.delete({ where: { id: origin.id } });
|
await prisma.originGood.delete({ where: { id: origin.id } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getGood returns detail and 404 for unknown id', async () => {
|
it('getGood returns family detail by family id and 404 for unknown id', async () => {
|
||||||
const first = await service.getGoods({
|
const first = await service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 1,
|
pageSize: 1,
|
||||||
@@ -344,19 +439,30 @@ describe('PublicService', () => {
|
|||||||
keyword: `Pub `,
|
keyword: `Pub `,
|
||||||
});
|
});
|
||||||
expect(first.items.length).toBe(1);
|
expect(first.items.length).toBe(1);
|
||||||
const detail = await service.getGood(`pub-sds-${stamp}`);
|
const detail = await service.getGood(familyId.toString());
|
||||||
expect(detail.goodId).toBe(first.items[0].goodId);
|
expect(detail.goodId).toBe(first.items[0].goodId);
|
||||||
expect(detail.productCode).toBe('OZ10827003');
|
expect(detail.productCode).toBe('OZ10827003');
|
||||||
expect(detail.details.productionProcess).toBe('白墨烫画');
|
expect(detail.details.productionProcess).toBe('白墨烫画');
|
||||||
expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1);
|
expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1);
|
||||||
expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1);
|
expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1);
|
||||||
expect(detail.variants).toHaveLength(1);
|
expect(detail.variants).toHaveLength(1);
|
||||||
|
expect(detail.family?.familyId).toBe(familyId.toString());
|
||||||
|
|
||||||
|
// 旧 sdsGoodId 寻址不再可达(族 ID 是唯一公开键)
|
||||||
|
await expect(service.getGood(`pub-sds-${stamp}`)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
|
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('home-goods dedupes by family (keeps the best-positioned good)', async () => {
|
||||||
|
const home = await service.getHomeGoods({ limit: 50 });
|
||||||
|
// fixture 族的两条带位商品(High index=1 / Mid index=5)→ 只出一条代表行
|
||||||
|
const ours = home.filter((h) => h.goodId === familyId.toString());
|
||||||
|
expect(ours).toHaveLength(1);
|
||||||
|
expect(ours[0].goodName).toBe(`Pub High ${stamp}`);
|
||||||
|
});
|
||||||
|
|
||||||
it('getTags returns tags with their group info, sorted by group then order', async () => {
|
it('getTags returns tags with their group info, sorted by group then order', async () => {
|
||||||
const tags = await service.getTags();
|
const tags = await service.getTags();
|
||||||
expect(tags.length).toBeGreaterThan(0);
|
expect(tags.length).toBeGreaterThan(0);
|
||||||
@@ -372,11 +478,368 @@ describe('PublicService', () => {
|
|||||||
const groups = await service.getTagGroups();
|
const groups = await service.getTagGroups();
|
||||||
expect(groups.length).toBeGreaterThan(0);
|
expect(groups.length).toBeGreaterThan(0);
|
||||||
const names = groups.map((g) => g.groupName);
|
const names = groups.map((g) => g.groupName);
|
||||||
|
// 链接级派生标签落地后,商品挂在 物流渠道/印刷工艺/印花数量 三组
|
||||||
expect(names).toContain('物流渠道');
|
expect(names).toContain('物流渠道');
|
||||||
expect(names).toContain('印刷位置');
|
expect(names).toContain('印花数量');
|
||||||
expect(names).toContain('印刷工艺');
|
expect(names).toContain('印刷工艺');
|
||||||
// Sorted by sortOrder
|
// Sorted by sortOrder
|
||||||
const sortOrders = groups.map((g) => g.sortOrder);
|
const sortOrders = groups.map((g) => g.sortOrder);
|
||||||
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders);
|
expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('merged secondary origin goods', () => {
|
||||||
|
it('family members union variants and media (secondary link joins the family)', async () => {
|
||||||
|
const secondary = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` },
|
||||||
|
});
|
||||||
|
const secVariant = await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: secondary.id,
|
||||||
|
sdsVariantId: `pub-var-sec-${stamp}`,
|
||||||
|
sku: `PUB-SEC-${stamp}`,
|
||||||
|
colorId: 'black',
|
||||||
|
colorName: '黑色',
|
||||||
|
imageUrl: 'http://img/black-sec',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 副链归入主 fixture 的族(族机制替代旧 good_origin_goods 关联)
|
||||||
|
await prisma.originGood.update({
|
||||||
|
where: { id: secondary.id },
|
||||||
|
data: { familyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await service.getGood(familyId.toString());
|
||||||
|
expect(detail.goodId).toBe(familyId.toString()); // 对外 goodId 恒为族ID
|
||||||
|
expect(detail.variants.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const black = detail.mediaByColor.find((g) => g.colorName === '黑色');
|
||||||
|
expect(black).toBeTruthy();
|
||||||
|
expect(black!.images).toContain('http://img/black-sec');
|
||||||
|
// sdsGoodId 不是公开键:副链 ID 无法寻址
|
||||||
|
await expect(service.getGood(`pub-secondary-${stamp}`)).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await prisma.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined);
|
||||||
|
await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes variants by color+size (primary wins) and merges specs/options', async () => {
|
||||||
|
const stamp2 = `${stamp}-merge2`;
|
||||||
|
const primaryOg = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `pub-pri-${stamp2}`, goodName: `Pub Pri ${stamp2}` },
|
||||||
|
});
|
||||||
|
const secondaryOg = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `pub-sec-${stamp2}`, goodName: `Pub Sec ${stamp2}` },
|
||||||
|
});
|
||||||
|
// Primary: Black|S and Black|M, size chart S/M, package S, options S/M.
|
||||||
|
await prisma.originGoodVariant.createMany({
|
||||||
|
data: [
|
||||||
|
{ originGoodId: primaryOg.id, sdsVariantId: `v1-${stamp2}`, sku: `SK1-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 16 },
|
||||||
|
{ originGoodId: primaryOg.id, sdsVariantId: `v2-${stamp2}`, sku: `SK2-${stamp2}`, colorName: 'black', sizeName: 'M', price: 16 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await prisma.originGoodDetail.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: primaryOg.id,
|
||||||
|
sizeChart: { rows: [{ sizeName: 'S', measurements: [{ key: 'chest', cm: '94' }] }, { sizeName: 'M', measurements: [{ key: 'chest', cm: '100' }] }] },
|
||||||
|
packageSpecs: { rows: [{ sizeName: 'S' }] },
|
||||||
|
options: { sizes: [{ name: 'S' }, { name: 'M' }] },
|
||||||
|
media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i2', url: 'http://img/pri-b', sortOrder: 1 }], primaryImageUrl: 'http://img/pri-a' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Secondary: duplicate Black|S with a DIFFERENT price (must be dropped,
|
||||||
|
// primary wins), plus a unique color Kelly|S; specs add XXXL rows.
|
||||||
|
await prisma.originGoodVariant.createMany({
|
||||||
|
data: [
|
||||||
|
{ originGoodId: secondaryOg.id, sdsVariantId: `v3-${stamp2}`, sku: `SK3-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 20 },
|
||||||
|
{ originGoodId: secondaryOg.id, sdsVariantId: `v4-${stamp2}`, sku: `SK4-${stamp2}`, colorName: 'Kelly', sizeName: 'S', price: 22 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await prisma.originGoodDetail.create({
|
||||||
|
data: {
|
||||||
|
originGoodId: secondaryOg.id,
|
||||||
|
sizeChart: { rows: [{ sizeName: 'XXXL', measurements: [{ key: 'chest', cm: '120' }] }] },
|
||||||
|
packageSpecs: { rows: [{ sizeName: 'M' }, { sizeName: 'XXXL' }] },
|
||||||
|
options: { sizes: [{ name: 'XXXL' }] },
|
||||||
|
media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i9', url: 'http://img/sec-x', sortOrder: 0 }], primaryImageUrl: 'http://img/pri-a' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 主副链同族 + 一条官网 Good(族化契约:Good 挂族才公开)
|
||||||
|
const mergeFamily = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `Pub Merge Family ${stamp2}`, primaryOriginGoodId: primaryOg.id },
|
||||||
|
});
|
||||||
|
await prisma.originGood.updateMany({
|
||||||
|
where: { id: { in: [primaryOg.id, secondaryOg.id] } },
|
||||||
|
data: { familyId: mergeFamily.id },
|
||||||
|
});
|
||||||
|
const mergedGood = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: `Pub Merged ${stamp2}`,
|
||||||
|
originGoodId: primaryOg.id,
|
||||||
|
familyId: mergeFamily.id,
|
||||||
|
countryId,
|
||||||
|
categoryId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await service.getGood(mergeFamily.id.toString());
|
||||||
|
// Variants: 3 unique color+size combos (case-insensitive); duplicate
|
||||||
|
// Black|S from the secondary deduped.
|
||||||
|
expect(
|
||||||
|
detail.variants.map((v) => `${v.colorName}/${v.sizeName}`.toLowerCase()).sort(),
|
||||||
|
).toEqual(['black/m', 'black/s', 'kelly/s']);
|
||||||
|
const blackS = detail.variants.find((v) => v.colorName === 'Black' && v.sizeName === 'S');
|
||||||
|
expect(blackS!.price).toBe('16'); // primary price wins over secondary 20
|
||||||
|
// Size chart: S/M from primary, XXXL appended from secondary.
|
||||||
|
const chartSizes = (detail.sizeChart as any).rows.map((r: any) => r.sizeName).sort();
|
||||||
|
expect(chartSizes).toEqual(['M', 'S', 'XXXL']);
|
||||||
|
// Package specs: S from primary, M/XXXL appended from secondary.
|
||||||
|
const pkgSizes = (detail.packageSpecs as any).rows.map((r: any) => r.sizeName).sort();
|
||||||
|
expect(pkgSizes).toEqual(['M', 'S', 'XXXL']);
|
||||||
|
// Options: sizes unioned S/M + XXXL.
|
||||||
|
const optSizes = (detail.options as any).sizes.map((s: any) => s.name).sort();
|
||||||
|
expect(optSizes).toEqual(['M', 'S', 'XXXL']);
|
||||||
|
// Media gallery: primary images first, secondary-only URL appended,
|
||||||
|
// duplicate URL (pri-a) kept once. Entries keep their object shape.
|
||||||
|
const media = detail.media as any;
|
||||||
|
expect(media.images.map((i: any) => i.url)).toEqual([
|
||||||
|
'http://img/pri-a',
|
||||||
|
'http://img/pri-b',
|
||||||
|
'http://img/sec-x',
|
||||||
|
]);
|
||||||
|
expect(media.primaryImageUrl).toBe('http://img/pri-a');
|
||||||
|
} finally {
|
||||||
|
await prisma.good.delete({ where: { id: mergedGood.id } });
|
||||||
|
await prisma.productFamily.delete({ where: { id: mergeFamily.id } });
|
||||||
|
await prisma.originGoodVariant.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } });
|
||||||
|
await prisma.originGoodDetail.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } });
|
||||||
|
await prisma.originGood.delete({ where: { id: primaryOg.id } });
|
||||||
|
await prisma.originGood.delete({ where: { id: secondaryOg.id } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGoods tree-order sorting (国家→二级→款→priority)', () => {
|
||||||
|
// 结构: 国家A(sort=1)>MidA>LeafA1(sort=1, 2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1
|
||||||
|
// 期望默认顺序: A款1(priority desc) -> A款2 -> B款1;B 的 priority=99 也不能越级
|
||||||
|
const stamp2 = `${stamp}-treeorder`;
|
||||||
|
const sdsA1 = `la1-${stamp2}`;
|
||||||
|
const sdsA2 = `la2-${stamp2}`;
|
||||||
|
const sdsB1 = `lb1-${stamp2}`;
|
||||||
|
const trash = {
|
||||||
|
goodIds: [] as bigint[],
|
||||||
|
familyIds: [] as bigint[],
|
||||||
|
originGoodIds: [] as bigint[],
|
||||||
|
categoryIds: [] as bigint[],
|
||||||
|
countryIds: [] as bigint[],
|
||||||
|
};
|
||||||
|
let orderedFamilyIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const cA = await prisma.country.create({
|
||||||
|
data: { countryName: `TreeOrder A ${stamp2}`, sortOrder: 1 },
|
||||||
|
});
|
||||||
|
const cB = await prisma.country.create({
|
||||||
|
data: { countryName: `TreeOrder B ${stamp2}`, sortOrder: 2 },
|
||||||
|
});
|
||||||
|
trash.countryIds = [cA.id, cB.id];
|
||||||
|
const midA = await prisma.category.create({
|
||||||
|
data: { categoryName: `TreeOrder MidA ${stamp2}`, sdsCategoryId: `ma-${stamp2}`, sortOrder: 1 },
|
||||||
|
});
|
||||||
|
const leafA1 = await prisma.category.create({
|
||||||
|
data: { categoryName: `TreeOrder LeafA1 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA1, sortOrder: 1 },
|
||||||
|
});
|
||||||
|
const leafA2 = await prisma.category.create({
|
||||||
|
data: { categoryName: `TreeOrder LeafA2 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA2, sortOrder: 2 },
|
||||||
|
});
|
||||||
|
const midB = await prisma.category.create({
|
||||||
|
data: { categoryName: `TreeOrder MidB ${stamp2}`, sdsCategoryId: `mb-${stamp2}`, sortOrder: 2 },
|
||||||
|
});
|
||||||
|
const leafB1 = await prisma.category.create({
|
||||||
|
data: { categoryName: `TreeOrder LeafB1 ${stamp2}`, parentCategoryId: midB.id, sdsCategoryId: sdsB1, sortOrder: 1 },
|
||||||
|
});
|
||||||
|
trash.categoryIds = [leafA1.id, leafA2.id, leafB1.id, midA.id, midB.id];
|
||||||
|
|
||||||
|
const mk = async (
|
||||||
|
countryId: bigint,
|
||||||
|
sdsCategoryId: string,
|
||||||
|
name: string,
|
||||||
|
priority: number,
|
||||||
|
) => {
|
||||||
|
const og = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `to-${name}-${stamp2}`, goodName: name, sdsCategoryId },
|
||||||
|
});
|
||||||
|
trash.originGoodIds.push(og.id);
|
||||||
|
const fam = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `to-fam-${name}-${stamp2}`, primaryOriginGoodId: og.id },
|
||||||
|
});
|
||||||
|
trash.familyIds.push(fam.id);
|
||||||
|
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: fam.id } });
|
||||||
|
const good = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: `TO${stamp2}-${name}`,
|
||||||
|
originGoodId: og.id,
|
||||||
|
familyId: fam.id,
|
||||||
|
countryId,
|
||||||
|
categoryId: sdsCategoryId === sdsA1 ? leafA1.id : sdsCategoryId === sdsA2 ? leafA2.id : leafB1.id,
|
||||||
|
goodPriority: priority,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
trash.goodIds.push(good.id);
|
||||||
|
return fam.id.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const a1Low = await mk(cA.id, sdsA1, 'A1Low', 1);
|
||||||
|
const a1High = await mk(cA.id, sdsA1, 'A1High', 9);
|
||||||
|
const a2 = await mk(cA.id, sdsA2, 'A2', 0);
|
||||||
|
const b1 = await mk(cB.id, sdsB1, 'B1', 99);
|
||||||
|
orderedFamilyIds = [a1High, a1Low, a2, b1];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined);
|
||||||
|
await prisma.productFamily.deleteMany({ where: { id: { in: trash.familyIds } } }).catch(() => undefined);
|
||||||
|
await prisma.originGood.deleteMany({ where: { id: { in: trash.originGoodIds } } }).catch(() => undefined);
|
||||||
|
for (const id of trash.categoryIds) {
|
||||||
|
await prisma.category.delete({ where: { id } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await prisma.country.deleteMany({ where: { id: { in: trash.countryIds } } }).catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFAULT: country > mid > leaf > priority (cross-country priority cannot jump the queue)', async () => {
|
||||||
|
const res = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
keyword: `TO${stamp2}`, // 唯一前缀圈定本夹具 4 条,避免全库分页截断
|
||||||
|
});
|
||||||
|
expect(res.total).toBe(4);
|
||||||
|
const idx = res.items.map((i) => i.goodId);
|
||||||
|
const pos = orderedFamilyIds.map((id) => idx.indexOf(id));
|
||||||
|
expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中
|
||||||
|
expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序
|
||||||
|
// 同款内 priority desc
|
||||||
|
expect(idx.indexOf(orderedFamilyIds[0])).toBeLessThan(idx.indexOf(orderedFamilyIds[1]));
|
||||||
|
// 款顺序:LeafA1 -> LeafA2
|
||||||
|
expect(idx.indexOf(orderedFamilyIds[1])).toBeLessThan(idx.indexOf(orderedFamilyIds[2]));
|
||||||
|
// 国家/款顺序优先于 priority:B1(99) 不能排到 A2(0) 前面
|
||||||
|
expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('family representative row consistency (列表/首页代表行对齐详情)', () => {
|
||||||
|
// 同族两条 Good:同 priority=10,Low 的 id 更小/createdAt 更早/价格更低/位置更好,
|
||||||
|
// High 的 createdAt 更新。详情代表行规则 = priority desc → createdAt desc → id asc
|
||||||
|
// → 详情永远取 High;列表/首页必须与详情一致,而不是随排序参数漂移到 Low。
|
||||||
|
const stamp3 = `${stamp}-rep`;
|
||||||
|
let repFamilyId: bigint;
|
||||||
|
let trash = {
|
||||||
|
goodIds: [] as bigint[],
|
||||||
|
positionIds: [] as bigint[],
|
||||||
|
originGoodIds: [] as bigint[],
|
||||||
|
};
|
||||||
|
const repLowName = `Rep Low ${stamp3}`;
|
||||||
|
const repHighName = `Rep High ${stamp3}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const posLow = await prisma.position.create({
|
||||||
|
data: { indexVal: 1, countryId, categoryId },
|
||||||
|
});
|
||||||
|
const posHigh = await prisma.position.create({
|
||||||
|
data: { indexVal: 5, countryId, categoryId },
|
||||||
|
});
|
||||||
|
trash.positionIds = [posLow.id, posHigh.id];
|
||||||
|
|
||||||
|
const ogLow = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `rep-low-${stamp3}`, goodName: repLowName, goodPrice: 10 },
|
||||||
|
});
|
||||||
|
const ogHigh = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `rep-high-${stamp3}`, goodName: repHighName, goodPrice: 20 },
|
||||||
|
});
|
||||||
|
trash.originGoodIds = [ogLow.id, ogHigh.id];
|
||||||
|
|
||||||
|
const family = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `rep-fam-${stamp3}`, primaryOriginGoodId: ogLow.id },
|
||||||
|
});
|
||||||
|
repFamilyId = family.id;
|
||||||
|
await prisma.originGood.updateMany({
|
||||||
|
where: { id: { in: [ogLow.id, ogHigh.id] } },
|
||||||
|
data: { familyId: family.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const gLow = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: repLowName,
|
||||||
|
originGoodId: ogLow.id,
|
||||||
|
familyId: family.id,
|
||||||
|
countryId,
|
||||||
|
categoryId,
|
||||||
|
goodPriority: 10,
|
||||||
|
positionId: posLow.id,
|
||||||
|
createdAt: new Date(stamp),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gHigh = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: repHighName,
|
||||||
|
originGoodId: ogHigh.id,
|
||||||
|
familyId: family.id,
|
||||||
|
countryId,
|
||||||
|
categoryId,
|
||||||
|
goodPriority: 10,
|
||||||
|
positionId: posHigh.id,
|
||||||
|
createdAt: new Date(stamp + 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
trash.goodIds = [gLow.id, gHigh.id];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined);
|
||||||
|
await prisma.position
|
||||||
|
.deleteMany({ where: { id: { in: trash.positionIds } } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await prisma.productFamily.delete({ where: { id: repFamilyId } }).catch(() => undefined);
|
||||||
|
await prisma.originGood
|
||||||
|
.deleteMany({ where: { id: { in: trash.originGoodIds } } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFAULT 列表代表行与详情一致(priority 并列时取 createdAt 最新,而非 id 最小)', async () => {
|
||||||
|
const detail = await service.getGood(repFamilyId.toString());
|
||||||
|
expect(detail.goodName).toBe(repHighName);
|
||||||
|
|
||||||
|
const list = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
keyword: 'Rep ', // 本文件夹具唯一前缀,圈定本族(goodName: Rep Low/High)
|
||||||
|
});
|
||||||
|
const ours = list.items.filter((i) => i.goodId === repFamilyId.toString());
|
||||||
|
expect(ours).toHaveLength(1);
|
||||||
|
expect(ours[0].goodName).toBe(detail.goodName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PRICE_ASC 列表代表行不漂移到价格更低的成员', async () => {
|
||||||
|
const detail = await service.getGood(repFamilyId.toString());
|
||||||
|
const list = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
keyword: 'Rep ',
|
||||||
|
sort: 'PRICE_ASC',
|
||||||
|
});
|
||||||
|
const ours = list.items.filter((i) => i.goodId === repFamilyId.toString());
|
||||||
|
expect(ours).toHaveLength(1);
|
||||||
|
expect(ours[0].goodName).toBe(detail.goodName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('home-goods 代表行与详情一致(不取位置更好的成员)', async () => {
|
||||||
|
const detail = await service.getGood(repFamilyId.toString());
|
||||||
|
const home = await service.getHomeGoods({ limit: 50, countryId: countryId.toString() });
|
||||||
|
const ours = home.filter((h) => h.goodId === repFamilyId.toString());
|
||||||
|
expect(ours).toHaveLength(1);
|
||||||
|
expect(ours[0].goodName).toBe(detail.goodName);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,27 @@ const PUBLIC_GOOD_INCLUDE = {
|
|||||||
include: {
|
include: {
|
||||||
detail: true,
|
detail: true,
|
||||||
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
|
family: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
familyCode: true,
|
||||||
|
familyName: true,
|
||||||
|
sizeChart: true,
|
||||||
|
packageSpecs: true,
|
||||||
|
priceMatrix: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mergedOriginGoods: {
|
||||||
|
orderBy: { createdAt: 'asc' as const },
|
||||||
|
include: {
|
||||||
|
originGood: {
|
||||||
|
include: {
|
||||||
|
detail: true,
|
||||||
|
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||||
@@ -39,12 +60,37 @@ const PUBLIC_GOOD_INCLUDE = {
|
|||||||
|
|
||||||
type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;
|
type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表/首页卡片的精简 include:重 JSON(变体 design_data、detail、merged 副源、
|
||||||
|
* 整份族矩阵)全部不进列表查询——全量商品拉这些字段实测要 ~2s,而列表 DTO
|
||||||
|
* 一个都不用。详情接口仍走 PUBLIC_GOOD_INCLUDE。
|
||||||
|
*/
|
||||||
|
const PUBLIC_GOOD_LIST_INCLUDE = {
|
||||||
|
country: true,
|
||||||
|
category: true,
|
||||||
|
tag: { include: { tagGroup: true } },
|
||||||
|
position: true,
|
||||||
|
originGood: {
|
||||||
|
select: { sdsGoodId: true, goodImage: true, goodPrice: true, sdsCategoryId: true },
|
||||||
|
},
|
||||||
|
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||||
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
|
type PublicGoodListRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_LIST_INCLUDE }>;
|
||||||
|
|
||||||
|
interface TreeOrderMeta {
|
||||||
|
countryOrder: Map<string, number>;
|
||||||
|
/** key: origin_goods.sds_category_id → 款所属二级(c2)/款(c3) 的顺序值 */
|
||||||
|
leafOrder: Map<string, { c2: number; c3: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PublicService {
|
export class PublicService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
|
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
|
||||||
const goodsWhere: Prisma.GoodWhereInput = {
|
const goodsWhere: Prisma.GoodWhereInput = {
|
||||||
|
familyId: { not: null },
|
||||||
originGood: { delisted: false },
|
originGood: { delisted: false },
|
||||||
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||||
};
|
};
|
||||||
@@ -88,15 +134,19 @@ export class PublicService {
|
|||||||
|
|
||||||
async getCountries(): Promise<PublicCountryDto[]> {
|
async getCountries(): Promise<PublicCountryDto[]> {
|
||||||
const rows = await this.prisma.country.findMany({
|
const rows = await this.prisma.country.findMany({
|
||||||
where: { goods: { some: { originGood: { delisted: false } } } },
|
where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
});
|
});
|
||||||
return rows.map(PublicCountryDto.from);
|
return rows.map(PublicCountryDto.from);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTags(): Promise<PublicTagDto[]> {
|
async getTags(): Promise<PublicTagDto[]> {
|
||||||
const rows = await this.prisma.tag.findMany({
|
const rows = await this.prisma.tag.findMany({
|
||||||
where: { goodTags: { some: { good: { originGood: { delisted: false } } } } },
|
where: {
|
||||||
|
goodTags: {
|
||||||
|
some: { good: { familyId: { not: null }, originGood: { delisted: false } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ tagGroup: { sortOrder: 'asc' } },
|
{ tagGroup: { sortOrder: 'asc' } },
|
||||||
{ sortOrder: 'asc' },
|
{ sortOrder: 'asc' },
|
||||||
@@ -109,6 +159,7 @@ export class PublicService {
|
|||||||
|
|
||||||
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
|
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
|
||||||
const goodWhere: Prisma.GoodWhereInput = {
|
const goodWhere: Prisma.GoodWhereInput = {
|
||||||
|
familyId: { not: null },
|
||||||
originGood: { delisted: false },
|
originGood: { delisted: false },
|
||||||
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||||
};
|
};
|
||||||
@@ -139,7 +190,11 @@ export class PublicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||||
const where: Prisma.GoodWhereInput = { originGood: { delisted: false } };
|
// 无族商品(自定义)不进公开列表:只认族
|
||||||
|
const where: Prisma.GoodWhereInput = {
|
||||||
|
familyId: { not: null },
|
||||||
|
originGood: { delisted: false },
|
||||||
|
};
|
||||||
if (query.countryId) where.countryId = BigInt(query.countryId);
|
if (query.countryId) where.countryId = BigInt(query.countryId);
|
||||||
if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||||
if (query.categoryId) {
|
if (query.categoryId) {
|
||||||
@@ -171,42 +226,181 @@ export class PublicService {
|
|||||||
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
|
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
|
||||||
: query.sort === 'NEWEST'
|
: query.sort === 'NEWEST'
|
||||||
? [{ createdAt: 'desc' }, { id: 'asc' }]
|
? [{ createdAt: 'desc' }, { id: 'asc' }]
|
||||||
: [
|
: [{ id: 'asc' }]; // DEFAULT:排序移到内存做(树序,见下)
|
||||||
{ goodPriority: 'desc' },
|
|
||||||
{ position: { indexVal: 'asc' } },
|
|
||||||
{ createdAt: 'desc' },
|
|
||||||
{ id: 'asc' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const [total, rows] = await this.prisma.$transaction([
|
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
|
||||||
this.prisma.good.count({ where }),
|
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
|
||||||
this.prisma.good.findMany({
|
// 再内存分组、分页作用于分组结果 —— 若量级上万需改为物化族表查询。
|
||||||
|
const rows = await this.prisma.good.findMany({
|
||||||
where,
|
where,
|
||||||
include: PUBLIC_GOOD_INCLUDE,
|
include: PUBLIC_GOOD_LIST_INCLUDE,
|
||||||
orderBy,
|
orderBy,
|
||||||
skip: (query.page - 1) * query.pageSize,
|
});
|
||||||
take: query.pageSize,
|
if (!query.sort || query.sort === 'DEFAULT') {
|
||||||
}),
|
// 默认排序 = 款序树:国家 → 款所属二级 → 款 → 优先级。
|
||||||
]);
|
// 款顺序存于新树 categories.sort_order(回填自排序表),商品经
|
||||||
|
// origin_goods.sds_category_id 定位到款;同一款下多条 Good 共享
|
||||||
|
// 前三层键,仅按 goodPriority 分先后。缺键(如款不在树中)沉到
|
||||||
|
// 所属国家分组末尾。
|
||||||
|
const meta = await this.loadTreeOrderMeta();
|
||||||
|
rows.sort((a, b) => this.compareByTreeOrder(meta, a, b));
|
||||||
|
}
|
||||||
|
const familyMinPrices = await this.loadFamilyMinPrices();
|
||||||
|
const grouped = new Map<string, PublicGoodListRow[]>();
|
||||||
|
for (const good of rows) {
|
||||||
|
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
||||||
|
const bucket = grouped.get(key);
|
||||||
|
if (bucket) bucket.push(good);
|
||||||
|
else grouped.set(key, [good]);
|
||||||
|
}
|
||||||
|
let items = [...grouped.values()].map((goods) => {
|
||||||
|
const rep = this.pickFamilyRepresentative(goods);
|
||||||
|
const dto = this.toPublicGood(rep);
|
||||||
|
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
|
||||||
|
const familyMin = rep.familyId
|
||||||
|
? familyMinPrices.get(rep.familyId.toString())
|
||||||
|
: undefined;
|
||||||
|
if (familyMin !== undefined) dto.price = familyMin;
|
||||||
|
return dto;
|
||||||
|
});
|
||||||
|
if (query.sort === 'PRICE_ASC' || query.sort === 'PRICE_DESC') {
|
||||||
|
// 分组后的最终价(族最低价)重排,null 价沉底
|
||||||
|
const num = (v: string | null) => (v === null ? Number.POSITIVE_INFINITY : Number(v));
|
||||||
|
items = items.sort((a, b) =>
|
||||||
|
query.sort === 'PRICE_ASC' ? num(a.price) - num(b.price) : num(b.price) - num(a.price),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const total = items.length;
|
||||||
|
const start = (query.page - 1) * query.pageSize;
|
||||||
return {
|
return {
|
||||||
items: rows.map((good) => this.toPublicGood(good)),
|
items: items.slice(start, start + query.pageSize),
|
||||||
total,
|
total,
|
||||||
page: query.page,
|
page: query.page,
|
||||||
pageSize: query.pageSize,
|
pageSize: query.pageSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
|
/**
|
||||||
const good = await this.prisma.good.findFirst({
|
* 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order
|
||||||
where: { originGood: { sdsGoodId: goodId, delisted: false } },
|
* (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。
|
||||||
include: PUBLIC_GOOD_INCLUDE,
|
*/
|
||||||
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
|
private async loadTreeOrderMeta(): Promise<TreeOrderMeta> {
|
||||||
});
|
const [countries, leaves] = await Promise.all([
|
||||||
if (!good) {
|
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
|
||||||
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
this.prisma.$queryRaw<
|
||||||
|
Array<{ sds_category_id: string; c2: number; c3: number }>
|
||||||
|
>`
|
||||||
|
SELECT leaf.sds_category_id,
|
||||||
|
COALESCE(mid.sort_order, 2147483647) AS c2,
|
||||||
|
COALESCE(leaf.sort_order, 2147483647) AS c3
|
||||||
|
FROM categories leaf
|
||||||
|
JOIN categories mid ON mid.category_id = leaf.parent_category_id
|
||||||
|
WHERE leaf.sds_category_id IS NOT NULL AND leaf.sds_category_id <> ''
|
||||||
|
`,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
countryOrder: new Map(countries.map((c) => [c.id.toString(), c.sortOrder])),
|
||||||
|
leafOrder: new Map(
|
||||||
|
leaves.map((l) => [l.sds_category_id, { c2: Number(l.c2), c3: Number(l.c3) }]),
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const dto = this.toPublicGoodDetail(good);
|
|
||||||
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
|
private compareByTreeOrder(meta: TreeOrderMeta, a: PublicGoodListRow, b: PublicGoodListRow): number {
|
||||||
|
const MAX = Number.MAX_SAFE_INTEGER;
|
||||||
|
const key = (g: PublicGoodListRow): [number, number, number, number, number] => {
|
||||||
|
const leaf = meta.leafOrder.get(g.originGood.sdsCategoryId ?? '');
|
||||||
|
return [
|
||||||
|
meta.countryOrder.get(g.countryId.toString()) ?? MAX,
|
||||||
|
leaf?.c2 ?? MAX,
|
||||||
|
leaf?.c3 ?? MAX,
|
||||||
|
-(g.goodPriority ?? 0),
|
||||||
|
Number(g.id),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
const ka = key(a);
|
||||||
|
const kb = key(b);
|
||||||
|
for (let i = 0; i < ka.length; i++) {
|
||||||
|
if (ka[i] !== kb[i]) return ka[i] - kb[i];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 族最低价一次 SQL 聚合:price_matrix 是每族 ~11KB 的 JSONB,按行 include
|
||||||
|
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
|
||||||
|
* 每族一个数字。非数字/缺失 price 的行跳过,与旧内存版过滤语义一致。
|
||||||
|
*/
|
||||||
|
private async loadFamilyMinPrices(): Promise<Map<string, string>> {
|
||||||
|
const rows = await this.prisma.$queryRaw<
|
||||||
|
Array<{ family_id: bigint | string; min_price: Prisma.Decimal | null }>
|
||||||
|
>`
|
||||||
|
SELECT f.family_id, MIN((r->>'price')::numeric) AS min_price
|
||||||
|
FROM product_families f
|
||||||
|
CROSS JOIN LATERAL jsonb_array_elements(f.price_matrix->'rows') AS r
|
||||||
|
WHERE (r->>'price') ~ '^-?[0-9]+(\.[0-9]+)?$'
|
||||||
|
GROUP BY f.family_id
|
||||||
|
`;
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.min_price !== null) {
|
||||||
|
result.set(row.family_id.toString(), String(Number(row.min_price)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情寻址契约(族化后):goodId 即族 ID;无族商品(自定义)不对外暴露。
|
||||||
|
* 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 404。
|
||||||
|
*/
|
||||||
|
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
|
||||||
|
const notFound = () =>
|
||||||
|
new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||||
|
if (!/^\d+$/.test(goodId)) throw notFound();
|
||||||
|
const detail = await this.getGoodByFamilyId(BigInt(goodId));
|
||||||
|
if (!detail) throw notFound();
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 族视角详情:代表 Good 提供公共字段(名称/主图/国家/分类),变体取全体成员并集 */
|
||||||
|
private async getGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
|
||||||
|
const [family, goods] = await Promise.all([
|
||||||
|
this.prisma.productFamily.findUnique({ where: { id: familyId } }),
|
||||||
|
this.prisma.good.findMany({
|
||||||
|
where: { familyId, originGood: { delisted: false } },
|
||||||
|
include: PUBLIC_GOOD_INCLUDE,
|
||||||
|
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }, { id: 'asc' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
// 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 走回退路径
|
||||||
|
if (!family || goods.length === 0) return null;
|
||||||
|
const rep = goods[0];
|
||||||
|
|
||||||
|
const members = await this.prisma.originGood.findMany({
|
||||||
|
where: { familyId, delisted: false },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
detail: true,
|
||||||
|
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const familyVariants = members.flatMap((m) =>
|
||||||
|
m.variants.map((variant) => ({ originGoodId: m.id, variant })),
|
||||||
|
);
|
||||||
|
const familyDetails = members
|
||||||
|
.filter((m) => m.id !== rep.originGoodId)
|
||||||
|
.map((m) => m.detail);
|
||||||
|
|
||||||
|
const dto = this.toPublicGoodDetail(rep, familyVariants, familyDetails);
|
||||||
|
// 尺码表/包装规格以族物化并集为准(款级公共数据),空并集回退主链接合并结果
|
||||||
|
if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'false') {
|
||||||
|
dto.sizeChart = (family.sizeChart as PublicGoodDetailDto['sizeChart']) ?? dto.sizeChart;
|
||||||
|
dto.packageSpecs =
|
||||||
|
(family.packageSpecs as PublicGoodDetailDto['packageSpecs']) ?? dto.packageSpecs;
|
||||||
|
}
|
||||||
|
dto.category.categoryIcon = await this.resolveCategoryIcon(rep.category);
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,10 +408,11 @@ export class PublicService {
|
|||||||
const rows = await this.prisma.good.findMany({
|
const rows = await this.prisma.good.findMany({
|
||||||
where: {
|
where: {
|
||||||
positionId: { not: null },
|
positionId: { not: null },
|
||||||
|
familyId: { not: null },
|
||||||
originGood: { delisted: false },
|
originGood: { delisted: false },
|
||||||
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
|
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
|
||||||
},
|
},
|
||||||
include: PUBLIC_GOOD_INCLUDE,
|
include: PUBLIC_GOOD_LIST_INCLUDE,
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ position: { indexVal: 'asc' } },
|
{ position: { indexVal: 'asc' } },
|
||||||
{ goodPriority: 'desc' },
|
{ goodPriority: 'desc' },
|
||||||
@@ -225,16 +420,53 @@ export class PublicService {
|
|||||||
],
|
],
|
||||||
take: query.limit,
|
take: query.limit,
|
||||||
});
|
});
|
||||||
return rows.map((good) => this.toPublicGood(good));
|
const familyMinPrices = await this.loadFamilyMinPrices();
|
||||||
|
// 首页同样按族去重(一族只出一条),代表行选取与详情/列表一致
|
||||||
|
const grouped = new Map<string, PublicGoodListRow[]>();
|
||||||
|
for (const good of rows) {
|
||||||
|
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
|
||||||
|
const bucket = grouped.get(key);
|
||||||
|
if (bucket) bucket.push(good);
|
||||||
|
else grouped.set(key, [good]);
|
||||||
|
}
|
||||||
|
const items: PublicGoodDto[] = [];
|
||||||
|
for (const goods of grouped.values()) {
|
||||||
|
const rep = this.pickFamilyRepresentative(goods);
|
||||||
|
const dto = this.toPublicGood(rep);
|
||||||
|
const familyMin = rep.familyId
|
||||||
|
? familyMinPrices.get(rep.familyId.toString())
|
||||||
|
: undefined;
|
||||||
|
if (familyMin !== undefined) dto.price = familyMin;
|
||||||
|
items.push(dto);
|
||||||
|
}
|
||||||
|
return items.slice(0, query.limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPublicGood(good: PublicGoodRow): PublicGoodDto {
|
/**
|
||||||
|
* 族代表行选取:与详情 getGoodByFamilyId 的 orderBy 保持一致
|
||||||
|
* (goodPriority desc → createdAt desc → id asc),
|
||||||
|
* 保证列表/首页与详情返回的 goodName、主图、分类等代表行字段一致。
|
||||||
|
*/
|
||||||
|
private pickFamilyRepresentative<T extends { goodPriority: number | null; createdAt: Date; id: bigint }>(
|
||||||
|
goods: T[],
|
||||||
|
): T {
|
||||||
|
return [...goods].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.goodPriority ?? 0) - (a.goodPriority ?? 0) ||
|
||||||
|
b.createdAt.getTime() - a.createdAt.getTime() ||
|
||||||
|
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 入参用精简行类型:列表(PublicGoodListRow)与详情(PublicGoodRow,字段超集)都能传 */
|
||||||
|
private toPublicGood(good: PublicGoodListRow): PublicGoodDto {
|
||||||
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
||||||
group
|
group
|
||||||
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
|
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
goodId: good.originGood.sdsGoodId,
|
// 有族 → 族ID(对外契约);无族(自定义商品)→ sdsGoodId
|
||||||
|
goodId: good.familyId ? good.familyId.toString() : good.originGood.sdsGoodId,
|
||||||
goodName: good.goodName,
|
goodName: good.goodName,
|
||||||
goodPriority: good.goodPriority,
|
goodPriority: good.goodPriority,
|
||||||
country: {
|
country: {
|
||||||
@@ -277,7 +509,7 @@ export class PublicService {
|
|||||||
* design-layer素材图 and the product-level blank garment photo are excluded
|
* design-layer素材图 and the product-level blank garment photo are excluded
|
||||||
* because they are not per-color gallery photos. */
|
* because they are not per-color gallery photos. */
|
||||||
private groupImagesByColor(
|
private groupImagesByColor(
|
||||||
variants: PublicGoodRow['originGood']['variants'],
|
variants: Array<PublicGoodRow['originGood']['variants'][number]>,
|
||||||
): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
|
): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
|
||||||
const groups = new Map<string, {
|
const groups = new Map<string, {
|
||||||
colorId: string | null;
|
colorId: string | null;
|
||||||
@@ -332,9 +564,46 @@ export class PublicService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
|
private toPublicGoodDetail(
|
||||||
|
good: PublicGoodRow,
|
||||||
|
familyVariants: Array<{
|
||||||
|
originGoodId: bigint;
|
||||||
|
variant: PublicGoodRow['originGood']['variants'][number];
|
||||||
|
}> = [],
|
||||||
|
/** 族成员 detail(款级公共规格并集来源之一;族视角详情传入,链接视角为空) */
|
||||||
|
familyDetails: Array<PublicGoodRow['originGood']['detail']> = [],
|
||||||
|
): PublicGoodDetailDto {
|
||||||
const base = this.toPublicGood(good);
|
const base = this.toPublicGood(good);
|
||||||
const detail = good.originGood.detail;
|
const detail = good.originGood.detail;
|
||||||
|
// Detail specs filled from secondaries (族成员 + 旧副源) for sizes/options
|
||||||
|
// the primary does not have.
|
||||||
|
const secondaryDetails = [
|
||||||
|
...familyDetails,
|
||||||
|
...good.mergedOriginGoods.map((m) => m.originGood.detail),
|
||||||
|
].filter((d): d is NonNullable<typeof d> => Boolean(d));
|
||||||
|
// 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期);
|
||||||
|
// 同一链接可能既是族成员又挂旧副源,先按 `${originGoodId}:${sdsVariantId}` 去重,
|
||||||
|
// 再按 color+size 去重(先到先得),避免站点出现重复的尺码×颜色行。
|
||||||
|
const seen = new Set<string>(['']);
|
||||||
|
const dedupe = (originGoodId: bigint, variant: PublicGoodRow['originGood']['variants'][number]) => {
|
||||||
|
const key = `${originGoodId}:${variant.sdsVariantId}`;
|
||||||
|
if (seen.has(key)) return null;
|
||||||
|
seen.add(key);
|
||||||
|
return variant;
|
||||||
|
};
|
||||||
|
good.originGood.variants.forEach((v) => dedupe(good.originGoodId, v));
|
||||||
|
const unionVariants = [
|
||||||
|
...good.originGood.variants,
|
||||||
|
...familyVariants
|
||||||
|
.map(({ originGoodId, variant }) => dedupe(originGoodId, variant))
|
||||||
|
.filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null),
|
||||||
|
...good.mergedOriginGoods.flatMap((m) =>
|
||||||
|
m.originGood.variants
|
||||||
|
.map((variant) => dedupe(m.originGoodId, variant))
|
||||||
|
.filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const allVariants = this.dedupeVariants(unionVariants);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
productCode: detail?.productCode ?? null,
|
productCode: detail?.productCode ?? null,
|
||||||
@@ -353,12 +622,12 @@ export class PublicService {
|
|||||||
designArea: detail?.designArea ?? null,
|
designArea: detail?.designArea ?? null,
|
||||||
pictureRequest: detail?.pictureRequest ?? null,
|
pictureRequest: detail?.pictureRequest ?? null,
|
||||||
},
|
},
|
||||||
media: (detail?.media as Record<string, unknown> | null) ?? null,
|
media: this.mergeMedia(detail, secondaryDetails),
|
||||||
mediaByColor: this.groupImagesByColor(good.originGood.variants),
|
mediaByColor: this.groupImagesByColor(allVariants),
|
||||||
options: (detail?.options as Record<string, unknown> | null) ?? null,
|
options: this.mergeOptions(detail, secondaryDetails),
|
||||||
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
|
sizeChart: this.mergeRowsByKey(detail, secondaryDetails, 'sizeChart'),
|
||||||
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
|
packageSpecs: this.mergeRowsByKey(detail, secondaryDetails, 'packageSpecs'),
|
||||||
variants: good.originGood.variants.map((variant) => ({
|
variants: allVariants.map((variant) => ({
|
||||||
id: variant.sdsVariantId,
|
id: variant.sdsVariantId,
|
||||||
sku: variant.sku,
|
sku: variant.sku,
|
||||||
sizeId: variant.sizeId,
|
sizeId: variant.sizeId,
|
||||||
@@ -377,9 +646,169 @@ export class PublicService {
|
|||||||
sortOrder: variant.sortOrder,
|
sortOrder: variant.sortOrder,
|
||||||
})),
|
})),
|
||||||
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
|
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
|
||||||
|
...this.familyBlock(good),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 族块(设计 D4:零新增公开端点):默认输出;仅当 PUBLIC_DETAIL_FROM_FAMILY
|
||||||
|
* 显式设为 'false' 时关闭(应急回退开关)。数据全部来自 ProductFamily 的物化 JSON。
|
||||||
|
*/
|
||||||
|
private familyBlock(
|
||||||
|
good: PublicGoodRow,
|
||||||
|
): Pick<PublicGoodDetailDto, 'family'> | Record<string, never> {
|
||||||
|
if (process.env.PUBLIC_DETAIL_FROM_FAMILY === 'false') return {};
|
||||||
|
const family = good.originGood.family;
|
||||||
|
if (!family || !family.priceMatrix) return {};
|
||||||
|
const matrix = family.priceMatrix as {
|
||||||
|
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: Array<{ price: string }>;
|
||||||
|
};
|
||||||
|
const prices = matrix.rows.map((r) => Number(r.price)).filter((n) => Number.isFinite(n));
|
||||||
|
return {
|
||||||
|
family: {
|
||||||
|
familyId: family.id.toString(),
|
||||||
|
familyCode: family.familyCode,
|
||||||
|
familyName: family.familyName,
|
||||||
|
sizes: matrix.sizes,
|
||||||
|
colors: matrix.colors,
|
||||||
|
printCounts: matrix.printCounts ?? [],
|
||||||
|
crafts: matrix.crafts,
|
||||||
|
logistics: matrix.logistics,
|
||||||
|
sizeChart: (family.sizeChart as Record<string, unknown> | null) ?? null,
|
||||||
|
packageSpecs: (family.packageSpecs as Record<string, unknown> | null) ?? null,
|
||||||
|
priceMatrix: matrix as unknown as Record<string, unknown>,
|
||||||
|
minPrice: prices.length ? String(Math.min(...prices)) : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Norm key for variant/dedupe matching: case-insensitive, trimmed. */
|
||||||
|
private static normName(value: string | null | undefined): string {
|
||||||
|
return (value ?? '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dedupe variants by color+size (case-insensitive); first (primary) wins. */
|
||||||
|
private dedupeVariants(variants: PublicGoodRow['originGood']['variants']) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: PublicGoodRow['originGood']['variants'] = [];
|
||||||
|
for (const variant of variants) {
|
||||||
|
const key = `${PublicService.normName(variant.colorName)}|${PublicService.normName(variant.sizeName)}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(variant);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a `{ rows: [...] }` spec (sizeChart / packageSpecs) across primary
|
||||||
|
* and secondary details: primary rows win, rows for sizes the primary
|
||||||
|
* lacks are appended from secondaries in order.
|
||||||
|
*/
|
||||||
|
private mergeRowsByKey(
|
||||||
|
primary: PublicGoodRow['originGood']['detail'],
|
||||||
|
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
|
||||||
|
field: 'sizeChart' | 'packageSpecs',
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const primaryRows = (primary?.[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
|
||||||
|
if (!Array.isArray(primaryRows) && secondaries.length === 0) return null;
|
||||||
|
const rows: Array<Record<string, unknown>> = Array.isArray(primaryRows) ? [...primaryRows] : [];
|
||||||
|
const seen = new Set(rows.map((r) => PublicService.normName(String(r?.sizeName ?? ''))));
|
||||||
|
for (const sec of secondaries) {
|
||||||
|
const secRows = (sec[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
|
||||||
|
if (!Array.isArray(secRows)) continue;
|
||||||
|
for (const row of secRows) {
|
||||||
|
const key = PublicService.normName(String(row?.sizeName ?? ''));
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return { ...(primary?.[field] as Record<string, unknown> | null ?? {}), rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge options across details: sizes/colors are unioned by name,
|
||||||
|
* primary entries win and secondary-only ones are appended.
|
||||||
|
*/
|
||||||
|
private mergeOptions(
|
||||||
|
primary: PublicGoodRow['originGood']['detail'],
|
||||||
|
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const primaryOptions = primary?.options as
|
||||||
|
| { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
|
||||||
|
| null;
|
||||||
|
if (!primaryOptions && secondaries.length === 0) return null;
|
||||||
|
const merged: Record<string, unknown> = { ...(primaryOptions ?? {}) };
|
||||||
|
for (const listKey of ['sizes', 'colors'] as const) {
|
||||||
|
const base = Array.isArray(primaryOptions?.[listKey])
|
||||||
|
? [...(primaryOptions![listKey] as Array<Record<string, unknown>>)]
|
||||||
|
: null;
|
||||||
|
if (!base && secondaries.length === 0) continue;
|
||||||
|
const rows = base ?? [];
|
||||||
|
const seen = new Set(rows.map((r) => PublicService.normName(String(r?.name ?? ''))));
|
||||||
|
for (const sec of secondaries) {
|
||||||
|
const secOptions = sec.options as
|
||||||
|
| { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
|
||||||
|
| null;
|
||||||
|
const secRows = secOptions?.[listKey];
|
||||||
|
if (!Array.isArray(secRows)) continue;
|
||||||
|
for (const row of secRows) {
|
||||||
|
const key = PublicService.normName(String(row?.name ?? ''));
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged[listKey] = rows;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge the gallery `media` across details: primary images first,
|
||||||
|
* secondary-only image URLs appended (URL-deduped). `primaryImageUrl`
|
||||||
|
* stays the primary's. Image entries keep their original shape
|
||||||
|
* (`{id,url,sortOrder}` objects or plain strings).
|
||||||
|
*/
|
||||||
|
private mergeMedia(
|
||||||
|
primary: PublicGoodRow['originGood']['detail'],
|
||||||
|
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const imageUrl = (img: unknown): string | null => {
|
||||||
|
if (typeof img === 'string') return img;
|
||||||
|
if (img && typeof img === 'object' && typeof (img as { url?: unknown }).url === 'string') {
|
||||||
|
return (img as { url: string }).url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const primaryMedia = (primary?.media as Record<string, unknown> | null) ?? null;
|
||||||
|
const primaryImages = Array.isArray(primaryMedia?.images)
|
||||||
|
? (primaryMedia!.images as unknown[])
|
||||||
|
: null;
|
||||||
|
if (!primaryImages && secondaries.length === 0) return null;
|
||||||
|
const images: unknown[] = primaryImages ? [...primaryImages] : [];
|
||||||
|
const seen = new Set(images.map(imageUrl).filter((u): u is string => Boolean(u)));
|
||||||
|
for (const sec of secondaries) {
|
||||||
|
const secMedia = sec.media as { images?: unknown } | null;
|
||||||
|
if (!Array.isArray(secMedia?.images)) continue;
|
||||||
|
for (const img of secMedia!.images as unknown[]) {
|
||||||
|
const url = imageUrl(img);
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
images.push(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (images.length === 0) return null;
|
||||||
|
return { ...(primaryMedia ?? {}), images };
|
||||||
|
}
|
||||||
|
|
||||||
private async buildTagGroupFilters(
|
private async buildTagGroupFilters(
|
||||||
selectedGroups: PublicTagFilterDto[],
|
selectedGroups: PublicTagFilterDto[],
|
||||||
): Promise<Prisma.GoodWhereInput[]> {
|
): Promise<Prisma.GoodWhereInput[]> {
|
||||||
|
|||||||