merge: bugfix/family-auto-merge-auth-cookie-yeuimu into develop (SKU 自动合并 + 掉线修复)
This commit is contained in:
@@ -95,4 +95,6 @@ Prisma Postgres 最佳实践: prisma-postgres 技能
|
|||||||
- **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied <name>` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。
|
- **数据库漂移处理**:连共享开发库时先跑 `prisma migrate status`;若报"列已存在"类错误,说明有人用 `db push` 带外改过库,用 `prisma migrate resolve --applied <name>` 把已存在的迁移标记为已应用,再 `migrate deploy` 应用真正缺的部分。切勿盲目 reset 共享库。
|
||||||
- **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。
|
- **连真实库的集成测试隔离**:jest 并行套件共用一个数据库时,(a) 夹具的天然键(sdsCategoryId、名称等)必须带运行时间戳唯一化,禁止跨运行共享字面量;(b) 全量型操作(如 auto-group 扫全库)会顺带扫到其他并行套件的夹具,其写入路径必须对"成员中途消失"宽容(跳过而非抛错),否则会随机挂测试。
|
||||||
- **跑全量 jest 前先停 dev server**:`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。
|
- **跑全量 jest 前先停 dev server**:`nest start --watch` 等常驻进程与测试共用数据库时,其重编译窗口/后台钩子会与测试写入竞争,造成"单跑绿、全量偶发红"的假阳性;验证基线前先停掉所有 watch 进程再跑。
|
||||||
- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或从同一排序函数生成期望。
|
- **中文断言勿手写字面量排序**:JS `Array.sort()` 对中文按 UTF-16 码位排(如 烫 U+70EB < 直 U+76F4),手写期望序列容易按拼音/习惯顺序写反;比较选项集合时用 `expect.arrayContaining` + 长度,或对两侧统一 `.sort()` 后再比较。
|
||||||
|
- **测试夹具必须自包含**:断言库内"存在某类数据"的用例(如标签组过滤)在全新/一次性数据库上必挂;夹具自己种下断言所依赖的数据,不依赖共享库的既有状态。
|
||||||
|
- **严禁直接热修运行容器内的代码**:本次线上 v2 镜像内被塞过未提交的词表改动(热转印),仓库重建镜像即复发且难排查;所有修复必须落在仓库并重建部署。排查"线上行为与代码不符"时先 `md5sum` 比对容器内源文件与仓库。
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export const productFamiliesApi = {
|
|||||||
linksUpdated: number
|
linksUpdated: number
|
||||||
goodsUpdated: number
|
goodsUpdated: number
|
||||||
familiesCreated: number
|
familiesCreated: number
|
||||||
|
familiesMerged: number
|
||||||
|
familiesSkipped: number
|
||||||
|
fragmentsConsolidated: number
|
||||||
|
fragmentsManualReview: number
|
||||||
familiesRecomputed: number
|
familiesRecomputed: number
|
||||||
}>('/product-families/organize')
|
}>('/product-families/organize')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,8 +36,11 @@ request.interceptors.response.use(
|
|||||||
) {
|
) {
|
||||||
config._retried = true
|
config._retried = true
|
||||||
try {
|
try {
|
||||||
|
// 规范拼接:VITE_API_BASE 可能带尾斜杠(如 /v2-api/),直接相加会产生
|
||||||
|
// /v2-api//auth/refresh 双斜杠导致 404
|
||||||
|
const base = (import.meta.env.VITE_API_BASE || '/api').replace(/\/+$/, '')
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`,
|
`${base}/auth/refresh`,
|
||||||
{},
|
{},
|
||||||
{ withCredentials: true },
|
{ withCredentials: true },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -414,6 +414,10 @@ export interface OriginGoodTagInfo {
|
|||||||
export interface OriginGoodTagsResult {
|
export interface OriginGoodTagsResult {
|
||||||
tagsManual: boolean
|
tagsManual: boolean
|
||||||
tags: OriginGoodTagInfo[]
|
tags: OriginGoodTagInfo[]
|
||||||
|
/** 人工勾选缺定价维度时按链接名自动补齐的标签名(缺啥补啥) */
|
||||||
|
filledDimensionTags?: string[]
|
||||||
|
/** 保存时链接无族且自动并入成功的目标族 id */
|
||||||
|
attachedFamilyId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductFamilyMember {
|
export interface ProductFamilyMember {
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ describe('sameFamily', () => {
|
|||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('任一方 familyId 为空 → 回退名称 3 段规则', () => {
|
it('任一方 familyId 为空 → 回退族语义键(国家+品名+SKU,物流/工艺不分族)', () => {
|
||||||
const a = node(null, '美国(包邮)T恤-DG001-单面印花')
|
const a = node(null, '美国(包邮)T恤-DG001-单面印花')
|
||||||
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG001-单面印花-某仓'))).toBe(true)
|
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG001-单面印花-某仓'))).toBe(true)
|
||||||
expect(sameFamily(a, node('12', '美国(包邮)T恤-DG001-单面印花'))).toBe(true)
|
expect(sameFamily(a, node('12', '美国(包邮)T恤-DG001-单面印花'))).toBe(true)
|
||||||
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-单面印花'))).toBe(false)
|
// 物流/工艺是矩阵维度而非分族维度:包邮 vs 不包邮、单面 vs 双面 同款同族
|
||||||
|
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-单面印花'))).toBe(true)
|
||||||
|
expect(sameFamily(a, node(null, '美国(不包邮)T恤-DG001-双面印花-美西一仓'))).toBe(true)
|
||||||
|
// 不同款号不同族
|
||||||
|
expect(sameFamily(a, node(null, '美国(包邮)T恤-DG002-单面印花'))).toBe(false)
|
||||||
|
// 不同国家不同族
|
||||||
|
expect(sameFamily(a, node(null, '英国(包邮)T恤-DG001-单面印花'))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('familyId 不同 → false(即使名称同段)', () => {
|
it('familyId 不同 → false(即使名称同段)', () => {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { sameOriginGroup } from './origin-name'
|
import { familyNameKey } from './origin-name'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 两个原产品节点是否属于同一产品族:
|
* 两个原产品节点是否属于同一产品族:
|
||||||
* 优先用服务端返回的 familyId 精确匹配;无族归属时回退名称 3 段规则
|
* 优先用服务端返回的 familyId 精确匹配;无族归属时回退族语义键
|
||||||
* (老数据/未回填场景兜底)。
|
* (国家+品名+SKU —— 物流/工艺是矩阵维度不分族;老数据/未回填场景兜底)。
|
||||||
*/
|
*/
|
||||||
export function sameFamily(
|
export function sameFamily(
|
||||||
a: { familyId?: string | null; goodName?: string | null },
|
a: { familyId?: string | null; goodName?: string | null },
|
||||||
b: { familyId?: string | null; goodName?: string | null },
|
b: { familyId?: string | null; goodName?: string | null },
|
||||||
): boolean {
|
): boolean {
|
||||||
if (a.familyId && b.familyId) return a.familyId === b.familyId
|
if (a.familyId && b.familyId) return a.familyId === b.familyId
|
||||||
return sameOriginGroup(a.goodName, b.goodName)
|
return familyNameKey(a.goodName) !== '' && familyNameKey(a.goodName) === familyNameKey(b.goodName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,30 @@ export function sameOriginGroup(
|
|||||||
return ka !== '' && ka === truncateToProcess(b);
|
return ka !== '' && ka === truncateToProcess(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 族语义分组键(与 api 端 familyNameKey 同构):第 1 段剥离(物流备注)括号,
|
||||||
|
* 保留 国家+品名+SKU,丢工艺/仓库段 —— 物流/工艺是价格矩阵维度而非分族维度。
|
||||||
|
*/
|
||||||
|
export function familyNameKey(name: string | null | undefined): string {
|
||||||
|
if (!name) return '';
|
||||||
|
const segs = name.split('-').map((s) => s.trim());
|
||||||
|
const head = segs[0] ?? '';
|
||||||
|
if (!head) return '';
|
||||||
|
const normalized = head.replace(/\(/g, '(').replace(/\)/g, ')');
|
||||||
|
const openAt = normalized.indexOf('(');
|
||||||
|
let stripped = normalized;
|
||||||
|
if (openAt >= 0) {
|
||||||
|
const closeAt = normalized.indexOf(')', openAt);
|
||||||
|
if (closeAt > openAt) {
|
||||||
|
stripped = normalized.slice(0, openAt) + normalized.slice(closeAt + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stripped = stripped.trim();
|
||||||
|
const sku = segs[1] ?? '';
|
||||||
|
if (!stripped) return sku || '';
|
||||||
|
return sku ? `${stripped}-${sku}` : stripped;
|
||||||
|
}
|
||||||
|
|
||||||
/** 链接名结构化解析结果:品名 + 型号(SKU 代码) */
|
/** 链接名结构化解析结果:品名 + 型号(SKU 代码) */
|
||||||
export interface ParsedLinkName {
|
export interface ParsedLinkName {
|
||||||
productName: string;
|
productName: string;
|
||||||
|
|||||||
@@ -1024,7 +1024,7 @@ async function onOrganize() {
|
|||||||
try {
|
try {
|
||||||
const r = await productFamiliesApi.organize()
|
const r = await productFamiliesApi.organize()
|
||||||
ElMessage.success(
|
ElMessage.success(
|
||||||
`整理完成:解析 ${r.labelsParsed} 条 / 标签更新 ${r.linksUpdated} 链接 / 新建族 ${r.familiesCreated} / 重算 ${r.familiesRecomputed} 族`,
|
`整理完成:解析 ${r.labelsParsed} 条 / 标签更新 ${r.linksUpdated} 链接 / 新建族 ${r.familiesCreated} / 并入 ${r.familiesMerged} 链接 / 碎片合并 ${r.fragmentsConsolidated ?? 0} 族${r.fragmentsManualReview ? `(待人工合并 ${r.fragmentsManualReview} 族)` : ''} / 重算 ${r.familiesRecomputed} 族`,
|
||||||
)
|
)
|
||||||
await loadAll()
|
await loadAll()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { productFamiliesApi } from '@/api/product-families'
|
|||||||
import { countriesApi } from '@/api/countries'
|
import { countriesApi } from '@/api/countries'
|
||||||
import { syncApi } from '@/api/sync'
|
import { syncApi } from '@/api/sync'
|
||||||
import { cleanLinkName, deriveLinkTagNames, linkDims } from '@/utils/origin-name'
|
import { cleanLinkName, deriveLinkTagNames, linkDims } from '@/utils/origin-name'
|
||||||
|
import type { OriginGoodTagsResult } from '@/types'
|
||||||
import { findCategoryPath, buildCascader } from '@/utils/category-tree'
|
import { findCategoryPath, buildCascader } from '@/utils/category-tree'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -364,8 +365,9 @@ async function loadEditFamily() {
|
|||||||
async function saveMemberTags(row: { id: string }) {
|
async function saveMemberTags(row: { id: string }) {
|
||||||
memberTagSaving.value = row.id
|
memberTagSaving.value = row.id
|
||||||
try {
|
try {
|
||||||
await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
|
const res = await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? [])
|
||||||
ElMessage.success('标签已保存')
|
const filled = (res as OriginGoodTagsResult | undefined)?.filledDimensionTags ?? []
|
||||||
|
ElMessage.success(filled.length ? `标签已保存(缺维度已自动补齐:${filled.join('、')})` : '标签已保存')
|
||||||
await loadEditFamily()
|
await loadEditFamily()
|
||||||
emit('changed')
|
emit('changed')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ export class AuthController {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
secure: isProd,
|
secure: isProd,
|
||||||
// Only ever sent to /auth/refresh and /auth/logout
|
// 浏览器实际请求路径带代理前缀(/api/auth/*、/v2-api/auth/*),cookie path
|
||||||
path: '/auth',
|
// 必须用 '/' 才能命中;否则 refresh cookie 永远带不上 → 访问令牌一过期就掉线
|
||||||
|
path: '/',
|
||||||
});
|
});
|
||||||
// The refresh token deliberately stays HttpOnly-only.
|
// The refresh token deliberately stays HttpOnly-only.
|
||||||
return {
|
return {
|
||||||
@@ -86,7 +87,7 @@ export class AuthController {
|
|||||||
): Promise<LoginResponseDto> {
|
): Promise<LoginResponseDto> {
|
||||||
const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
|
const token = req.cookies?.[REFRESH_TOKEN_COOKIE];
|
||||||
if (!token) {
|
if (!token) {
|
||||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
|
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/' });
|
||||||
throw new UnauthorizedException('Missing refresh token');
|
throw new UnauthorizedException('Missing refresh token');
|
||||||
}
|
}
|
||||||
const result = await this.authService.refresh(token);
|
const result = await this.authService.refresh(token);
|
||||||
@@ -100,7 +101,7 @@ export class AuthController {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
secure: isProd,
|
secure: isProd,
|
||||||
path: '/auth',
|
path: '/',
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
@@ -126,7 +127,7 @@ export class AuthController {
|
|||||||
): Promise<{ success: true }> {
|
): Promise<{ success: true }> {
|
||||||
await this.authService.logout(req.user.id);
|
await this.authService.logout(req.user.id);
|
||||||
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
|
res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' });
|
||||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' });
|
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/' });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
|
|
||||||
describe('OriginGoodsService', () => {
|
describe('OriginGoodsService', () => {
|
||||||
let service: OriginGoodsService;
|
let service: OriginGoodsService;
|
||||||
|
let organize: OrganizeService;
|
||||||
|
let families: ProductFamiliesService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
const createdSds: string[] = [];
|
const createdSds: string[] = [];
|
||||||
@@ -16,6 +18,8 @@ describe('OriginGoodsService', () => {
|
|||||||
providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService],
|
providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(OriginGoodsService);
|
service = moduleRef.get(OriginGoodsService);
|
||||||
|
organize = moduleRef.get(OrganizeService);
|
||||||
|
families = moduleRef.get(ProductFamiliesService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
|
|
||||||
@@ -161,4 +165,96 @@ describe('OriginGoodsService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateTags:缺啥补啥 + 自动归族', () => {
|
||||||
|
const tagIdByNames = async (names: string[]) => {
|
||||||
|
const rows = await prisma.tag.findMany({ where: { tagName: { in: names } } });
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
};
|
||||||
|
const tagNamesOf = async (ogId: bigint) => {
|
||||||
|
const rows = await prisma.originGoodTag.findMany({
|
||||||
|
where: { originGoodId: ogId },
|
||||||
|
include: { tag: true },
|
||||||
|
});
|
||||||
|
return rows.map((r) => r.tag.tagName).sort();
|
||||||
|
};
|
||||||
|
let ogFill: any;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ogFill = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `ogt-fill-${stamp}`, goodName: '美国(包邮)测试K-OG1-单面印花' },
|
||||||
|
});
|
||||||
|
createdSds.push(ogFill.sdsGoodId);
|
||||||
|
// 派生一次,确保三个维度组的标签字典存在
|
||||||
|
await organize.deriveTagsForOg(ogFill.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺维度自动补齐:只勾「不包邮」→ 补 单面印花+烫画,全部人工行', async () => {
|
||||||
|
const [nfs] = await tagIdByNames(['不包邮']);
|
||||||
|
const res = await service.updateTags(ogFill.id, [Number(nfs)]);
|
||||||
|
expect(res.tagsManual).toBe(true);
|
||||||
|
expect(res.tags.map((t: any) => t.tagName).sort()).toEqual(
|
||||||
|
['不包邮', '单面印花', '烫画'].sort(),
|
||||||
|
);
|
||||||
|
expect((res as any).filledDimensionTags?.sort()).toEqual(['单面印花', '烫画'].sort());
|
||||||
|
const rows = await prisma.originGoodTag.findMany({ where: { originGoodId: ogFill.id } });
|
||||||
|
expect(rows.every((r) => r.manual)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('人工勾齐不补;旧词「热转印」经别名视为工艺维度已齐', async () => {
|
||||||
|
const full = await tagIdByNames(['包邮', '单面印花', '烫画']);
|
||||||
|
const res1 = await service.updateTags(ogFill.id, full.map(Number));
|
||||||
|
expect((res1 as any).filledDimensionTags ?? []).toEqual([]);
|
||||||
|
// 热转印(旧词)满足工艺维度,不再补 烫画
|
||||||
|
const alias = await tagIdByNames(['包邮', '单面印花', '热转印']);
|
||||||
|
// 旧词可能不存在于字典:ensureDerivedTagMap 只建封闭词;此处兜底创建
|
||||||
|
if (alias.length === 3) {
|
||||||
|
const res2 = await service.updateTags(ogFill.id, alias.map(Number));
|
||||||
|
expect((res2 as any).filledDimensionTags ?? []).toEqual([]);
|
||||||
|
} else {
|
||||||
|
const g = await prisma.tagGroup.upsert({
|
||||||
|
where: { groupName: '印刷工艺' },
|
||||||
|
create: { groupName: '印刷工艺' },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
const t = await prisma.tag.upsert({
|
||||||
|
where: { tagName: '热转印' },
|
||||||
|
create: { tagName: '热转印', tagGroupId: g.id },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
const [baoyou, danmian] = await tagIdByNames(['包邮', '单面印花']);
|
||||||
|
const res2 = await service.updateTags(ogFill.id, [Number(baoyou), Number(danmian), Number(t.id)]);
|
||||||
|
expect((res2 as any).filledDimensionTags ?? []).toEqual([]);
|
||||||
|
}
|
||||||
|
expect(await tagNamesOf(ogFill.id)).toEqual(['包邮', '单面印花', '热转印'].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无族链接保存标签后自动并入匹配族(同分类优先),响应带 attachedFamilyId', async () => {
|
||||||
|
const cat = `cat-ogt-${stamp}`;
|
||||||
|
const a = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `ogt-fam-${stamp}-a`, goodName: '美国(包邮)测试K-OG2-双面印花', sdsCategoryId: cat },
|
||||||
|
});
|
||||||
|
const b = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `ogt-fam-${stamp}-b`, goodName: '美国(不包邮)测试K-OG2-双面印花', sdsCategoryId: cat },
|
||||||
|
});
|
||||||
|
createdSds.push(a.sdsGoodId, b.sdsGoodId);
|
||||||
|
const family = await families.create({
|
||||||
|
familyName: `测试K族${stamp}`,
|
||||||
|
originGoodIds: [a.id.toString()],
|
||||||
|
primaryOriginGoodId: a.id.toString(),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await organize.deriveTagsForOg(b.id);
|
||||||
|
const tagIds = (await prisma.originGoodTag.findMany({ where: { originGoodId: b.id } })).map((r) => Number(r.tagId));
|
||||||
|
const res = await service.updateTags(b.id, tagIds);
|
||||||
|
expect((res as any).attachedFamilyId).toBe(String(family.id));
|
||||||
|
const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } });
|
||||||
|
expect(after.familyId).toBe(BigInt(family.id));
|
||||||
|
} finally {
|
||||||
|
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined);
|
||||||
|
await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined);
|
||||||
|
await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
import { OrganizeService } from '../product-families/organize.service';
|
import { OrganizeService } from '../product-families/organize.service';
|
||||||
|
import { ProductFamiliesService } from '../product-families/product-families.service';
|
||||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||||
|
|
||||||
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
|
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
|
||||||
@@ -21,6 +22,10 @@ export interface OriginGoodTagItem {
|
|||||||
export interface OriginGoodTagsResult {
|
export interface OriginGoodTagsResult {
|
||||||
tagsManual: boolean;
|
tagsManual: boolean;
|
||||||
tags: OriginGoodTagItem[];
|
tags: OriginGoodTagItem[];
|
||||||
|
/** 人工勾选缺定价维度时按链接名自动补齐的标签名(缺啥补啥) */
|
||||||
|
filledDimensionTags?: string[];
|
||||||
|
/** 保存时链接无族且自动并入成功的目标族 id */
|
||||||
|
attachedFamilyId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedOriginGoods {
|
export interface PaginatedOriginGoods {
|
||||||
@@ -91,6 +96,7 @@ export class OriginGoodsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly familyRecompute: FamilyRecomputeService,
|
private readonly familyRecompute: FamilyRecomputeService,
|
||||||
private readonly organize: OrganizeService,
|
private readonly organize: OrganizeService,
|
||||||
|
private readonly families: ProductFamiliesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 链接当前标签(含 manual 标记) */
|
/** 链接当前标签(含 manual 标记) */
|
||||||
@@ -120,16 +126,17 @@ export class OriginGoodsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 人工接管链接标签:全量替换为 manual 行(自动同步永不覆盖),
|
* 人工接管链接标签:人工勾选全量保存为 manual 行(自动同步永不覆盖);
|
||||||
* 并把有效标签镜像到该链接名下的商品。
|
* 三个定价维度组(印花数量/工艺/物流)缺哪组按链接名自动补哪组(缺啥补啥),
|
||||||
|
* 保证链接 SKU 不掉出族价格矩阵;未归族链接随后自动并入匹配族。
|
||||||
*/
|
*/
|
||||||
async updateTags(id: bigint, tagIds: number[]): Promise<OriginGoodTagsResult> {
|
async updateTags(id: bigint, tagIds: number[]): Promise<OriginGoodTagsResult> {
|
||||||
const og = await this.prisma.originGood.findUnique({
|
const og = await this.prisma.originGood.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { id: true },
|
select: { id: true, goodName: true, source: true },
|
||||||
});
|
});
|
||||||
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
if (!og) throw new NotFoundException(`Origin good ${id} not found`);
|
||||||
const uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) =>
|
let uniqueIds = [...new Set(tagIds.map((v) => BigInt(v)))].sort((a, b) =>
|
||||||
Number(a - b),
|
Number(a - b),
|
||||||
);
|
);
|
||||||
if (uniqueIds.length) {
|
if (uniqueIds.length) {
|
||||||
@@ -138,6 +145,16 @@ export class OriginGoodsService {
|
|||||||
throw new BadRequestException('存在无效标签');
|
throw new BadRequestException('存在无效标签');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// CUSTOM 成员的归因走管理员显式 craftLabel/logisticsLabel,名称不解析、不补齐
|
||||||
|
const fill =
|
||||||
|
og.source === 'SDS'
|
||||||
|
? await this.organize.fillMissingDimTags(og.goodName, uniqueIds)
|
||||||
|
: { tagIds: [] as bigint[], names: [] as string[] };
|
||||||
|
if (fill.tagIds.length) {
|
||||||
|
uniqueIds = [...new Set([...uniqueIds, ...fill.tagIds])].sort((a, b) =>
|
||||||
|
Number(a - b),
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
|
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
|
||||||
...(!uniqueIds.length
|
...(!uniqueIds.length
|
||||||
@@ -159,8 +176,20 @@ export class OriginGoodsService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
select: { familyId: true },
|
select: { familyId: true },
|
||||||
});
|
});
|
||||||
if (ogFull?.familyId) await this.familyRecompute.recomputeFamily(ogFull.familyId);
|
let attachedFamilyId: string | undefined;
|
||||||
return this.getTags(id);
|
if (ogFull?.familyId) {
|
||||||
|
await this.familyRecompute.recomputeFamily(ogFull.familyId);
|
||||||
|
} else {
|
||||||
|
// 无族链接:自动并入匹配的同款族(同 SDS 分类优先,回退族语义名称键)
|
||||||
|
const fid = await this.families.attachToMatchingFamily(id);
|
||||||
|
if (fid) attachedFamilyId = fid.toString();
|
||||||
|
}
|
||||||
|
const base = await this.getTags(id);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
filledDimensionTags: fill.names,
|
||||||
|
...(attachedFamilyId ? { attachedFamilyId } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 恢复自动:清掉全部标签行(含人工行),按链接名称重新派生(显式人工动作) */
|
/** 恢复自动:清掉全部标签行(含人工行),按链接名称重新派生(显式人工动作) */
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ export function isAutoTagGroupName(name: string): boolean {
|
|||||||
return /物流|工艺|位置|印花数量/.test(name);
|
return /物流|工艺|位置|印花数量/.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工艺旧词 → 封闭词表别名:人工标签历史上建过「热转印」(= 烫画,热转印工艺),
|
||||||
|
* 归一后才能进价格矩阵 —— 别名只影响矩阵归因,不改标签字典本身。
|
||||||
|
*/
|
||||||
|
export const CRAFT_TAG_ALIASES: Record<string, string> = {
|
||||||
|
热转印: '烫画',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeCraftTag(name: string): string {
|
||||||
|
return CRAFT_TAG_ALIASES[name] ?? name;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DerivedTagGroupSpec {
|
export interface DerivedTagGroupSpec {
|
||||||
/** 标签组名(查找用 includes 匹配,缺失时自动建组) */
|
/** 标签组名(查找用 includes 匹配,缺失时自动建组) */
|
||||||
group: string;
|
group: string;
|
||||||
|
|||||||
@@ -265,6 +265,38 @@ describe('FamilyRecomputeService', () => {
|
|||||||
expect(matrix.rows[0].logistics).toBe('不包邮');
|
expect(matrix.rows[0].logistics).toBe('不包邮');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('价格矩阵:人工标签旧词「热转印」归一为「烫画」进矩阵(不再被封闭词表踢出)', async () => {
|
||||||
|
const a = await mkOriginGood({
|
||||||
|
goodName: '英国(不包邮)测试毯-PM2-单面印花',
|
||||||
|
variants: [
|
||||||
|
{ sdsVariantId: 'v1', sku: 'PM2-S', sizeId: 'size_S', sizeName: 'S', colorId: 'color_w', colorName: '白色', price: 30 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// 人工勾了旧词表里的 热转印(历史上人工建的标签),其余维度齐全
|
||||||
|
const tagIds: bigint[] = [];
|
||||||
|
for (const [groupName, tagName] of [['印花数量', '单面印花'], ['印刷工艺', '热转印'], ['物流渠道', '不包邮']] as const) {
|
||||||
|
const group = await prisma.tagGroup.findFirst({ where: { groupName } });
|
||||||
|
const g = group ?? (await prisma.tagGroup.create({ data: { groupName } }));
|
||||||
|
if (!group) createdTagGroupIds.push(g.id);
|
||||||
|
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].craft).toBe('烫画'); // 归一后的封闭词表值
|
||||||
|
expect(matrix.crafts).toEqual(['烫画']);
|
||||||
|
});
|
||||||
|
|
||||||
it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => {
|
it('覆盖:命中改价 manual=true,未命中新增行并扩充选项', async () => {
|
||||||
const a = await mkOriginGood({
|
const a = await mkOriginGood({
|
||||||
goodName: '美国(包邮)测试O-PO1-单面印花',
|
goodName: '美国(包邮)测试O-PO1-单面印花',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } 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 { isAutoTagGroupName } from './auto-tag-rules';
|
import { isAutoTagGroupName, normalizeCraftTag } from './auto-tag-rules';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
|
* 产品族重算:并集尺码表/包装规则 + 五维价格矩阵物化。
|
||||||
@@ -88,14 +88,16 @@ export function memberMatrixCombos(input: {
|
|||||||
tagNames: string[];
|
tagNames: string[];
|
||||||
customLabels?: { craft?: string | null; logistics?: string | null };
|
customLabels?: { craft?: string | null; logistics?: string | null };
|
||||||
}): MatrixCombo[] {
|
}): MatrixCombo[] {
|
||||||
|
// 旧词别名归一(热转印→烫画):只影响矩阵归因,封闭词表本身不变
|
||||||
|
const tagNames = input.tagNames.map(normalizeCraftTag);
|
||||||
const values = (dim: DimKey): string[] =>
|
const values = (dim: DimKey): string[] =>
|
||||||
DIM_VALUES[dim].filter((v) => input.tagNames.includes(v));
|
DIM_VALUES[dim].filter((v) => tagNames.includes(v));
|
||||||
|
|
||||||
let printCounts = values('printCount');
|
let printCounts = values('printCount');
|
||||||
let crafts = values('craft');
|
let crafts = values('craft');
|
||||||
let logistics = values('logistics');
|
let logistics = values('logistics');
|
||||||
// CUSTOM 成员:管理员显式标签是唯一来源(craftLabel/logisticsLabel,自由文本)
|
// CUSTOM 成员:管理员显式标签是唯一来源(craftLabel/logisticsLabel,自由文本)
|
||||||
const craftLabel = input.customLabels?.craft ?? '';
|
const craftLabel = normalizeCraftTag(input.customLabels?.craft ?? '');
|
||||||
const logisticsLabel = input.customLabels?.logistics ?? '';
|
const logisticsLabel = input.customLabels?.logistics ?? '';
|
||||||
if (!crafts.length && craftLabel) crafts = [craftLabel];
|
if (!crafts.length && craftLabel) crafts = [craftLabel];
|
||||||
if (!logistics.length && logisticsLabel) logistics = [logisticsLabel];
|
if (!logistics.length && logisticsLabel) logistics = [logisticsLabel];
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
|
|
||||||
async function goodTagNames(goodId: bigint): Promise<string[]> {
|
async function goodTagNames(goodId: bigint): Promise<string[]> {
|
||||||
const rows = await prisma.goodTag.findMany({ where: { goodId }, include: { tag: true } });
|
const rows = await prisma.goodTag.findMany({ where: { goodId }, include: { tag: true } });
|
||||||
return rows.map((r) => r.tag.tagName);
|
return rows.map((r) => r.tag.tagName).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkTagNames(ogId: bigint): Promise<string[]> {
|
async function linkTagNames(ogId: bigint): Promise<string[]> {
|
||||||
const rows = await prisma.originGoodTag.findMany({ where: { originGoodId: ogId }, include: { tag: true } });
|
const rows = await prisma.originGoodTag.findMany({ where: { originGoodId: ogId }, include: { tag: true } });
|
||||||
return rows.map((r) => r.tag.tagName);
|
return rows.map((r) => r.tag.tagName).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
it('链接派生标签落在链接上,商品镜像链接标签;旧自动组标签剔除、人工分组保留', async () => {
|
it('链接派生标签落在链接上,商品镜像链接标签;旧自动组标签剔除、人工分组保留', async () => {
|
||||||
@@ -128,7 +128,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
expect(r1.goodsUpdated).toBe(2);
|
expect(r1.goodsUpdated).toBe(2);
|
||||||
|
|
||||||
// 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印(光板归并为不打印)/不包邮
|
// 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印(光板归并为不打印)/不包邮
|
||||||
expect(await linkTagNames(og1.id)).toEqual(['包邮', '烫画', '单面印花']);
|
expect(await linkTagNames(og1.id)).toEqual(['包邮', '单面印花', '烫画']);
|
||||||
expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印']);
|
expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印']);
|
||||||
|
|
||||||
// 商品级:镜像各自链接(+人工分组保留,旧自动组剔除)
|
// 商品级:镜像各自链接(+人工分组保留,旧自动组剔除)
|
||||||
@@ -173,28 +173,30 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
ids.good.push(good.id);
|
ids.good.push(good.id);
|
||||||
|
|
||||||
await organize.deriveFamilyTags(family.id);
|
await organize.deriveFamilyTags(family.id);
|
||||||
expect(await linkTagNames(og.id)).toEqual(['包邮', '烫画', '单面印花']);
|
expect(await linkTagNames(og.id)).toEqual(['包邮', '单面印花', '烫画']);
|
||||||
|
|
||||||
// 人工接管:解析错了(实际是直喷)→ 改成 直喷
|
// 人工接管:解析错了(实际是直喷)→ 改成 直喷
|
||||||
const zhpena = await prisma.tag.findFirst({ where: { tagName: '直喷' } });
|
const zhpena = await prisma.tag.findFirst({ where: { tagName: '直喷' } });
|
||||||
const baoyou = 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)]);
|
const result = await originGoods.updateTags(og.id, [Number(zhpena!.id), Number(baoyou!.id)]);
|
||||||
expect(result.tagsManual).toBe(true);
|
expect(result.tagsManual).toBe(true);
|
||||||
expect(result.tags.map((t) => t.tagName)).toEqual(['包邮', '直喷']);
|
// 人工勾了 直喷/包邮,缺印花数量维度 → 按名称自动补「单面印花」(缺啥补啥)
|
||||||
|
expect(result.tags.map((t) => t.tagName).sort()).toEqual(['包邮', '单面印花', '直喷']);
|
||||||
|
expect(result.filledDimensionTags).toEqual(['单面印花']);
|
||||||
expect(result.tags.every((t) => t.manual)).toBe(true);
|
expect(result.tags.every((t) => t.manual)).toBe(true);
|
||||||
// 商品镜像跟随人工修正
|
// 商品镜像跟随人工修正(含补齐维度)
|
||||||
expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']);
|
expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '直喷']);
|
||||||
|
|
||||||
// 再次族同步:人工行不被覆盖
|
// 再次族同步:人工行(含补齐行)不被覆盖
|
||||||
await organize.deriveFamilyTags(family.id);
|
await organize.deriveFamilyTags(family.id);
|
||||||
expect(await linkTagNames(og.id)).toEqual(['包邮', '直喷']);
|
expect(await linkTagNames(og.id)).toEqual(['包邮', '单面印花', '直喷']);
|
||||||
expect(await goodTagNames(good.id)).toEqual(['包邮', '直喷']);
|
expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '直喷']);
|
||||||
|
|
||||||
// 恢复自动 → 回到名称派生结果
|
// 恢复自动 → 回到名称派生结果
|
||||||
const reset = await originGoods.resetTags(og.id);
|
const reset = await originGoods.resetTags(og.id);
|
||||||
expect(reset.tagsManual).toBe(false);
|
expect(reset.tagsManual).toBe(false);
|
||||||
expect(reset.tags.map((t) => t.tagName)).toEqual(['包邮', '烫画', '单面印花']);
|
expect(reset.tags.map((t) => t.tagName)).toEqual(['包邮', '单面印花', '烫画']);
|
||||||
expect(await goodTagNames(good.id)).toEqual(['包邮', '烫画', '单面印花']);
|
expect(await goodTagNames(good.id)).toEqual(['包邮', '单面印花', '烫画']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('自定义来源链接不派生标签,仅镜像人工配置', async () => {
|
it('自定义来源链接不派生标签,仅镜像人工配置', async () => {
|
||||||
@@ -259,7 +261,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
await organize.deriveFamilyTags(family.id);
|
await organize.deriveFamilyTags(family.id);
|
||||||
await recompute.recomputeFamily(family.id);
|
await recompute.recomputeFamily(family.id);
|
||||||
const tagsBefore = await linkTagNames(og.id);
|
const tagsBefore = await linkTagNames(og.id);
|
||||||
expect(tagsBefore).toEqual(['包邮', '烫画', '单面印花']);
|
expect(tagsBefore).toEqual(['包邮', '单面印花', '烫画']);
|
||||||
let fam = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
let fam = await prisma.productFamily.findUniqueOrThrow({ where: { id: family.id } });
|
||||||
expect((fam.priceMatrix as any).rows).toHaveLength(1);
|
expect((fam.priceMatrix as any).rows).toHaveLength(1);
|
||||||
|
|
||||||
@@ -283,4 +285,68 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
expect(refreshed).toHaveLength(3);
|
expect(refreshed).toHaveLength(3);
|
||||||
expect(refreshed).toEqual(expect.arrayContaining(['包邮', '双面印花', '直喷']));
|
expect(refreshed).toEqual(expect.arrayContaining(['包邮', '双面印花', '直喷']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('整理碎片合并:纯碎片族并入带商品族(保公开 goodId 稳定)并删除;多族带商品进人工复审', async () => {
|
||||||
|
const cat = `cat-frag-${stamp}`;
|
||||||
|
const mkFrag = async (suffix: string) => {
|
||||||
|
const og = await prisma.originGood.create({
|
||||||
|
data: { sdsGoodId: `frag-${stamp}-${suffix}`, goodName: `美国(包邮)碎片测试-FG1-单面印花-${suffix}`, sdsCategoryId: cat },
|
||||||
|
});
|
||||||
|
ids.originGood.push(og.id);
|
||||||
|
const f = await prisma.productFamily.create({
|
||||||
|
data: { familyName: `碎片族${stamp}-${suffix}`, primaryOriginGoodId: og.id },
|
||||||
|
});
|
||||||
|
ids.family.push(f.id);
|
||||||
|
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: f.id } });
|
||||||
|
return { og, f };
|
||||||
|
};
|
||||||
|
const loose = await mkFrag('a'); // 纯碎片(无商品/无覆盖价)
|
||||||
|
const curated = await mkFrag('b'); // 带商品 → keeper(公开 goodId=族ID)
|
||||||
|
const good = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: `碎片商品${stamp}`,
|
||||||
|
originGoodId: curated.og.id,
|
||||||
|
familyId: curated.f.id,
|
||||||
|
countryId: ids.country[0],
|
||||||
|
categoryId: ids.category[0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ids.good.push(good.id);
|
||||||
|
// 另一个也带商品的族 → 不自动动,进人工复审
|
||||||
|
const curated2 = await mkFrag('c');
|
||||||
|
const good2 = await prisma.good.create({
|
||||||
|
data: {
|
||||||
|
goodName: `碎片商品2${stamp}`,
|
||||||
|
originGoodId: curated2.og.id,
|
||||||
|
familyId: curated2.f.id,
|
||||||
|
countryId: ids.country[0],
|
||||||
|
categoryId: ids.category[0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ids.good.push(good2.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await organize.consolidateFragments();
|
||||||
|
// 纯碎片族成员并入带商品族,碎片族被删除
|
||||||
|
const moved = await prisma.originGood.findUniqueOrThrow({ where: { id: loose.og.id } });
|
||||||
|
expect(moved.familyId).toBe(curated.f.id);
|
||||||
|
const gone = await prisma.productFamily.findUnique({ where: { id: loose.f.id } });
|
||||||
|
expect(gone).toBeNull();
|
||||||
|
// 带商品的族保留;两个带商品族互为碎片 → 较新的进人工复审
|
||||||
|
const kept = await prisma.productFamily.findUniqueOrThrow({ where: { id: curated.f.id } });
|
||||||
|
expect(kept).toBeTruthy();
|
||||||
|
const review = (result.manualReview ?? []).find((r: any) => r.fragmentFamilyId === String(curated2.f.id));
|
||||||
|
expect(review).toBeTruthy();
|
||||||
|
expect(review?.keeperFamilyId).toBe(String(curated.f.id));
|
||||||
|
expect(review?.reason).toContain('has-goods');
|
||||||
|
} finally {
|
||||||
|
await prisma.goodTag.deleteMany({ where: { goodId: { in: ids.good } } }).catch(() => undefined);
|
||||||
|
await prisma.good.deleteMany({ where: { id: { in: ids.good } } }).catch(() => undefined);
|
||||||
|
ids.good = [];
|
||||||
|
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } }).catch(() => undefined);
|
||||||
|
ids.family = [];
|
||||||
|
await prisma.originGood.deleteMany({ where: { id: { in: ids.originGood } } }).catch(() => undefined);
|
||||||
|
ids.originGood = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DERIVED_TAG_GROUP_SPECS,
|
DERIVED_TAG_GROUP_SPECS,
|
||||||
deriveLinkTagNames,
|
deriveLinkTagNames,
|
||||||
isAutoTagGroupName,
|
isAutoTagGroupName,
|
||||||
|
normalizeCraftTag,
|
||||||
} from './auto-tag-rules';
|
} from './auto-tag-rules';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +34,7 @@ export class OrganizeService {
|
|||||||
const labels = await this.backfillLabels();
|
const labels = await this.backfillLabels();
|
||||||
const tags = await this.deriveAllTags();
|
const tags = await this.deriveAllTags();
|
||||||
const grouped = await this.families.autoGroup(true);
|
const grouped = await this.families.autoGroup(true);
|
||||||
|
const fragments = await this.consolidateFragments();
|
||||||
const families = await this.prisma.productFamily.findMany({ select: { id: true } });
|
const families = await this.prisma.productFamily.findMany({ select: { id: true } });
|
||||||
for (const f of families) {
|
for (const f of families) {
|
||||||
await this.recompute.recomputeFamily(f.id);
|
await this.recompute.recomputeFamily(f.id);
|
||||||
@@ -43,12 +45,121 @@ export class OrganizeService {
|
|||||||
linksUpdated: tags.linksUpdated,
|
linksUpdated: tags.linksUpdated,
|
||||||
goodsUpdated: tags.goodsUpdated,
|
goodsUpdated: tags.goodsUpdated,
|
||||||
familiesCreated: grouped.applied,
|
familiesCreated: grouped.applied,
|
||||||
|
familiesMerged: grouped.merged,
|
||||||
|
familiesSkipped: grouped.skipped?.length ?? 0,
|
||||||
|
fragmentsConsolidated: fragments.consolidated,
|
||||||
|
fragmentsManualReview: fragments.manualReview.length,
|
||||||
familiesRecomputed: families.length,
|
familiesRecomputed: families.length,
|
||||||
};
|
};
|
||||||
this.logger.log(`organize done: ${JSON.stringify(result)}`);
|
this.logger.log(`organize done: ${JSON.stringify(result)}`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 碎片族合并:同一 SDS 分类出现多个族时(历史 autoGroup 只建不并所致),
|
||||||
|
* 把「纯碎片族」(autoManaged、无商品、无覆盖价)的成员并入最老族并删除空族;
|
||||||
|
* 带商品/覆盖价或人工锁定的碎片族保留,写进人工复审报告(由后台手动移动成员)。
|
||||||
|
* keeper 选择:同簇中最早带商品的族,否则最老的 autoManaged 族。
|
||||||
|
*/
|
||||||
|
async consolidateFragments(): Promise<{
|
||||||
|
consolidated: number;
|
||||||
|
manualReview: Array<{ keeperFamilyId: string; fragmentFamilyId: string; reason: string }>;
|
||||||
|
}> {
|
||||||
|
const families = await this.prisma.productFamily.findMany({
|
||||||
|
select: { id: true, autoManaged: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
const members = await this.prisma.originGood.findMany({
|
||||||
|
where: { familyId: { not: null }, delisted: false },
|
||||||
|
select: { familyId: true, sdsCategoryId: true },
|
||||||
|
});
|
||||||
|
const goodCounts = await this.prisma.good.groupBy({
|
||||||
|
by: ['familyId'],
|
||||||
|
_count: { _all: true },
|
||||||
|
where: { familyId: { not: null } },
|
||||||
|
});
|
||||||
|
const goodsByFamily = new Map(goodCounts.map((g) => [g.familyId!.toString(), g._count._all]));
|
||||||
|
const overrideCounts = await this.prisma.familyPriceOverride.groupBy({
|
||||||
|
by: ['familyId'],
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const overridesByFamily = new Map(
|
||||||
|
overrideCounts.map((o) => [o.familyId.toString(), o._count._all]),
|
||||||
|
);
|
||||||
|
const autoManagedById = new Map(families.map((f) => [f.id.toString(), f.autoManaged]));
|
||||||
|
const familyCatIds = new Map<string, Set<string>>();
|
||||||
|
for (const m of members) {
|
||||||
|
if (!m.sdsCategoryId) continue;
|
||||||
|
const key = m.familyId!.toString();
|
||||||
|
familyCatIds.set(key, (familyCatIds.get(key) ?? new Set()).add(m.sdsCategoryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 SDS 分类聚簇:同分类的多个族互为碎片
|
||||||
|
const clusterByFamily = new Map<string, Set<string>>();
|
||||||
|
const catOwners = new Map<string, string[]>();
|
||||||
|
for (const f of families) {
|
||||||
|
const cats = familyCatIds.get(f.id.toString()) ?? new Set();
|
||||||
|
for (const cat of cats) {
|
||||||
|
const owners = catOwners.get(cat) ?? [];
|
||||||
|
owners.push(f.id.toString());
|
||||||
|
catOwners.set(cat, owners);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const owners of catOwners.values()) {
|
||||||
|
if (owners.length < 2) continue;
|
||||||
|
for (const id of owners) {
|
||||||
|
const cluster = clusterByFamily.get(id) ?? new Set();
|
||||||
|
owners.forEach((o) => cluster.add(o));
|
||||||
|
clusterByFamily.set(id, cluster);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let consolidated = 0;
|
||||||
|
const manualReview: Array<{ keeperFamilyId: string; fragmentFamilyId: string; reason: string }> = [];
|
||||||
|
const processedClusters = new Set<string>();
|
||||||
|
for (const [familyIdStr, cluster] of clusterByFamily) {
|
||||||
|
const sorted = [...cluster].sort((a, b) => Number(BigInt(a) - BigInt(b)));
|
||||||
|
const clusterKey = sorted.join(',');
|
||||||
|
if (processedClusters.has(clusterKey)) continue;
|
||||||
|
processedClusters.add(clusterKey);
|
||||||
|
// keeper:最早带商品的族,否则最老的 autoManaged 族
|
||||||
|
const withGoods = sorted.find((id) => (goodsByFamily.get(id) ?? 0) > 0);
|
||||||
|
const keeper =
|
||||||
|
withGoods ??
|
||||||
|
sorted.find((id) => autoManagedById.get(id) === true) ??
|
||||||
|
sorted[0];
|
||||||
|
for (const id of sorted) {
|
||||||
|
if (id === keeper) continue;
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if ((goodsByFamily.get(id) ?? 0) > 0) reasons.push('has-goods');
|
||||||
|
if ((overridesByFamily.get(id) ?? 0) > 0) reasons.push('has-overrides');
|
||||||
|
if (autoManagedById.get(id) !== true) reasons.push('manual-locked');
|
||||||
|
if (reasons.length) {
|
||||||
|
manualReview.push({
|
||||||
|
keeperFamilyId: keeper,
|
||||||
|
fragmentFamilyId: id,
|
||||||
|
reason: reasons.join(','),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 纯碎片:成员并入 keeper,删除空族
|
||||||
|
const keeperId = BigInt(keeper);
|
||||||
|
await this.prisma.originGood.updateMany({
|
||||||
|
where: { familyId: BigInt(id) },
|
||||||
|
data: { familyId: keeperId },
|
||||||
|
});
|
||||||
|
await this.prisma.good.updateMany({
|
||||||
|
where: { familyId: BigInt(id) },
|
||||||
|
data: { familyId: keeperId },
|
||||||
|
});
|
||||||
|
await this.prisma.productFamily.delete({ where: { id: BigInt(id) } });
|
||||||
|
consolidated += 1;
|
||||||
|
}
|
||||||
|
await this.recompute.recomputeFamily(BigInt(keeper));
|
||||||
|
}
|
||||||
|
return { consolidated, manualReview };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回填结构化解析列(skuCode/logisticsLabel/craftLabel/warehouseLabel)。
|
* 回填结构化解析列(skuCode/logisticsLabel/craftLabel/warehouseLabel)。
|
||||||
* 只补 NULL 列——存量正确数据不覆盖(同步已不再写入这些列)。
|
* 只补 NULL 列——存量正确数据不覆盖(同步已不再写入这些列)。
|
||||||
@@ -267,6 +378,36 @@ export class OrganizeService {
|
|||||||
return { linksUpdated };
|
return { linksUpdated };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人工标签缺维补齐:三个定价维度组(印花数量/工艺/物流)任一组在当前标签里
|
||||||
|
* 没有封闭词表取值时,按链接名称派生补上;工艺维度经别名归一后判断(热转印≈烫画)。
|
||||||
|
* 人工已勾的维度永不覆盖;名称派生不出该维度时不补(由调用方提示)。
|
||||||
|
*/
|
||||||
|
async fillMissingDimTags(
|
||||||
|
goodName: string | null,
|
||||||
|
currentTagIds: bigint[],
|
||||||
|
): Promise<{ tagIds: bigint[]; names: string[] }> {
|
||||||
|
const tagMap = await this.ensureDerivedTagMap();
|
||||||
|
const current = currentTagIds.length
|
||||||
|
? await this.prisma.tag.findMany({
|
||||||
|
where: { id: { in: currentTagIds } },
|
||||||
|
select: { tagName: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const currentNames = new Set(current.map((t) => normalizeCraftTag(t.tagName)));
|
||||||
|
const derivedNames = deriveLinkTagNames(goodName);
|
||||||
|
const missingNames: string[] = [];
|
||||||
|
for (const spec of DERIVED_TAG_GROUP_SPECS) {
|
||||||
|
if (spec.tags.some((name) => currentNames.has(name))) continue;
|
||||||
|
const fromName = spec.tags.find((name) => derivedNames.includes(name));
|
||||||
|
if (fromName) missingNames.push(fromName);
|
||||||
|
}
|
||||||
|
const tagIds = missingNames
|
||||||
|
.map((name) => tagMap.get(name))
|
||||||
|
.filter((id): id is bigint => id !== undefined);
|
||||||
|
return { tagIds, names: missingNames };
|
||||||
|
}
|
||||||
|
|
||||||
private async autoGroupIds(): Promise<{ autoGroupIds: Set<string> }> {
|
private async autoGroupIds(): Promise<{ autoGroupIds: Set<string> }> {
|
||||||
const groups = await this.prisma.tagGroup.findMany({
|
const groups = await this.prisma.tagGroup.findMany({
|
||||||
select: { id: true, groupName: true },
|
select: { id: true, groupName: true },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { parseOriginName, originGroupKey } from './origin-name.parser';
|
import { parseOriginName, originGroupKey, familyNameKey } from './origin-name.parser';
|
||||||
|
|
||||||
describe('parseOriginName', () => {
|
describe('parseOriginName', () => {
|
||||||
it('解析完整四段名(含仓库)', () => {
|
it('解析完整四段名(含仓库)', () => {
|
||||||
@@ -102,3 +102,41 @@ describe('originGroupKey', () => {
|
|||||||
expect(originGroupKey('')).toBe('');
|
expect(originGroupKey('')).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('familyNameKey(族语义分组键)', () => {
|
||||||
|
it('物流差异同键:包邮/不包邮归一族', () => {
|
||||||
|
expect(familyNameKey('英国(不包邮)鼠标垫-GBIU017-单面印花')).toBe(
|
||||||
|
familyNameKey('英国(包邮*运费订单结算时支付)鼠标垫-GBIU017-单面印花'),
|
||||||
|
);
|
||||||
|
expect(familyNameKey('英国(不包邮)鼠标垫-GBIU017-单面印花')).toBe('英国鼠标垫-GBIU017');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('工艺/仓库段不参与:跨工艺归一族', () => {
|
||||||
|
expect(familyNameKey('美国(包邮)T恤-DG001-单面印花-美西一仓')).toBe(
|
||||||
|
familyNameKey('美国(不包邮)T恤-DG001-双面印花'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不同 SKU / 不同国家不同键', () => {
|
||||||
|
expect(familyNameKey('美国(包邮)T恤-DG001-单面印花')).not.toBe(
|
||||||
|
familyNameKey('美国(包邮)T恤-DG002-单面印花'),
|
||||||
|
);
|
||||||
|
expect(familyNameKey('美国(包邮)T恤-DG001-单面印花')).not.toBe(
|
||||||
|
familyNameKey('英国(包邮)T恤-DG001-单面印花'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('半角/全角括号混用与无括号都能出键', () => {
|
||||||
|
expect(familyNameKey('美国(不包邮)T恤-DG001-单面印花')).toBe('美国T恤-DG001');
|
||||||
|
expect(familyNameKey('美国T恤-DG001-单面印花')).toBe('美国T恤-DG001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 SKU 段时仅用品名段', () => {
|
||||||
|
expect(familyNameKey('美国(包邮)T恤')).toBe('美国T恤');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空名返回空串', () => {
|
||||||
|
expect(familyNameKey(null)).toBe('');
|
||||||
|
expect(familyNameKey('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,3 +63,29 @@ export function originGroupKey(name: string | null | undefined): string {
|
|||||||
if (!name) return '';
|
if (!name) return '';
|
||||||
return name.split('-').slice(0, 3).join('-');
|
return name.split('-').slice(0, 3).join('-');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 族语义分组键(自动建族/归族的回退键):第 1 段剥离(物流备注)括号内容,
|
||||||
|
* 保留 国家+品名,拼接 SKU 段,丢弃工艺/仓库段 —— 物流、工艺、印花是价格矩阵
|
||||||
|
* 维度而非分族维度,同款链接(同国家+品名+SKU)必须归一族。
|
||||||
|
*/
|
||||||
|
export function familyNameKey(name: string | null | undefined): string {
|
||||||
|
if (!name) return '';
|
||||||
|
const segs = name.split('-').map((s) => s.trim());
|
||||||
|
const head = segs[0] ?? '';
|
||||||
|
if (!head) return '';
|
||||||
|
// 半角/全角括号混用先归一(与 splitSegment1 一致)
|
||||||
|
const normalized = head.replace(/\(/g, '(').replace(/\)/g, ')');
|
||||||
|
const openAt = normalized.indexOf('(');
|
||||||
|
let stripped = normalized;
|
||||||
|
if (openAt >= 0) {
|
||||||
|
const closeAt = normalized.indexOf(')', openAt);
|
||||||
|
if (closeAt > openAt) {
|
||||||
|
stripped = normalized.slice(0, openAt) + normalized.slice(closeAt + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stripped = stripped.trim();
|
||||||
|
const sku = segs[1] ?? '';
|
||||||
|
if (!stripped) return sku || '';
|
||||||
|
return sku ? `${stripped}-${sku}` : stripped;
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,19 +108,20 @@ describe('ProductFamiliesService', () => {
|
|||||||
await service.patch(BigInt(f.id), { autoManaged: true });
|
await service.patch(BigInt(f.id), { autoManaged: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('auto-group:无分类时按名称键分组;预览不写库;apply 幂等', async () => {
|
it('auto-group:无分类时按族语义键分组(物流/工艺不分族);预览不写库;apply 幂等', async () => {
|
||||||
const g1a = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花`);
|
const g1a = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花`);
|
||||||
const g1b = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花-某仓`);
|
const g1b = await mkOriginGood(`自动组${stamp}(包邮)卫衣-ZZ${stamp}-单面印花-某仓`);
|
||||||
const g2 = await mkOriginGood(`自动组${stamp}(不包邮)卫衣-ZZ${stamp}-单面印花`);
|
const g2 = await mkOriginGood(`自动组${stamp}(不包邮)卫衣-ZZ${stamp}-单面印花`);
|
||||||
|
|
||||||
const preview = (await service.autoGroup(false)) as any;
|
const preview = (await service.autoGroup(false)) as any;
|
||||||
expect(preview.applied).toBe(0);
|
expect(preview.applied).toBe(0);
|
||||||
const hit = preview.groups.find((g: any) => g.groupKey.includes(`ZZ${stamp}`) && g.memberCount === 2);
|
const hit = preview.groups.find((g: any) => g.groupKey.includes(`ZZ${stamp}`) && g.memberCount === 3);
|
||||||
expect(hit).toBeTruthy();
|
expect(hit).toBeTruthy();
|
||||||
expect(hit.familyName).toContain('卫衣');
|
expect(hit.familyName).toContain('卫衣');
|
||||||
|
expect(hit.action).toBe('create');
|
||||||
|
|
||||||
const applied = (await service.autoGroup(true)) as any;
|
const applied = (await service.autoGroup(true)) as any;
|
||||||
expect(applied.applied).toBeGreaterThanOrEqual(2); // 包邮组 + 不包邮组(物流不同不同组)
|
expect(applied.applied).toBeGreaterThanOrEqual(1); // 同款三链接一族(物流是矩阵维度)
|
||||||
const families = await prisma.productFamily.findMany({
|
const families = await prisma.productFamily.findMany({
|
||||||
where: { familyName: { contains: `自动组${stamp}` } },
|
where: { familyName: { contains: `自动组${stamp}` } },
|
||||||
});
|
});
|
||||||
@@ -130,8 +131,9 @@ describe('ProductFamiliesService', () => {
|
|||||||
select: { familyId: true },
|
select: { familyId: true },
|
||||||
});
|
});
|
||||||
expect(grouped.every((g) => g.familyId !== null)).toBe(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); // 物流不同不同族
|
expect(grouped[0].familyId).toBe(grouped[1].familyId);
|
||||||
|
expect(grouped[2].familyId).toBe(grouped[0].familyId);
|
||||||
|
|
||||||
// 幂等:候选已清空
|
// 幂等:候选已清空
|
||||||
const again = (await service.autoGroup(true)) as any;
|
const again = (await service.autoGroup(true)) as any;
|
||||||
@@ -139,6 +141,59 @@ describe('ProductFamiliesService', () => {
|
|||||||
expect(hitAgain).toHaveLength(0);
|
expect(hitAgain).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('auto-group:已有同款族时并入而非新建(不再产生 -2 碎片族)', async () => {
|
||||||
|
const cat = `cat-merge-${stamp}`;
|
||||||
|
const a = await mkOriginGood(`美国(包邮)测试M-MG1-单面印花`, { sdsCategoryId: cat });
|
||||||
|
const b = await mkOriginGood(`美国(不包邮)测试M-MG1-双面印花`, { sdsCategoryId: cat });
|
||||||
|
// 先给 a 建族(模拟早先整理只见到 a)
|
||||||
|
const family = await service.create({
|
||||||
|
familyName: `测试M族${stamp}`,
|
||||||
|
originGoodIds: [a.id.toString()],
|
||||||
|
primaryOriginGoodId: a.id.toString(),
|
||||||
|
});
|
||||||
|
createdFamilyIds.push(BigInt(family.id));
|
||||||
|
try {
|
||||||
|
const result = (await service.autoGroup(true)) as any;
|
||||||
|
const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } });
|
||||||
|
expect(after.familyId).toBe(BigInt(family.id)); // b 并入已有族
|
||||||
|
expect(result.merged).toBeGreaterThanOrEqual(1);
|
||||||
|
// 该分类没有产生第二个族
|
||||||
|
const dup = await prisma.productFamily.findMany({
|
||||||
|
where: { familyName: { contains: `测试M族${stamp}` } },
|
||||||
|
});
|
||||||
|
expect(dup).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined);
|
||||||
|
await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined);
|
||||||
|
await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-group:同款已有族但为人工锁定(autoManaged=false)时不并入也不新建', async () => {
|
||||||
|
const cat = `cat-manual-${stamp}`;
|
||||||
|
const a = await mkOriginGood(`美国(包邮)测试M-MG2-单面印花`, { sdsCategoryId: cat });
|
||||||
|
const b = await mkOriginGood(`美国(不包邮)测试M-MG2-单面印花`, { sdsCategoryId: cat });
|
||||||
|
const family = await service.create({
|
||||||
|
familyName: `测试M锁定族${stamp}`,
|
||||||
|
originGoodIds: [a.id.toString()],
|
||||||
|
primaryOriginGoodId: a.id.toString(),
|
||||||
|
});
|
||||||
|
createdFamilyIds.push(BigInt(family.id));
|
||||||
|
try {
|
||||||
|
await prisma.productFamily.update({ where: { id: BigInt(family.id) }, data: { autoManaged: false } });
|
||||||
|
const result = (await service.autoGroup(true)) as any;
|
||||||
|
const after = await prisma.originGood.findUniqueOrThrow({ where: { id: b.id } });
|
||||||
|
expect(after.familyId).toBeNull(); // 不并入人工族
|
||||||
|
const sameCat = await prisma.productFamily.findMany({ where: { familyName: { contains: `测试M锁定族${stamp}` } } });
|
||||||
|
expect(sameCat).toHaveLength(1); // 也不新建平行族
|
||||||
|
expect((result.skipped ?? []).length).toBeGreaterThanOrEqual(0); // 报告字段存在
|
||||||
|
} finally {
|
||||||
|
await prisma.originGoodTag.deleteMany({ where: { originGoodId: { in: [a.id, b.id] } } }).catch(() => undefined);
|
||||||
|
await prisma.originGood.deleteMany({ where: { id: { in: [a.id, b.id] } } }).catch(() => undefined);
|
||||||
|
await prisma.productFamily.deleteMany({ where: { id: BigInt(family.id) } }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('auto-group:同 SDS 分类(产品模型)跨工艺/编码归一族,族名取分类名', async () => {
|
it('auto-group:同 SDS 分类(产品模型)跨工艺/编码归一族,族名取分类名', async () => {
|
||||||
const cat = await prisma.category.create({
|
const cat = await prisma.category.create({
|
||||||
data: { categoryName: `ZZF${stamp} 180G纯棉T恤(ZZA${stamp})`, sdsCategoryId: `cat-hook-${stamp}` },
|
data: { categoryName: `ZZF${stamp} 180G纯棉T恤(ZZA${stamp})`, sdsCategoryId: `cat-hook-${stamp}` },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { FamilyRecomputeService, PriceMatrix } from './family-recompute.service';
|
import { FamilyRecomputeService, PriceMatrix } from './family-recompute.service';
|
||||||
import { originGroupKey, parseOriginName } from './origin-name.parser';
|
import { originGroupKey, parseOriginName, familyNameKey } from './origin-name.parser';
|
||||||
import {
|
import {
|
||||||
CreateCustomMemberDto,
|
CreateCustomMemberDto,
|
||||||
CreateProductFamilyDto,
|
CreateProductFamilyDto,
|
||||||
@@ -147,7 +147,11 @@ export class ProductFamiliesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 自动成族:SDS 叶子分类即产品模型(如 "DG001 180G纯棉T恤(JSA002)"),
|
/** 自动成族:SDS 叶子分类即产品模型(如 "DG001 180G纯棉T恤(JSA002)"),
|
||||||
* 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退名称 3 段键;apply=false 仅预览 */
|
* 同分类链接归一族(跨工艺/物流/仓库/编码);无分类回退族语义名称键
|
||||||
|
* (国家+品名+SKU,物流/工艺是矩阵维度不分族)。
|
||||||
|
* 已有同款 autoManaged 族时并入(不再新建 -2 碎片族);同款仅有
|
||||||
|
* 人工锁定族(autoManaged=false)时跳过该组,不并入也不新建。
|
||||||
|
* apply=false 仅预览(action 标注 create/merge/skip)。 */
|
||||||
async autoGroup(apply: boolean) {
|
async autoGroup(apply: boolean) {
|
||||||
const candidates = await this.prisma.originGood.findMany({
|
const candidates = await this.prisma.originGood.findMany({
|
||||||
where: { familyId: null, delisted: false },
|
where: { familyId: null, delisted: false },
|
||||||
@@ -160,64 +164,184 @@ export class ProductFamiliesService {
|
|||||||
});
|
});
|
||||||
const catName = new Map(categories.map((c) => [c.sdsCategoryId as string, c.categoryName]));
|
const catName = new Map(categories.map((c) => [c.sdsCategoryId as string, c.categoryName]));
|
||||||
|
|
||||||
|
// 已有族的成员指纹:分类/名称键 → 候选族 id(用于"并入而非新建")
|
||||||
|
const familyMembers = await this.prisma.originGood.findMany({
|
||||||
|
where: { familyId: { not: null }, delisted: false },
|
||||||
|
select: { familyId: true, sdsCategoryId: true, goodName: true },
|
||||||
|
});
|
||||||
|
const catFamilies = new Map<string, bigint[]>();
|
||||||
|
const nameFamilies = new Map<string, bigint[]>();
|
||||||
|
const push = (map: Map<string, bigint[]>, key: string, id: bigint) => {
|
||||||
|
map.set(key, [...(map.get(key) ?? []), id]);
|
||||||
|
};
|
||||||
|
for (const m of familyMembers) {
|
||||||
|
if (m.sdsCategoryId) push(catFamilies, m.sdsCategoryId, m.familyId!);
|
||||||
|
const nk = familyNameKey(m.goodName);
|
||||||
|
if (nk) push(nameFamilies, nk, m.familyId!);
|
||||||
|
}
|
||||||
|
const autoFamilyIds = new Set(
|
||||||
|
(
|
||||||
|
await this.prisma.productFamily.findMany({
|
||||||
|
where: { autoManaged: true },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((f) => f.id),
|
||||||
|
);
|
||||||
|
/** 匹配可并入的已有族:分类优先,回退名称键;仅 autoManaged,多候选取最老 */
|
||||||
|
const matchAutoFamily = (cat: string | null, nameKey: string): bigint | null => {
|
||||||
|
const pool = [
|
||||||
|
...new Set([...(cat ? (catFamilies.get(cat) ?? []) : []), ...(nameKey ? (nameFamilies.get(nameKey) ?? []) : [])]),
|
||||||
|
]
|
||||||
|
.filter((id) => autoFamilyIds.has(id))
|
||||||
|
.sort((a, b) => Number(a - b));
|
||||||
|
return pool[0] ?? null;
|
||||||
|
};
|
||||||
|
/** 该组是否已有同款族(含人工锁定族,用于 skip 判断) */
|
||||||
|
const hasAnyFamily = (cat: string | null, nameKey: string): boolean =>
|
||||||
|
Boolean(cat && catFamilies.has(cat)) || Boolean(nameKey && nameFamilies.has(nameKey));
|
||||||
|
|
||||||
const groups = new Map<string, typeof candidates>();
|
const groups = new Map<string, typeof candidates>();
|
||||||
|
const groupKeys = new Map<string, { cat: string | null; nameKey: string }>();
|
||||||
for (const og of candidates) {
|
for (const og of candidates) {
|
||||||
const nameKey = originGroupKey(og.goodName);
|
const nameKey = familyNameKey(og.goodName);
|
||||||
const key = og.sdsCategoryId ? `cat:${og.sdsCategoryId}` : nameKey ? `name:${nameKey}` : '';
|
const key = og.sdsCategoryId ? `cat:${og.sdsCategoryId}` : nameKey ? `name:${nameKey}` : '';
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
|
groupKeys.set(key, { cat: og.sdsCategoryId ?? null, nameKey });
|
||||||
const arr = groups.get(key);
|
const arr = groups.get(key);
|
||||||
if (arr) arr.push(og);
|
if (arr) arr.push(og);
|
||||||
else groups.set(key, [og]);
|
else groups.set(key, [og]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = [...groups.values()].map((members) => {
|
const resolveGroup = (key: string) => {
|
||||||
|
const members = groups.get(key)!;
|
||||||
|
const { cat, nameKey } = groupKeys.get(key)!;
|
||||||
const parsed = parseOriginName(members[0].goodName);
|
const parsed = parseOriginName(members[0].goodName);
|
||||||
const categoryName = members[0].sdsCategoryId
|
const categoryName = cat ? (catName.get(cat) ?? null) : null;
|
||||||
? (catName.get(members[0].sdsCategoryId) ?? null)
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
groupKey: members[0].sdsCategoryId ? `cat:${members[0].sdsCategoryId}` : `name:${originGroupKey(members[0].goodName)}`,
|
members,
|
||||||
familyName:
|
cat,
|
||||||
categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName),
|
nameKey,
|
||||||
familyCode: codeFromCategoryName(categoryName) ?? parsed.skuCode ?? null,
|
parsed,
|
||||||
memberCount: members.length,
|
categoryName,
|
||||||
sampleNames: members.slice(0, 3).map((m) => m.goodName ?? ''),
|
familyName: categoryName ?? parsed.productName ?? nameKey,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = [...groups.keys()].map((key) => {
|
||||||
|
const g = resolveGroup(key);
|
||||||
|
const target = g.cat || g.nameKey ? matchAutoFamily(g.cat, g.nameKey) : null;
|
||||||
|
const skip = !target && hasAnyFamily(g.cat, g.nameKey);
|
||||||
|
return {
|
||||||
|
groupKey: key,
|
||||||
|
familyName: g.familyName,
|
||||||
|
familyCode: codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? null,
|
||||||
|
memberCount: g.members.length,
|
||||||
|
sampleNames: g.members.slice(0, 3).map((m) => m.goodName ?? ''),
|
||||||
|
action: target ? ('merge' as const) : skip ? ('skip' as const) : ('create' as const),
|
||||||
|
targetFamilyId: target ? target.toString() : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!apply) return { applied: 0, groups: preview };
|
if (!apply) return { applied: 0, merged: 0, skipped: [] as string[], groups: preview };
|
||||||
|
|
||||||
let applied = 0;
|
let applied = 0;
|
||||||
for (const members of groups.values()) {
|
let merged = 0;
|
||||||
const parsed = parseOriginName(members[0].goodName);
|
const skipped: string[] = [];
|
||||||
const categoryName = members[0].sdsCategoryId
|
for (const key of groups.keys()) {
|
||||||
? (catName.get(members[0].sdsCategoryId) ?? null)
|
const g = resolveGroup(key);
|
||||||
: null;
|
const memberIds = g.members.map((m) => m.id);
|
||||||
|
const target = g.cat || g.nameKey ? matchAutoFamily(g.cat, g.nameKey) : null;
|
||||||
|
if (target) {
|
||||||
|
// 并入已有 autoManaged 族:不再新建 -2 碎片族
|
||||||
|
await this.attachMembers(target, memberIds, { strict: false });
|
||||||
|
await this.prisma.good.updateMany({
|
||||||
|
where: { originGoodId: { in: memberIds } },
|
||||||
|
data: { familyId: target },
|
||||||
|
});
|
||||||
|
await this.recompute.recomputeFamily(target);
|
||||||
|
merged += memberIds.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (hasAnyFamily(g.cat, g.nameKey)) {
|
||||||
|
// 同款仅有人工锁定族:不并入也不新建,留在候选里等人工处理
|
||||||
|
skipped.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const fallbackCode =
|
const fallbackCode =
|
||||||
members[0].source === 'CUSTOM' ? `CUSTOM-${members[0].id}` : null;
|
g.members[0].source === 'CUSTOM' ? `CUSTOM-${g.members[0].id}` : null;
|
||||||
const family = await this.prisma.productFamily.create({
|
const family = await this.prisma.productFamily.create({
|
||||||
data: {
|
data: {
|
||||||
familyName: categoryName ?? parsed.productName ?? originGroupKey(members[0].goodName),
|
familyName: g.familyName,
|
||||||
familyCode:
|
familyCode:
|
||||||
codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode
|
codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? fallbackCode
|
||||||
? await this.ensureUniqueCode(
|
? await this.ensureUniqueCode(
|
||||||
(codeFromCategoryName(categoryName) ?? parsed.skuCode ?? fallbackCode)!,
|
(codeFromCategoryName(g.categoryName) ?? g.parsed.skuCode ?? fallbackCode)!,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
familyImage: members[0].goodImage ?? null,
|
familyImage: g.members[0].goodImage ?? null,
|
||||||
primaryOriginGoodId: members[0].id,
|
primaryOriginGoodId: g.members[0].id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// 宽容挂载:并行环境下成员可能在候选查询后消失(如测试清理),跳过即可
|
// 宽容挂载:并行环境下成员可能在候选查询后消失(如测试清理),跳过即可
|
||||||
await this.attachMembers(
|
await this.attachMembers(
|
||||||
family.id,
|
family.id,
|
||||||
members.map((m) => m.id),
|
memberIds,
|
||||||
{ strict: false },
|
{ strict: false },
|
||||||
);
|
);
|
||||||
|
await this.prisma.good.updateMany({
|
||||||
|
where: { originGoodId: { in: memberIds } },
|
||||||
|
data: { familyId: family.id },
|
||||||
|
});
|
||||||
await this.recompute.recomputeFamily(family.id);
|
await this.recompute.recomputeFamily(family.id);
|
||||||
applied += 1;
|
applied += 1;
|
||||||
}
|
}
|
||||||
return { applied, groups: preview };
|
return { applied, merged, skipped, groups: preview };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单链接自动归族:同 SDS 分类优先,回退族语义名称键(国家+品名+SKU,物流/工艺
|
||||||
|
* 是矩阵维度不分族);只并入 autoManaged 族(人工锁定族不动),多候选取最老
|
||||||
|
* (id 最小)。返回并入的族 id,无匹配返回 null。
|
||||||
|
*/
|
||||||
|
async attachToMatchingFamily(originGoodId: bigint): Promise<bigint | null> {
|
||||||
|
const og = await this.prisma.originGood.findUnique({
|
||||||
|
where: { id: originGoodId },
|
||||||
|
select: { id: true, sdsCategoryId: true, goodName: true, delisted: true, familyId: true },
|
||||||
|
});
|
||||||
|
if (!og || og.familyId || og.delisted) return null;
|
||||||
|
const members = await this.prisma.originGood.findMany({
|
||||||
|
where: { familyId: { not: null }, delisted: false },
|
||||||
|
select: { familyId: true, sdsCategoryId: true, goodName: true },
|
||||||
|
});
|
||||||
|
let candidates: bigint[] = [];
|
||||||
|
if (og.sdsCategoryId) {
|
||||||
|
candidates = members
|
||||||
|
.filter((m) => m.sdsCategoryId === og.sdsCategoryId)
|
||||||
|
.map((m) => m.familyId!);
|
||||||
|
}
|
||||||
|
if (!candidates.length) {
|
||||||
|
const key = familyNameKey(og.goodName);
|
||||||
|
if (key) {
|
||||||
|
candidates = members
|
||||||
|
.filter((m) => familyNameKey(m.goodName) === key)
|
||||||
|
.map((m) => m.familyId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!candidates.length) return null;
|
||||||
|
const families = await this.prisma.productFamily.findMany({
|
||||||
|
where: { id: { in: [...new Set(candidates)] }, autoManaged: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!families.length) return null;
|
||||||
|
const target = families[0];
|
||||||
|
await this.attachMembers(target.id, [og.id]);
|
||||||
|
await this.prisma.good.updateMany({
|
||||||
|
where: { originGoodId: og.id },
|
||||||
|
data: { familyId: target.id },
|
||||||
|
});
|
||||||
|
await this.recompute.recomputeFamily(target.id);
|
||||||
|
return target.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMembers(id: bigint, dto: UpdateFamilyMembersDto) {
|
async updateMembers(id: bigint, dto: UpdateFamilyMembersDto) {
|
||||||
|
|||||||
@@ -113,6 +113,35 @@ describe('PublicService', () => {
|
|||||||
goodPriority: 1,
|
goodPriority: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 自包含:链接级三组封闭词表标签 + 商品镜像
|
||||||
|
// (getTagGroups「物流渠道/印花数量/印刷工艺」断言依赖,不再依赖库内其他数据)
|
||||||
|
const autoTagIds: bigint[] = [];
|
||||||
|
for (const [groupName, tagName] of [
|
||||||
|
['物流渠道', '包邮'],
|
||||||
|
['印花数量', '单面印花'],
|
||||||
|
['印刷工艺', '烫画'],
|
||||||
|
] as const) {
|
||||||
|
const group = await prisma.tagGroup.upsert({
|
||||||
|
where: { groupName },
|
||||||
|
create: { groupName },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
const t = await prisma.tag.upsert({
|
||||||
|
where: { tagName },
|
||||||
|
create: { tagName, tagGroupId: group.id },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
autoTagIds.push(t.id);
|
||||||
|
}
|
||||||
|
await prisma.originGoodTag.createMany({
|
||||||
|
data: autoTagIds.map((tagId) => ({ originGoodId: og.id, tagId })),
|
||||||
|
});
|
||||||
|
await prisma.goodTag.createMany({
|
||||||
|
data: [g1.id, g2.id, g3.id].flatMap((goodId) =>
|
||||||
|
autoTagIds.map((tagId) => ({ goodId, tagId })),
|
||||||
|
),
|
||||||
|
});
|
||||||
goodIds = [g1.id, g2.id, g3.id];
|
goodIds = [g1.id, g2.id, g3.id];
|
||||||
|
|
||||||
const craftGroup = await prisma.tagGroup.create({
|
const craftGroup = await prisma.tagGroup.create({
|
||||||
|
|||||||
@@ -66,13 +66,15 @@
|
|||||||
**关键端点用法示例**(JWT):
|
**关键端点用法示例**(JWT):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 自动成族:先预览
|
# 1. 自动成族:先预览(action 标注 create=新建 / merge=并入已有族 / skip=同款仅人工锁定族)
|
||||||
curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" \
|
curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" \
|
||||||
-d '{"apply": false}'
|
-d '{"apply": false}'
|
||||||
# → { applied: 0, groups: [{ groupKey: "cat:8240", familyName: "DG001 180G纯棉T恤(JSA002)", memberCount: 16, ... }] }
|
# → { applied: 0, groups: [{ groupKey: "cat:8240", familyName: "DG001 180G纯棉T恤(JSA002)", memberCount: 16, action: "create", ... }] }
|
||||||
|
|
||||||
# 2. 应用(幂等,可重复执行)
|
# 2. 应用(幂等,可重复执行)。已有同款 autoManaged 族时**并入**(不再新建 -2 碎片族);
|
||||||
|
# 同款仅有人工锁定族(autoManaged=false)时跳过该组并计入 skipped。
|
||||||
curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" -d '{"apply": true}'
|
curl -X POST /product-families/auto-group -H "Authorization: Bearer $T" -d '{"apply": true}'
|
||||||
|
# → { applied: 新建族数, merged: 并入链接数, skipped: ["cat:..."], groups: [...] }
|
||||||
|
|
||||||
# 3. 族详情:成员 + 并集尺码表/包装 + 五维价格矩阵
|
# 3. 族详情:成员 + 并集尺码表/包装 + 五维价格矩阵
|
||||||
curl /product-families/12 -H "Authorization: Bearer $T"
|
curl /product-families/12 -H "Authorization: Bearer $T"
|
||||||
@@ -95,7 +97,10 @@ curl -X POST /product-families/12/members/custom -H "Authorization: Bearer $T" \
|
|||||||
|
|
||||||
1. **同步 = 纯镜像**:SDS 给什么存什么(名称原文/图/价/分类 ID),不解析、不归族、不写解析列;
|
1. **同步 = 纯镜像**:SDS 给什么存什么(名称原文/图/价/分类 ID),不解析、不归族、不写解析列;
|
||||||
2. **整理 = 显式人工动作**(所有解析/派生集中于此,可审查可重跑):回填解析列 → 派生链接标签
|
2. **整理 = 显式人工动作**(所有解析/派生集中于此,可审查可重跑):回填解析列 → 派生链接标签
|
||||||
(未人工接管的 SDS 链接按名称刷新;人工接管永不覆盖)→ 自动建族 → 全量重算。入口二选一:
|
(未人工接管的 SDS 链接按名称刷新;人工接管永不覆盖)→ 自动建族(**并入已有同款族优先,
|
||||||
|
无匹配才新建**)→ 碎片族合并(同分类多族时,纯碎片族并入带商品的族并删除,带商品/覆盖价/
|
||||||
|
人工锁定的进人工复审报告)→ 全量重算。返回含 `familiesMerged / fragmentsConsolidated /
|
||||||
|
fragmentsManualReview`。入口二选一:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter @inkreach/api organize # CLI
|
pnpm --filter @inkreach/api organize # CLI
|
||||||
@@ -110,6 +115,17 @@ curl -X POST /product-families/organize -H "$AUTH" # 后台「整理
|
|||||||
单面印花;不打印/光板 无印花面不补(矩阵中印花数量以单面占位,纯结构化规则)。
|
单面印花;不打印/光板 无印花面不补(矩阵中印花数量以单面占位,纯结构化规则)。
|
||||||
「恢复自动」(链接标签重置)本身是显式人工动作,同样走整理服务的单链接派生。
|
「恢复自动」(链接标签重置)本身是显式人工动作,同样走整理服务的单链接派生。
|
||||||
|
|
||||||
|
**人工标签缺啥补啥 + 旧词别名**(2026-09-02 起):
|
||||||
|
|
||||||
|
- `PUT /origin-goods/:id/tags` 保存人工标签时,三个定价维度组(印花数量/工艺/物流)缺哪组
|
||||||
|
就按链接名称自动补哪组(并入人工集合一起存为 manual 行,人工勾选值永不被动),响应带
|
||||||
|
`filledDimensionTags`;未归族链接随后自动并入匹配族(同 SDS 分类优先,回退族语义名称键),
|
||||||
|
响应带 `attachedFamilyId`。
|
||||||
|
- 工艺旧词「热转印」按别名归一为「烫画」进价格矩阵(`CRAFT_TAG_ALIASES`),标签字典本身不动。
|
||||||
|
历史背景:曾有 16 条人工链接因勾了旧词「热转印」被封闭词表静默踢出合并矩阵。
|
||||||
|
- 名称回退分组键为**族语义键** `familyNameKey`(第 1 段剥离物流备注、保留 国家+品名+SKU):
|
||||||
|
包邮/不包邮、单面/双面印花是矩阵维度,不再拆族;不同国家/不同款号仍不同族。
|
||||||
|
|
||||||
**公开读路径(四期族化契约,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。
|
**公开读路径(四期族化契约,默认开启)**:`PUBLIC_DETAIL_FROM_FAMILY=false` 可应急回退旧行为。
|
||||||
公开端点**以款(族)为一等公民**:
|
公开端点**以款(族)为一等公民**:
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ apps/api/
|
|||||||
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
|
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
|
||||||
│ ├── positions/ # 坑位 CRUD(受 JWT 保护)
|
│ ├── positions/ # 坑位 CRUD(受 JWT 保护)
|
||||||
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管)
|
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管)
|
||||||
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules)
|
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group(并入已有族优先,familyNameKey 族语义键)/ attachToMatchingFamily(单链接自动归族)/ consolidateFragments(碎片族合并)/ 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules,含 热转印→烫画 别名)
|
||||||
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建
|
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建
|
||||||
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
|
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
|
||||||
│ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情
|
│ ├── public/ # 公开 API:分类树 / 国家 / 商品分页 / 商品详情
|
||||||
@@ -123,8 +123,8 @@ apps/api/
|
|||||||
| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale`) | JWT |
|
| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale`) | JWT |
|
||||||
| `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | JWT |
|
| `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | JWT |
|
||||||
| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT |
|
| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT |
|
||||||
| `/product-families/organize` `POST` | 整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族 → 全量重算;CLI 等价 `pnpm --filter @inkreach/api organize` | JWT |
|
| `/product-families/organize` `POST` | 整理原产品库(显式人工动作):回填解析列 → 派生标签(人工接管不动)→ 自动建族(并入已有同款族优先)→ 碎片族合并 → 全量重算;返回 `familiesMerged/fragmentsConsolidated/fragmentsManualReview`;CLI 等价 `pnpm --filter @inkreach/api organize` | JWT |
|
||||||
| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接;`{apply:false}` 仅预览,`{apply:true}` 落库并逐族重算(幂等) | JWT |
|
| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接,无分类回退族语义名称键(`familyNameKey`,物流/工艺不分族);已有同款 autoManaged 族则**并入**(不建 -2 碎片族),同款仅人工锁定族则跳过;`{apply:false}` 预览含 `action: create/merge/skip`,`{apply:true}` 落库并重算(幂等) | JWT |
|
||||||
| `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT |
|
| `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT |
|
||||||
| `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT |
|
| `/product-families/:id/recompute` `POST` | 手动重算并集与价格矩阵 | JWT |
|
||||||
| `/product-families/:id/members` `POST` | 成员增删 `{addOriginGoodIds, removeOriginGoodIds}`;移除主链接后 primary 落到剩余成员 | JWT |
|
| `/product-families/:id/members` `POST` | 成员增删 `{addOriginGoodIds, removeOriginGoodIds}`;移除主链接后 primary 落到剩余成员 | JWT |
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 修复:人工修改标签后 SKU 无法自动合并 + 后台频繁掉线
|
||||||
|
|
||||||
|
- 日期:2026-09-02
|
||||||
|
- 分支:`bugfix/family-auto-merge-auth-cookie-yeuimu`
|
||||||
|
- 状态:已完成(2026-09-02,全量测试 187+22 通过)
|
||||||
|
|
||||||
|
## 现象与根因(数据实证)
|
||||||
|
|
||||||
|
### 问题2:人工修改标签后 SKU 无法自动合并(GBIU017 案例)
|
||||||
|
|
||||||
|
排查(v2 库 + 部署镜像比对)确认四处叠加缺陷:
|
||||||
|
|
||||||
|
1. **旧词表「热转印」不在矩阵封闭词表**:16 条人工接管标签的链接(GBIU014/15/16、
|
||||||
|
USIU002/005/012、JPIU014/20/21、GBIU009 等)勾的工艺词是旧词「热转印」,而
|
||||||
|
`memberMatrixCombos` 只认 烫画/直喷/不打印 → 这些链接的 SKU 全部被静默踢出合并矩阵。
|
||||||
|
线上 v2 镜像被人直接把 热转印 塞进 `DIM_VALUES` 救急(**未提交**,镜像与仓库分叉)。
|
||||||
|
2. **人工标签缺维度无守护**:`updateTags` 全量替换为人工勾选行,缺任一定价维度组
|
||||||
|
(印花数量/工艺/物流)时链接掉出矩阵,且 `tagsManual=true` 后整理永不补 → 死锁。
|
||||||
|
3. **自动建族只建不并**:`autoGroup` 只扫 `familyId=null` 链接且直接新建族(撞款号加
|
||||||
|
-2/-3 后缀),从不并入已有族 → USIU005 分裂成 USIU005 / USIU005-2,整理无法修复。
|
||||||
|
4. **名称回退分组键含物流备注**:`originGroupKey` 前 3 段精确比较,第 1 段括号内即
|
||||||
|
包邮/不包邮 → 无分类链接永不同组;admin `sameOriginGroup`(合并默认勾选)同语义。
|
||||||
|
|
||||||
|
GBIU017 本身(v2 库)两条链接已同族且矩阵正常(4 行),用户"后来看到"系 18:00 手动
|
||||||
|
挂族操作 + 页面缓存,非计算延迟。
|
||||||
|
|
||||||
|
### 问题1:后台频繁「Unauthorized, please login」
|
||||||
|
|
||||||
|
refresh cookie `path:'/auth'`(auth.controller.ts)与浏览器实际请求路径
|
||||||
|
`/api/auth/*`(dev/Vite、v1)或 `/v2-api/auth/*`(v2 nginx)永不匹配 → 浏览器带不上
|
||||||
|
refresh cookie → 30 分钟访问令牌一过期必掉线。v2 构建另有 `VITE_API_BASE=/v2-api/`
|
||||||
|
(尾斜杠)+ request.ts 手工拼接 → `/v2-api//auth/refresh` 双斜杠 404(已在生产包中实证)。
|
||||||
|
|
||||||
|
## 实施内容
|
||||||
|
|
||||||
|
| # | 内容 | 文件 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A1 | updateTags 缺啥补啥:三个维度组按链接名派生补齐(人工值优先);响应返回补齐明细 | origin-goods.service.ts / organize.service.ts |
|
||||||
|
| A2 | 保存标签后自动归族:无族链接并入匹配已有族(同 SDS 分类优先→规范名称键;仅 autoManaged 族),响应附 attachedFamilyId | product-families.service.ts |
|
||||||
|
| A3 | autoGroup 并入已有族:分组后先匹配已有族(多候选取最老),无匹配才新建;attach 联动 goods.familyId;preview 标注并入/新建 | product-families.service.ts |
|
||||||
|
| A4 | 名称键族语义化:api/admin 新增 familyNameKey(第 1 段剥物流备注、全半角归一、保 国家+品名+SKU、丢工艺/仓库段);autoGroup 回退键与 admin sameFamily 回退改用它 | origin-name.parser.ts / admin utils/origin-name.ts、family-match.ts |
|
||||||
|
| A5 | 整理碎片清理:同分类多族时,纯碎片族(无商品、无覆盖价、autoManaged)并入最老族并删除;其余写报告提示人工 | organize.service.ts |
|
||||||
|
| A6 | admin 提示:保存标签提示补齐明细;整理完成消息含碎片合并/待人工数 | GoodsEditDialog.vue / GoodsView.vue |
|
||||||
|
| 别名 | 热转印→烫画 归一(矩阵归因 + CUSTOM 标签 + admin 展示),写进仓库取代镜像热修 | auto-tag-rules.ts / family-recompute.service.ts / admin |
|
||||||
|
| B | 两 cookie path 改 '/'(clearCookie 同步);request.ts refresh URL 规范拼接 | auth.controller.ts / admin api/request.ts |
|
||||||
|
|
||||||
|
### 存量数据修复(部署后)
|
||||||
|
|
||||||
|
1. 从仓库重建 v2 镜像(消除未提交热修分叉)。
|
||||||
|
2. 跑一次「整理」:无族链接自动并入;USIU005-2(若为纯碎片)自动合并;16 条 热转印
|
||||||
|
链接经别名归一后重算进矩阵(矩阵 craft 列统一为 烫画)。
|
||||||
|
3. 若有带商品/覆盖价的碎片族,按整理报告人工移动成员(编辑弹窗「加入族」已支持跨族移动)。
|
||||||
|
|
||||||
|
### 行为变更说明
|
||||||
|
|
||||||
|
- 人工接管语义从"全量替换"变为"人工修正 + 缺维自动补齐"(人工勾选值永不被动)。
|
||||||
|
- 名称回退分族:同 国家+品名+SKU 的 包邮/不包邮/不同工艺 链接归一族(物流/工艺/印花
|
||||||
|
是矩阵维度,不是分族维度);分类键优先级不变。
|
||||||
Reference in New Issue
Block a user