feat(goods): support merged secondary origin goods in create/update/detail
This commit is contained in:
@@ -15,6 +15,13 @@ export class BatchCreateItemDto {
|
||||
@Min(1)
|
||||
originGoodId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
mergedOriginGoodIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
|
||||
@@ -20,6 +20,13 @@ export class CreateGoodDto {
|
||||
@Min(1)
|
||||
originGoodId!: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
mergedOriginGoodIds?: number[];
|
||||
|
||||
@ApiProperty()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
|
||||
@@ -33,6 +33,31 @@ export interface GoodRelations {
|
||||
_count?: { variants: number };
|
||||
} | null;
|
||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||
mergedOriginGoods?: Array<{
|
||||
originGood: {
|
||||
id: bigint;
|
||||
sdsGoodId: string;
|
||||
source: 'SDS' | 'CUSTOM';
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: unknown;
|
||||
detail?: { syncedAt: Date } | Record<string, unknown> | null;
|
||||
variants?: Array<{ [key: string]: unknown }>;
|
||||
_count?: { variants: number };
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MergedOriginGoodSummary {
|
||||
id: string;
|
||||
sdsGoodId: string;
|
||||
source: 'SDS' | 'CUSTOM';
|
||||
isCustom: boolean;
|
||||
goodName: string | null;
|
||||
goodImage: string | null;
|
||||
goodPrice: string | null;
|
||||
hasDetail: boolean;
|
||||
variantCount: number;
|
||||
}
|
||||
|
||||
export class GoodDto {
|
||||
@@ -81,6 +106,9 @@ export class GoodDto {
|
||||
@ApiProperty({ required: false, type: Array })
|
||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||
|
||||
@ApiProperty({ required: false, type: Array })
|
||||
mergedOriginGoods!: MergedOriginGoodSummary[];
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
position?: { id: string; indexVal: number } | null;
|
||||
|
||||
@@ -147,6 +175,21 @@ export class GoodDto {
|
||||
tagFontColor: gt.tag.tagFontColor,
|
||||
}))
|
||||
: [],
|
||||
mergedOriginGoods: (rel.mergedOriginGoods ?? []).map((m) => ({
|
||||
id: m.originGood.id.toString(),
|
||||
sdsGoodId: m.originGood.sdsGoodId,
|
||||
source: m.originGood.source,
|
||||
isCustom: m.originGood.source === 'CUSTOM',
|
||||
goodName: m.originGood.goodName,
|
||||
goodImage: m.originGood.goodImage,
|
||||
goodPrice:
|
||||
m.originGood.goodPrice === null || m.originGood.goodPrice === undefined
|
||||
? null
|
||||
: (m.originGood.goodPrice as { toString(): string }).toString(),
|
||||
hasDetail: Boolean(m.originGood.detail),
|
||||
variantCount:
|
||||
m.originGood._count?.variants ?? m.originGood.variants?.length ?? 0,
|
||||
})),
|
||||
position: rel.position
|
||||
? {
|
||||
id: rel.position.id.toString(),
|
||||
@@ -194,16 +237,39 @@ export class GoodDetailDto extends GoodDto {
|
||||
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
||||
const base = GoodDto.from(good, rel);
|
||||
const detail = rel.originGood?.detail;
|
||||
return {
|
||||
...base,
|
||||
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
||||
variants: (rel.originGood?.variants ?? []).map((variant) => ({
|
||||
const toAnnotated = (
|
||||
variant: Record<string, unknown>,
|
||||
originGoodId: string,
|
||||
originGoodName: string | null,
|
||||
) => ({
|
||||
...variant,
|
||||
price:
|
||||
variant.price === null || variant.price === undefined
|
||||
? null
|
||||
: (variant.price as { toString(): string }).toString(),
|
||||
})),
|
||||
: (variant.price as unknown as { toString(): string }).toString(),
|
||||
originGoodId,
|
||||
originGoodName,
|
||||
});
|
||||
const primaryId = good.originGoodId.toString();
|
||||
const primaryName = rel.originGood?.goodName ?? null;
|
||||
const mergedVariants = [
|
||||
...(rel.originGood?.variants ?? []).map((variant) =>
|
||||
toAnnotated(variant as Record<string, unknown>, primaryId, primaryName),
|
||||
),
|
||||
...(rel.mergedOriginGoods ?? []).flatMap((m) =>
|
||||
(m.originGood.variants ?? []).map((variant) =>
|
||||
toAnnotated(
|
||||
variant,
|
||||
m.originGood.id.toString(),
|
||||
m.originGood.goodName,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
return {
|
||||
...base,
|
||||
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
||||
variants: mergedVariants,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ export class UpdateGoodDto {
|
||||
@Min(1)
|
||||
originGoodId?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID 全量覆盖,不含主源' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
mergedOriginGoodIds?: number[];
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
|
||||
@@ -261,6 +261,135 @@ describe('GoodsService', () => {
|
||||
expect(after.total).toBe(before.total);
|
||||
});
|
||||
|
||||
describe('merged origin goods', () => {
|
||||
it('creates a good with merged origin goods and reads them back', async () => {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} merged`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
mergedOriginGoodIds: [Number(originGoodIds[2]), Number(originGoodIds[3])],
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
expect(created.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||||
[originGoodIds[2].toString(), originGoodIds[3].toString()].sort(),
|
||||
);
|
||||
const fetched = await service.findOne(BigInt(created.id));
|
||||
expect(fetched.mergedOriginGoods.length).toBe(2);
|
||||
});
|
||||
|
||||
it('rejects mergedOriginGoodIds containing the primary', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
goodName: `Goods Test ${stamp} bad-primary`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
mergedOriginGoodIds: [Number(originGoodIds[1])],
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects mergedOriginGoodIds that do not exist', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
goodName: `Goods Test ${stamp} bad-missing`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
mergedOriginGoodIds: [999999999],
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('replaces merged origin goods on update', async () => {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} replace`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
const updated = await service.update(BigInt(created.id), {
|
||||
mergedOriginGoodIds: [Number(originGoodIds[3]), Number(originGoodIds[4])],
|
||||
});
|
||||
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||||
[originGoodIds[3].toString(), originGoodIds[4].toString()].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('moves old primary into merged list when switching primary', async () => {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} switch`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
const updated = await service.update(BigInt(created.id), {
|
||||
originGoodId: Number(originGoodIds[2]),
|
||||
mergedOriginGoodIds: [Number(originGoodIds[1]), Number(originGoodIds[3])],
|
||||
});
|
||||
expect(updated.originGoodId).toBe(originGoodIds[2].toString());
|
||||
expect(updated.mergedOriginGoods.map((m) => m.id).sort()).toEqual(
|
||||
[originGoodIds[1].toString(), originGoodIds[3].toString()].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('cascades merged rows on good removal', async () => {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} cascade`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
await service.remove(BigInt(created.id));
|
||||
const rows = await prisma.goodOriginGood.count({
|
||||
where: { goodId: BigInt(created.id) },
|
||||
});
|
||||
expect(rows).toBe(0);
|
||||
});
|
||||
|
||||
it('returns merged variants with source annotation in detail', async () => {
|
||||
const v1 = await prisma.originGoodVariant.create({
|
||||
data: {
|
||||
originGoodId: originGoodIds[1],
|
||||
sdsVariantId: `mv-pri-${stamp}`,
|
||||
sku: `MV-PRI-${stamp}`,
|
||||
colorName: '黑色',
|
||||
},
|
||||
});
|
||||
const v2 = await prisma.originGoodVariant.create({
|
||||
data: {
|
||||
originGoodId: originGoodIds[2],
|
||||
sdsVariantId: `mv-sec-${stamp}`,
|
||||
sku: `MV-SEC-${stamp}`,
|
||||
colorName: '白色',
|
||||
},
|
||||
});
|
||||
try {
|
||||
const created = await service.create({
|
||||
goodName: `Goods Test ${stamp} variants`,
|
||||
originGoodId: Number(originGoodIds[1]),
|
||||
mergedOriginGoodIds: [Number(originGoodIds[2])],
|
||||
countryId: Number(countryId),
|
||||
categoryId: Number(categoryId),
|
||||
});
|
||||
const detail = await service.findOne(BigInt(created.id));
|
||||
const sources = new Set(
|
||||
detail.variants.map((v) => v['originGoodId'] as string),
|
||||
);
|
||||
expect(sources.has(originGoodIds[1].toString())).toBe(true);
|
||||
expect(sources.has(originGoodIds[2].toString())).toBe(true);
|
||||
expect(detail.variants).toHaveLength(2);
|
||||
expect(detail.mergedOriginGoods.find((m) => m.id === originGoodIds[2].toString())?.variantCount).toBe(1);
|
||||
} finally {
|
||||
await prisma.originGoodVariant.delete({ where: { id: v1.id } });
|
||||
await prisma.originGoodVariant.delete({ where: { id: v2.id } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundException for unknown id', async () => {
|
||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
|
||||
@@ -33,6 +33,18 @@ const GOOD_INCLUDE = {
|
||||
},
|
||||
},
|
||||
goodTags: { include: { tag: true } },
|
||||
mergedOriginGoods: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
originGood: {
|
||||
include: {
|
||||
detail: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
_count: { select: { variants: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.GoodInclude;
|
||||
|
||||
@Injectable()
|
||||
@@ -75,6 +87,7 @@ export class GoodsService {
|
||||
position: g.position,
|
||||
originGood: g.originGood,
|
||||
goodTags: g.goodTags,
|
||||
mergedOriginGoods: g.mergedOriginGoods,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
@@ -95,11 +108,17 @@ export class GoodsService {
|
||||
position: good.position,
|
||||
originGood: good.originGood,
|
||||
goodTags: good.goodTags,
|
||||
mergedOriginGoods: good.mergedOriginGoods,
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||
await this.ensureReferences(dto);
|
||||
const mergedIds = this.dedupeMergedIds(
|
||||
BigInt(dto.originGoodId),
|
||||
dto.mergedOriginGoodIds,
|
||||
);
|
||||
await this.ensureMergedOriginGoods(mergedIds);
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.good.create({
|
||||
data: {
|
||||
@@ -120,6 +139,14 @@ export class GoodsService {
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (mergedIds.length > 0) {
|
||||
await tx.goodOriginGood.createMany({
|
||||
data: mergedIds.map((originGoodId) => ({
|
||||
goodId: created.id,
|
||||
originGoodId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: GOOD_INCLUDE,
|
||||
@@ -131,6 +158,7 @@ export class GoodsService {
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
mergedOriginGoods: result.mergedOriginGoods,
|
||||
});
|
||||
});
|
||||
if (
|
||||
@@ -246,6 +274,17 @@ export class GoodsService {
|
||||
|
||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||
await this.findOne(id);
|
||||
let mergedIds: bigint[] | undefined;
|
||||
if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) {
|
||||
const current = await this.prisma.good.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: { originGoodId: true },
|
||||
});
|
||||
const primaryId =
|
||||
dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId;
|
||||
mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds);
|
||||
await this.ensureMergedOriginGoods(mergedIds);
|
||||
}
|
||||
const data: Prisma.GoodUpdateInput = {};
|
||||
if (dto.goodName !== undefined) data.goodName = dto.goodName;
|
||||
if (dto.originGoodId !== undefined) {
|
||||
@@ -286,6 +325,17 @@ export class GoodsService {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (mergedIds !== undefined) {
|
||||
await tx.goodOriginGood.deleteMany({ where: { goodId: id } });
|
||||
if (mergedIds.length > 0) {
|
||||
await tx.goodOriginGood.createMany({
|
||||
data: mergedIds.map((originGoodId) => ({
|
||||
goodId: id,
|
||||
originGoodId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
const updated = await tx.good.update({
|
||||
where: { id },
|
||||
data,
|
||||
@@ -298,6 +348,7 @@ export class GoodsService {
|
||||
position: updated.position,
|
||||
originGood: updated.originGood,
|
||||
goodTags: updated.goodTags,
|
||||
mergedOriginGoods: updated.mergedOriginGoods,
|
||||
});
|
||||
});
|
||||
if (
|
||||
@@ -388,6 +439,24 @@ export class GoodsService {
|
||||
})),
|
||||
});
|
||||
}
|
||||
const itemMerged = this.dedupeMergedIds(og.id, item.mergedOriginGoodIds);
|
||||
if (itemMerged.length > 0) {
|
||||
const existRows = await tx.originGood.findMany({
|
||||
where: { id: { in: itemMerged } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existRows.length !== itemMerged.length) {
|
||||
const found = new Set(existRows.map((r) => r.id.toString()));
|
||||
const missing = itemMerged.find((mid) => !found.has(mid.toString()));
|
||||
throw new BadRequestException(`Origin good ${missing} not found`);
|
||||
}
|
||||
await tx.goodOriginGood.createMany({
|
||||
data: itemMerged.map((originGoodId) => ({
|
||||
goodId: row.id,
|
||||
originGoodId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const result = await tx.good.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
include: GOOD_INCLUDE,
|
||||
@@ -399,6 +468,7 @@ export class GoodsService {
|
||||
position: result.position,
|
||||
originGood: result.originGood,
|
||||
goodTags: result.goodTags,
|
||||
mergedOriginGoods: result.mergedOriginGoods,
|
||||
}));
|
||||
}
|
||||
return created;
|
||||
@@ -446,6 +516,31 @@ export class GoodsService {
|
||||
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
||||
}
|
||||
|
||||
/** Dedupe merged ids and reject any that equals the primary source. */
|
||||
private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] {
|
||||
if (!ids || ids.length === 0) return [];
|
||||
const unique = [...new Set(ids.map((id) => BigInt(id)))];
|
||||
if (unique.includes(primaryId)) {
|
||||
throw new BadRequestException(
|
||||
'mergedOriginGoodIds 不能包含主源 originGoodId',
|
||||
);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
private async ensureMergedOriginGoods(ids: bigint[]) {
|
||||
if (ids.length === 0) return;
|
||||
const rows = await this.prisma.originGood.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (rows.length !== ids.length) {
|
||||
const found = new Set(rows.map((r) => r.id.toString()));
|
||||
const missing = ids.find((id) => !found.has(id.toString()));
|
||||
throw new BadRequestException(`Origin good ${missing} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCountry(id: number) {
|
||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
||||
|
||||
Reference in New Issue
Block a user