feat(product-family): link-level tags (origin_good_tags) with manual override + member detail enrichment
This commit is contained in:
@@ -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;
|
||||||
@@ -43,6 +43,9 @@ model OriginGood {
|
|||||||
detail OriginGoodDetail?
|
detail OriginGoodDetail?
|
||||||
variants OriginGoodVariant[]
|
variants OriginGoodVariant[]
|
||||||
mergedIntoGoods GoodOriginGood[]
|
mergedIntoGoods GoodOriginGood[]
|
||||||
|
// 链接级标签:自动派生(manual=false)或人工接管(tagsManual=true 后全部 manual)
|
||||||
|
tagsManual Boolean @default(false) @map("tags_manual")
|
||||||
|
originGoodTags OriginGoodTag[]
|
||||||
|
|
||||||
@@index([sdsCategoryId])
|
@@index([sdsCategoryId])
|
||||||
@@index([source])
|
@@index([source])
|
||||||
@@ -53,6 +56,23 @@ model OriginGood {
|
|||||||
@@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")
|
||||||
@@ -182,6 +202,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])
|
||||||
|
|||||||
@@ -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,22 @@ export class OriginGoodsController {
|
|||||||
findAll(@Query() query: QueryOriginGoodDto) {
|
findAll(@Query() query: QueryOriginGoodDto) {
|
||||||
return this.service.findAll(query);
|
return this.service.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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,6 @@
|
|||||||
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 { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
describe('OriginGoodsService', () => {
|
describe('OriginGoodsService', () => {
|
||||||
@@ -10,7 +11,7 @@ describe('OriginGoodsService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [OriginGoodsService, PrismaService],
|
providers: [OriginGoodsService, FamilyRecomputeService, PrismaService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(OriginGoodsService);
|
service = moduleRef.get(OriginGoodsService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
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 { 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;
|
||||||
@@ -67,7 +86,94 @@ 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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 链接当前标签(含 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);
|
||||||
|
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 } }),
|
||||||
|
]);
|
||||||
|
if (og.familyId) {
|
||||||
|
// 族内链接:整族重派生 + 商品镜像
|
||||||
|
await this.familyRecompute.syncFamilyTags(og.familyId);
|
||||||
|
} else {
|
||||||
|
await this.familyRecompute.refreshLinkTags(id);
|
||||||
|
}
|
||||||
|
return this.getTags(id);
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
async findAll(query: QueryOriginGoodDto): Promise<PaginatedOriginGoods> {
|
||||||
const { page, pageSize, keyword } = query;
|
const { page, pageSize, keyword } = query;
|
||||||
|
|||||||
@@ -300,20 +300,93 @@ export class FamilyRecomputeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 族 → 商品标签同步:标签与「产品链接」一一对应,按每条链接自身的名称解析
|
* 族 → 标签同步(标签与「产品链接」一一对应):
|
||||||
* (印花数量 / 工艺 / 物流,规则见 auto-tag-rules.ts)。派生组缺失的组/标签
|
* 1) 链接级:未人工接管(tagsManual=false)的 SDS 链接,按链接名称刷新
|
||||||
* 自动补建;仅更新发生变化的商品,幂等。
|
* origin_good_tags 的派生行(manual=false);人工行(manual=true)永远保留;
|
||||||
|
* 2) 商品级镜像:good 标签 = 自身链接的有效标签 ∪ 非自动组的既有标签。
|
||||||
|
* 仅更新发生变化的行,幂等。
|
||||||
*/
|
*/
|
||||||
async syncFamilyTags(familyId: bigint): Promise<{ goodsUpdated: number }> {
|
async syncFamilyTags(
|
||||||
|
familyId: bigint,
|
||||||
|
): Promise<{ goodsUpdated: number; linksUpdated: number }> {
|
||||||
const tagMap = await this.ensureDerivedTagMap();
|
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 };
|
||||||
|
|
||||||
|
// 1) 链接级派生
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 商品级镜像:派生写入后重新读取链接标签(上面的 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({
|
const goods = await this.prisma.good.findMany({
|
||||||
where: { familyId },
|
where: { familyId },
|
||||||
include: {
|
include: {
|
||||||
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
|
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
|
||||||
originGood: { select: { goodName: true, source: true } },
|
originGood: { select: { id: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!goods.length) return { goodsUpdated: 0 };
|
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 groups = await this.prisma.tagGroup.findMany({
|
const groups = await this.prisma.tagGroup.findMany({
|
||||||
select: { id: true, groupName: true },
|
select: { id: true, groupName: true },
|
||||||
@@ -324,18 +397,14 @@ export class FamilyRecomputeService {
|
|||||||
|
|
||||||
let goodsUpdated = 0;
|
let goodsUpdated = 0;
|
||||||
for (const good of goods) {
|
for (const good of goods) {
|
||||||
// 保留:非自动分组的既有标签(人工管理)∪ 本链接名称的派生标签
|
const linkTagIds = good.originGood
|
||||||
|
? (tagsByOg.get(good.originGood.id.toString()) ?? [])
|
||||||
|
: [];
|
||||||
|
// 保留:非自动分组的既有标签(人工管理)∪ 链接有效标签
|
||||||
const keep = good.goodTags
|
const keep = good.goodTags
|
||||||
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
|
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
|
||||||
.map((gt) => gt.tagId);
|
.map((gt) => gt.tagId);
|
||||||
const derivedNames =
|
const target = [...new Set([...keep, ...linkTagIds])].sort((a, b) =>
|
||||||
good.originGood && good.originGood.source === 'SDS'
|
|
||||||
? deriveLinkTagNames(good.originGood.goodName)
|
|
||||||
: [];
|
|
||||||
const derivedIds = derivedNames
|
|
||||||
.map((name) => tagMap.get(name))
|
|
||||||
.filter((id): id is bigint => id !== undefined);
|
|
||||||
const target = [...new Set([...keep, ...derivedIds])].sort((a, b) =>
|
|
||||||
Number(a - b),
|
Number(a - b),
|
||||||
);
|
);
|
||||||
const current = good.goodTags.map((gt) => gt.tagId).sort((a, b) => Number(a - b));
|
const current = good.goodTags.map((gt) => gt.tagId).sort((a, b) => Number(a - b));
|
||||||
@@ -354,7 +423,94 @@ export class FamilyRecomputeService {
|
|||||||
]);
|
]);
|
||||||
goodsUpdated += 1;
|
goodsUpdated += 1;
|
||||||
}
|
}
|
||||||
return { goodsUpdated };
|
return { goodsUpdated, linksUpdated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单链接:未人工接管时按名称刷新派生标签,并把有效标签镜像到其名下商品 */
|
||||||
|
async refreshLinkTags(ogId: bigint): Promise<void> {
|
||||||
|
const og = await this.prisma.originGood.findUnique({
|
||||||
|
where: { id: ogId },
|
||||||
|
include: { originGoodTags: true },
|
||||||
|
});
|
||||||
|
if (!og) return;
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await this.mirrorLinkTagsToGoods(og.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把链接的有效标签镜像到其名下商品(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 })),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,天然去重) */
|
/** 确保派生标签组与标签存在,返回「标签名 → 标签 id」映射(并发下取最小 id,天然去重) */
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { FamilyRecomputeService } from './family-recompute.service';
|
import { FamilyRecomputeService } from './family-recompute.service';
|
||||||
|
import { OriginGoodsService } from '../origin-goods/origin-goods.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
/** 族 → 商品标签自动同步:标签按每条链接自身名称派生(印花数量/工艺/物流) */
|
/**
|
||||||
describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
* 标签与「产品链接」一一对应:
|
||||||
let service: FamilyRecomputeService;
|
* 1) 链接级:未人工接管的 SDS 链接按名称派生 origin_good_tags(manual=false);
|
||||||
|
* 2) 商品级:good 标签镜像其链接的有效标签(保留非自动组人工标签);
|
||||||
|
* 3) 人工接管(updateTags)后自动同步不再覆盖,恢复自动(resetTags)回到派生。
|
||||||
|
*/
|
||||||
|
describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
||||||
|
let recompute: FamilyRecomputeService;
|
||||||
|
let originGoods: OriginGoodsService;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
const ids = {
|
const ids = {
|
||||||
@@ -19,9 +26,10 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [FamilyRecomputeService, PrismaService],
|
providers: [FamilyRecomputeService, OriginGoodsService, PrismaService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(FamilyRecomputeService);
|
recompute = moduleRef.get(FamilyRecomputeService);
|
||||||
|
originGoods = moduleRef.get(OriginGoodsService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
|
|
||||||
@@ -34,6 +42,7 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await prisma.goodTag.deleteMany({ where: { goodId: { in: ids.good } } });
|
await prisma.goodTag.deleteMany({ where: { goodId: { in: ids.good } } });
|
||||||
await prisma.good.deleteMany({ where: { id: { 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.originGood.deleteMany({ where: { id: { in: ids.originGood } } });
|
||||||
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } });
|
await prisma.productFamily.deleteMany({ where: { id: { in: ids.family } } });
|
||||||
await prisma.tag.deleteMany({ where: { id: { in: ids.tag } } });
|
await prisma.tag.deleteMany({ where: { id: { in: ids.tag } } });
|
||||||
@@ -43,23 +52,22 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
|||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function tagNames(goodId: bigint): Promise<string[]> {
|
async function goodTagNames(goodId: bigint): Promise<string[]> {
|
||||||
const rows = await prisma.goodTag.findMany({
|
const rows = await prisma.goodTag.findMany({ where: { goodId }, include: { tag: true } });
|
||||||
where: { goodId },
|
|
||||||
include: { tag: true },
|
|
||||||
});
|
|
||||||
return rows.map((r) => r.tag.tagName);
|
return rows.map((r) => r.tag.tagName);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('每个商品的标签只来自自己的链接名称;旧自动组标签被剔除;人工分组保留', async () => {
|
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] } });
|
const styleTag = await prisma.tag.create({ data: { tagName: `潮流${stamp}`, tagGroupId: ids.group[0] } });
|
||||||
ids.tag.push(styleTag.id);
|
ids.tag.push(styleTag.id);
|
||||||
const legacyPositionTag = await prisma.tag.findFirst({
|
const legacyPositionTag = await prisma.tag.findFirst({
|
||||||
where: { tagName: '单面印', tagGroup: { groupName: { contains: '印刷位置' } } },
|
where: { tagName: '单面印', tagGroup: { groupName: { contains: '印刷位置' } } },
|
||||||
});
|
});
|
||||||
const legacyBaoyou = await prisma.tag.findFirst({
|
|
||||||
where: { tagName: '包邮', tagGroup: { groupName: { contains: '物流渠道' } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const og1 = await prisma.originGood.create({
|
const og1 = await prisma.originGood.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -106,59 +114,83 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
ids.good.push(good1.id, good2.id);
|
ids.good.push(good1.id, good2.id);
|
||||||
// 预置:人工分组标签(保留)+ 旧自动组标签(应被剔除)
|
|
||||||
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: styleTag.id } });
|
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: styleTag.id } });
|
||||||
if (legacyPositionTag) {
|
if (legacyPositionTag) {
|
||||||
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: legacyPositionTag.id } });
|
await prisma.goodTag.create({ data: { goodId: good1.id, tagId: legacyPositionTag.id } });
|
||||||
}
|
}
|
||||||
if (legacyBaoyou) {
|
|
||||||
await prisma.goodTag.create({ data: { goodId: good2.id, tagId: legacyBaoyou.id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
const r1 = await service.syncFamilyTags(family.id);
|
const r1 = await recompute.syncFamilyTags(family.id);
|
||||||
|
expect(r1.linksUpdated).toBe(2);
|
||||||
expect(r1.goodsUpdated).toBe(2);
|
expect(r1.goodsUpdated).toBe(2);
|
||||||
|
|
||||||
const names1 = await tagNames(good1.id);
|
// 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印/光板/不包邮
|
||||||
expect(names1).toContain('单面印花');
|
expect(await linkTagNames(og1.id)).toEqual(['包邮', '烫画', '单面印花']);
|
||||||
expect(names1).toContain('烫画');
|
expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印', '光板']);
|
||||||
expect(names1).toContain('包邮');
|
|
||||||
expect(names1).toContain(`潮流${stamp}`);
|
// 商品级:镜像各自链接(+人工分组保留,旧自动组剔除)
|
||||||
|
const names1 = await goodTagNames(good1.id);
|
||||||
|
expect(names1).toEqual(expect.arrayContaining(['包邮', '烫画', '单面印花', `潮流${stamp}`]));
|
||||||
expect(names1).not.toContain('单面印');
|
expect(names1).not.toContain('单面印');
|
||||||
expect(names1).not.toContain('双面印花');
|
expect(await goodTagNames(good2.id)).toEqual(['不包邮', '不打印', '光板']);
|
||||||
expect(names1).not.toContain('不包邮');
|
|
||||||
|
|
||||||
// og2 名称含 不打印 + 光板(物流备注)→ 两个工艺标签,无默认烫画、无印花数量标签
|
// 幂等
|
||||||
const names2 = await tagNames(good2.id);
|
const r2 = await recompute.syncFamilyTags(family.id);
|
||||||
expect(names2).toContain('不打印');
|
expect(r2.linksUpdated).toBe(0);
|
||||||
expect(names2).toContain('光板');
|
|
||||||
expect(names2).toContain('不包邮');
|
|
||||||
expect(names2).not.toContain('烫画');
|
|
||||||
expect(names2).not.toContain('包邮');
|
|
||||||
expect(names2).not.toContain('单面印花');
|
|
||||||
|
|
||||||
// 幂等:无变化不写
|
|
||||||
const r2 = await service.syncFamilyTags(family.id);
|
|
||||||
expect(r2.goodsUpdated).toBe(0);
|
expect(r2.goodsUpdated).toBe(0);
|
||||||
|
|
||||||
// 链接名称变化 → good1 标签跟随新名称
|
|
||||||
await prisma.originGood.update({
|
|
||||||
where: { id: og1.id },
|
|
||||||
data: { goodName: `美国(不包邮)180g纯棉T恤成人款-DG${stamp}-双面印花` },
|
|
||||||
});
|
|
||||||
await service.syncFamilyTags(family.id);
|
|
||||||
const names3 = await tagNames(good1.id);
|
|
||||||
expect(names3).toContain('双面印花');
|
|
||||||
expect(names3).toContain('烫画');
|
|
||||||
expect(names3).toContain('不包邮');
|
|
||||||
expect(names3).toContain(`潮流${stamp}`);
|
|
||||||
expect(names3).not.toContain('单面印花');
|
|
||||||
expect(names3).not.toContain('包邮');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('自定义来源的成员不派生标签,仅剔除自动组标签', async () => {
|
it('人工接管链接标签后自动同步不覆盖,商品跟随;恢复自动回到派生', async () => {
|
||||||
const realBaoyou = await prisma.tag.findFirst({
|
const og = await prisma.originGood.create({
|
||||||
where: { tagName: '包邮', tagGroup: { groupName: { contains: '物流渠道' } } },
|
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 recompute.syncFamilyTags(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 recompute.syncFamilyTags(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({
|
const ogCustom = await prisma.originGood.create({
|
||||||
data: {
|
data: {
|
||||||
source: 'CUSTOM',
|
source: 'CUSTOM',
|
||||||
@@ -171,10 +203,7 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
|||||||
data: { familyName: `自定义标签族-${stamp}`, primaryOriginGoodId: ogCustom.id },
|
data: { familyName: `自定义标签族-${stamp}`, primaryOriginGoodId: ogCustom.id },
|
||||||
});
|
});
|
||||||
ids.family.push(family.id);
|
ids.family.push(family.id);
|
||||||
await prisma.originGood.update({
|
await prisma.originGood.update({ where: { id: ogCustom.id }, data: { familyId: family.id } });
|
||||||
where: { id: ogCustom.id },
|
|
||||||
data: { familyId: family.id },
|
|
||||||
});
|
|
||||||
const good = await prisma.good.create({
|
const good = await prisma.good.create({
|
||||||
data: {
|
data: {
|
||||||
originGoodId: ogCustom.id,
|
originGoodId: ogCustom.id,
|
||||||
@@ -185,14 +214,14 @@ describe('FamilyRecomputeService.syncFamilyTags(按链接派生)', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
ids.good.push(good.id);
|
ids.good.push(good.id);
|
||||||
if (realBaoyou) {
|
|
||||||
await prisma.goodTag.create({ data: { goodId: good.id, tagId: realBaoyou.id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = await service.syncFamilyTags(family.id);
|
const r = await recompute.syncFamilyTags(family.id);
|
||||||
expect(r.goodsUpdated).toBe(realBaoyou ? 1 : 0);
|
expect(r.linksUpdated).toBe(0);
|
||||||
const names = await tagNames(good.id);
|
expect(await goodTagNames(good.id)).toEqual([]);
|
||||||
expect(names).not.toContain('包邮');
|
|
||||||
expect(names).not.toContain('烫画');
|
// 自定义链接同样可人工配置标签
|
||||||
|
const baoyou = await prisma.tag.findFirst({ where: { tagName: '包邮' } });
|
||||||
|
await originGoods.updateTags(ogCustom.id, [Number(baoyou!.id)]);
|
||||||
|
expect(await goodTagNames(good.id)).toEqual(['包邮']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,17 +21,29 @@ function codeFromCategoryName(categoryName: string | null | undefined): string |
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FAMILY_INCLUDE = { originGoods: {
|
const FAMILY_INCLUDE = { originGoods: {
|
||||||
|
orderBy: { id: 'asc' as const },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
sdsGoodId: true,
|
sdsGoodId: true,
|
||||||
goodName: true,
|
goodName: true,
|
||||||
goodImage: true,
|
goodImage: true,
|
||||||
|
goodPrice: true,
|
||||||
source: true,
|
source: true,
|
||||||
delisted: true,
|
delisted: true,
|
||||||
skuCode: true,
|
skuCode: true,
|
||||||
logisticsLabel: true,
|
logisticsLabel: true,
|
||||||
craftLabel: true,
|
craftLabel: true,
|
||||||
warehouseLabel: 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,
|
priceOverrides: true,
|
||||||
|
|||||||
@@ -372,8 +372,9 @@ 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user