Files
inkreach-official-website/apps/api/src/sync/sync.service.ts
T
yeuimu ab79325ab0 perf(public): 公开读路径进程内分域缓存——meta/goods/matrix 版本域 + TTL 兜底 + in-flight 合并
- PublicCacheService:分域版本号失效(bump 即作废,不等 TTL)、loader 期间
  bump 的竞态防护(返回但不回写)、并发 miss 单飞、PUBLIC_CACHE_DISABLED
  /TTL_MS/MAX_ENTRIES 应急开关;@Global 模块
- PublicService 六端点接缓存:列表缓存全量物化(分页切片在缓存外按请求执行,
  修复'所有页返回第一页'的切片缓存错误)、详情/首页/树/标签组/树序元数据/
  族最低价聚合各按依赖域缓存
- 全写路径挂钩 bump:admin CRUD(goods/categories/countries/tags/tag-groups/
  positions/origin-goods 标签)、sync 三同步、族重算、整理全家桶——
  事务提交成功后失效对应域,product-families 经 recompute 天然覆盖
- 测试:PublicCacheService 单测 13 例 + 失效链路集成 8 例(读命中/写后立即可见
  端到端/每条写路径域断言);既有两套件测数据语义改为显式禁缓存
2026-09-03 03:40:20 +08:00

