feat(admin): manage and sync product details

This commit is contained in:
yeuimu
2026-08-21 10:18:57 +08:00
parent 7d09077f1d
commit d375df810d
18 changed files with 507 additions and 106 deletions
+61 -1
View File
@@ -12,6 +12,24 @@ export interface GoodRelations {
goodName: string | null;
goodImage: string | null;
goodPrice: unknown;
detail?: {
productCode: string | null;
syncedAt: Date;
sizeChart: unknown;
packageSpecs: unknown;
[key: string]: unknown;
} | null;
variants?: Array<{
sdsVariantId: string;
sku: string;
sizeName: string | null;
colorName: string | null;
colorHex: string | null;
price: unknown;
enabled: boolean;
[key: string]: unknown;
}>;
_count?: { variants: number };
} | null;
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
}
@@ -72,6 +90,12 @@ export class GoodDto {
goodName: string | null;
goodImage: string | null;
goodPrice: string | null;
hasDetail: boolean;
detailSyncedAt: string | null;
variantCount: number;
sizeRowCount: number;
packageRowCount: number;
productCode: string | null;
} | null;
static from(
@@ -137,10 +161,46 @@ export class GoodDto {
rel.originGood.goodPrice === undefined
? null
: (rel.originGood.goodPrice as { toString(): string }).toString(),
hasDetail: Boolean(rel.originGood.detail),
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
variantCount: rel.originGood._count?.variants ?? rel.originGood.variants?.length ?? 0,
sizeRowCount: GoodDto.jsonRows(rel.originGood.detail?.sizeChart),
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
productCode: rel.originGood.detail?.productCode ?? null,
}
: null,
};
}
private static jsonRows(value: unknown): number {
if (!value || typeof value !== 'object' || !('rows' in value)) return 0;
const rows = (value as { rows?: unknown }).rows;
return Array.isArray(rows) ? rows.length : 0;
}
}
export class GoodDetailDto extends GoodDto {
@ApiProperty({ nullable: true, type: Object })
originDetail!: Record<string, unknown> | null;
@ApiProperty({ type: Array })
variants!: Array<Record<string, unknown>>;
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) => ({
...variant,
price:
variant.price === null || variant.price === undefined
? null
: (variant.price as { toString(): string }).toString(),
})),
};
}
}
export interface PaginatedGoods {
@@ -148,4 +208,4 @@ export interface PaginatedGoods {
total: number;
page: number;
pageSize: number;
}
}
+18 -18
View File
@@ -36,18 +36,30 @@ export class GoodsController {
return this.service.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get one good with relations' })
findOne(@Param('id', ParseIntPipe) id: string) {
return this.service.findOne(BigInt(id));
}
@Post()
@ApiOperation({ summary: 'Create a good' })
create(@Body() dto: CreateGoodDto) {
return this.service.create(dto);
}
@Patch('batch-priority')
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
batchPriority(@Body() dto: BatchPriorityDto) {
return this.service.batchUpdatePriority(dto);
}
@Post('batch')
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
batchCreate(@Body() dto: BatchCreateGoodDto) {
return this.service.batchCreate(dto);
}
@Get(':id')
@ApiOperation({ summary: 'Get one good with relations' })
findOne(@Param('id', ParseIntPipe) id: string) {
return this.service.findOne(BigInt(id));
}
@Patch(':id')
@ApiOperation({ summary: 'Update a good' })
update(
@@ -62,16 +74,4 @@ export class GoodsController {
remove(@Param('id', ParseIntPipe) id: string) {
return this.service.remove(BigInt(id));
}
@Patch('batch-priority')
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
batchPriority(@Body() dto: BatchPriorityDto) {
return this.service.batchUpdatePriority(dto);
}
@Post('batch')
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
batchCreate(@Body() dto: BatchCreateGoodDto) {
return this.service.batchCreate(dto);
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { GoodsController } from './goods.controller';
import { GoodsService } from './goods.service';
import { SyncModule } from '../sync/sync.module';
@Module({
imports: [SyncModule],
controllers: [GoodsController],
providers: [GoodsService],
exports: [GoodsService],
+9 -1
View File
@@ -5,6 +5,7 @@ import {
} from '@nestjs/common';
import { GoodsService } from './goods.service';
import { PrismaService } from '../prisma/prisma.service';
import { SyncService } from '../sync/sync.service';
describe('GoodsService', () => {
let service: GoodsService;
@@ -22,7 +23,14 @@ describe('GoodsService', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [GoodsService, PrismaService],
providers: [
GoodsService,
PrismaService,
{
provide: SyncService,
useValue: { queueProductDetailSync: jest.fn() },
},
],
}).compile();
service = moduleRef.get(GoodsService);
prisma = moduleRef.get(PrismaService);
+38 -10
View File
@@ -10,20 +10,30 @@ import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto';
import { GoodDto, PaginatedGoods } from './dto/good.dto';
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
import { SyncService } from '../sync/sync.service';
const GOOD_INCLUDE = {
country: true,
category: true,
tag: true,
position: true,
originGood: true,
originGood: {
include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
_count: { select: { variants: true } },
},
},
goodTags: { include: { tag: true } },
} satisfies Prisma.GoodInclude;
@Injectable()
export class GoodsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly syncService: SyncService,
) {}
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
@@ -65,13 +75,13 @@ export class GoodsService {
};
}
async findOne(id: bigint): Promise<GoodDto> {
async findOne(id: bigint): Promise<GoodDetailDto> {
const good = await this.prisma.good.findUnique({
where: { id },
include: GOOD_INCLUDE,
});
if (!good) throw new NotFoundException(`Good ${id} not found`);
return GoodDto.from(good, {
return GoodDetailDto.fromGood(good, {
country: good.country,
category: good.category,
tag: good.tag,
@@ -83,7 +93,7 @@ export class GoodsService {
async create(dto: CreateGoodDto): Promise<GoodDto> {
await this.ensureReferences(dto);
return this.prisma.$transaction(async (tx) => {
const result = await this.prisma.$transaction(async (tx) => {
const created = await tx.good.create({
data: {
goodName: dto.goodName,
@@ -116,6 +126,10 @@ export class GoodsService {
goodTags: result.goodTags,
});
});
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) {
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
}
return result;
}
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
@@ -148,7 +162,7 @@ export class GoodsService {
}
}
return this.prisma.$transaction(async (tx) => {
const result = await this.prisma.$transaction(async (tx) => {
if (dto.tagIds !== undefined) {
await tx.goodTag.deleteMany({ where: { goodId: id } });
if (dto.tagIds.length > 0) {
@@ -174,6 +188,10 @@ export class GoodsService {
goodTags: updated.goodTags,
});
});
if (result.originGood?.sdsGoodId && !result.originGood.hasDetail) {
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
}
return result;
}
async remove(id: bigint): Promise<{ id: string }> {
@@ -187,7 +205,7 @@ export class GoodsService {
* or none do.
*/
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
return this.prisma.$transaction(async (tx) => {
const result = await this.prisma.$transaction(async (tx) => {
for (const item of dto.items) {
await tx.good.update({
where: { id: BigInt(item.id) },
@@ -196,6 +214,7 @@ export class GoodsService {
}
return { count: dto.items.length };
});
return result;
}
/**
@@ -209,7 +228,7 @@ export class GoodsService {
await this.ensureTag(tagId);
}
}
return this.prisma.$transaction(async (tx) => {
const result = await this.prisma.$transaction(async (tx) => {
const created: GoodDto[] = [];
for (const item of dto.items) {
const og = await tx.originGood.findUnique({
@@ -254,6 +273,15 @@ export class GoodsService {
}
return created;
});
for (const goodId of new Set(
result
.filter((item) => !item.originGood?.hasDetail)
.map((item) => item.originGood?.sdsGoodId)
.filter((id): id is string => Boolean(id)),
)) {
this.syncService.queueProductDetailSync(goodId);
}
return result;
}
/**
@@ -313,4 +341,4 @@ export class GoodsService {
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
}
}
}
}