fix(product-family): auto-merge SKUs after manual tag edits; fix session cookie path

问题2(SKU 无法自动合并,GBIU017 案例)四处叠加根因修复:
- 人工标签旧词「热转印」被封闭词表静默踢出合并矩阵 → CRAFT_TAG_ALIASES
  别名归一为「烫画」(矩阵归因与 CUSTOM 标签同路径生效)
- updateTags 缺任一定价维度组即整链掉出矩阵且人工接管后永不恢复 →
  缺啥补啥(按链接名派生补齐,人工勾选值不动,响应带 filledDimensionTags)
- autoGroup 只建新族从不并入已有族(产生 USIU005-2 类碎片)→ 并入已有
  autoManaged 同款族优先,无匹配才新建;同款仅人工锁定族则跳过并报告
- 名称回退分组键含物流备注,包邮/不包邮永不同组 → 新增族语义键
  familyNameKey(国家+品名+SKU),api/admin 两侧同构,合并默认勾选随之修复

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

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

测试:api jest 187/187、admin vitest 22/22、双侧 tsc 0 错误;
public.service 夹具改为自包含(不依赖共享库既有数据)。
This commit is contained in:
yeuimu
2026-09-02 18:39:35 +08:00
parent e34398a002
commit f1e81872e7
25 changed files with 848 additions and 78 deletions
@@ -7,6 +7,8 @@ import { PrismaService } from '../prisma/prisma.service';
describe('OriginGoodsService', () => {
let service: OriginGoodsService;
let organize: OrganizeService;
let families: ProductFamiliesService;
let prisma: PrismaService;
const stamp = Date.now();
const createdSds: string[] = [];
@@ -16,6 +18,8 @@ describe('OriginGoodsService', () => {
providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService],
}).compile();
service = moduleRef.get(OriginGoodsService);
organize = moduleRef.get(OrganizeService);
families = moduleRef.get(ProductFamiliesService);
prisma = moduleRef.get(PrismaService);
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 { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { OrganizeService } from '../product-families/organize.service';
import { ProductFamiliesService } from '../product-families/product-families.service';
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
@@ -21,6 +22,10 @@ export interface OriginGoodTagItem {
export interface OriginGoodTagsResult {
tagsManual: boolean;
tags: OriginGoodTagItem[];
/** 人工勾选缺定价维度时按链接名自动补齐的标签名(缺啥补啥) */
filledDimensionTags?: string[];
/** 保存时链接无族且自动并入成功的目标族 id */
attachedFamilyId?: string;
}
export interface PaginatedOriginGoods {
@@ -91,6 +96,7 @@ export class OriginGoodsService {
private readonly prisma: PrismaService,
private readonly familyRecompute: FamilyRecomputeService,
private readonly organize: OrganizeService,
private readonly families: ProductFamiliesService,
) {}
/** 链接当前标签(含 manual 标记) */
@@ -120,16 +126,17 @@ export class OriginGoodsService {
}
/**
* 人工接管链接标签:全量替换为 manual 行(自动同步永不覆盖)
* 并把有效标签镜像到该链接名下的商品。
* 人工接管链接标签:人工勾选全量保存为 manual 行(自动同步永不覆盖)
* 三个定价维度组(印花数量/工艺/物流)缺哪组按链接名自动补哪组(缺啥补啥),
* 保证链接 SKU 不掉出族价格矩阵;未归族链接随后自动并入匹配族。
*/
async updateTags(id: bigint, tagIds: number[]): Promise<OriginGoodTagsResult> {
const og = await this.prisma.originGood.findUnique({
where: { id },
select: { id: true },
select: { id: true, goodName: true, source: true },
});
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),
);
if (uniqueIds.length) {
@@ -138,6 +145,16 @@ export class OriginGoodsService {
throw new BadRequestException('存在无效标签');
}
}
// CUSTOM 成员的归因走管理员显式 craftLabel/logisticsLabel,名称不解析、不补齐
const fill =
og.source === 'SDS'
? await this.organize.fillMissingDimTags(og.goodName, uniqueIds)
: { tagIds: [] as bigint[], names: [] as string[] };
if (fill.tagIds.length) {
uniqueIds = [...new Set([...uniqueIds, ...fill.tagIds])].sort((a, b) =>
Number(a - b),
);
}
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({ where: { originGoodId: id } }),
...(!uniqueIds.length
@@ -159,8 +176,20 @@ export class OriginGoodsService {
where: { id },
select: { familyId: true },
});
if (ogFull?.familyId) await this.familyRecompute.recomputeFamily(ogFull.familyId);
return this.getTags(id);
let attachedFamilyId: string | undefined;
if (ogFull?.familyId) {
await this.familyRecompute.recomputeFamily(ogFull.familyId);
} else {
// 无族链接:自动并入匹配的同款族(同 SDS 分类优先,回退族语义名称键)
const fid = await this.families.attachToMatchingFamily(id);
if (fid) attachedFamilyId = fid.toString();
}
const base = await this.getTags(id);
return {
...base,
filledDimensionTags: fill.names,
...(attachedFamilyId ? { attachedFamilyId } : {}),
};
}
/** 恢复自动:清掉全部标签行(含人工行),按链接名称重新派生(显式人工动作) */