793 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
SdsClientService,
SdsCategoryTreeNode,
SdsProduct,
SdsProductDetail,
} from './sds-client.service';
import { normalizeProductDetail } from './sds-product-detail.mapper';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
import { PublicCacheService } from '../public/public-cache.service';
export interface CategorySyncResult {
inserted: number;
updated: number;
total: number;
deletedStale: number;
}
/** 全角/半角括号段(非嵌套),分类名清洗用 */
const PAREN_GROUP = /[^()()]*|\([^()()]*\)/g;
/**
* 分类展示名清洗:删除所有括号段(型号限定词如 `(JSA002)`、内容性括号如
* `(黑+卡其)`/`(短裤 & 长裤)`,整段删除),折叠多余空白。剥空(名称仅剩
* 括号段)时原样返回。分类名每小时被 SDS 同步覆盖,清洗必须在此入库路径生效
* 才能持久;款号 token(如 `DG001`)不受影响。
*/
export function cleanCategoryDisplayName(name: string | null | undefined): string | null | undefined {
if (!name) return name;
let cleaned = name.replace(PAREN_GROUP, ' ');
// 嵌套/残缺括号剥除后可能留下空括号对
cleaned = cleaned.replace(/\s*|\(\s*\)/g, ' ');
cleaned = cleaned.replace(/\s+/g, ' ').trim();
return cleaned || name;
}
export interface ProductSyncResult {
inserted: number;
updated: number;
total: number;
leafCategories: number;
delisted: number;
}
/**
* Safety guards so a degenerate/partial SDS response never triggers a
* destructive operation (stale category deletion / mass delist marking).
* Upstream normally returns ~226 categories and ~150 leaf categories with
* hundreds of products — the floors below only trigger on abnormal responses.
*/
export const SYNC_GUARDS = {
MIN_CATEGORY_COUNT: 10,
MIN_CATEGORY_RATIO: 0.5,
MIN_LEAF_CATEGORIES: 10,
MIN_SEEN_GOODS: 50,
} as const;
/**
* True when the fetched category count is suspiciously small compared to the
* categories already synced from SDS, i.e. the upstream response is likely
* partial/degenerate. In that case stale deletion must be skipped.
*/
export function shouldSkipStaleDeletion(fetched: number, existingSds: number): boolean {
if (existingSds <= 0) return false;
return (
fetched < SYNC_GUARDS.MIN_CATEGORY_COUNT ||
fetched < SYNC_GUARDS.MIN_CATEGORY_RATIO * existingSds
);
}
/**
* True only when both the leaf-category count and the number of seen products
* are healthy enough to trust the "not seen upstream => delisted" conclusion.
*/
export function shouldRunDelistDetection(leafCategories: number, seenGoods: number): boolean {
return (
leafCategories >= SYNC_GUARDS.MIN_LEAF_CATEGORIES &&
seenGoods >= SYNC_GUARDS.MIN_SEEN_GOODS
);
}
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
private running = { categories: false, products: false, details: false };
constructor(
private readonly prisma: PrismaService,
private readonly sds: SdsClientService,
private readonly familyRecompute: FamilyRecomputeService,
private readonly publicCache: PublicCacheService,
) {}
/**
* Hourly full sync — runs `syncCategories` first (since product
* sync depends on knowing which leaf categories exist) and then
* `syncProducts`.
*/
@Cron(CronExpression.EVERY_HOUR)
async hourlyCron(): Promise<void> {
try {
await this.syncCategories();
await this.syncProducts();
} catch (err) {
this.logger.error('Hourly cron sync failed', err as Error);
}
}
/** Fire-and-forget wrappers for manual triggers via HTTP. */
async startCategorySync(): Promise<{ message: string }> {
if (this.running.categories) {
return { message: 'Category sync already in progress' };
}
void this.syncCategories().catch((err) =>
this.logger.error('Category sync failed', err as Error),
);
return { message: 'Category sync started' };
}
async startProductSync(): Promise<{ message: string }> {
if (this.running.products) {
return { message: 'Product sync already in progress' };
}
void this.syncProducts().catch((err) =>
this.logger.error('Product sync failed', err as Error),
);
return { message: 'Product sync started' };
}
/** Refresh all active SDS product details once per day at 03:30. */
@Cron('0 30 3 * * *', { timeZone: 'Asia/Shanghai' })
async dailyProductDetailCron(): Promise<void> {
try {
await this.syncProductDetails();
} catch (err) {
this.logger.error('Daily product detail sync failed', err as Error);
}
}
async startProductDetailSync(): Promise<{ message: string }> {
if (this.running.details) {
return { message: 'Product detail sync already in progress' };
}
void this.syncProductDetails().catch((err) =>
this.logger.error('Product detail sync failed', err as Error),
);
return { message: 'Product detail sync started' };
}
/** Check if a sync type is currently running. */
isRunning(type: 'categories' | 'products' | 'details'): boolean {
return this.running[type];
}
async syncCategories(): Promise<CategorySyncResult> {
if (this.running.categories) {
throw new Error('Category sync already in progress');
}
this.running.categories = true;
const log = await this.prisma.syncLog.create({
data: { type: 'CATEGORIES', status: 'RUNNING' },
});
try {
const tree = await this.sds.fetchCategoryTree();
const flat = this.flattenCategoryTree(tree);
this.logger.log(`Fetched ${flat.length} SDS categories`);
const seenSdsIds = new Set(flat.map((n) => n.sdsId));
// Guard: if the upstream tree is suspiciously small vs what we already
// have from SDS, skip stale deletion entirely — a partial response must
// never wipe the category library.
const existingSdsCount = await this.prisma.category.count({
where: { sdsCategoryId: { not: null } },
});
const skipStaleDeletion = shouldSkipStaleDeletion(flat.length, existingSdsCount);
if (skipStaleDeletion) {
this.logger.warn(
`Skipping stale category deletion: fetched=${flat.length} existingSds=${existingSdsCount} ` +
`(below guard thresholds)`,
);
}
// Single transaction: upsert + wire parents + delete stale.
// SDS tree is the source of truth — anything not in the response gets deleted
// (unless the response looks degenerate, see guard above).
const { inserted, updated, deletedStale } = await this.prisma.$transaction(async (tx) => {
let ins = 0;
let upd = 0;
// 1. Upsert all SDS categories
for (const node of flat) {
const existing = await tx.category.findUnique({
where: { sdsCategoryId: node.sdsId },
});
if (!existing) {
await tx.category.create({
data: {
sdsCategoryId: node.sdsId,
categoryName: cleanCategoryDisplayName(node.name) ?? node.name,
categoryIcon: node.icon ?? null,
},
});
ins++;
} else {
await tx.category.update({
where: { id: existing.id },
data: {
categoryName: cleanCategoryDisplayName(node.name) ?? node.name,
categoryIcon: node.icon ?? null,
},
});
upd++;
}
}
// 2. Wire parent-child relationships
for (const node of flat) {
if (!node.parentSdsId) continue;
const child = await tx.category.findUnique({
where: { sdsCategoryId: node.sdsId },
});
const parent = await tx.category.findUnique({
where: { sdsCategoryId: node.parentSdsId },
});
if (child && parent && child.parentCategoryId !== parent.id) {
await tx.category.update({
where: { id: child.id },
data: { parentCategoryId: parent.id },
});
}
}
// 3. Delete stale categories (in DB but not in SDS response)
// Detach parent links first, then delete leaf-first to respect FK constraints.
// Skipped entirely when the response looks degenerate (see guard above).
let deletedStale = 0;
if (!skipStaleDeletion) {
const staleCats = await tx.category.findMany({
where: { sdsCategoryId: { notIn: [...seenSdsIds] } },
select: { id: true },
});
const staleIds = staleCats.map((c) => c.id);
// Protect categories that have configured goods — onDelete: Restrict
const goodsInStale = await tx.good.groupBy({
by: ['categoryId'],
where: { categoryId: { in: staleIds } },
});
const protectedIds = new Set(goodsInStale.map((g) => g.categoryId));
const deletableIds = staleIds.filter((id) => !protectedIds.has(id));
deletedStale = deletableIds.length;
// Detach all deletable categories from their parents
if (deletableIds.length > 0) {
await tx.category.updateMany({
where: { id: { in: deletableIds } },
data: { parentCategoryId: null },
});
// Also detach any non-deletable children pointing to deletable parents
await tx.category.updateMany({
where: { parentCategoryId: { in: deletableIds } },
data: { parentCategoryId: null },
});
// Delete leaf-first (repeatedly remove nodes with no children)
let remaining = [...deletableIds];
while (remaining.length > 0) {
const withChildren = await tx.category.findMany({
where: { parentCategoryId: { in: remaining } },
select: { parentCategoryId: true },
distinct: ['parentCategoryId'],
});
const hasChildSet = new Set(
withChildren.filter((c) => c.parentCategoryId).map((c) => c.parentCategoryId!.toString()),
);
const leaves = remaining.filter((id) => !hasChildSet.has(id.toString()));
if (leaves.length === 0) break; // safety: circular dependency
await tx.category.deleteMany({ where: { id: { in: leaves } } });
remaining = remaining.filter((id) => !leaves.some((l) => l === id));
}
}
}
return { inserted: ins, updated: upd, deletedStale };
});
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `inserted=${inserted} updated=${updated} total=${flat.length} staleDeleted=${deletedStale}`,
},
});
// 分类树/树序元数据变化 → public 缓存失效(全局约束 §2:不等 TTL)
this.publicCache.bump('meta', 'goods');
return { inserted, updated, total: flat.length, deletedStale };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'FAILED',
finishedAt: new Date(),
message,
},
});
throw err;
} finally {
this.running.categories = false;
}
}
async syncProducts(): Promise<ProductSyncResult> {
if (this.running.products) {
throw new Error('Product sync already in progress');
}
this.running.products = true;
const log = await this.prisma.syncLog.create({
data: { type: 'PRODUCTS', status: 'RUNNING' },
});
try {
// Identify leaf categories — those with no children.
const all = await this.prisma.category.findMany({
select: { id: true, sdsCategoryId: true },
});
const parents = await this.prisma.category.findMany({
where: { parent: { isNot: null } },
select: { parentCategoryId: true },
});
const parentIds = new Set(parents.map((p) => p.parentCategoryId!));
const leafRows = all.filter((c) => !parentIds.has(c.id) && c.sdsCategoryId);
let inserted = 0;
let updated = 0;
let total = 0;
const seenSdsGoodIds = new Set<string>();
for (const leaf of leafRows) {
const sdsCategoryId = leaf.sdsCategoryId!;
let page = 1;
// eslint-disable-next-line no-constant-condition
while (true) {
const resp = await this.sds.fetchProductsPage(sdsCategoryId, page, 50);
const products = resp.items ?? resp.content ?? [];
if (products.length === 0) break;
for (const product of products) {
seenSdsGoodIds.add(String(product.id));
const upserted = await this.upsertOriginGood(product, sdsCategoryId);
if (upserted === 'inserted') inserted++;
else updated++;
total++;
}
if (products.length < 50) break;
page++;
if (page > 200) {
// Safety net — at most 10k products per category.
this.logger.warn(`Reached 200-page safety cap for ${sdsCategoryId}`);
break;
}
}
}
// Detect delisted products: mark origin goods not seen in upstream as delisted,
// and re-activate any previously delisted goods that reappeared.
// Guard: only trust this conclusion when the sync covered a healthy number of
// leaf categories and saw a healthy number of products — otherwise a partial
// sync must never mass-delist the product library.
let delistedCount = 0;
let reactivatedCount = 0;
const runDelist = shouldRunDelistDetection(leafRows.length, seenSdsGoodIds.size);
if (runDelist) {
const delistedResult = await this.prisma.originGood.updateMany({
where: {
source: 'SDS',
sdsGoodId: { notIn: [...seenSdsGoodIds] },
delisted: false,
},
data: { delisted: true },
});
const reactivatedResult = await this.prisma.originGood.updateMany({
where: {
source: 'SDS',
sdsGoodId: { in: [...seenSdsGoodIds] },
delisted: true,
},
data: { delisted: false },
});
delistedCount = delistedResult.count;
reactivatedCount = reactivatedResult.count;
} else {
this.logger.warn(
`Skipping delist detection: leafCategories=${leafRows.length} seenGoods=${seenSdsGoodIds.size} ` +
`(below guard thresholds)`,
);
}
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
},
});
// 链接镜像(名称/图/价/上下架)变化 → public 商品列表/详情失效
this.publicCache.bump('goods');
return {
inserted,
updated,
total,
leafCategories: leafRows.length,
delisted: delistedCount,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'FAILED',
finishedAt: new Date(),
message,
},
});
throw err;
} finally {
this.running.products = false;
}
}
async getStatus(limit = 20) {
return this.prisma.syncLog.findMany({
orderBy: { startedAt: 'desc' },
take: limit,
});
}
async syncProductDetails(): Promise<{
total: number;
synced: number;
failed: number;
}> {
if (this.running.details) {
throw new Error('Product detail sync already in progress');
}
this.running.details = true;
const log = await this.prisma.syncLog.create({
data: { type: 'PRODUCT_DETAILS', status: 'RUNNING' },
});
try {
const result = await this.syncAllProductDetails(async (progress) => {
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
message: `processed=${progress.processed}/${progress.total} synced=${progress.synced} failed=${progress.failed}`,
},
});
});
await this.prisma.syncLog.update({
where: { id: log.id },
data: {
status: 'SUCCESS',
finishedAt: new Date(),
message: `total=${result.total} synced=${result.synced} failed=${result.failed}`,
},
});
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await this.prisma.syncLog.update({
where: { id: log.id },
data: { status: 'FAILED', finishedAt: new Date(), message },
});
throw error;
} finally {
this.running.details = false;
}
}
async syncOneProductDetail(goodId: string): Promise<{
goodId: string;
variants: number;
detailSyncedAt: string;
}> {
const originGood = await this.prisma.originGood.findUnique({
where: { sdsGoodId: goodId },
select: { id: true, source: true },
});
if (!originGood) {
throw new NotFoundException(`SDS product ${goodId} not found locally`);
}
if (originGood.source !== 'SDS') {
throw new BadRequestException('自定义商品不支持从 SDS 同步详情');
}
const upstream = await this.sds.fetchProductDetail(goodId);
const normalized = normalizeProductDetail(upstream);
await this.persistProductDetail(originGood.id, upstream);
return {
goodId,
variants: normalized.variants.length,
detailSyncedAt: new Date().toISOString(),
};
}
queueProductDetailSync(goodId: string): void {
void this.syncOneProductDetail(goodId).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Queued detail sync failed for ${goodId}: ${message}`);
});
}
async syncConfiguredProductDetails(): Promise<{ synced: number; failed: number }> {
const result = await this.syncMatchingProductDetails({
delisted: false,
source: 'SDS',
goods: { some: {} },
});
return { synced: result.synced, failed: result.failed };
}
async syncAllProductDetails(
onProgress?: (progress: {
processed: number;
total: number;
synced: number;
failed: number;
}) => Promise<void>,
): Promise<{ total: number; synced: number; failed: number }> {
return this.syncMatchingProductDetails(
{ delisted: false, source: 'SDS' },
onProgress,
2,
);
}
private async syncMatchingProductDetails(
where: Prisma.OriginGoodWhereInput,
onProgress?: (progress: {
processed: number;
total: number;
synced: number;
failed: number;
}) => Promise<void>,
attempts = 1,
): Promise<{ total: number; synced: number; failed: number }> {
const originGoods = await this.prisma.originGood.findMany({
where,
select: { id: true, sdsGoodId: true },
orderBy: { id: 'asc' },
});
let synced = 0;
let failed = 0;
let processed = 0;
for (const originGood of originGoods) {
let lastError: unknown;
let succeeded = false;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const upstream = await this.sds.fetchProductDetail(originGood.sdsGoodId);
await this.persistProductDetail(originGood.id, upstream);
synced++;
succeeded = true;
break;
} catch (error) {
lastError = error;
}
}
if (!succeeded) {
failed++;
const message = lastError instanceof Error ? lastError.message : String(lastError);
this.logger.warn(`Failed to sync SDS detail ${originGood.sdsGoodId}: ${message}`);
}
processed++;
if (onProgress && (processed % 10 === 0 || processed === originGoods.length)) {
await onProgress({ processed, total: originGoods.length, synced, failed });
}
}
return { total: originGoods.length, 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;
// Backfill the origin good's price from upstream min_price when present
if (upstream.min_price !== undefined && upstream.min_price !== null) {
await tx.originGood.update({
where: { id: originGoodId },
data: { goodPrice: new Prisma.Decimal(Number(upstream.min_price)) },
});
}
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 } } : {}),
},
});
});
// 详情/变体/价格落库 → public 详情缓存失效;族矩阵变化由重算钩子 bump matrix
this.publicCache.bump('goods');
// 族成员的详情/变体变化 → 异步重算该族(进程内去重)
await this.maybeEnqueueFamilyRecompute(originGoodId);
}
/** 详情同步后的族重算钩子:链接有族归属才入队 */
private async maybeEnqueueFamilyRecompute(originGoodId: bigint): Promise<void> {
const og = await this.prisma.originGood.findUnique({
where: { id: originGoodId },
select: { familyId: true },
});
if (og?.familyId) this.familyRecompute.enqueue(og.familyId);
}
/**
* 新链接自动挂族已移除(解析去运行时化):归族由整理(OrganizeService)显式完成。
* 详见 plans/refactor/organize-script-refactor.md。
*/
/**
* Flattens the SDS nested tree into a list of `{ sdsId, parentSdsId?, name, icon? }`.
*/
flattenCategoryTree(
nodes: SdsCategoryTreeNode[],
parentSdsId?: string,
): Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> {
const out: Array<{ sdsId: string; parentSdsId?: string; name: string; icon?: string }> = [];
const walk = (node: SdsCategoryTreeNode, parent?: string) => {
const sdsId = String(node.id);
if (sdsId === '' || sdsId === 'undefined' || sdsId === 'null') return;
out.push({
sdsId,
parentSdsId: parent,
name: String(node.name ?? node.title ?? sdsId),
icon: node.icon ? String(node.icon) : undefined,
});
if (Array.isArray(node.children)) {
for (const child of node.children) walk(child, sdsId);
}
};
for (const root of nodes) walk(root, parentSdsId);
return out;
}
private async upsertOriginGood(
product: SdsProduct,
sdsCategoryId: string,
): Promise<'inserted' | 'updated'> {
const sdsGoodId = String(product.id);
const existing = await this.prisma.originGood.findUnique({
where: { sdsGoodId },
});
const goodName = String(product.name ?? product.title ?? sdsGoodId);
const goodImage = product.psd_img_url
? String(product.psd_img_url)
: product.blankDesignUrl
? String(product.blankDesignUrl)
: product.thumbImgUrl
? String(product.thumbImgUrl)
: product.show_img
? String(product.show_img)
: product.img_url
? String(product.img_url)
: product.pic
? String(product.pic)
: product.image
? String(product.image)
: null;
const priceValue = product.currentPrice ?? product.price;
let goodPrice: Prisma.Decimal | null = null;
if (priceValue !== undefined && priceValue !== null) {
const n = typeof priceValue === 'string' ? Number(priceValue) : priceValue;
if (Number.isFinite(n)) {
goodPrice = new Prisma.Decimal(n);
}
}
// 纯镜像:只存 SDS 原文(名称/图/价/分类 ID)。
// 结构化解析列(skuCode/logisticsLabel/craftLabel/warehouseLabel)与自动归族
// 由整理(OrganizeService,显式人工触发)负责,同步不解析、不推断。
const data: Prisma.OriginGoodUncheckedUpdateInput = {
sdsCategoryId,
goodName,
goodImage,
goodPrice,
};
if (!existing) {
await this.prisma.originGood.create({
data: {
sdsGoodId,
sdsCategoryId,
goodName,
goodImage,
goodPrice,
},
select: { id: true },
});
return 'inserted';
}
await this.prisma.originGood.update({
where: { id: existing.id },
data,
});
return 'updated';
}
}