feat(api): add mini program catalog endpoints and product details
This commit is contained in:
@@ -21,7 +21,8 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts"
|
"configure:product-center-icons": "ts-node prisma/configure-product-center-icons.ts",
|
||||||
|
"import:product-detail": "ts-node prisma/import-product-detail.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^3.0.1",
|
"@nestjs/axios": "^3.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { SyncService } from '../src/sync/sync.service';
|
||||||
|
import { SdsProductDetail } from '../src/sync/sds-client.service';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const inputPath = process.argv[2];
|
||||||
|
if (!inputPath) {
|
||||||
|
throw new Error('Usage: pnpm --filter @inkreach/api import:product-detail -- <product_detail.txt>');
|
||||||
|
}
|
||||||
|
const absolutePath = resolve(inputPath);
|
||||||
|
const raw = await readFile(absolutePath, 'utf8');
|
||||||
|
const jsonStart = raw.indexOf('{');
|
||||||
|
if (jsonStart < 0) throw new Error('No JSON object found in product detail file');
|
||||||
|
const detail = JSON.parse(raw.slice(jsonStart)) as SdsProductDetail;
|
||||||
|
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
|
||||||
|
try {
|
||||||
|
const result = await app.get(SyncService).importProductDetail(detail);
|
||||||
|
process.stdout.write(
|
||||||
|
`Imported SDS product ${result.goodId}: ${result.variants} variants, ` +
|
||||||
|
`${result.sizeRows} size rows, ${result.packageRows} package rows, ` +
|
||||||
|
`${result.configuredGoods} configured goods\n`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- Cache SDS product details separately from website merchandising configuration.
|
||||||
|
CREATE TABLE "origin_good_details" (
|
||||||
|
"origin_good_id" BIGINT NOT NULL,
|
||||||
|
"product_code" TEXT,
|
||||||
|
"english_name" TEXT,
|
||||||
|
"blank_design_url" TEXT,
|
||||||
|
"details_page_video_url" TEXT,
|
||||||
|
"texture_name" TEXT,
|
||||||
|
"production_cycle_hours" INTEGER,
|
||||||
|
"min_weight_g" DECIMAL(12,3),
|
||||||
|
"reminder" TEXT,
|
||||||
|
"production_process" TEXT,
|
||||||
|
"material_description" TEXT,
|
||||||
|
"product_performance" TEXT,
|
||||||
|
"applicable_scenarios" TEXT,
|
||||||
|
"washing_instructions" TEXT,
|
||||||
|
"special_description" TEXT,
|
||||||
|
"design_explanation" TEXT,
|
||||||
|
"design_area" TEXT,
|
||||||
|
"picture_request" TEXT,
|
||||||
|
"size_chart" JSONB,
|
||||||
|
"package_specs" JSONB,
|
||||||
|
"options" JSONB,
|
||||||
|
"media" JSONB,
|
||||||
|
"upstream_updated_at" TIMESTAMPTZ(6),
|
||||||
|
"synced_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "origin_good_details_pkey" PRIMARY KEY ("origin_good_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "origin_good_variants" (
|
||||||
|
"origin_good_variant_id" BIGSERIAL NOT NULL,
|
||||||
|
"origin_good_id" BIGINT NOT NULL,
|
||||||
|
"sds_variant_id" TEXT NOT NULL,
|
||||||
|
"sku" TEXT NOT NULL,
|
||||||
|
"size_id" TEXT,
|
||||||
|
"size_name" TEXT,
|
||||||
|
"color_id" TEXT,
|
||||||
|
"color_name" TEXT,
|
||||||
|
"color_hex" TEXT,
|
||||||
|
"image_url" TEXT,
|
||||||
|
"price" DECIMAL(12,2),
|
||||||
|
"original_price" DECIMAL(12,2),
|
||||||
|
"weight_g" DECIMAL(12,3),
|
||||||
|
"box_length_cm" DECIMAL(12,3),
|
||||||
|
"box_width_cm" DECIMAL(12,3),
|
||||||
|
"box_height_cm" DECIMAL(12,3),
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"design_data" JSONB,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "origin_good_variants_pkey" PRIMARY KEY ("origin_good_variant_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "origin_good_variants_origin_good_id_sds_variant_id_key"
|
||||||
|
ON "origin_good_variants"("origin_good_id", "sds_variant_id");
|
||||||
|
CREATE INDEX "origin_good_variants_origin_good_id_sort_order_idx"
|
||||||
|
ON "origin_good_variants"("origin_good_id", "sort_order");
|
||||||
|
CREATE INDEX "origin_good_variants_sku_idx" ON "origin_good_variants"("sku");
|
||||||
|
|
||||||
|
ALTER TABLE "origin_good_details"
|
||||||
|
ADD CONSTRAINT "origin_good_details_origin_good_id_fkey"
|
||||||
|
FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id")
|
||||||
|
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
ALTER TABLE "origin_good_variants"
|
||||||
|
ADD CONSTRAINT "origin_good_variants_origin_good_id_fkey"
|
||||||
|
FOREIGN KEY ("origin_good_id") REFERENCES "origin_goods"("origin_good_id")
|
||||||
|
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
+100
-33
@@ -15,23 +15,90 @@ datasource db {
|
|||||||
|
|
||||||
// ---------- Origin Goods ----------
|
// ---------- Origin Goods ----------
|
||||||
model OriginGood {
|
model OriginGood {
|
||||||
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
id BigInt @id @default(autoincrement()) @map("origin_good_id")
|
||||||
sdsGoodId String @unique @map("sds_good_id")
|
sdsGoodId String @unique @map("sds_good_id")
|
||||||
// Cached SDS product metadata (filled during sync)
|
// Cached SDS product metadata (filled during sync)
|
||||||
sdsCategoryId String? @map("sds_category_id")
|
sdsCategoryId String? @map("sds_category_id")
|
||||||
goodName String? @map("good_name")
|
goodName String? @map("good_name")
|
||||||
goodImage String? @map("good_image")
|
goodImage String? @map("good_image")
|
||||||
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
goodPrice Decimal? @map("good_price") @db.Decimal(12, 2)
|
||||||
delisted Boolean @default(false)
|
delisted Boolean @default(false)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
goods Good[]
|
goods Good[]
|
||||||
|
detail OriginGoodDetail?
|
||||||
|
variants OriginGoodVariant[]
|
||||||
|
|
||||||
@@index([sdsCategoryId])
|
@@index([sdsCategoryId])
|
||||||
@@map("origin_goods")
|
@@map("origin_goods")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Origin Good Details (cached from SDS /products/{id}) ----------
|
||||||
|
model OriginGoodDetail {
|
||||||
|
originGoodId BigInt @id @map("origin_good_id")
|
||||||
|
productCode String? @map("product_code")
|
||||||
|
englishName String? @map("english_name")
|
||||||
|
blankDesignUrl String? @map("blank_design_url")
|
||||||
|
detailsPageVideoUrl String? @map("details_page_video_url")
|
||||||
|
textureName String? @map("texture_name")
|
||||||
|
productionCycleHours Int? @map("production_cycle_hours")
|
||||||
|
minWeightG Decimal? @map("min_weight_g") @db.Decimal(12, 3)
|
||||||
|
reminder String?
|
||||||
|
productionProcess String? @map("production_process")
|
||||||
|
materialDescription String? @map("material_description")
|
||||||
|
productPerformance String? @map("product_performance")
|
||||||
|
applicableScenarios String? @map("applicable_scenarios")
|
||||||
|
washingInstructions String? @map("washing_instructions")
|
||||||
|
specialDescription String? @map("special_description")
|
||||||
|
designExplanation String? @map("design_explanation")
|
||||||
|
designArea String? @map("design_area")
|
||||||
|
pictureRequest String? @map("picture_request")
|
||||||
|
sizeChart Json? @map("size_chart")
|
||||||
|
packageSpecs Json? @map("package_specs")
|
||||||
|
options Json?
|
||||||
|
media Json?
|
||||||
|
upstreamUpdatedAt DateTime? @map("upstream_updated_at") @db.Timestamptz(6)
|
||||||
|
syncedAt DateTime @default(now()) @map("synced_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@map("origin_good_details")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Origin Good Variants (cached SDS child products / SKUs) ----------
|
||||||
|
model OriginGoodVariant {
|
||||||
|
id BigInt @id @default(autoincrement()) @map("origin_good_variant_id")
|
||||||
|
originGoodId BigInt @map("origin_good_id")
|
||||||
|
sdsVariantId String @map("sds_variant_id")
|
||||||
|
sku String
|
||||||
|
sizeId String? @map("size_id")
|
||||||
|
sizeName String? @map("size_name")
|
||||||
|
colorId String? @map("color_id")
|
||||||
|
colorName String? @map("color_name")
|
||||||
|
colorHex String? @map("color_hex")
|
||||||
|
imageUrl String? @map("image_url")
|
||||||
|
price Decimal? @db.Decimal(12, 2)
|
||||||
|
originalPrice Decimal? @map("original_price") @db.Decimal(12, 2)
|
||||||
|
weightG Decimal? @map("weight_g") @db.Decimal(12, 3)
|
||||||
|
boxLengthCm Decimal? @map("box_length_cm") @db.Decimal(12, 3)
|
||||||
|
boxWidthCm Decimal? @map("box_width_cm") @db.Decimal(12, 3)
|
||||||
|
boxHeightCm Decimal? @map("box_height_cm") @db.Decimal(12, 3)
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
designData Json? @map("design_data")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@unique([originGoodId, sdsVariantId])
|
||||||
|
@@index([originGoodId, sortOrder])
|
||||||
|
@@index([sku])
|
||||||
|
@@map("origin_good_variants")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Countries ----------
|
// ---------- Countries ----------
|
||||||
model Country {
|
model Country {
|
||||||
id BigInt @id @default(autoincrement()) @map("country_id")
|
id BigInt @id @default(autoincrement()) @map("country_id")
|
||||||
@@ -48,18 +115,18 @@ model Country {
|
|||||||
|
|
||||||
// ---------- Categories (self-referential tree) ----------
|
// ---------- Categories (self-referential tree) ----------
|
||||||
model Category {
|
model Category {
|
||||||
id BigInt @id @default(autoincrement()) @map("category_id")
|
id BigInt @id @default(autoincrement()) @map("category_id")
|
||||||
parentCategoryId BigInt? @map("parent_category_id")
|
parentCategoryId BigInt? @map("parent_category_id")
|
||||||
categoryName String @map("category_name")
|
categoryName String @map("category_name")
|
||||||
categoryIcon String? @map("category_icon")
|
categoryIcon String? @map("category_icon")
|
||||||
sdsCategoryId String? @unique @map("sds_category_id")
|
sdsCategoryId String? @unique @map("sds_category_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
parent Category? @relation("CategoryToCategory", fields: [parentCategoryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||||
children Category[] @relation("CategoryToCategory")
|
children Category[] @relation("CategoryToCategory")
|
||||||
goods Good[]
|
goods Good[]
|
||||||
positions Position[]
|
positions Position[]
|
||||||
|
|
||||||
@@index([parentCategoryId])
|
@@index([parentCategoryId])
|
||||||
@@map("categories")
|
@@map("categories")
|
||||||
@@ -94,7 +161,7 @@ model Tag {
|
|||||||
|
|
||||||
goods Good[]
|
goods Good[]
|
||||||
goodTags GoodTag[]
|
goodTags GoodTag[]
|
||||||
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])
|
||||||
@@index([tagGroupId, sortOrder])
|
@@index([tagGroupId, sortOrder])
|
||||||
@@ -122,17 +189,17 @@ model Position {
|
|||||||
|
|
||||||
// ---------- Goods ----------
|
// ---------- Goods ----------
|
||||||
model Good {
|
model Good {
|
||||||
id BigInt @id @default(autoincrement()) @map("good_id")
|
id BigInt @id @default(autoincrement()) @map("good_id")
|
||||||
originGoodId BigInt @map("origin_good_id")
|
originGoodId BigInt @map("origin_good_id")
|
||||||
countryId BigInt @map("country_id")
|
countryId BigInt @map("country_id")
|
||||||
categoryId BigInt @map("category_id")
|
categoryId BigInt @map("category_id")
|
||||||
tagId BigInt? @map("tag_id")
|
tagId BigInt? @map("tag_id")
|
||||||
positionId BigInt? @map("position_id")
|
positionId BigInt? @map("position_id")
|
||||||
goodName String @map("good_name")
|
goodName String @map("good_name")
|
||||||
goodImage String? @map("good_image")
|
goodImage String? @map("good_image")
|
||||||
goodPriority Int @default(0) @map("good_priority")
|
goodPriority Int @default(0) @map("good_priority")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
originGood OriginGood @relation(fields: [originGoodId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||||
country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
country Country @relation(fields: [countryId], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export class PublicCategoryNodeDto {
|
|||||||
@ApiProperty({ type: [PublicCategoryNodeDto] })
|
@ApiProperty({ type: [PublicCategoryNodeDto] })
|
||||||
children!: PublicCategoryNodeDto[];
|
children!: PublicCategoryNodeDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: '当前节点及其后代分类的商品数量' })
|
||||||
|
productCount!: number;
|
||||||
|
|
||||||
static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto {
|
static from(category: PrismaCategory, children: PublicCategoryNodeDto[] = []): PublicCategoryNodeDto {
|
||||||
return {
|
return {
|
||||||
id: category.id.toString(),
|
id: category.id.toString(),
|
||||||
@@ -26,6 +29,7 @@ export class PublicCategoryNodeDto {
|
|||||||
? category.parentCategoryId.toString()
|
? category.parentCategoryId.toString()
|
||||||
: null,
|
: null,
|
||||||
children,
|
children,
|
||||||
|
productCount: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { PublicGoodDto } from './public-good.dto';
|
||||||
|
|
||||||
|
export class PublicGoodDetailDto extends PublicGoodDto {
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
productCode!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
englishName!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
productionCycleHours!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
minWeightG!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Object })
|
||||||
|
details!: Record<string, string | null>;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true, type: Object })
|
||||||
|
media!: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true, type: Object })
|
||||||
|
options!: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true, type: Object })
|
||||||
|
sizeChart!: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true, type: Object })
|
||||||
|
packageSpecs!: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Array })
|
||||||
|
variants!: Array<{
|
||||||
|
id: string;
|
||||||
|
sku: string;
|
||||||
|
sizeId: string | null;
|
||||||
|
sizeName: string | null;
|
||||||
|
colorId: string | null;
|
||||||
|
colorName: string | null;
|
||||||
|
colorHex: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
price: string | null;
|
||||||
|
originalPrice: string | null;
|
||||||
|
weightG: string | null;
|
||||||
|
boxLengthCm: string | null;
|
||||||
|
boxWidthCm: string | null;
|
||||||
|
boxHeightCm: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
detailSyncedAt!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicTagGroupFilterDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
groupName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
groupIcon!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
groupColor!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
sortOrder!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Array })
|
||||||
|
tags!: Array<{
|
||||||
|
id: string;
|
||||||
|
tagName: string;
|
||||||
|
tagColor: string | null;
|
||||||
|
tagFontColor: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
productCount: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
|
|
||||||
export class PublicGoodDto {
|
export class PublicGoodDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
id!: string;
|
goodId!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
goodName!: string;
|
goodName!: string;
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsNumberString,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const stringList = ({ value }: { value: unknown }): string[] | undefined => {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
return values
|
||||||
|
.flatMap((item) => String(item).split(','))
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
export class PublicQueryGoodDto {
|
export class PublicQueryGoodDto {
|
||||||
@ApiProperty({ required: false, default: 1 })
|
@ApiProperty({ required: false, default: 1 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -16,7 +28,7 @@ export class PublicQueryGoodDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
page: number = 1;
|
page: number = 1;
|
||||||
|
|
||||||
@ApiProperty({ required: false, default: 20 })
|
@ApiProperty({ required: false, default: 20, maximum: 200 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -24,25 +36,78 @@ export class PublicQueryGoodDto {
|
|||||||
@Max(200)
|
@Max(200)
|
||||||
pageSize: number = 20;
|
pageSize: number = 20;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false, type: String, description: '不传表示全部国家' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@IsNumberString()
|
||||||
@IsInt()
|
countryId?: string;
|
||||||
countryId?: number;
|
|
||||||
|
@ApiProperty({ required: false, type: String })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [String], description: '标签 ID;支持重复参数或逗号分隔' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(stringList)
|
||||||
|
@IsArray()
|
||||||
|
@IsNumberString({}, { each: true })
|
||||||
|
tagIds?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [String], description: '印刷工艺标签 ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(stringList)
|
||||||
|
@IsArray()
|
||||||
|
@IsNumberString({}, { each: true })
|
||||||
|
craftIds?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, type: [String], description: '材质标签 ID' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(stringList)
|
||||||
|
@IsArray()
|
||||||
|
@IsNumberString({}, { each: true })
|
||||||
|
materialIds?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, enum: ['FREE_SHIPPING', 'NOT_FREE_SHIPPING'], isArray: true })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(stringList)
|
||||||
|
@IsArray()
|
||||||
|
@IsIn(['FREE_SHIPPING', 'NOT_FREE_SHIPPING'], { each: true })
|
||||||
|
freeShipping?: Array<'FREE_SHIPPING' | 'NOT_FREE_SHIPPING'>;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt()
|
|
||||||
categoryId?: number;
|
|
||||||
|
|
||||||
@ApiProperty({ required: false, description: 'Comma-separated tag IDs, e.g. "30,34"' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
tagIds?: string;
|
minPrice?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
maxPrice?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, enum: ['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['DEFAULT', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST'])
|
||||||
|
sort?: 'DEFAULT' | 'PRICE_ASC' | 'PRICE_DESC' | 'NEWEST';
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PublicCountryQueryDto {
|
||||||
|
@ApiProperty({ required: false, type: String, description: '不传表示全部国家' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumberString()
|
||||||
|
countryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicHomeGoodsQueryDto extends PublicCountryQueryDto {
|
||||||
|
@ApiProperty({ required: false, default: 10, maximum: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(50)
|
||||||
|
limit: number = 10;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
|
||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||||
import { PublicService } from './public.service';
|
import { PublicService } from './public.service';
|
||||||
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
|
import {
|
||||||
|
PublicCountryQueryDto,
|
||||||
|
PublicHomeGoodsQueryDto,
|
||||||
|
PublicQueryGoodDto,
|
||||||
|
} from './dto/public-query-good.dto';
|
||||||
import { PublicTagDto } from './dto/public-tag.dto';
|
import { PublicTagDto } from './dto/public-tag.dto';
|
||||||
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
|
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
|
||||||
|
import { PublicGoodDto } from './dto/public-good.dto';
|
||||||
|
|
||||||
@ApiTags('public')
|
@ApiTags('public')
|
||||||
@Controller('public')
|
@Controller('public')
|
||||||
@@ -17,9 +21,9 @@ export class PublicController {
|
|||||||
constructor(private readonly service: PublicService) {}
|
constructor(private readonly service: PublicService) {}
|
||||||
|
|
||||||
@Get('categories')
|
@Get('categories')
|
||||||
@ApiOperation({ summary: 'Public list of categories that have goods' })
|
@ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' })
|
||||||
getCategories() {
|
getCategories(@Query() query: PublicCountryQueryDto) {
|
||||||
return this.service.getCategoriesTree();
|
return this.service.getCategoriesTree(query.countryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('countries')
|
@Get('countries')
|
||||||
@@ -35,20 +39,30 @@ export class PublicController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('tag-groups')
|
@Get('tag-groups')
|
||||||
@ApiOperation({ summary: 'Public list of tag groups that have goods' })
|
@ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' })
|
||||||
getTagGroups(): Promise<PublicTagGroupDto[]> {
|
@ApiOkResponse({ type: [PublicTagGroupFilterDto] })
|
||||||
return this.service.getTagGroups();
|
getTagGroups(@Query() query: PublicCountryQueryDto): Promise<PublicTagGroupFilterDto[]> {
|
||||||
|
return this.service.getTagGroups(query.countryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('goods')
|
@Get('goods')
|
||||||
@ApiOperation({ summary: 'Public paginated goods with filters' })
|
@ApiOperation({ summary: '分页获取商品' })
|
||||||
getGoods(@Query() query: PublicQueryGoodDto) {
|
getGoods(@Query() query: PublicQueryGoodDto) {
|
||||||
return this.service.getGoods(query);
|
return this.service.getGoods(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('goods/:id')
|
@Get('goods/:goodId')
|
||||||
@ApiOperation({ summary: 'Public good detail' })
|
@ApiOperation({ summary: '获取商品完整详情' })
|
||||||
getGood(@Param('id', ParseIntPipe) id: string) {
|
@ApiParam({ name: 'goodId', type: String, example: '168746' })
|
||||||
return this.service.getGood(BigInt(id));
|
@ApiOkResponse({ type: PublicGoodDetailDto })
|
||||||
|
getGood(@Param('goodId') goodId: string): Promise<PublicGoodDetailDto> {
|
||||||
|
return this.service.getGood(goodId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('home-goods')
|
||||||
|
@ApiOperation({ summary: '获取首页商品;可按国家返回' })
|
||||||
|
@ApiOkResponse({ type: [PublicGoodDto] })
|
||||||
|
getHomeGoods(@Query() query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
||||||
|
return this.service.getHomeGoods(query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ describe('PublicService', () => {
|
|||||||
let childCategoryId: bigint;
|
let childCategoryId: bigint;
|
||||||
let otherCategoryId: bigint;
|
let otherCategoryId: bigint;
|
||||||
let tagId: bigint;
|
let tagId: bigint;
|
||||||
|
let filterGroupIds: bigint[] = [];
|
||||||
|
let filterTagIds: bigint[] = [];
|
||||||
let originGoodId: bigint;
|
let originGoodId: bigint;
|
||||||
let goodIds: bigint[] = [];
|
let goodIds: bigint[] = [];
|
||||||
|
|
||||||
@@ -98,6 +100,50 @@ describe('PublicService', () => {
|
|||||||
});
|
});
|
||||||
goodIds = [g1.id, g2.id, g3.id];
|
goodIds = [g1.id, g2.id, g3.id];
|
||||||
|
|
||||||
|
const craftGroup = await prisma.tagGroup.create({
|
||||||
|
data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 },
|
||||||
|
});
|
||||||
|
const materialGroup = await prisma.tagGroup.create({
|
||||||
|
data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 },
|
||||||
|
});
|
||||||
|
filterGroupIds = [craftGroup.id, materialGroup.id];
|
||||||
|
const craftA = await prisma.tag.create({
|
||||||
|
data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id },
|
||||||
|
});
|
||||||
|
const craftB = await prisma.tag.create({
|
||||||
|
data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id },
|
||||||
|
});
|
||||||
|
const cotton = await prisma.tag.create({
|
||||||
|
data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id },
|
||||||
|
});
|
||||||
|
filterTagIds = [craftA.id, craftB.id, cotton.id];
|
||||||
|
await prisma.goodTag.createMany({
|
||||||
|
data: [
|
||||||
|
{ goodId: g1.id, tagId: craftA.id },
|
||||||
|
{ goodId: g1.id, tagId: cotton.id },
|
||||||
|
{ goodId: g2.id, tagId: craftB.id },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.originGoodDetail.create({
|
||||||
|
data: {
|
||||||
|
originGoodId,
|
||||||
|
productCode: 'OZ10827003',
|
||||||
|
productionProcess: '白墨烫画',
|
||||||
|
sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] },
|
||||||
|
packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.originGoodVariant.create({
|
||||||
|
data: {
|
||||||
|
originGoodId,
|
||||||
|
sdsVariantId: `pub-variant-${stamp}`,
|
||||||
|
sku: `OZ${stamp}`,
|
||||||
|
sizeName: 'S',
|
||||||
|
price: 38,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||||
// returns more than one category.
|
// returns more than one category.
|
||||||
await prisma.good.create({
|
await prisma.good.create({
|
||||||
@@ -134,6 +180,8 @@ describe('PublicService', () => {
|
|||||||
where: { countryId },
|
where: { countryId },
|
||||||
});
|
});
|
||||||
await prisma.tag.delete({ where: { id: tagId } });
|
await prisma.tag.delete({ where: { id: tagId } });
|
||||||
|
await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } });
|
||||||
|
await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } });
|
||||||
await prisma.originGood.delete({ where: { id: originGoodId } });
|
await prisma.originGood.delete({ where: { id: originGoodId } });
|
||||||
// Delete children before parent (FK self-relation is RESTRICT).
|
// Delete children before parent (FK self-relation is RESTRICT).
|
||||||
await prisma.category.delete({ where: { id: childCategoryId } });
|
await prisma.category.delete({ where: { id: childCategoryId } });
|
||||||
@@ -172,8 +220,8 @@ describe('PublicService', () => {
|
|||||||
const filtered = await service.getGoods({
|
const filtered = await service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
countryId: Number(countryId),
|
countryId: countryId.toString(),
|
||||||
categoryId: Number(categoryId), // includes child
|
categoryId: categoryId.toString(), // includes child
|
||||||
keyword: `Pub `,
|
keyword: `Pub `,
|
||||||
});
|
});
|
||||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||||
@@ -184,7 +232,7 @@ describe('PublicService', () => {
|
|||||||
const result = await service.getGoods({
|
const result = await service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
countryId: Number(countryId),
|
countryId: countryId.toString(),
|
||||||
keyword: `Pub `,
|
keyword: `Pub `,
|
||||||
});
|
});
|
||||||
const priorities = result.items.map((g) => g.goodPriority);
|
const priorities = result.items.map((g) => g.goodPriority);
|
||||||
@@ -193,31 +241,59 @@ describe('PublicService', () => {
|
|||||||
expect(priorities).toEqual(sorted);
|
expect(priorities).toEqual(sorted);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses OR within one tag group and AND across tag groups', async () => {
|
||||||
|
const sameGroup = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
countryId: countryId.toString(),
|
||||||
|
keyword: `Pub `,
|
||||||
|
tagIds: filterTagIds.slice(0, 2).map(String),
|
||||||
|
});
|
||||||
|
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
|
||||||
|
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const acrossGroups = await service.getGoods({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
countryId: countryId.toString(),
|
||||||
|
keyword: `Pub `,
|
||||||
|
tagIds: filterTagIds.map(String),
|
||||||
|
});
|
||||||
|
expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`);
|
||||||
|
expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns the SDS product id as the public product id', async () => {
|
it('returns the SDS product id as the public product id', async () => {
|
||||||
const result = await service.getGoods({
|
const result = await service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 1,
|
pageSize: 1,
|
||||||
countryId: Number(countryId),
|
countryId: countryId.toString(),
|
||||||
keyword: `Pub High ${stamp}`,
|
keyword: `Pub High ${stamp}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.items).toHaveLength(1);
|
expect(result.items).toHaveLength(1);
|
||||||
expect(result.items[0].id).toBe(`pub-sds-${stamp}`);
|
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
|
||||||
expect(result.items[0].id).not.toBe(goodIds[0].toString());
|
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getGood returns detail and 404 for unknown id', async () => {
|
it('getGood returns detail and 404 for unknown id', async () => {
|
||||||
const first = await service.getGoods({
|
const first = await service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 1,
|
pageSize: 1,
|
||||||
countryId: Number(countryId),
|
countryId: countryId.toString(),
|
||||||
keyword: `Pub `,
|
keyword: `Pub `,
|
||||||
});
|
});
|
||||||
expect(first.items.length).toBe(1);
|
expect(first.items.length).toBe(1);
|
||||||
const detail = await service.getGood(goodIds[0]);
|
const detail = await service.getGood(`pub-sds-${stamp}`);
|
||||||
expect(detail.id).toBe(first.items[0].id);
|
expect(detail.goodId).toBe(first.items[0].goodId);
|
||||||
|
expect(detail.productCode).toBe('OZ10827003');
|
||||||
|
expect(detail.details.productionProcess).toBe('白墨烫画');
|
||||||
|
expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1);
|
||||||
|
expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1);
|
||||||
|
expect(detail.variants).toHaveLength(1);
|
||||||
|
|
||||||
await expect(service.getGood(BigInt(99999999))).rejects.toBeInstanceOf(
|
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PublicQueryGoodDto } from './dto/public-query-good.dto';
|
import {
|
||||||
|
PublicHomeGoodsQueryDto,
|
||||||
|
PublicQueryGoodDto,
|
||||||
|
} from './dto/public-query-good.dto';
|
||||||
import { PublicCategoryNodeDto } from './dto/public-category.dto';
|
import { PublicCategoryNodeDto } from './dto/public-category.dto';
|
||||||
import { PublicCountryDto } from './dto/public-country.dto';
|
import { PublicCountryDto } from './dto/public-country.dto';
|
||||||
import { PublicTagDto } from './dto/public-tag.dto';
|
import { PublicTagDto } from './dto/public-tag.dto';
|
||||||
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
|
import { PublicTagGroupDto } from './dto/public-tag-group.dto';
|
||||||
import { PublicGoodDto } from './dto/public-good.dto';
|
import { PublicGoodDto } from './dto/public-good.dto';
|
||||||
|
import {
|
||||||
|
PublicGoodDetailDto,
|
||||||
|
PublicTagGroupFilterDto,
|
||||||
|
} from './dto/public-good-detail.dto';
|
||||||
|
|
||||||
export interface PublicPaginatedGoods {
|
export interface PublicPaginatedGoods {
|
||||||
items: PublicGoodDto[];
|
items: PublicGoodDto[];
|
||||||
@@ -20,17 +27,28 @@ const PUBLIC_GOOD_INCLUDE = {
|
|||||||
category: true,
|
category: true,
|
||||||
tag: { include: { tagGroup: true } },
|
tag: { include: { tagGroup: true } },
|
||||||
position: true,
|
position: true,
|
||||||
originGood: true,
|
originGood: {
|
||||||
|
include: {
|
||||||
|
detail: true,
|
||||||
|
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
goodTags: { include: { tag: { include: { tagGroup: true } } } },
|
||||||
} satisfies Prisma.GoodInclude;
|
} satisfies Prisma.GoodInclude;
|
||||||
|
|
||||||
|
type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PublicService {
|
export class PublicService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async getCategoriesTree(): Promise<PublicCategoryNodeDto[]> {
|
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
|
||||||
|
const goodsWhere: Prisma.GoodWhereInput = {
|
||||||
|
originGood: { delisted: false },
|
||||||
|
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||||
|
};
|
||||||
const leafCategories = await this.prisma.category.findMany({
|
const leafCategories = await this.prisma.category.findMany({
|
||||||
where: { goods: { some: {} } },
|
where: { goods: { some: goodsWhere } },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
});
|
});
|
||||||
const ancestorIds = new Set<bigint>();
|
const ancestorIds = new Set<bigint>();
|
||||||
@@ -46,22 +64,30 @@ export class PublicService {
|
|||||||
cursor = parent.parentCategoryId;
|
cursor = parent.parentCategoryId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ancestorRows = ancestorIds.size > 0
|
const ancestorRows = ancestorIds.size
|
||||||
? await this.prisma.category.findMany({
|
? await this.prisma.category.findMany({
|
||||||
where: { id: { in: [...ancestorIds] } },
|
where: { id: { in: [...ancestorIds] } },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const allRows = [...leafCategories, ...ancestorRows].filter(
|
const allRows = [...leafCategories, ...ancestorRows].filter(
|
||||||
(row, idx, arr) => arr.findIndex((r) => r.id === row.id) === idx,
|
(row, index, rows) => rows.findIndex((item) => item.id === row.id) === index,
|
||||||
);
|
);
|
||||||
allRows.sort((a, b) => Number(a.id - b.id));
|
allRows.sort((a, b) => Number(a.id - b.id));
|
||||||
return this.buildTree(allRows);
|
const directCounts = await this.prisma.good.groupBy({
|
||||||
|
by: ['categoryId'],
|
||||||
|
where: goodsWhere,
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
return this.buildTree(
|
||||||
|
allRows,
|
||||||
|
new Map(directCounts.map((row) => [row.categoryId, row._count._all])),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCountries(): Promise<PublicCountryDto[]> {
|
async getCountries(): Promise<PublicCountryDto[]> {
|
||||||
const rows = await this.prisma.country.findMany({
|
const rows = await this.prisma.country.findMany({
|
||||||
where: { goods: { some: {} } },
|
where: { goods: { some: { originGood: { delisted: false } } } },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
});
|
});
|
||||||
return rows.map(PublicCountryDto.from);
|
return rows.map(PublicCountryDto.from);
|
||||||
@@ -69,7 +95,7 @@ export class PublicService {
|
|||||||
|
|
||||||
async getTags(): Promise<PublicTagDto[]> {
|
async getTags(): Promise<PublicTagDto[]> {
|
||||||
const rows = await this.prisma.tag.findMany({
|
const rows = await this.prisma.tag.findMany({
|
||||||
where: { goodTags: { some: {} } },
|
where: { goodTags: { some: { good: { originGood: { delisted: false } } } } },
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ tagGroup: { sortOrder: 'asc' } },
|
{ tagGroup: { sortOrder: 'asc' } },
|
||||||
{ sortOrder: 'asc' },
|
{ sortOrder: 'asc' },
|
||||||
@@ -80,99 +106,139 @@ export class PublicService {
|
|||||||
return rows.map(PublicTagDto.from);
|
return rows.map(PublicTagDto.from);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTagGroups(): Promise<PublicTagGroupDto[]> {
|
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
|
||||||
|
const goodWhere: Prisma.GoodWhereInput = {
|
||||||
|
originGood: { delisted: false },
|
||||||
|
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||||
|
};
|
||||||
const rows = await this.prisma.tagGroup.findMany({
|
const rows = await this.prisma.tagGroup.findMany({
|
||||||
where: { tags: { some: { goodTags: { some: {} } } } },
|
where: { tags: { some: { goodTags: { some: { good: goodWhere } } } } },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
include: {
|
||||||
|
tags: {
|
||||||
|
where: { goodTags: { some: { good: goodWhere } } },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
include: {
|
||||||
|
_count: { select: { goodTags: { where: { good: goodWhere } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return rows.map(PublicTagGroupDto.from);
|
return rows.map((group) => ({
|
||||||
|
...PublicTagGroupDto.from(group),
|
||||||
|
tags: group.tags.map((tag) => ({
|
||||||
|
id: tag.id.toString(),
|
||||||
|
tagName: tag.tagName,
|
||||||
|
tagColor: tag.tagColor,
|
||||||
|
tagFontColor: tag.tagFontColor,
|
||||||
|
sortOrder: tag.sortOrder,
|
||||||
|
productCount: tag._count.goodTags,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||||
const where: Prisma.GoodWhereInput = {
|
const where: Prisma.GoodWhereInput = { originGood: { delisted: false } };
|
||||||
originGood: { delisted: false },
|
if (query.countryId) where.countryId = BigInt(query.countryId);
|
||||||
};
|
if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
||||||
if (query.countryId !== undefined) where.countryId = BigInt(query.countryId);
|
if (query.categoryId) {
|
||||||
if (query.tagIds) {
|
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
|
||||||
const ids = query.tagIds
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((s) => BigInt(s));
|
|
||||||
if (ids.length > 0) {
|
|
||||||
// AND logic: 商品必须同时具备所有选中的 tag
|
|
||||||
where.AND = ids.map((id) => ({ goodTags: { some: { tagId: id } } }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (query.keyword) {
|
|
||||||
where.goodName = { contains: query.keyword, mode: 'insensitive' };
|
const tagIds = [
|
||||||
|
...new Set([
|
||||||
|
...(query.tagIds ?? []),
|
||||||
|
...(query.craftIds ?? []),
|
||||||
|
...(query.materialIds ?? []),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
const tagFilters = await this.buildTagGroupFilters(tagIds, query.freeShipping);
|
||||||
|
if (tagFilters.length) where.AND = tagFilters;
|
||||||
|
|
||||||
|
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
|
||||||
|
const maxPrice = this.parsePrice(query.maxPrice, 'maxPrice');
|
||||||
|
if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) {
|
||||||
|
throw new BadRequestException('minPrice 不能大于 maxPrice');
|
||||||
}
|
}
|
||||||
if (query.categoryId !== undefined) {
|
if (minPrice !== null || maxPrice !== null) {
|
||||||
const ids = await this.collectCategoryDescendants(BigInt(query.categoryId));
|
where.originGood = {
|
||||||
where.categoryId = { in: ids };
|
delisted: false,
|
||||||
|
goodPrice: {
|
||||||
|
...(minPrice !== null ? { gte: minPrice } : {}),
|
||||||
|
...(maxPrice !== null ? { lte: maxPrice } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orderBy: Prisma.GoodOrderByWithRelationInput[] =
|
||||||
|
query.sort === 'PRICE_ASC'
|
||||||
|
? [{ originGood: { goodPrice: 'asc' } }, { id: 'asc' }]
|
||||||
|
: query.sort === 'PRICE_DESC'
|
||||||
|
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
|
||||||
|
: query.sort === 'NEWEST'
|
||||||
|
? [{ createdAt: 'desc' }, { id: 'asc' }]
|
||||||
|
: [
|
||||||
|
{ goodPriority: 'desc' },
|
||||||
|
{ position: { indexVal: 'asc' } },
|
||||||
|
{ createdAt: 'desc' },
|
||||||
|
{ id: 'asc' },
|
||||||
|
];
|
||||||
|
|
||||||
const [total, rows] = await this.prisma.$transaction([
|
const [total, rows] = await this.prisma.$transaction([
|
||||||
this.prisma.good.count({ where }),
|
this.prisma.good.count({ where }),
|
||||||
this.prisma.good.findMany({
|
this.prisma.good.findMany({
|
||||||
where,
|
where,
|
||||||
include: PUBLIC_GOOD_INCLUDE,
|
include: PUBLIC_GOOD_INCLUDE,
|
||||||
// Server-side primary sort; PublicGoodDto retains original indexes
|
orderBy,
|
||||||
// for stable pagination but the final ORDER BY is mirrored below.
|
|
||||||
orderBy: [
|
|
||||||
{ goodPriority: 'desc' },
|
|
||||||
{ position: { indexVal: 'asc' } },
|
|
||||||
{ createdAt: 'desc' },
|
|
||||||
],
|
|
||||||
skip: (query.page - 1) * query.pageSize,
|
skip: (query.page - 1) * query.pageSize,
|
||||||
take: query.pageSize,
|
take: query.pageSize,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: rows.map((g) => this.toPublicGood(g)),
|
items: rows.map((good) => this.toPublicGood(good)),
|
||||||
total,
|
total,
|
||||||
page: query.page,
|
page: query.page,
|
||||||
pageSize: query.pageSize,
|
pageSize: query.pageSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGood(id: bigint): Promise<PublicGoodDto> {
|
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
|
||||||
const good = await this.prisma.good.findUnique({
|
const good = await this.prisma.good.findFirst({
|
||||||
where: { id },
|
where: { originGood: { sdsGoodId: goodId, delisted: false } },
|
||||||
include: PUBLIC_GOOD_INCLUDE,
|
include: PUBLIC_GOOD_INCLUDE,
|
||||||
|
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
|
||||||
});
|
});
|
||||||
if (!good) throw new NotFoundException(`Good ${id} not found`);
|
if (!good) {
|
||||||
return this.toPublicGood(good);
|
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
return this.toPublicGoodDetail(good);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPublicGood(good: {
|
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
||||||
id: bigint;
|
const rows = await this.prisma.good.findMany({
|
||||||
goodName: string;
|
where: {
|
||||||
goodImage: string | null;
|
positionId: { not: null },
|
||||||
goodPriority: number;
|
originGood: { delisted: false },
|
||||||
country: { id: bigint; countryName: string; countryIcon: string | null };
|
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
|
||||||
category: { id: bigint; categoryName: string; categoryIcon: string | null };
|
},
|
||||||
tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } | null;
|
include: PUBLIC_GOOD_INCLUDE,
|
||||||
position: { id: bigint; indexVal: number } | null;
|
orderBy: [
|
||||||
originGood: {
|
{ position: { indexVal: 'asc' } },
|
||||||
sdsGoodId: string;
|
{ goodPriority: 'desc' },
|
||||||
goodImage: string | null;
|
{ id: 'asc' },
|
||||||
goodPrice: { toString(): string } | null;
|
],
|
||||||
};
|
take: query.limit,
|
||||||
goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[];
|
});
|
||||||
createdAt: Date;
|
return rows.map((good) => this.toPublicGood(good));
|
||||||
}): PublicGoodDto {
|
}
|
||||||
const formatGroup = (g: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
|
||||||
g
|
private toPublicGood(good: PublicGoodRow): PublicGoodDto {
|
||||||
? {
|
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
|
||||||
id: g.id.toString(),
|
group
|
||||||
groupName: g.groupName,
|
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
|
||||||
sortOrder: g.sortOrder,
|
|
||||||
}
|
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
id: good.originGood.sdsGoodId,
|
goodId: good.originGood.sdsGoodId,
|
||||||
goodName: good.goodName,
|
goodName: good.goodName,
|
||||||
goodPriority: good.goodPriority,
|
goodPriority: good.goodPriority,
|
||||||
country: {
|
country: {
|
||||||
@@ -194,63 +260,153 @@ export class PublicService {
|
|||||||
group: formatGroup(good.tag.tagGroup),
|
group: formatGroup(good.tag.tagGroup),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
tags: good.goodTags.map((gt) => ({
|
tags: good.goodTags.map(({ tag }) => ({
|
||||||
id: gt.tag.id.toString(),
|
id: tag.id.toString(),
|
||||||
tagName: gt.tag.tagName,
|
tagName: tag.tagName,
|
||||||
tagColor: gt.tag.tagColor,
|
tagColor: tag.tagColor,
|
||||||
tagFontColor: gt.tag.tagFontColor,
|
tagFontColor: tag.tagFontColor,
|
||||||
group: formatGroup(gt.tag.tagGroup),
|
group: formatGroup(tag.tagGroup),
|
||||||
})),
|
})),
|
||||||
position: good.position
|
position: good.position
|
||||||
? {
|
? { id: good.position.id.toString(), indexVal: good.position.indexVal }
|
||||||
id: good.position.id.toString(),
|
|
||||||
indexVal: good.position.indexVal,
|
|
||||||
}
|
|
||||||
: null,
|
: null,
|
||||||
image: good.goodImage ?? good.originGood?.goodImage ?? null,
|
image: good.goodImage ?? good.originGood.goodImage,
|
||||||
price:
|
price: good.originGood.goodPrice?.toString() ?? null,
|
||||||
good.originGood?.goodPrice === null ||
|
|
||||||
good.originGood?.goodPrice === undefined
|
|
||||||
? null
|
|
||||||
: good.originGood.goodPrice.toString(),
|
|
||||||
createdAt: good.createdAt.toISOString(),
|
createdAt: good.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toPublicGoodDetail(good: PublicGoodRow): PublicGoodDetailDto {
|
||||||
|
const base = this.toPublicGood(good);
|
||||||
|
const detail = good.originGood.detail;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
productCode: detail?.productCode ?? null,
|
||||||
|
englishName: detail?.englishName ?? null,
|
||||||
|
productionCycleHours: detail?.productionCycleHours ?? null,
|
||||||
|
minWeightG: detail?.minWeightG?.toString() ?? null,
|
||||||
|
details: {
|
||||||
|
reminder: detail?.reminder ?? null,
|
||||||
|
productionProcess: detail?.productionProcess ?? null,
|
||||||
|
materialDescription: detail?.materialDescription ?? null,
|
||||||
|
productPerformance: detail?.productPerformance ?? null,
|
||||||
|
applicableScenarios: detail?.applicableScenarios ?? null,
|
||||||
|
washingInstructions: detail?.washingInstructions ?? null,
|
||||||
|
specialDescription: detail?.specialDescription ?? null,
|
||||||
|
designExplanation: detail?.designExplanation ?? null,
|
||||||
|
designArea: detail?.designArea ?? null,
|
||||||
|
pictureRequest: detail?.pictureRequest ?? null,
|
||||||
|
},
|
||||||
|
media: (detail?.media as Record<string, unknown> | null) ?? null,
|
||||||
|
options: (detail?.options as Record<string, unknown> | null) ?? null,
|
||||||
|
sizeChart: (detail?.sizeChart as Record<string, unknown> | null) ?? null,
|
||||||
|
packageSpecs: (detail?.packageSpecs as Record<string, unknown> | null) ?? null,
|
||||||
|
variants: good.originGood.variants.map((variant) => ({
|
||||||
|
id: variant.sdsVariantId,
|
||||||
|
sku: variant.sku,
|
||||||
|
sizeId: variant.sizeId,
|
||||||
|
sizeName: variant.sizeName,
|
||||||
|
colorId: variant.colorId,
|
||||||
|
colorName: variant.colorName,
|
||||||
|
colorHex: variant.colorHex,
|
||||||
|
imageUrl: variant.imageUrl,
|
||||||
|
price: variant.price?.toString() ?? null,
|
||||||
|
originalPrice: variant.originalPrice?.toString() ?? null,
|
||||||
|
weightG: variant.weightG?.toString() ?? null,
|
||||||
|
boxLengthCm: variant.boxLengthCm?.toString() ?? null,
|
||||||
|
boxWidthCm: variant.boxWidthCm?.toString() ?? null,
|
||||||
|
boxHeightCm: variant.boxHeightCm?.toString() ?? null,
|
||||||
|
enabled: variant.enabled,
|
||||||
|
sortOrder: variant.sortOrder,
|
||||||
|
})),
|
||||||
|
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildTagGroupFilters(
|
||||||
|
selectedTagIds: string[],
|
||||||
|
freeShipping?: Array<'FREE_SHIPPING' | 'NOT_FREE_SHIPPING'>,
|
||||||
|
): Promise<Prisma.GoodWhereInput[]> {
|
||||||
|
const selected = selectedTagIds.length
|
||||||
|
? await this.prisma.tag.findMany({
|
||||||
|
where: { id: { in: selectedTagIds.map((id) => BigInt(id)) } },
|
||||||
|
select: { id: true, tagGroupId: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
if (selected.length !== selectedTagIds.length) {
|
||||||
|
throw new BadRequestException('包含不存在的标签 ID');
|
||||||
|
}
|
||||||
|
if (freeShipping?.length) {
|
||||||
|
const names = freeShipping.map((value) =>
|
||||||
|
value === 'FREE_SHIPPING' ? '包邮' : '不包邮',
|
||||||
|
);
|
||||||
|
const shippingTags = await this.prisma.tag.findMany({
|
||||||
|
where: { tagName: { in: names }, tagGroup: { groupName: '物流渠道' } },
|
||||||
|
select: { id: true, tagGroupId: true },
|
||||||
|
});
|
||||||
|
if (shippingTags.length !== new Set(names).size) {
|
||||||
|
throw new BadRequestException('物流渠道标签配置不完整');
|
||||||
|
}
|
||||||
|
selected.push(...shippingTags);
|
||||||
|
}
|
||||||
|
const byGroup = new Map<string, bigint[]>();
|
||||||
|
for (const tag of selected) {
|
||||||
|
const key = tag.tagGroupId?.toString() ?? `tag:${tag.id.toString()}`;
|
||||||
|
const ids = byGroup.get(key) ?? [];
|
||||||
|
if (!ids.some((id) => id === tag.id)) ids.push(tag.id);
|
||||||
|
byGroup.set(key, ids);
|
||||||
|
}
|
||||||
|
return [...byGroup.values()].map((ids) => ({
|
||||||
|
goodTags: { some: { tagId: { in: ids } } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePrice(value: string | undefined, field: string): number | null {
|
||||||
|
if (value === undefined || value === '') return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
throw new BadRequestException(`${field} 必须是大于等于 0 的金额`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
|
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
|
||||||
const ids: bigint[] = [rootId];
|
const ids: bigint[] = [rootId];
|
||||||
let frontier: bigint[] = [rootId];
|
let frontier: bigint[] = [rootId];
|
||||||
while (frontier.length > 0) {
|
while (frontier.length) {
|
||||||
const children = await this.prisma.category.findMany({
|
const children = await this.prisma.category.findMany({
|
||||||
where: { parentCategoryId: { in: frontier } },
|
where: { parentCategoryId: { in: frontier } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (children.length === 0) break;
|
if (!children.length) break;
|
||||||
const childIds = children.map((c) => c.id);
|
frontier = children.map((child) => child.id);
|
||||||
ids.push(...childIds);
|
ids.push(...frontier);
|
||||||
frontier = childIds;
|
|
||||||
}
|
}
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildTree(
|
private buildTree(
|
||||||
rows: PrismaCategory[],
|
rows: PrismaCategory[],
|
||||||
|
directCounts: Map<bigint, number>,
|
||||||
): PublicCategoryNodeDto[] {
|
): PublicCategoryNodeDto[] {
|
||||||
const byId = new Map<bigint, PublicCategoryNodeDto>();
|
const byId = new Map<bigint, PublicCategoryNodeDto>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
byId.set(row.id, PublicCategoryNodeDto.from(row, []));
|
const node = PublicCategoryNodeDto.from(row, []);
|
||||||
|
node.productCount = directCounts.get(row.id) ?? 0;
|
||||||
|
byId.set(row.id, node);
|
||||||
}
|
}
|
||||||
const roots: PublicCategoryNodeDto[] = [];
|
const roots: PublicCategoryNodeDto[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const node = byId.get(row.id)!;
|
const node = byId.get(row.id)!;
|
||||||
if (row.parentCategoryId === null) {
|
const parent = row.parentCategoryId === null ? null : byId.get(row.parentCategoryId);
|
||||||
roots.push(node);
|
if (parent) parent.children.push(node);
|
||||||
} else {
|
else roots.push(node);
|
||||||
const parent = byId.get(row.parentCategoryId);
|
|
||||||
if (parent) parent.children.push(node);
|
|
||||||
else roots.push(node);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const total = (node: PublicCategoryNodeDto): number => {
|
||||||
|
node.productCount += node.children.reduce((sum, child) => sum + total(child), 0);
|
||||||
|
return node.productCount;
|
||||||
|
};
|
||||||
|
roots.forEach(total);
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,23 @@ describe('SdsClientService', () => {
|
|||||||
expect(result).toHaveLength(20);
|
expect(result).toHaveLength(20);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetchProductDetail', () => {
|
||||||
|
it('requests /products/{goodId} and validates the returned id', async () => {
|
||||||
|
http.get.mockReturnValue(of({ data: { id: 168746, sku: 'OZ10827003' } }));
|
||||||
|
|
||||||
|
const result = await service.fetchProductDetail('168746');
|
||||||
|
|
||||||
|
expect(result.sku).toBe('OZ10827003');
|
||||||
|
expect(http.get).toHaveBeenCalledWith(
|
||||||
|
'https://mapi.sdspod.com/products/168746',
|
||||||
|
expect.objectContaining({ headers: expect.any(Object) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a mismatched product response', async () => {
|
||||||
|
http.get.mockReturnValue(of({ data: { id: 1 } }));
|
||||||
|
await expect(service.fetchProductDetail('168746')).rejects.toThrow(/id mismatch/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,86 @@ export interface SdsProductsPage {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SdsProductVariant extends Record<string, unknown> {
|
||||||
|
id?: number | string;
|
||||||
|
sku?: string;
|
||||||
|
size?: string;
|
||||||
|
sizeId?: number | string;
|
||||||
|
sizeDto?: { id?: number | string; sizeName?: string };
|
||||||
|
colorId?: number | string;
|
||||||
|
color_name?: string;
|
||||||
|
color?: {
|
||||||
|
colorId?: number | string;
|
||||||
|
color?: string;
|
||||||
|
color_name?: string;
|
||||||
|
chineseName?: string;
|
||||||
|
};
|
||||||
|
currentPrice?: number | string;
|
||||||
|
originalPrice?: number | string;
|
||||||
|
unit_price?: number | string;
|
||||||
|
min_price?: number | string;
|
||||||
|
weight?: number | string;
|
||||||
|
box_length?: number | string;
|
||||||
|
box_width?: number | string;
|
||||||
|
box_height?: number | string;
|
||||||
|
status?: number | string;
|
||||||
|
delFlag?: number | string;
|
||||||
|
size_sort?: number | string;
|
||||||
|
attribute_sort?: string;
|
||||||
|
psd_img_url?: string;
|
||||||
|
img_url?: string;
|
||||||
|
blankDesignUrl?: string;
|
||||||
|
designPrototype?: {
|
||||||
|
detailImgUrls?: Array<{ imageUrl?: string }>;
|
||||||
|
prototypeResultGroups?: Array<{ resultImage?: string }>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SdsProductDetail extends Record<string, unknown> {
|
||||||
|
id: number | string;
|
||||||
|
name?: string;
|
||||||
|
sku?: string;
|
||||||
|
english_name?: string;
|
||||||
|
blankDesignUrl?: string;
|
||||||
|
detailsPageVideoUrl?: string;
|
||||||
|
productionCycle?: number | string;
|
||||||
|
minWeight?: number | string;
|
||||||
|
min_price?: number | string;
|
||||||
|
updateTime?: number | string;
|
||||||
|
psd_img_url?: string;
|
||||||
|
img_url?: string;
|
||||||
|
texture?: { name?: string };
|
||||||
|
product_details?: {
|
||||||
|
reminder?: string;
|
||||||
|
production_process?: string;
|
||||||
|
material_description?: string;
|
||||||
|
product_performance?: string;
|
||||||
|
applicable_scenarios?: string;
|
||||||
|
washing_instructions?: string;
|
||||||
|
special_description?: string;
|
||||||
|
design_explanation?: string;
|
||||||
|
design_area?: string;
|
||||||
|
picture_request?: string;
|
||||||
|
product_size?: string;
|
||||||
|
packaging_specification?: string;
|
||||||
|
};
|
||||||
|
subproducts?: {
|
||||||
|
attributers?: Array<{
|
||||||
|
size?: string;
|
||||||
|
sizeId?: number | string;
|
||||||
|
colors?: Array<{
|
||||||
|
colorId?: number | string;
|
||||||
|
color?: string;
|
||||||
|
color_name?: string;
|
||||||
|
chineseName?: string;
|
||||||
|
colorSort?: number | string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
items?: SdsProductVariant[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
|
* Thin wrapper around the SDS (mapi.sdspod.com) endpoints that the
|
||||||
* `SyncService` consumes.
|
* `SyncService` consumes.
|
||||||
@@ -137,4 +217,17 @@ export class SdsClientService {
|
|||||||
}
|
}
|
||||||
return data as SdsProductsPage;
|
return data as SdsProductsPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchProductDetail(goodId: string | number): Promise<SdsProductDetail> {
|
||||||
|
const url = `${this.baseUrl}/products/${encodeURIComponent(String(goodId))}`;
|
||||||
|
const data = await this.request<unknown>('get', url);
|
||||||
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||||
|
throw new Error(`SDS product detail returned ${typeof data}, expected object`);
|
||||||
|
}
|
||||||
|
const detail = data as SdsProductDetail;
|
||||||
|
if (String(detail.id) !== String(goodId)) {
|
||||||
|
throw new Error(`SDS product detail id mismatch: expected ${goodId}, got ${String(detail.id)}`);
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
normalizeProductDetail,
|
||||||
|
parsePackageSpecs,
|
||||||
|
parseSizeChart,
|
||||||
|
} from './sds-product-detail.mapper';
|
||||||
|
|
||||||
|
const sizeTable = JSON.stringify([
|
||||||
|
['尺码', '衣长(cm/in)', '胸围(cm/in)', '肩宽(cm/in)', '袖长(cm/in)'].map((content) => ({ content, remark: '' })),
|
||||||
|
['S', '71', '92', '43', '22'].map((content) => ({ content, remark: '' })),
|
||||||
|
['M', '74', '102', '45', '22'].map((content) => ({ content, remark: '' })),
|
||||||
|
['L', '76', '112', '48', '23'].map((content) => ({ content, remark: '' })),
|
||||||
|
['XL', '79', '122', '51', '23'].map((content) => ({ content, remark: '' })),
|
||||||
|
['2XL', '82', '132', '53', '25'].map((content) => ({ content, remark: '' })),
|
||||||
|
['3XL', '84', '142', '56', '25'].map((content) => ({ content, remark: '' })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const packageTable = JSON.stringify([
|
||||||
|
['尺码', '包装尺寸(cm)', '包装尺寸(in)', '包装体积(cm³)', '包装体积(in³)', '含包装重量(g)', '含包装重量(lb)'].map((content) => ({ content })),
|
||||||
|
['S', '36.0*26.0*1.0\t', '14.17*10.24*0.39\t', '936.00', '57.12', '208.00', '0.46'].map((content) => ({ content })),
|
||||||
|
['M', '36.0*26.0*1.0', '14.17*10.24*0.39', '936.00', '57.12', '218.00', '0.48'].map((content) => ({ content })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe('SDS product detail mapper', () => {
|
||||||
|
it('parses the product_detail.txt size table into structured rows', () => {
|
||||||
|
const chart = parseSizeChart(sizeTable) as any;
|
||||||
|
expect(chart.columns.map((column: any) => column.key)).toEqual([
|
||||||
|
'bodyLength',
|
||||||
|
'chest',
|
||||||
|
'shoulder',
|
||||||
|
'sleeveLength',
|
||||||
|
]);
|
||||||
|
expect(chart.rows).toHaveLength(6);
|
||||||
|
expect(chart.rows[0].measurements[0]).toEqual({ key: 'bodyLength', cm: '71', in: '27.95' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses packaging dimensions and weights', () => {
|
||||||
|
const specs = parsePackageSpecs(packageTable) as any;
|
||||||
|
expect(specs.rows).toHaveLength(2);
|
||||||
|
expect(specs.rows[0].dimensionsCm).toEqual({ length: '36.0', width: '26.0', height: '1.0' });
|
||||||
|
expect(specs.rows[0].grossWeightG).toBe('208.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes detail text, options and variants', () => {
|
||||||
|
const normalized = normalizeProductDetail({
|
||||||
|
id: 168746,
|
||||||
|
sku: 'OZ10827003',
|
||||||
|
english_name: 't-shirt',
|
||||||
|
productionCycle: 24,
|
||||||
|
minWeight: 250,
|
||||||
|
product_details: {
|
||||||
|
production_process: '白墨烫画',
|
||||||
|
material_description: '100%纯棉',
|
||||||
|
product_size: sizeTable,
|
||||||
|
packaging_specification: packageTable,
|
||||||
|
},
|
||||||
|
subproducts: {
|
||||||
|
attributers: [{
|
||||||
|
size: 'S',
|
||||||
|
sizeId: 1922304,
|
||||||
|
colors: [{ colorId: 1139383, color: '#000300', color_name: 'black', colorSort: 1 }],
|
||||||
|
}],
|
||||||
|
items: [{
|
||||||
|
id: 168747,
|
||||||
|
sku: 'OZ10827003001',
|
||||||
|
size: 'S',
|
||||||
|
sizeId: 1922304,
|
||||||
|
colorId: 1139383,
|
||||||
|
color: { colorId: 1139383, color: '#000300', color_name: 'black' },
|
||||||
|
currentPrice: 38,
|
||||||
|
originalPrice: 38,
|
||||||
|
weight: 250,
|
||||||
|
box_length: 30,
|
||||||
|
box_width: 20,
|
||||||
|
box_height: 5,
|
||||||
|
status: 1,
|
||||||
|
delFlag: '0',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.productCode).toBe('OZ10827003');
|
||||||
|
expect(normalized.productionProcess).toBe('白墨烫画');
|
||||||
|
expect(normalized.variants[0].sku).toBe('OZ10827003001');
|
||||||
|
expect(normalized.variants[0].price?.toString()).toBe('38');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { SdsProductDetail, SdsProductVariant } from './sds-client.service';
|
||||||
|
|
||||||
|
type TableCell = { content?: unknown; remark?: unknown };
|
||||||
|
type Table = TableCell[][];
|
||||||
|
|
||||||
|
export interface NormalizedVariant {
|
||||||
|
sdsVariantId: string;
|
||||||
|
sku: string;
|
||||||
|
sizeId: string | null;
|
||||||
|
sizeName: string | null;
|
||||||
|
colorId: string | null;
|
||||||
|
colorName: string | null;
|
||||||
|
colorHex: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
price: Prisma.Decimal | null;
|
||||||
|
originalPrice: Prisma.Decimal | null;
|
||||||
|
weightG: Prisma.Decimal | null;
|
||||||
|
boxLengthCm: Prisma.Decimal | null;
|
||||||
|
boxWidthCm: Prisma.Decimal | null;
|
||||||
|
boxHeightCm: Prisma.Decimal | null;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
designData: Prisma.InputJsonValue | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedProductDetail {
|
||||||
|
productCode: string | null;
|
||||||
|
englishName: string | null;
|
||||||
|
blankDesignUrl: string | null;
|
||||||
|
detailsPageVideoUrl: string | null;
|
||||||
|
textureName: string | null;
|
||||||
|
productionCycleHours: number | null;
|
||||||
|
minWeightG: Prisma.Decimal | null;
|
||||||
|
reminder: string | null;
|
||||||
|
productionProcess: string | null;
|
||||||
|
materialDescription: string | null;
|
||||||
|
productPerformance: string | null;
|
||||||
|
applicableScenarios: string | null;
|
||||||
|
washingInstructions: string | null;
|
||||||
|
specialDescription: string | null;
|
||||||
|
designExplanation: string | null;
|
||||||
|
designArea: string | null;
|
||||||
|
pictureRequest: string | null;
|
||||||
|
sizeChart: Prisma.InputJsonValue | null;
|
||||||
|
packageSpecs: Prisma.InputJsonValue | null;
|
||||||
|
options: Prisma.InputJsonValue | null;
|
||||||
|
media: Prisma.InputJsonValue | null;
|
||||||
|
upstreamUpdatedAt: Date | null;
|
||||||
|
variants: NormalizedVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (value: unknown): string | null => {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized.length > 0 ? normalized : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const decimal = (value: unknown): Prisma.Decimal | null => {
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? new Prisma.Decimal(n) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const integer = (value: unknown): number | null => {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isInteger(n) ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseTable(raw: unknown): Table | null {
|
||||||
|
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed) || parsed.length < 2) return null;
|
||||||
|
const rows = parsed.filter(Array.isArray) as Table;
|
||||||
|
return rows.length >= 2 ? rows : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = (row: TableCell[], index: number): string =>
|
||||||
|
text(row[index]?.content)?.replace(/\t/g, '').trim() ?? '';
|
||||||
|
|
||||||
|
const measurementKey = (header: string, index: number): string => {
|
||||||
|
if (header.includes('衣长')) return 'bodyLength';
|
||||||
|
if (header.includes('胸围')) return 'chest';
|
||||||
|
if (header.includes('肩宽')) return 'shoulder';
|
||||||
|
if (header.includes('袖长')) return 'sleeveLength';
|
||||||
|
return `measurement${index}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseSizeChart(raw: unknown): Prisma.InputJsonValue | null {
|
||||||
|
const table = parseTable(raw);
|
||||||
|
if (!table) return null;
|
||||||
|
const [header, ...body] = table;
|
||||||
|
const columns = header.slice(1).map((item, index) => {
|
||||||
|
const name = text(item.content)?.replace(/\s*\(cm\/in\)\s*/i, '') ?? `规格${index + 1}`;
|
||||||
|
return { key: measurementKey(name, index + 1), name };
|
||||||
|
});
|
||||||
|
const rows = body
|
||||||
|
.map((row, rowIndex) => {
|
||||||
|
const sizeName = cell(row, 0);
|
||||||
|
if (!sizeName) return null;
|
||||||
|
return {
|
||||||
|
sizeId: `size_${rowIndex}`,
|
||||||
|
sizeName,
|
||||||
|
measurements: columns.map((column, index) => {
|
||||||
|
const cm = cell(row, index + 1);
|
||||||
|
const cmNumber = Number(cm);
|
||||||
|
return {
|
||||||
|
key: column.key,
|
||||||
|
cm: cm || null,
|
||||||
|
in: Number.isFinite(cmNumber) ? (cmNumber / 2.54).toFixed(2) : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||||
|
return { columns, rows } as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimensions(value: string): { length: string; width: string; height: string } | null {
|
||||||
|
const parts = value
|
||||||
|
.replace(/[×x]/gi, '*')
|
||||||
|
.split('*')
|
||||||
|
.map((part) => part.trim());
|
||||||
|
if (parts.length !== 3 || parts.some((part) => !Number.isFinite(Number(part)))) return null;
|
||||||
|
return { length: parts[0], width: parts[1], height: parts[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePackageSpecs(raw: unknown): Prisma.InputJsonValue | null {
|
||||||
|
const table = parseTable(raw);
|
||||||
|
if (!table) return null;
|
||||||
|
const rows = table.slice(1)
|
||||||
|
.map((row, rowIndex) => {
|
||||||
|
const sizeName = cell(row, 0);
|
||||||
|
if (!sizeName) return null;
|
||||||
|
return {
|
||||||
|
sizeId: `size_${rowIndex}`,
|
||||||
|
sizeName,
|
||||||
|
dimensionsCm: dimensions(cell(row, 1)),
|
||||||
|
dimensionsIn: dimensions(cell(row, 2)),
|
||||||
|
volumeCm3: cell(row, 3) || null,
|
||||||
|
volumeIn3: cell(row, 4) || null,
|
||||||
|
grossWeightG: cell(row, 5) || null,
|
||||||
|
grossWeightLb: cell(row, 6) || null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||||
|
return { rows } as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(detail: SdsProductDetail): Prisma.InputJsonValue | null {
|
||||||
|
const attributers = detail.subproducts?.attributers;
|
||||||
|
if (!Array.isArray(attributers)) return null;
|
||||||
|
const sizeMap = new Map<string, { id: string; name: string; sortOrder: number; enabled: boolean }>();
|
||||||
|
const colorMap = new Map<string, { id: string; name: string; hex: string | null; sortOrder: number; enabled: boolean }>();
|
||||||
|
attributers.forEach((attribute, sizeIndex) => {
|
||||||
|
const sizeName = text(attribute.size);
|
||||||
|
const sizeId = text(attribute.sizeId) ?? `size_${sizeIndex}`;
|
||||||
|
if (sizeName) sizeMap.set(sizeId, { id: sizeId, name: sizeName, sortOrder: sizeIndex, enabled: true });
|
||||||
|
if (Array.isArray(attribute.colors)) {
|
||||||
|
attribute.colors.forEach((color, colorIndex) => {
|
||||||
|
const colorId = text(color.colorId) ?? `color_${colorIndex}`;
|
||||||
|
if (!colorMap.has(colorId)) {
|
||||||
|
colorMap.set(colorId, {
|
||||||
|
id: colorId,
|
||||||
|
name: text(color.chineseName) ?? text(color.color_name) ?? colorId,
|
||||||
|
hex: text(color.color),
|
||||||
|
sortOrder: integer(color.colorSort) ?? colorIndex,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { sizes: [...sizeMap.values()], colors: [...colorMap.values()] } as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMedia(detail: SdsProductDetail, variants: SdsProductVariant[]): Prisma.InputJsonValue | null {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const add = (value: unknown) => {
|
||||||
|
const url = text(value);
|
||||||
|
if (url && !urls.includes(url)) urls.push(url);
|
||||||
|
};
|
||||||
|
add(detail.blankDesignUrl);
|
||||||
|
add(detail.psd_img_url);
|
||||||
|
add(detail.img_url);
|
||||||
|
for (const variant of variants) {
|
||||||
|
add(variant.psd_img_url);
|
||||||
|
add(variant.img_url);
|
||||||
|
add(variant.blankDesignUrl);
|
||||||
|
for (const image of variant.designPrototype?.detailImgUrls ?? []) add(image.imageUrl);
|
||||||
|
for (const image of variant.designPrototype?.prototypeResultGroups ?? []) add(image.resultImage);
|
||||||
|
}
|
||||||
|
if (urls.length === 0) return null;
|
||||||
|
return {
|
||||||
|
primaryImageUrl: urls[0],
|
||||||
|
images: urls.map((url, index) => ({ id: `image_${index}`, url, sortOrder: index })),
|
||||||
|
} as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVariant(variant: SdsProductVariant, index: number): NormalizedVariant | null {
|
||||||
|
const sdsVariantId = text(variant.id);
|
||||||
|
const sku = text(variant.sku);
|
||||||
|
if (!sdsVariantId || !sku) return null;
|
||||||
|
return {
|
||||||
|
sdsVariantId,
|
||||||
|
sku,
|
||||||
|
sizeId: text(variant.sizeId) ?? text(variant.sizeDto?.id),
|
||||||
|
sizeName: text(variant.size) ?? text(variant.sizeDto?.sizeName),
|
||||||
|
colorId: text(variant.colorId) ?? text(variant.color?.colorId),
|
||||||
|
colorName: text(variant.color?.chineseName) ?? text(variant.color_name) ?? text(variant.color?.color_name),
|
||||||
|
colorHex: text(variant.color?.color),
|
||||||
|
imageUrl: text(variant.psd_img_url) ?? text(variant.img_url) ?? text(variant.blankDesignUrl),
|
||||||
|
price: decimal(variant.currentPrice ?? variant.unit_price ?? variant.min_price),
|
||||||
|
originalPrice: decimal(variant.originalPrice),
|
||||||
|
weightG: decimal(variant.weight),
|
||||||
|
boxLengthCm: decimal(variant.box_length),
|
||||||
|
boxWidthCm: decimal(variant.box_width),
|
||||||
|
boxHeightCm: decimal(variant.box_height),
|
||||||
|
enabled: Number(variant.status ?? 1) === 1 && String(variant.delFlag ?? '0') === '0',
|
||||||
|
sortOrder: integer(variant.attribute_sort?.split('-')[0]) ?? integer(variant.size_sort) ?? index,
|
||||||
|
designData: variant.designPrototype ? (variant.designPrototype as Prisma.InputJsonValue) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProductDetail(detail: SdsProductDetail): NormalizedProductDetail {
|
||||||
|
const productDetails = detail.product_details ?? {};
|
||||||
|
const sourceVariants = Array.isArray(detail.subproducts?.items) ? detail.subproducts!.items! : [];
|
||||||
|
const variants = sourceVariants
|
||||||
|
.map(normalizeVariant)
|
||||||
|
.filter((variant): variant is NormalizedVariant => variant !== null);
|
||||||
|
const updatedAt = Number(detail.updateTime);
|
||||||
|
return {
|
||||||
|
productCode: text(detail.sku),
|
||||||
|
englishName: text(detail.english_name),
|
||||||
|
blankDesignUrl: text(detail.blankDesignUrl),
|
||||||
|
detailsPageVideoUrl: text(detail.detailsPageVideoUrl),
|
||||||
|
textureName: text(detail.texture?.name),
|
||||||
|
productionCycleHours: integer(detail.productionCycle),
|
||||||
|
minWeightG: decimal(detail.minWeight),
|
||||||
|
reminder: text(productDetails.reminder),
|
||||||
|
productionProcess: text(productDetails.production_process),
|
||||||
|
materialDescription: text(productDetails.material_description),
|
||||||
|
productPerformance: text(productDetails.product_performance),
|
||||||
|
applicableScenarios: text(productDetails.applicable_scenarios),
|
||||||
|
washingInstructions: text(productDetails.washing_instructions),
|
||||||
|
specialDescription: text(productDetails.special_description),
|
||||||
|
designExplanation: text(productDetails.design_explanation),
|
||||||
|
designArea: text(productDetails.design_area),
|
||||||
|
pictureRequest: text(productDetails.picture_request),
|
||||||
|
sizeChart: parseSizeChart(productDetails.product_size),
|
||||||
|
packageSpecs: parsePackageSpecs(productDetails.packaging_specification),
|
||||||
|
options: normalizeOptions(detail),
|
||||||
|
media: normalizeMedia(detail, sourceVariants),
|
||||||
|
upstreamUpdatedAt: Number.isFinite(updatedAt) ? new Date(updatedAt) : null,
|
||||||
|
variants,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ describe('SyncService', () => {
|
|||||||
const sdsMock: Partial<SdsClientService> = {
|
const sdsMock: Partial<SdsClientService> = {
|
||||||
fetchCategoryTree: jest.fn(),
|
fetchCategoryTree: jest.fn(),
|
||||||
fetchProductsPage: jest.fn(),
|
fetchProductsPage: jest.fn(),
|
||||||
|
fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
|
||||||
};
|
};
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||||
@@ -29,6 +30,9 @@ describe('SyncService', () => {
|
|||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(SyncService);
|
service = moduleRef.get(SyncService);
|
||||||
|
jest
|
||||||
|
.spyOn(service, 'syncConfiguredProductDetails')
|
||||||
|
.mockResolvedValue({ synced: 0, failed: 0 });
|
||||||
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
|
sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SdsClientService, SdsCategoryTreeNode, SdsProduct } from './sds-client.service';
|
import {
|
||||||
|
SdsClientService,
|
||||||
|
SdsCategoryTreeNode,
|
||||||
|
SdsProduct,
|
||||||
|
SdsProductDetail,
|
||||||
|
} from './sds-client.service';
|
||||||
|
import { normalizeProductDetail } from './sds-product-detail.mapper';
|
||||||
|
|
||||||
export interface CategorySyncResult {
|
export interface CategorySyncResult {
|
||||||
inserted: number;
|
inserted: number;
|
||||||
@@ -17,6 +23,8 @@ export interface ProductSyncResult {
|
|||||||
total: number;
|
total: number;
|
||||||
leafCategories: number;
|
leafCategories: number;
|
||||||
delisted: number;
|
delisted: number;
|
||||||
|
detailsSynced: number;
|
||||||
|
detailFailures: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -339,15 +347,28 @@ export class SyncService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only hydrate full details for products selected in the website catalog.
|
||||||
|
// This keeps the hourly sync bounded and preserves the existing Good/tag
|
||||||
|
// merchandising model. A failed detail request never erases cached data.
|
||||||
|
const detailResult = await this.syncConfiguredProductDetails();
|
||||||
|
|
||||||
await this.prisma.syncLog.update({
|
await this.prisma.syncLog.update({
|
||||||
where: { id: log.id },
|
where: { id: log.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
|
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length} detailsSynced=${detailResult.synced} detailFailures=${detailResult.failed}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { inserted, updated, total, leafCategories: leafRows.length, delisted: delistedCount };
|
return {
|
||||||
|
inserted,
|
||||||
|
updated,
|
||||||
|
total,
|
||||||
|
leafCategories: leafRows.length,
|
||||||
|
delisted: delistedCount,
|
||||||
|
detailsSynced: detailResult.synced,
|
||||||
|
detailFailures: detailResult.failed,
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
await this.prisma.syncLog.update({
|
await this.prisma.syncLog.update({
|
||||||
@@ -371,6 +392,120 @@ export class SyncService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
|
||||||
|
const configured = await this.prisma.originGood.findMany({
|
||||||
|
where: { delisted: false, goods: { some: {} } },
|
||||||
|
select: { id: true, sdsGoodId: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
let synced = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const originGood of configured) {
|
||||||
|
try {
|
||||||
|
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
|
||||||
|
await this.persistProductDetail(originGood.id, upstream);
|
||||||
|
synced++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { synced, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async importProductDetail(upstream: SdsProductDetail): Promise<{
|
||||||
|
goodId: string;
|
||||||
|
variants: number;
|
||||||
|
sizeRows: number;
|
||||||
|
packageRows: number;
|
||||||
|
configuredGoods: number;
|
||||||
|
}> {
|
||||||
|
const goodId = String(upstream.id);
|
||||||
|
const normalized = normalizeProductDetail(upstream);
|
||||||
|
const originGood = await this.prisma.originGood.upsert({
|
||||||
|
where: { sdsGoodId: goodId },
|
||||||
|
create: {
|
||||||
|
sdsGoodId: goodId,
|
||||||
|
goodName: String(upstream.name ?? goodId),
|
||||||
|
goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || null,
|
||||||
|
goodPrice:
|
||||||
|
upstream.min_price === undefined || upstream.min_price === null
|
||||||
|
? null
|
||||||
|
: new Prisma.Decimal(Number(upstream.min_price)),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
goodName: upstream.name ? String(upstream.name) : undefined,
|
||||||
|
goodImage: String(upstream.psd_img_url ?? upstream.img_url ?? upstream.blankDesignUrl ?? '') || undefined,
|
||||||
|
goodPrice:
|
||||||
|
upstream.min_price === undefined || upstream.min_price === null
|
||||||
|
? undefined
|
||||||
|
: new Prisma.Decimal(Number(upstream.min_price)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.persistProductDetail(originGood.id, upstream);
|
||||||
|
const configuredGoods = await this.prisma.good.count({
|
||||||
|
where: { originGoodId: originGood.id },
|
||||||
|
});
|
||||||
|
const sizeChart = normalized.sizeChart as { rows?: unknown[] } | null;
|
||||||
|
const packageSpecs = normalized.packageSpecs as { rows?: unknown[] } | null;
|
||||||
|
return {
|
||||||
|
goodId,
|
||||||
|
variants: normalized.variants.length,
|
||||||
|
sizeRows: sizeChart?.rows?.length ?? 0,
|
||||||
|
packageRows: packageSpecs?.rows?.length ?? 0,
|
||||||
|
configuredGoods,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistProductDetail(originGoodId: bigint, upstream: SdsProductDetail): Promise<void> {
|
||||||
|
const normalized = normalizeProductDetail(upstream);
|
||||||
|
const { variants, ...detail } = normalized;
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const json = (value: Prisma.InputJsonValue | null) => value ?? Prisma.DbNull;
|
||||||
|
await tx.originGoodDetail.upsert({
|
||||||
|
where: { originGoodId },
|
||||||
|
create: {
|
||||||
|
originGoodId,
|
||||||
|
...detail,
|
||||||
|
sizeChart: json(detail.sizeChart),
|
||||||
|
packageSpecs: json(detail.packageSpecs),
|
||||||
|
options: json(detail.options),
|
||||||
|
media: json(detail.media),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...detail,
|
||||||
|
sizeChart: json(detail.sizeChart),
|
||||||
|
packageSpecs: json(detail.packageSpecs),
|
||||||
|
options: json(detail.options),
|
||||||
|
media: json(detail.media),
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const seenVariantIds: string[] = [];
|
||||||
|
for (const variant of variants) {
|
||||||
|
seenVariantIds.push(variant.sdsVariantId);
|
||||||
|
const { designData, ...data } = variant;
|
||||||
|
await tx.originGoodVariant.upsert({
|
||||||
|
where: {
|
||||||
|
originGoodId_sdsVariantId: {
|
||||||
|
originGoodId,
|
||||||
|
sdsVariantId: variant.sdsVariantId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: { originGoodId, ...data, designData: json(designData) },
|
||||||
|
update: { ...data, designData: json(designData) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.originGoodVariant.deleteMany({
|
||||||
|
where: {
|
||||||
|
originGoodId,
|
||||||
|
...(seenVariantIds.length ? { sdsVariantId: { notIn: seenVariantIds } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
|
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ interface BackendTag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BackendProduct {
|
interface BackendProduct {
|
||||||
id: string;
|
goodId: string;
|
||||||
goodName: string;
|
goodName: string;
|
||||||
goodPriority: number;
|
goodPriority: number;
|
||||||
country: { id: string; countryName: string; countryIcon: string | null };
|
country: { id: string; countryName: string; countryIcon: string | null };
|
||||||
@@ -243,7 +243,7 @@ export function resolveDefaultProductFilters(
|
|||||||
|
|
||||||
function mapProduct(p: BackendProduct, backendUrl: string): Product {
|
function mapProduct(p: BackendProduct, backendUrl: string): Product {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.goodId,
|
||||||
name: p.goodName,
|
name: p.goodName,
|
||||||
priority: p.goodPriority,
|
priority: p.goodPriority,
|
||||||
image: resolveBackendAssetUrl(p.image, backendUrl),
|
image: resolveBackendAssetUrl(p.image, backendUrl),
|
||||||
|
|||||||
Reference in New Issue
Block a user