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)
|
@Min(1)
|
||||||
originGoodId!: number;
|
originGoodId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
|
mergedOriginGoodIds?: number[];
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export class CreateGoodDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
originGoodId!: number;
|
originGoodId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID,不含主源' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
|
mergedOriginGoodIds?: number[];
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
|||||||
@@ -6,33 +6,58 @@ export interface GoodRelations {
|
|||||||
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
|
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
|
||||||
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
|
||||||
position?: { id: bigint; indexVal: number } | null;
|
position?: { id: bigint; indexVal: number } | null;
|
||||||
originGood?: {
|
originGood?: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
sdsGoodId: string;
|
sdsGoodId: string;
|
||||||
source: 'SDS' | 'CUSTOM';
|
source: 'SDS' | 'CUSTOM';
|
||||||
goodName: string | null;
|
goodName: string | null;
|
||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: unknown;
|
goodPrice: unknown;
|
||||||
detail?: {
|
detail?: {
|
||||||
productCode: string | null;
|
productCode: string | null;
|
||||||
syncedAt: Date;
|
syncedAt: Date;
|
||||||
sizeChart: unknown;
|
sizeChart: unknown;
|
||||||
packageSpecs: unknown;
|
packageSpecs: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
variants?: Array<{
|
variants?: Array<{
|
||||||
sdsVariantId: string;
|
sdsVariantId: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
sizeName: string | null;
|
sizeName: string | null;
|
||||||
colorName: string | null;
|
colorName: string | null;
|
||||||
colorHex: string | null;
|
colorHex: string | null;
|
||||||
price: unknown;
|
price: unknown;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}>;
|
}>;
|
||||||
_count?: { variants: number };
|
_count?: { variants: number };
|
||||||
} | null;
|
} | null;
|
||||||
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
|
||||||
|
mergedOriginGoods?: Array<{
|
||||||
|
originGood: {
|
||||||
|
id: bigint;
|
||||||
|
sdsGoodId: string;
|
||||||
|
source: 'SDS' | 'CUSTOM';
|
||||||
|
goodName: string | null;
|
||||||
|
goodImage: string | null;
|
||||||
|
goodPrice: unknown;
|
||||||
|
detail?: { syncedAt: Date } | Record<string, unknown> | null;
|
||||||
|
variants?: Array<{ [key: string]: unknown }>;
|
||||||
|
_count?: { variants: number };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergedOriginGoodSummary {
|
||||||
|
id: string;
|
||||||
|
sdsGoodId: string;
|
||||||
|
source: 'SDS' | 'CUSTOM';
|
||||||
|
isCustom: boolean;
|
||||||
|
goodName: string | null;
|
||||||
|
goodImage: string | null;
|
||||||
|
goodPrice: string | null;
|
||||||
|
hasDetail: boolean;
|
||||||
|
variantCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GoodDto {
|
export class GoodDto {
|
||||||
@@ -81,24 +106,27 @@ export class GoodDto {
|
|||||||
@ApiProperty({ required: false, type: Array })
|
@ApiProperty({ required: false, type: Array })
|
||||||
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: Array })
|
||||||
|
mergedOriginGoods!: MergedOriginGoodSummary[];
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
position?: { id: string; indexVal: number } | null;
|
position?: { id: string; indexVal: number } | null;
|
||||||
|
|
||||||
@ApiProperty({ required: false, nullable: true })
|
@ApiProperty({ required: false, nullable: true })
|
||||||
originGood?: {
|
originGood?: {
|
||||||
id: string;
|
id: string;
|
||||||
sdsGoodId: string;
|
sdsGoodId: string;
|
||||||
source: 'SDS' | 'CUSTOM';
|
source: 'SDS' | 'CUSTOM';
|
||||||
isCustom: boolean;
|
isCustom: boolean;
|
||||||
goodName: string | null;
|
goodName: string | null;
|
||||||
goodImage: string | null;
|
goodImage: string | null;
|
||||||
goodPrice: string | null;
|
goodPrice: string | null;
|
||||||
hasDetail: boolean;
|
hasDetail: boolean;
|
||||||
detailSyncedAt: string | null;
|
detailSyncedAt: string | null;
|
||||||
variantCount: number;
|
variantCount: number;
|
||||||
sizeRowCount: number;
|
sizeRowCount: number;
|
||||||
packageRowCount: number;
|
packageRowCount: number;
|
||||||
productCode: string | null;
|
productCode: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
static from(
|
static from(
|
||||||
@@ -147,70 +175,108 @@ export class GoodDto {
|
|||||||
tagFontColor: gt.tag.tagFontColor,
|
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
|
position: rel.position
|
||||||
? {
|
? {
|
||||||
id: rel.position.id.toString(),
|
id: rel.position.id.toString(),
|
||||||
indexVal: rel.position.indexVal,
|
indexVal: rel.position.indexVal,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
originGood: rel.originGood
|
originGood: rel.originGood
|
||||||
? {
|
? {
|
||||||
id: rel.originGood.id.toString(),
|
id: rel.originGood.id.toString(),
|
||||||
sdsGoodId: rel.originGood.sdsGoodId,
|
sdsGoodId: rel.originGood.sdsGoodId,
|
||||||
source: rel.originGood.source,
|
source: rel.originGood.source,
|
||||||
isCustom: rel.originGood.source === 'CUSTOM',
|
isCustom: rel.originGood.source === 'CUSTOM',
|
||||||
goodName: rel.originGood.goodName,
|
goodName: rel.originGood.goodName,
|
||||||
goodImage: rel.originGood.goodImage,
|
goodImage: rel.originGood.goodImage,
|
||||||
goodPrice:
|
goodPrice:
|
||||||
rel.originGood.goodPrice === null ||
|
rel.originGood.goodPrice === null ||
|
||||||
rel.originGood.goodPrice === undefined
|
rel.originGood.goodPrice === undefined
|
||||||
? null
|
? null
|
||||||
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
: (rel.originGood.goodPrice as { toString(): string }).toString(),
|
||||||
hasDetail: Boolean(rel.originGood.detail),
|
hasDetail: Boolean(rel.originGood.detail),
|
||||||
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
|
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
|
||||||
variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0,
|
variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0,
|
||||||
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
|
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
|
||||||
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
|
||||||
productCode: rel.originGood.detail?.productCode ?? null,
|
productCode: rel.originGood.detail?.productCode ?? null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static jsonRows(value: unknown): number {
|
private static jsonRows(value: unknown): number {
|
||||||
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
|
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
|
||||||
const rows = (value as { rows?: unknown }).rows;
|
const rows = (value as { rows?: unknown }).rows;
|
||||||
return Array.isArray(rows) ? rows.length : 0;
|
return Array.isArray(rows) ? rows.length : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GoodDetailDto extends GoodDto {
|
export class GoodDetailDto extends GoodDto {
|
||||||
@ApiProperty({ nullable: true, type: Object })
|
@ApiProperty({ nullable: true, type: Object })
|
||||||
originDetail!: Record<string, unknown> | null;
|
originDetail!: Record<string, unknown> | null;
|
||||||
|
|
||||||
@ApiProperty({ type: Array })
|
@ApiProperty({ type: Array })
|
||||||
variants!: Array<Record<string, unknown>>;
|
variants!: Array<Record<string, unknown>>;
|
||||||
|
|
||||||
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
static fromGood(good: PrismaGood, rel: GoodRelations): GoodDetailDto {
|
||||||
const base = GoodDto.from(good, rel);
|
const base = GoodDto.from(good, rel);
|
||||||
const detail = rel.originGood?.detail;
|
const detail = rel.originGood?.detail;
|
||||||
return {
|
const toAnnotated = (
|
||||||
...base,
|
variant: Record<string, unknown>,
|
||||||
originDetail: detail ? { ...detail, syncedAt: detail.syncedAt.toISOString() } : null,
|
originGoodId: string,
|
||||||
variants: (rel.originGood?.variants ?? []).map((variant) => ({
|
originGoodName: string | null,
|
||||||
...variant,
|
) => ({
|
||||||
price:
|
...variant,
|
||||||
variant.price === null || variant.price === undefined
|
price:
|
||||||
? null
|
variant.price === null || variant.price === undefined
|
||||||
: (variant.price as { toString(): string }).toString(),
|
? null
|
||||||
})),
|
: (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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedGoods {
|
export interface PaginatedGoods {
|
||||||
items: GoodDto[];
|
items: GoodDto[];
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export class UpdateGoodDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
originGoodId?: number;
|
originGoodId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true, type: [Number], description: '副源原产品 ID 全量覆盖,不含主源' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Min(1, { each: true })
|
||||||
|
mergedOriginGoodIds?: number[];
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { GoodsService } from './goods.service';
|
import { GoodsService } from './goods.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
describe('GoodsService', () => {
|
describe('GoodsService', () => {
|
||||||
let service: GoodsService;
|
let service: GoodsService;
|
||||||
@@ -23,14 +23,14 @@ describe('GoodsService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
GoodsService,
|
GoodsService,
|
||||||
PrismaService,
|
PrismaService,
|
||||||
{
|
{
|
||||||
provide: SyncService,
|
provide: SyncService,
|
||||||
useValue: { queueProductDetailSync: jest.fn() },
|
useValue: { queueProductDetailSync: jest.fn() },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(GoodsService);
|
service = moduleRef.get(GoodsService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
@@ -101,7 +101,7 @@ describe('GoodsService', () => {
|
|||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates and reads back a good', async () => {
|
it('creates and reads back a good', async () => {
|
||||||
const created = await service.create({
|
const created = await service.create({
|
||||||
goodName: `Goods Test ${stamp} basic`,
|
goodName: `Goods Test ${stamp} basic`,
|
||||||
originGoodId: Number(originGoodIds[0]),
|
originGoodId: Number(originGoodIds[0]),
|
||||||
@@ -117,52 +117,52 @@ describe('GoodsService', () => {
|
|||||||
|
|
||||||
const fetched = await service.findOne(BigInt(created.id));
|
const fetched = await service.findOne(BigInt(created.id));
|
||||||
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates, edits, and removes a fully editable custom good', async () => {
|
it('creates, edits, and removes a fully editable custom good', async () => {
|
||||||
const created = await service.createCustom({
|
const created = await service.createCustom({
|
||||||
goodName: `Goods Test ${stamp} custom`,
|
goodName: `Goods Test ${stamp} custom`,
|
||||||
goodImage: 'https://example.com/custom.png',
|
goodImage: 'https://example.com/custom.png',
|
||||||
goodPrice: '29.90',
|
goodPrice: '29.90',
|
||||||
countryId: Number(countryId),
|
countryId: Number(countryId),
|
||||||
categoryId: Number(categoryId),
|
categoryId: Number(categoryId),
|
||||||
tagIds: [Number(tagId)],
|
tagIds: [Number(tagId)],
|
||||||
detail: {
|
detail: {
|
||||||
productCode: `CUSTOM-${stamp}`,
|
productCode: `CUSTOM-${stamp}`,
|
||||||
materialDescription: 'Cotton',
|
materialDescription: 'Cotton',
|
||||||
sizeChart: { columns: [], rows: [] },
|
sizeChart: { columns: [], rows: [] },
|
||||||
packageSpecs: { rows: [] },
|
packageSpecs: { rows: [] },
|
||||||
},
|
},
|
||||||
variants: [
|
variants: [
|
||||||
{ sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' },
|
{ sku: `CUSTOM-SKU-${stamp}`, sizeName: 'S', price: '29.90' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(created.originGood?.source).toBe('CUSTOM');
|
expect(created.originGood?.source).toBe('CUSTOM');
|
||||||
expect(created.originGood?.isCustom).toBe(true);
|
expect(created.originGood?.isCustom).toBe(true);
|
||||||
expect(created.originGood?.goodPrice).toBe('29.9');
|
expect(created.originGood?.goodPrice).toBe('29.9');
|
||||||
expect(created.variants).toHaveLength(1);
|
expect(created.variants).toHaveLength(1);
|
||||||
|
|
||||||
const updated = await service.updateCustomContent(BigInt(created.id), {
|
const updated = await service.updateCustomContent(BigInt(created.id), {
|
||||||
goodName: `Goods Test ${stamp} custom edited`,
|
goodName: `Goods Test ${stamp} custom edited`,
|
||||||
goodPrice: '39.90',
|
goodPrice: '39.90',
|
||||||
detail: { materialDescription: 'Organic cotton' },
|
detail: { materialDescription: 'Organic cotton' },
|
||||||
variants: [
|
variants: [
|
||||||
{ sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' },
|
{ sku: `CUSTOM-SKU-${stamp}-M`, sizeName: 'M', price: '39.90' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(updated.goodName).toContain('custom edited');
|
expect(updated.goodName).toContain('custom edited');
|
||||||
expect(updated.originGood?.goodPrice).toBe('39.9');
|
expect(updated.originGood?.goodPrice).toBe('39.9');
|
||||||
expect(updated.originDetail?.materialDescription).toBe('Organic cotton');
|
expect(updated.originDetail?.materialDescription).toBe('Organic cotton');
|
||||||
expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`);
|
expect(updated.originDetail?.productCode).toBe(`CUSTOM-${stamp}`);
|
||||||
expect(updated.variants[0]?.sizeName).toBe('M');
|
expect(updated.variants[0]?.sizeName).toBe('M');
|
||||||
|
|
||||||
const customOriginId = BigInt(updated.originGoodId);
|
const customOriginId = BigInt(updated.originGoodId);
|
||||||
await service.remove(BigInt(updated.id));
|
await service.remove(BigInt(updated.id));
|
||||||
await expect(
|
await expect(
|
||||||
prisma.originGood.findUnique({ where: { id: customOriginId } }),
|
prisma.originGood.findUnique({ where: { id: customOriginId } }),
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters by countryId, tagId, positionId and keyword', async () => {
|
it('filters by countryId, tagId, positionId and keyword', async () => {
|
||||||
const result = await service.findAll({
|
const result = await service.findAll({
|
||||||
@@ -261,6 +261,135 @@ describe('GoodsService', () => {
|
|||||||
expect(after.total).toBe(before.total);
|
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 () => {
|
it('throws NotFoundException for unknown id', async () => {
|
||||||
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
+376
-281
@@ -9,38 +9,50 @@ import { CreateGoodDto } from './dto/create-good.dto';
|
|||||||
import { UpdateGoodDto } from './dto/update-good.dto';
|
import { UpdateGoodDto } from './dto/update-good.dto';
|
||||||
import { QueryGoodDto } from './dto/query-good.dto';
|
import { QueryGoodDto } from './dto/query-good.dto';
|
||||||
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
|
||||||
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
import { BatchPriorityDto } from './dto/batch-priority.dto';
|
||||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import {
|
import {
|
||||||
CreateCustomGoodDto,
|
CreateCustomGoodDto,
|
||||||
CustomGoodDetailDto,
|
CustomGoodDetailDto,
|
||||||
CustomGoodVariantDto,
|
CustomGoodVariantDto,
|
||||||
UpdateCustomGoodContentDto,
|
UpdateCustomGoodContentDto,
|
||||||
} from './dto/custom-good.dto';
|
} from './dto/custom-good.dto';
|
||||||
|
|
||||||
const GOOD_INCLUDE = {
|
const GOOD_INCLUDE = {
|
||||||
country: true,
|
country: true,
|
||||||
category: true,
|
category: true,
|
||||||
tag: true,
|
tag: true,
|
||||||
position: true,
|
position: true,
|
||||||
originGood: {
|
originGood: {
|
||||||
include: {
|
include: {
|
||||||
detail: true,
|
detail: true,
|
||||||
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
_count: { select: { variants: true } },
|
_count: { select: { variants: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
goodTags: { include: { tag: true } },
|
goodTags: { include: { tag: true } },
|
||||||
|
mergedOriginGoods: {
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
include: {
|
||||||
|
originGood: {
|
||||||
|
include: {
|
||||||
|
detail: true,
|
||||||
|
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
_count: { select: { variants: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
} satisfies Prisma.GoodInclude;
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoodsService {
|
export class GoodsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly syncService: SyncService,
|
private readonly syncService: SyncService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||||
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
|
||||||
@@ -75,6 +87,7 @@ export class GoodsService {
|
|||||||
position: g.position,
|
position: g.position,
|
||||||
originGood: g.originGood,
|
originGood: g.originGood,
|
||||||
goodTags: g.goodTags,
|
goodTags: g.goodTags,
|
||||||
|
mergedOriginGoods: g.mergedOriginGoods,
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
@@ -82,25 +95,31 @@ export class GoodsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: bigint): Promise<GoodDetailDto> {
|
async findOne(id: bigint): Promise<GoodDetailDto> {
|
||||||
const good = await this.prisma.good.findUnique({
|
const good = await this.prisma.good.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: GOOD_INCLUDE,
|
include: GOOD_INCLUDE,
|
||||||
});
|
});
|
||||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||||
return GoodDetailDto.fromGood(good, {
|
return GoodDetailDto.fromGood(good, {
|
||||||
country: good.country,
|
country: good.country,
|
||||||
category: good.category,
|
category: good.category,
|
||||||
tag: good.tag,
|
tag: good.tag,
|
||||||
position: good.position,
|
position: good.position,
|
||||||
originGood: good.originGood,
|
originGood: good.originGood,
|
||||||
goodTags: good.goodTags,
|
goodTags: good.goodTags,
|
||||||
|
mergedOriginGoods: good.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
async create(dto: CreateGoodDto): Promise<GoodDto> {
|
||||||
await this.ensureReferences(dto);
|
await this.ensureReferences(dto);
|
||||||
const result = await this.prisma.$transaction(async (tx) => {
|
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({
|
const created = await tx.good.create({
|
||||||
data: {
|
data: {
|
||||||
goodName: dto.goodName,
|
goodName: dto.goodName,
|
||||||
@@ -120,132 +139,152 @@ 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({
|
const result = await tx.good.findUniqueOrThrow({
|
||||||
where: { id: created.id },
|
where: { id: created.id },
|
||||||
include: GOOD_INCLUDE,
|
include: GOOD_INCLUDE,
|
||||||
});
|
});
|
||||||
return GoodDto.from(result, {
|
return GoodDto.from(result, {
|
||||||
country: result.country,
|
country: result.country,
|
||||||
category: result.category,
|
category: result.category,
|
||||||
tag: result.tag,
|
tag: result.tag,
|
||||||
position: result.position,
|
position: result.position,
|
||||||
originGood: result.originGood,
|
originGood: result.originGood,
|
||||||
goodTags: result.goodTags,
|
goodTags: result.goodTags,
|
||||||
});
|
mergedOriginGoods: result.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
if (
|
});
|
||||||
result.originGood?.source === 'SDS' &&
|
if (
|
||||||
result.originGood.sdsGoodId &&
|
result.originGood?.source === 'SDS' &&
|
||||||
!result.originGood.hasDetail
|
result.originGood.sdsGoodId &&
|
||||||
) {
|
!result.originGood.hasDetail
|
||||||
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
) {
|
||||||
}
|
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||||
return result;
|
}
|
||||||
}
|
return result;
|
||||||
|
}
|
||||||
async createCustom(dto: CreateCustomGoodDto): Promise<GoodDetailDto> {
|
|
||||||
await this.ensureCountry(dto.countryId);
|
async createCustom(dto: CreateCustomGoodDto): Promise<GoodDetailDto> {
|
||||||
await this.ensureCategory(dto.categoryId);
|
await this.ensureCountry(dto.countryId);
|
||||||
if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId);
|
await this.ensureCategory(dto.categoryId);
|
||||||
for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId);
|
if (dto.positionId !== undefined) await this.ensurePosition(dto.positionId);
|
||||||
|
for (const tagId of dto.tagIds ?? []) await this.ensureTag(tagId);
|
||||||
const goodId = await this.prisma.$transaction(async (tx) => {
|
|
||||||
const originGood = await tx.originGood.create({
|
const goodId = await this.prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const originGood = await tx.originGood.create({
|
||||||
source: 'CUSTOM',
|
data: {
|
||||||
sdsGoodId: `custom-${randomUUID()}`,
|
source: 'CUSTOM',
|
||||||
goodName: dto.goodName,
|
sdsGoodId: `custom-${randomUUID()}`,
|
||||||
goodImage: dto.goodImage ?? null,
|
goodName: dto.goodName,
|
||||||
goodPrice: this.decimal(dto.goodPrice),
|
goodImage: dto.goodImage ?? null,
|
||||||
detail: {
|
goodPrice: this.decimal(dto.goodPrice),
|
||||||
create: this.customDetailData(
|
detail: {
|
||||||
dto.detail ?? {},
|
create: this.customDetailData(
|
||||||
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput,
|
dto.detail ?? {},
|
||||||
},
|
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
if (dto.variants?.length) {
|
});
|
||||||
await this.replaceCustomVariants(tx, originGood.id, dto.variants);
|
if (dto.variants?.length) {
|
||||||
}
|
await this.replaceCustomVariants(tx, originGood.id, dto.variants);
|
||||||
const good = await tx.good.create({
|
}
|
||||||
data: {
|
const good = await tx.good.create({
|
||||||
originGoodId: originGood.id,
|
data: {
|
||||||
countryId: BigInt(dto.countryId),
|
originGoodId: originGood.id,
|
||||||
categoryId: BigInt(dto.categoryId),
|
countryId: BigInt(dto.countryId),
|
||||||
positionId:
|
categoryId: BigInt(dto.categoryId),
|
||||||
dto.positionId === undefined ? null : BigInt(dto.positionId),
|
positionId:
|
||||||
goodName: dto.goodName,
|
dto.positionId === undefined ? null : BigInt(dto.positionId),
|
||||||
goodImage: dto.goodImage ?? null,
|
goodName: dto.goodName,
|
||||||
goodPriority: dto.goodPriority ?? 0,
|
goodImage: dto.goodImage ?? null,
|
||||||
},
|
goodPriority: dto.goodPriority ?? 0,
|
||||||
});
|
},
|
||||||
if (dto.tagIds?.length) {
|
});
|
||||||
await tx.goodTag.createMany({
|
if (dto.tagIds?.length) {
|
||||||
data: dto.tagIds.map((tagId) => ({
|
await tx.goodTag.createMany({
|
||||||
goodId: good.id,
|
data: dto.tagIds.map((tagId) => ({
|
||||||
tagId: BigInt(tagId),
|
goodId: good.id,
|
||||||
})),
|
tagId: BigInt(tagId),
|
||||||
});
|
})),
|
||||||
}
|
});
|
||||||
return good.id;
|
}
|
||||||
});
|
return good.id;
|
||||||
return this.findOne(goodId);
|
});
|
||||||
}
|
return this.findOne(goodId);
|
||||||
|
}
|
||||||
async updateCustomContent(
|
|
||||||
id: bigint,
|
async updateCustomContent(
|
||||||
dto: UpdateCustomGoodContentDto,
|
id: bigint,
|
||||||
): Promise<GoodDetailDto> {
|
dto: UpdateCustomGoodContentDto,
|
||||||
const existing = await this.prisma.good.findUnique({
|
): Promise<GoodDetailDto> {
|
||||||
where: { id },
|
const existing = await this.prisma.good.findUnique({
|
||||||
include: { originGood: true },
|
where: { id },
|
||||||
});
|
include: { originGood: true },
|
||||||
if (!existing) throw new NotFoundException(`Good ${id} not found`);
|
});
|
||||||
if (existing.originGood.source !== 'CUSTOM') {
|
if (!existing) throw new NotFoundException(`Good ${id} not found`);
|
||||||
throw new BadRequestException('SDS 映射商品的上游信息不可修改');
|
if (existing.originGood.source !== 'CUSTOM') {
|
||||||
}
|
throw new BadRequestException('SDS 映射商品的上游信息不可修改');
|
||||||
|
}
|
||||||
await this.prisma.$transaction(async (tx) => {
|
|
||||||
await tx.originGood.update({
|
await this.prisma.$transaction(async (tx) => {
|
||||||
where: { id: existing.originGoodId },
|
await tx.originGood.update({
|
||||||
data: {
|
where: { id: existing.originGoodId },
|
||||||
goodName: dto.goodName,
|
data: {
|
||||||
goodImage: dto.goodImage,
|
goodName: dto.goodName,
|
||||||
goodPrice:
|
goodImage: dto.goodImage,
|
||||||
dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice),
|
goodPrice:
|
||||||
},
|
dto.goodPrice === undefined ? undefined : this.decimal(dto.goodPrice),
|
||||||
});
|
},
|
||||||
if (dto.detail !== undefined) {
|
});
|
||||||
await tx.originGoodDetail.upsert({
|
if (dto.detail !== undefined) {
|
||||||
where: { originGoodId: existing.originGoodId },
|
await tx.originGoodDetail.upsert({
|
||||||
create: {
|
where: { originGoodId: existing.originGoodId },
|
||||||
originGoodId: existing.originGoodId,
|
create: {
|
||||||
...(this.customDetailData(
|
originGoodId: existing.originGoodId,
|
||||||
dto.detail,
|
...(this.customDetailData(
|
||||||
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput),
|
dto.detail,
|
||||||
},
|
) as Prisma.OriginGoodDetailUncheckedCreateWithoutOriginGoodInput),
|
||||||
update: this.customDetailData(dto.detail, true),
|
},
|
||||||
});
|
update: this.customDetailData(dto.detail, true),
|
||||||
}
|
});
|
||||||
if (dto.variants !== undefined) {
|
}
|
||||||
await this.replaceCustomVariants(
|
if (dto.variants !== undefined) {
|
||||||
tx,
|
await this.replaceCustomVariants(
|
||||||
existing.originGoodId,
|
tx,
|
||||||
dto.variants,
|
existing.originGoodId,
|
||||||
);
|
dto.variants,
|
||||||
}
|
);
|
||||||
const goodData: Prisma.GoodUpdateInput = {};
|
}
|
||||||
if (dto.goodName !== undefined) goodData.goodName = dto.goodName;
|
const goodData: Prisma.GoodUpdateInput = {};
|
||||||
if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage;
|
if (dto.goodName !== undefined) goodData.goodName = dto.goodName;
|
||||||
if (Object.keys(goodData).length) {
|
if (dto.goodImage !== undefined) goodData.goodImage = dto.goodImage;
|
||||||
await tx.good.update({ where: { id }, data: goodData });
|
if (Object.keys(goodData).length) {
|
||||||
}
|
await tx.good.update({ where: { id }, data: goodData });
|
||||||
});
|
}
|
||||||
return this.findOne(id);
|
});
|
||||||
}
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
|
let mergedIds: bigint[] | undefined;
|
||||||
|
if (dto.mergedOriginGoodIds !== undefined || dto.originGoodId !== undefined) {
|
||||||
|
const current = await this.prisma.good.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: { originGoodId: true },
|
||||||
|
});
|
||||||
|
const primaryId =
|
||||||
|
dto.originGoodId !== undefined ? BigInt(dto.originGoodId) : current.originGoodId;
|
||||||
|
mergedIds = this.dedupeMergedIds(primaryId, dto.mergedOriginGoodIds);
|
||||||
|
await this.ensureMergedOriginGoods(mergedIds);
|
||||||
|
}
|
||||||
const data: Prisma.GoodUpdateInput = {};
|
const data: Prisma.GoodUpdateInput = {};
|
||||||
if (dto.goodName !== undefined) data.goodName = dto.goodName;
|
if (dto.goodName !== undefined) data.goodName = dto.goodName;
|
||||||
if (dto.originGoodId !== undefined) {
|
if (dto.originGoodId !== undefined) {
|
||||||
@@ -274,7 +313,7 @@ export class GoodsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
if (dto.tagIds !== undefined) {
|
if (dto.tagIds !== undefined) {
|
||||||
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
await tx.goodTag.deleteMany({ where: { goodId: id } });
|
||||||
if (dto.tagIds.length > 0) {
|
if (dto.tagIds.length > 0) {
|
||||||
@@ -286,47 +325,59 @@ 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({
|
const updated = await tx.good.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
include: GOOD_INCLUDE,
|
include: GOOD_INCLUDE,
|
||||||
});
|
});
|
||||||
return GoodDto.from(updated, {
|
return GoodDto.from(updated, {
|
||||||
country: updated.country,
|
country: updated.country,
|
||||||
category: updated.category,
|
category: updated.category,
|
||||||
tag: updated.tag,
|
tag: updated.tag,
|
||||||
position: updated.position,
|
position: updated.position,
|
||||||
originGood: updated.originGood,
|
originGood: updated.originGood,
|
||||||
goodTags: updated.goodTags,
|
goodTags: updated.goodTags,
|
||||||
});
|
mergedOriginGoods: updated.mergedOriginGoods,
|
||||||
});
|
});
|
||||||
if (
|
});
|
||||||
result.originGood?.source === 'SDS' &&
|
if (
|
||||||
result.originGood.sdsGoodId &&
|
result.originGood?.source === 'SDS' &&
|
||||||
!result.originGood.hasDetail
|
result.originGood.sdsGoodId &&
|
||||||
) {
|
!result.originGood.hasDetail
|
||||||
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
) {
|
||||||
}
|
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
|
||||||
return result;
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: bigint): Promise<{ id: string }> {
|
async remove(id: bigint): Promise<{ id: string }> {
|
||||||
const good = await this.prisma.good.findUnique({
|
const good = await this.prisma.good.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { originGood: true },
|
include: { originGood: true },
|
||||||
});
|
});
|
||||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.good.delete({ where: { id } });
|
await tx.good.delete({ where: { id } });
|
||||||
if (good.originGood.source === 'CUSTOM') {
|
if (good.originGood.source === 'CUSTOM') {
|
||||||
const remaining = await tx.good.count({
|
const remaining = await tx.good.count({
|
||||||
where: { originGoodId: good.originGoodId },
|
where: { originGoodId: good.originGoodId },
|
||||||
});
|
});
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
await tx.originGood.delete({ where: { id: good.originGoodId } });
|
await tx.originGood.delete({ where: { id: good.originGoodId } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { id: id.toString() };
|
return { id: id.toString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,16 +386,16 @@ export class GoodsService {
|
|||||||
* or none do.
|
* or none do.
|
||||||
*/
|
*/
|
||||||
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
|
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
|
||||||
const result = await this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
for (const item of dto.items) {
|
for (const item of dto.items) {
|
||||||
await tx.good.update({
|
await tx.good.update({
|
||||||
where: { id: BigInt(item.id) },
|
where: { id: BigInt(item.id) },
|
||||||
data: { goodPriority: item.priority },
|
data: { goodPriority: item.priority },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { count: dto.items.length };
|
return { count: dto.items.length };
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -358,7 +409,7 @@ export class GoodsService {
|
|||||||
await this.ensureTag(tagId);
|
await this.ensureTag(tagId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = await this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
const created: GoodDto[] = [];
|
const created: GoodDto[] = [];
|
||||||
for (const item of dto.items) {
|
for (const item of dto.items) {
|
||||||
const og = await tx.originGood.findUnique({
|
const og = await tx.originGood.findUnique({
|
||||||
@@ -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({
|
const result = await tx.good.findUniqueOrThrow({
|
||||||
where: { id: row.id },
|
where: { id: row.id },
|
||||||
include: GOOD_INCLUDE,
|
include: GOOD_INCLUDE,
|
||||||
@@ -399,23 +468,24 @@ export class GoodsService {
|
|||||||
position: result.position,
|
position: result.position,
|
||||||
originGood: result.originGood,
|
originGood: result.originGood,
|
||||||
goodTags: result.goodTags,
|
goodTags: result.goodTags,
|
||||||
|
mergedOriginGoods: result.mergedOriginGoods,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
for (const goodId of new Set(
|
for (const goodId of new Set(
|
||||||
result
|
result
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.originGood?.source === 'SDS' &&
|
item.originGood?.source === 'SDS' &&
|
||||||
!item.originGood.hasDetail,
|
!item.originGood.hasDetail,
|
||||||
)
|
)
|
||||||
.map((item) => item.originGood?.sdsGoodId)
|
.map((item) => item.originGood?.sdsGoodId)
|
||||||
.filter((id): id is string => Boolean(id)),
|
.filter((id): id is string => Boolean(id)),
|
||||||
)) {
|
)) {
|
||||||
this.syncService.queueProductDetailSync(goodId);
|
this.syncService.queueProductDetailSync(goodId);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -446,6 +516,31 @@ export class GoodsService {
|
|||||||
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dedupe merged ids and reject any that equals the primary source. */
|
||||||
|
private dedupeMergedIds(primaryId: bigint, ids?: number[]): bigint[] {
|
||||||
|
if (!ids || ids.length === 0) return [];
|
||||||
|
const unique = [...new Set(ids.map((id) => BigInt(id)))];
|
||||||
|
if (unique.includes(primaryId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'mergedOriginGoodIds 不能包含主源 originGoodId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureMergedOriginGoods(ids: bigint[]) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const rows = await this.prisma.originGood.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (rows.length !== ids.length) {
|
||||||
|
const found = new Set(rows.map((r) => r.id.toString()));
|
||||||
|
const missing = ids.find((id) => !found.has(id.toString()));
|
||||||
|
throw new BadRequestException(`Origin good ${missing} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureCountry(id: number) {
|
private async ensureCountry(id: number) {
|
||||||
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
|
||||||
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
if (!c) throw new BadRequestException(`Country ${id} not found`);
|
||||||
@@ -456,12 +551,12 @@ export class GoodsService {
|
|||||||
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
if (!c) throw new BadRequestException(`Category ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureTag(id: number) {
|
private async ensureTag(id: number) {
|
||||||
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
|
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
|
||||||
if (!t) throw new BadRequestException(`Tag ${id} not found`);
|
if (!t) throw new BadRequestException(`Tag ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureReferences(dto: CreateGoodDto) {
|
private async ensureReferences(dto: CreateGoodDto) {
|
||||||
await this.ensureOriginGood(dto.originGoodId);
|
await this.ensureOriginGood(dto.originGoodId);
|
||||||
await this.ensureCountry(dto.countryId);
|
await this.ensureCountry(dto.countryId);
|
||||||
await this.ensureCategory(dto.categoryId);
|
await this.ensureCategory(dto.categoryId);
|
||||||
@@ -474,94 +569,94 @@ export class GoodsService {
|
|||||||
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
|
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
|
||||||
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensurePosition(id: number) {
|
private async ensurePosition(id: number) {
|
||||||
const position = await this.prisma.position.findUnique({
|
const position = await this.prisma.position.findUnique({
|
||||||
where: { id: BigInt(id) },
|
where: { id: BigInt(id) },
|
||||||
});
|
});
|
||||||
if (!position) throw new BadRequestException(`Position ${id} not found`);
|
if (!position) throw new BadRequestException(`Position ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private decimal(value: string | null | undefined): Prisma.Decimal | null {
|
private decimal(value: string | null | undefined): Prisma.Decimal | null {
|
||||||
return value === undefined || value === null || value === ''
|
return value === undefined || value === null || value === ''
|
||||||
? null
|
? null
|
||||||
: new Prisma.Decimal(value);
|
: new Prisma.Decimal(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private customDetailData(
|
private customDetailData(
|
||||||
detail: CustomGoodDetailDto,
|
detail: CustomGoodDetailDto,
|
||||||
preserveMissing = false,
|
preserveMissing = false,
|
||||||
): Prisma.OriginGoodDetailUncheckedUpdateInput {
|
): Prisma.OriginGoodDetailUncheckedUpdateInput {
|
||||||
const nullable = <T>(value: T | null | undefined): T | null | undefined =>
|
const nullable = <T>(value: T | null | undefined): T | null | undefined =>
|
||||||
preserveMissing && value === undefined ? undefined : value ?? null;
|
preserveMissing && value === undefined ? undefined : value ?? null;
|
||||||
const decimal = (value: string | null | undefined) =>
|
const decimal = (value: string | null | undefined) =>
|
||||||
preserveMissing && value === undefined ? undefined : this.decimal(value);
|
preserveMissing && value === undefined ? undefined : this.decimal(value);
|
||||||
const json = (
|
const json = (
|
||||||
value: Record<string, unknown> | null | undefined,
|
value: Record<string, unknown> | null | undefined,
|
||||||
): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined =>
|
): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | undefined =>
|
||||||
preserveMissing && value === undefined
|
preserveMissing && value === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: value === null || value === undefined
|
: value === null || value === undefined
|
||||||
? Prisma.DbNull
|
? Prisma.DbNull
|
||||||
: (value as Prisma.InputJsonValue);
|
: (value as Prisma.InputJsonValue);
|
||||||
return {
|
return {
|
||||||
productCode: nullable(detail.productCode),
|
productCode: nullable(detail.productCode),
|
||||||
englishName: nullable(detail.englishName),
|
englishName: nullable(detail.englishName),
|
||||||
blankDesignUrl: nullable(detail.blankDesignUrl),
|
blankDesignUrl: nullable(detail.blankDesignUrl),
|
||||||
detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl),
|
detailsPageVideoUrl: nullable(detail.detailsPageVideoUrl),
|
||||||
textureName: nullable(detail.textureName),
|
textureName: nullable(detail.textureName),
|
||||||
productionCycleHours: nullable(detail.productionCycleHours),
|
productionCycleHours: nullable(detail.productionCycleHours),
|
||||||
minWeightG: decimal(detail.minWeightG),
|
minWeightG: decimal(detail.minWeightG),
|
||||||
reminder: nullable(detail.reminder),
|
reminder: nullable(detail.reminder),
|
||||||
productionProcess: nullable(detail.productionProcess),
|
productionProcess: nullable(detail.productionProcess),
|
||||||
materialDescription: nullable(detail.materialDescription),
|
materialDescription: nullable(detail.materialDescription),
|
||||||
productPerformance: nullable(detail.productPerformance),
|
productPerformance: nullable(detail.productPerformance),
|
||||||
applicableScenarios: nullable(detail.applicableScenarios),
|
applicableScenarios: nullable(detail.applicableScenarios),
|
||||||
washingInstructions: nullable(detail.washingInstructions),
|
washingInstructions: nullable(detail.washingInstructions),
|
||||||
specialDescription: nullable(detail.specialDescription),
|
specialDescription: nullable(detail.specialDescription),
|
||||||
designExplanation: nullable(detail.designExplanation),
|
designExplanation: nullable(detail.designExplanation),
|
||||||
designArea: nullable(detail.designArea),
|
designArea: nullable(detail.designArea),
|
||||||
pictureRequest: nullable(detail.pictureRequest),
|
pictureRequest: nullable(detail.pictureRequest),
|
||||||
sizeChart: json(detail.sizeChart),
|
sizeChart: json(detail.sizeChart),
|
||||||
packageSpecs: json(detail.packageSpecs),
|
packageSpecs: json(detail.packageSpecs),
|
||||||
options: json(detail.options),
|
options: json(detail.options),
|
||||||
media: json(detail.media),
|
media: json(detail.media),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async replaceCustomVariants(
|
private async replaceCustomVariants(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
originGoodId: bigint,
|
originGoodId: bigint,
|
||||||
variants: CustomGoodVariantDto[],
|
variants: CustomGoodVariantDto[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await tx.originGoodVariant.deleteMany({ where: { originGoodId } });
|
await tx.originGoodVariant.deleteMany({ where: { originGoodId } });
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
await tx.originGoodVariant.create({
|
await tx.originGoodVariant.create({
|
||||||
data: {
|
data: {
|
||||||
originGoodId,
|
originGoodId,
|
||||||
sdsVariantId: `custom-${randomUUID()}`,
|
sdsVariantId: `custom-${randomUUID()}`,
|
||||||
sku: variant.sku,
|
sku: variant.sku,
|
||||||
sizeId: variant.sizeId ?? null,
|
sizeId: variant.sizeId ?? null,
|
||||||
sizeName: variant.sizeName ?? null,
|
sizeName: variant.sizeName ?? null,
|
||||||
colorId: variant.colorId ?? null,
|
colorId: variant.colorId ?? null,
|
||||||
colorName: variant.colorName ?? null,
|
colorName: variant.colorName ?? null,
|
||||||
colorHex: variant.colorHex ?? null,
|
colorHex: variant.colorHex ?? null,
|
||||||
imageUrl: variant.imageUrl ?? null,
|
imageUrl: variant.imageUrl ?? null,
|
||||||
price: this.decimal(variant.price),
|
price: this.decimal(variant.price),
|
||||||
originalPrice: this.decimal(variant.originalPrice),
|
originalPrice: this.decimal(variant.originalPrice),
|
||||||
weightG: this.decimal(variant.weightG),
|
weightG: this.decimal(variant.weightG),
|
||||||
boxLengthCm: this.decimal(variant.boxLengthCm),
|
boxLengthCm: this.decimal(variant.boxLengthCm),
|
||||||
boxWidthCm: this.decimal(variant.boxWidthCm),
|
boxWidthCm: this.decimal(variant.boxWidthCm),
|
||||||
boxHeightCm: this.decimal(variant.boxHeightCm),
|
boxHeightCm: this.decimal(variant.boxHeightCm),
|
||||||
enabled: variant.enabled ?? true,
|
enabled: variant.enabled ?? true,
|
||||||
sortOrder: variant.sortOrder ?? 0,
|
sortOrder: variant.sortOrder ?? 0,
|
||||||
designData:
|
designData:
|
||||||
variant.designData === null || variant.designData === undefined
|
variant.designData === null || variant.designData === undefined
|
||||||
? Prisma.DbNull
|
? Prisma.DbNull
|
||||||
: (variant.designData as Prisma.InputJsonValue),
|
: (variant.designData as Prisma.InputJsonValue),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user