484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
|
|
import axios from 'axios';
|
|||
|
|
import db from '../config/database';
|
|||
|
|
import { CDCPipeline } from '../core/pipeline/CDCPipeline';
|
|||
|
|
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
|||
|
|
import { MultiTenantCore } from '../domains/Tenant/MultiTenantCore';
|
|||
|
|
import { Product } from '../models/Product';
|
|||
|
|
import { logger } from '../utils/logger';
|
|||
|
|
import { AgingInventoryService } from './AgingInventoryService';
|
|||
|
|
import { AIService } from './AIService';
|
|||
|
|
import { ArbitrageService } from './ArbitrageService';
|
|||
|
|
import { CompetitorService } from './CompetitorService';
|
|||
|
|
import { ImageFingerprintService } from './ImageFingerprintService';
|
|||
|
|
import { InventoryService } from './InventoryService';
|
|||
|
|
import { MarketingService } from './MarketingService';
|
|||
|
|
import { SupplyChainService } from './SupplyChainService';
|
|||
|
|
import { TaxService } from './TaxService';
|
|||
|
|
import { VisionFactoryService } from './VisionFactoryService';
|
|||
|
|
|
|||
|
|
export class ProductService {
|
|||
|
|
private static TABLE_NAME = 'cf_product';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 初始化数据库表
|
|||
|
|
*/
|
|||
|
|
static async initTable() {
|
|||
|
|
const hasProductTable = await db.schema.hasTable(this.TABLE_NAME);
|
|||
|
|
if (!hasProductTable) {
|
|||
|
|
console.log(`📦 Creating ${this.TABLE_NAME} table...`);
|
|||
|
|
await db.schema.createTable(this.TABLE_NAME, (table) => {
|
|||
|
|
table.increments('id').primary();
|
|||
|
|
table.string('tenant_id', 50).notNullable().index(); // [CORE_SEC_45] 租户隔离
|
|||
|
|
table.string('platform', 50).notNullable();
|
|||
|
|
table.string('site', 16).defaultTo('GLOBAL'); // [V30.0] 站点维度
|
|||
|
|
table.string('productId', 100).notNullable();
|
|||
|
|
table.text('title');
|
|||
|
|
table.text('originalTitle');
|
|||
|
|
table.decimal('price', 15, 2).defaultTo(0);
|
|||
|
|
table.decimal('originalPrice', 15, 2).defaultTo(0);
|
|||
|
|
table.string('currency', 10).defaultTo('CNY');
|
|||
|
|
|
|||
|
|
// 物理属性:CM, KG, M3 (符合业务规则)
|
|||
|
|
table.decimal('length_cm', 10, 2).defaultTo(0);
|
|||
|
|
table.decimal('width_cm', 10, 2).defaultTo(0);
|
|||
|
|
table.decimal('height_cm', 10, 2).defaultTo(0);
|
|||
|
|
table.decimal('weight_kg', 10, 3).defaultTo(0);
|
|||
|
|
|
|||
|
|
table.string('mainImage', 500);
|
|||
|
|
table.string('detailUrl', 1000);
|
|||
|
|
table.string('shopName', 200);
|
|||
|
|
table.string('shopId', 100);
|
|||
|
|
table.bigInteger('sales').defaultTo(0);
|
|||
|
|
table.double('rating').defaultTo(0);
|
|||
|
|
table.text('description'); // 商品详情
|
|||
|
|
table.json('attributes');
|
|||
|
|
table.json('images');
|
|||
|
|
table.json('skus');
|
|||
|
|
table.string('phash', 64); // [CORE_AI_10] 图像指纹
|
|||
|
|
table.string('semantic_hash', 64);
|
|||
|
|
table.text('vector_embedding');
|
|||
|
|
table.string('status', 32).defaultTo('DRAFTED'); // DRAFTED, PENDING_REVIEW, APPROVED, REJECTED
|
|||
|
|
table.decimal('ai_confidence', 5, 4).defaultTo(1.0); // AI 信心指数
|
|||
|
|
table.timestamps(true, true);
|
|||
|
|
|
|||
|
|
// 索引优化
|
|||
|
|
table.index(['tenant_id', 'platform', 'site'], 'idx_prod_geo');
|
|||
|
|
table.unique(['tenant_id', 'platform', 'productId'], 'uk_prod_id');
|
|||
|
|
table.index(['phash'], 'idx_prod_phash');
|
|||
|
|
table.index(['semantic_hash'], 'idx_prod_semantic');
|
|||
|
|
});
|
|||
|
|
console.log(`✅ Table ${this.TABLE_NAME} created`);
|
|||
|
|
} else {
|
|||
|
|
// 动态列检查 (CORE_SEC_45)
|
|||
|
|
const hasTenantId = await db.schema.hasColumn(this.TABLE_NAME, 'tenant_id');
|
|||
|
|
if (!hasTenantId) {
|
|||
|
|
await db.schema.alterTable(this.TABLE_NAME, (table) => {
|
|||
|
|
table.string('tenant_id', 50).after('id').index();
|
|||
|
|
});
|
|||
|
|
console.log(`✅ Column tenant_id added to ${this.TABLE_NAME}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 动态列检查 (CORE_AI_10)
|
|||
|
|
const hasPhash = await db.schema.hasColumn(this.TABLE_NAME, 'phash');
|
|||
|
|
if (!hasPhash) {
|
|||
|
|
await db.schema.alterTable(this.TABLE_NAME, (table) => {
|
|||
|
|
table.string('phash', 64).after('skus').index();
|
|||
|
|
});
|
|||
|
|
console.log(`✅ Column phash added to ${this.TABLE_NAME}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasDescription = await db.schema.hasColumn(this.TABLE_NAME, 'description');
|
|||
|
|
if (!hasDescription) {
|
|||
|
|
await db.schema.alterTable(this.TABLE_NAME, (table) => {
|
|||
|
|
table.text('description').after('rating');
|
|||
|
|
});
|
|||
|
|
console.log(`✅ Column description added to ${this.TABLE_NAME}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasSemanticHash = await db.schema.hasColumn(this.TABLE_NAME, 'semantic_hash');
|
|||
|
|
if (!hasSemanticHash) {
|
|||
|
|
await db.schema.alterTable(this.TABLE_NAME, (table) => {
|
|||
|
|
table.string('semantic_hash', 64).after('phash').index();
|
|||
|
|
});
|
|||
|
|
console.log(`✅ Column semantic_hash added to ${this.TABLE_NAME}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasVectorEmbedding = await db.schema.hasColumn(this.TABLE_NAME, 'vector_embedding');
|
|||
|
|
if (!hasVectorEmbedding) {
|
|||
|
|
await db.schema.alterTable(this.TABLE_NAME, (table) => {
|
|||
|
|
table.text('vector_embedding').after('semantic_hash');
|
|||
|
|
});
|
|||
|
|
console.log(`✅ Column vector_embedding added to ${this.TABLE_NAME}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasStatus = await db.schema.hasColumn(this.TABLE_NAME, 'status');
|
|||
|
|
if (!hasStatus) {
|
|||
|
|
await db.schema.alterTable(this.TABLE_NAME, (table) => {
|
|||
|
|
table.string('status', 32).after('vector_embedding').defaultTo('DRAFTED');
|
|||
|
|
});
|
|||
|
|
console.log(`✅ Column status added to ${this.TABLE_NAME}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasAiConfidence = await db.schema.hasColumn(this.TABLE_NAME, 'ai_confidence');
|
|||
|
|
if (!hasAiConfidence) {
|
|||
|
|
await db.schema.alterTable(this.TABLE_NAME, (table) => {
|
|||
|
|
table.decimal('ai_confidence', 5, 4).after('status').defaultTo(1.0);
|
|||
|
|
});
|
|||
|
|
console.log(`✅ Column ai_confidence added to ${this.TABLE_NAME}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [BIZ_EXT_11] 智能库存预测分析
|
|||
|
|
*/
|
|||
|
|
static async predictProductDemand(tenantId: string, id: number) {
|
|||
|
|
const product = await this.getById(tenantId, id);
|
|||
|
|
if (!product || !product.skus) return null;
|
|||
|
|
|
|||
|
|
const predictions = [];
|
|||
|
|
for (const sku of product.skus) {
|
|||
|
|
const result = await InventoryService.predictSKUDemand(id.toString(), sku.skuId);
|
|||
|
|
predictions.push(result);
|
|||
|
|
}
|
|||
|
|
return predictions;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [BIZ_EXT_12] 执行流失营销挽留任务
|
|||
|
|
*/
|
|||
|
|
static async runAbandonedRecoveryTask() {
|
|||
|
|
return await MarketingService.processAbandonedCarts();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [CORE_DEV_04] 抓取并生成图像指纹
|
|||
|
|
*/
|
|||
|
|
static async collectAndFingerprint(tenantId: string, id: number) {
|
|||
|
|
const product = await this.getById(tenantId, id);
|
|||
|
|
if (!product || !product.mainImage) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await axios.get(product.mainImage, { responseType: 'arraybuffer' });
|
|||
|
|
const buffer = Buffer.from(response.data, 'binary');
|
|||
|
|
const hash = await ImageFingerprintService.generatePHash(buffer);
|
|||
|
|
|
|||
|
|
// 更新产品图像指纹 (CORE_DEV_04)
|
|||
|
|
await this.update(tenantId, id, { phash: hash } as any);
|
|||
|
|
return hash;
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error(`[ProductService] Fingerprinting failed: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [BIZ_EXT_05] 库存周转分析与清仓建议
|
|||
|
|
*/
|
|||
|
|
static async analyzeInventoryAging(tenantId: string, id: number) {
|
|||
|
|
const product = await this.getById(tenantId, id);
|
|||
|
|
if (!product || !product.skus) return null;
|
|||
|
|
|
|||
|
|
const agingData = [];
|
|||
|
|
for (const sku of product.skus) {
|
|||
|
|
const info = await AgingInventoryService.analyzeSKUAging(id.toString(), sku.skuId);
|
|||
|
|
const suggestions = info.map(item => ({
|
|||
|
|
...item,
|
|||
|
|
clearance: AgingInventoryService.getClearanceSuggestion(item.ageDays)
|
|||
|
|
}));
|
|||
|
|
agingData.push({ skuId: sku.skuId, agingInfo: suggestions });
|
|||
|
|
}
|
|||
|
|
return agingData;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [BIZ_EXT_08] 全球税务合规引擎
|
|||
|
|
*/
|
|||
|
|
static async calculateProductTax(tenantId: string, id: number, countryCode: string, isB2B: boolean = false) {
|
|||
|
|
const product = await this.getById(tenantId, id);
|
|||
|
|
if (!product || !product.costPrice) return null;
|
|||
|
|
|
|||
|
|
return await TaxService.calculateTax({
|
|||
|
|
countryCode,
|
|||
|
|
baseAmount: product.costPrice,
|
|||
|
|
currency: 'USD',
|
|||
|
|
isB2B,
|
|||
|
|
iossNumber: isB2B ? 'IOSS-TEST-123' : undefined
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [CORE_AI_15] 视觉/语义复合检索
|
|||
|
|
* @description 通过视觉哈希、语义哈希或向量相似度寻找同款
|
|||
|
|
*/
|
|||
|
|
static async findByFingerprint(tenantId: string, params: {
|
|||
|
|
phash?: string;
|
|||
|
|
semanticHash?: string;
|
|||
|
|
vectorEmbedding?: number[];
|
|||
|
|
threshold?: number;
|
|||
|
|
}) {
|
|||
|
|
const { phash, semanticHash, vectorEmbedding, threshold = 0.9 } = params;
|
|||
|
|
const query = MultiTenantCore.from(tenantId, this.TABLE_NAME);
|
|||
|
|
|
|||
|
|
// 1. 优先尝试精确哈希匹配 (快速路径)
|
|||
|
|
if (phash) {
|
|||
|
|
const match = await query.clone().where({ phash }).first();
|
|||
|
|
if (match) return [this.parseProduct(match)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (semanticHash) {
|
|||
|
|
const match = await query.clone().where({ semantic_hash: semanticHash }).first();
|
|||
|
|
if (match) return [this.parseProduct(match)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 向量相似度检索 (全量搜索,在大规模场景下应使用专门的向量库)
|
|||
|
|
if (vectorEmbedding) {
|
|||
|
|
const products = await query.clone().whereNotNull('vector_embedding').select('*');
|
|||
|
|
const results = products
|
|||
|
|
.map((p: any) => {
|
|||
|
|
const pEmbedding = JSON.parse(p.vector_embedding);
|
|||
|
|
const similarity = this.cosineSimilarity(vectorEmbedding, pEmbedding);
|
|||
|
|
return { ...p, similarity };
|
|||
|
|
})
|
|||
|
|
.filter((p: any) => p.similarity >= threshold)
|
|||
|
|
.sort((a: any, b: any) => b.similarity - a.similarity);
|
|||
|
|
return results;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static cosineSimilarity(vecA: number[], vecB: number[]): number {
|
|||
|
|
let dotProduct = 0;
|
|||
|
|
let normA = 0;
|
|||
|
|
let normB = 0;
|
|||
|
|
for (let i = 0; i < vecA.length; i++) {
|
|||
|
|
dotProduct += vecA[i] * vecB[i];
|
|||
|
|
normA += vecA[i] * vecA[i];
|
|||
|
|
normB += vecB[i] * vecB[i];
|
|||
|
|
}
|
|||
|
|
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [BIZ_EXT_09] 全球同行嗅探器
|
|||
|
|
*/
|
|||
|
|
static async sniffCompetitors(tenantId: string, id: number) {
|
|||
|
|
const product = await this.getById(tenantId, id);
|
|||
|
|
if (!product || !product.images || product.images.length === 0) return null;
|
|||
|
|
|
|||
|
|
const results = [];
|
|||
|
|
for (const imageUrl of product.images) {
|
|||
|
|
// 假设 imageUrl 就是指纹,或者先转为指纹
|
|||
|
|
const res = await CompetitorService.findCrossPlatformMatches(imageUrl);
|
|||
|
|
results.push(...res);
|
|||
|
|
}
|
|||
|
|
return results;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [BIZ_EXT_10] 供应链全链路溯源
|
|||
|
|
*/
|
|||
|
|
static async trackSupplyChain(tenantId: string, id: number) {
|
|||
|
|
const product = await this.getById(tenantId, id);
|
|||
|
|
if (!product) return null;
|
|||
|
|
return await SupplyChainService.traceSourceFactory(product.mainImage);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [BIZ_EXT_07] 分析选品利差
|
|||
|
|
*/
|
|||
|
|
static async analyzeProductArbitrage(tenantId: string, id: number) {
|
|||
|
|
const product = await this.getById(tenantId, id);
|
|||
|
|
if (!product || !product.costPrice) return null;
|
|||
|
|
|
|||
|
|
return await ArbitrageService.analyzeArbitrage(id.toString(), product.costPrice);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [BIZ_DEV_02] 侵权合规性审计
|
|||
|
|
*/
|
|||
|
|
static async auditProductIP(tenantId: string, id: number) {
|
|||
|
|
const product = await this.getById(tenantId, id);
|
|||
|
|
if (!product) return null;
|
|||
|
|
|
|||
|
|
const result = await AIService.checkIPRisk({
|
|||
|
|
title: product.title,
|
|||
|
|
description: product.description,
|
|||
|
|
imageUrls: product.images || []
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (result.isRisky) {
|
|||
|
|
await this.update(tenantId, id, { status: 'rejected' } as any); // 假设 status 是 cf_product 字段
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [BIZ_PROD_12] AI 商品全自动“清洗”与本地化 (Washer)
|
|||
|
|
* @description 过滤非法字符/Logo,重写地道 Listing,并生成目标 market 营销图
|
|||
|
|
*/
|
|||
|
|
static async washAndLocalize(tenantId: string, id: number, targetMarket: string, targetLang: string = 'en') {
|
|||
|
|
const product = await this.getById(tenantId, id);
|
|||
|
|
if (!product) throw new Error('Product not found');
|
|||
|
|
|
|||
|
|
logger.info(`[ProductService] Starting "Washer" for Product ${id} (${targetMarket})`);
|
|||
|
|
|
|||
|
|
// 1. 语义清洗与本地化 (AIService)
|
|||
|
|
const localization = await AIService.localizeForIndependentStation({
|
|||
|
|
title: product.title || product.originalTitle || '',
|
|||
|
|
description: product.description || '',
|
|||
|
|
targetMarket
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 2. 视觉本地化增强 (VisionFactoryService)
|
|||
|
|
let marketingImage = product.mainImage;
|
|||
|
|
if (product.mainImage) {
|
|||
|
|
marketingImage = await VisionFactoryService.generateMarketingImage({
|
|||
|
|
originalImageUrl: product.mainImage,
|
|||
|
|
productTitle: localization.seoTitle,
|
|||
|
|
targetMarket,
|
|||
|
|
aestheticStyle: targetMarket === 'Middle East' ? 'Gold & Luxury' : 'Minimalist'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 更新产品数据
|
|||
|
|
const updateData: Partial<Product> = {
|
|||
|
|
title: localization.seoTitle,
|
|||
|
|
description: localization.localizedBodyHtml,
|
|||
|
|
mainImage: marketingImage,
|
|||
|
|
// 将 SEO 标签作为扩展属性存储 (此处模拟)
|
|||
|
|
attributes: {
|
|||
|
|
...product.attributes,
|
|||
|
|
seo_tags: JSON.stringify(localization.tags),
|
|||
|
|
json_ld: localization.jsonLd
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await this.update(tenantId, id, updateData);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
id,
|
|||
|
|
originalTitle: product.title,
|
|||
|
|
newTitle: localization.seoTitle,
|
|||
|
|
marketingImage,
|
|||
|
|
tags: localization.tags
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static async getAll(tenantId: string, params?: { platform?: string; status?: string }) {
|
|||
|
|
const query = MultiTenantCore.from(tenantId, this.TABLE_NAME);
|
|||
|
|
if (params?.platform) query.where({ platform: params.platform });
|
|||
|
|
if (params?.status) query.where({ status: params.status });
|
|||
|
|
const products = await query.select('*');
|
|||
|
|
return products.map((p: any) => this.parseProduct(p));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static async getById(tenantId: string, id: number): Promise<Product | null> {
|
|||
|
|
const product = await MultiTenantCore.from(tenantId, this.TABLE_NAME).where({ id }).first();
|
|||
|
|
return product ? this.parseProduct(product) : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static async create(tenantId: string, product: Partial<Product>): Promise<number> {
|
|||
|
|
const data = {
|
|||
|
|
...this.formatProduct(product),
|
|||
|
|
tenant_id: tenantId,
|
|||
|
|
created_at: new Date(),
|
|||
|
|
updated_at: new Date(),
|
|||
|
|
};
|
|||
|
|
const [id] = await db(this.TABLE_NAME).insert(data);
|
|||
|
|
return id;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [CORE_DEV_08] 集成向量化存储 & [CORE_DEV_11] CDC 拦截
|
|||
|
|
*/
|
|||
|
|
static async update(tenantId: string, id: number, product: Partial<Product>): Promise<void> {
|
|||
|
|
const owned = await MultiTenantCore.ensureOwnership(tenantId, this.TABLE_NAME, id);
|
|||
|
|
if (!owned) throw new Error('Unauthorized access to product record');
|
|||
|
|
|
|||
|
|
const data = {
|
|||
|
|
...this.formatProduct(product),
|
|||
|
|
updated_at: new Date(),
|
|||
|
|
};
|
|||
|
|
await MultiTenantCore.from(tenantId, this.TABLE_NAME).where({ id }).update(data);
|
|||
|
|
|
|||
|
|
const updatedProduct = await this.getById(tenantId, id);
|
|||
|
|
|
|||
|
|
// 1. 触发 CDC 变更拦截 (CORE_DEV_11)
|
|||
|
|
if (updatedProduct) {
|
|||
|
|
CDCPipeline.onProductChange(id, 'UPDATE', updatedProduct).catch(err => {
|
|||
|
|
logger.error(`[ProductService] CDC intercept failed: ${err.message}`);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static async delete(tenantId: string, id: number): Promise<void> {
|
|||
|
|
const owned = await MultiTenantCore.ensureOwnership(tenantId, this.TABLE_NAME, id);
|
|||
|
|
if (!owned) throw new Error('Unauthorized access to product record');
|
|||
|
|
|
|||
|
|
await MultiTenantCore.from(tenantId, this.TABLE_NAME).where({ id }).delete();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static async findByPhash(tenantId: string, phash: string): Promise<Product[]> {
|
|||
|
|
const results = await MultiTenantCore.from(tenantId, this.TABLE_NAME).where({ phash }).select('*');
|
|||
|
|
return results.map((p: any) => this.parseProduct(p));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* [BIZ_EXT_01] 模拟商品同步逻辑
|
|||
|
|
*/
|
|||
|
|
static async syncProduct(productId: string, tenantId: string) {
|
|||
|
|
logger.info(`[ProductService] Syncing product ${productId} for tenant ${tenantId}`);
|
|||
|
|
// 实际业务中应对接第三方平台 API 获取最新状态并更新数据库
|
|||
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 更新商品库存 (BIZ_INV_05)
|
|||
|
|
*/
|
|||
|
|
static async updateStock(tenantId: string, productId: string, delta: number) {
|
|||
|
|
logger.info(`[ProductService] Updating stock for ${productId} by ${delta}`);
|
|||
|
|
|
|||
|
|
const product = await db(this.TABLE_NAME).where({ id: productId, tenant_id: tenantId }).first();
|
|||
|
|
if (!product) throw new Error('Product not found');
|
|||
|
|
|
|||
|
|
await db(this.TABLE_NAME).where({ id: productId }).update({
|
|||
|
|
stock: (product.stock || 0) + delta,
|
|||
|
|
updated_at: new Date()
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// [BIZ_GOV_20] 发布业务事件到总线,触发自动审计
|
|||
|
|
DomainEventBus.getInstance().publish({
|
|||
|
|
tenantId,
|
|||
|
|
module: 'INVENTORY',
|
|||
|
|
action: 'STOCK_UPDATE',
|
|||
|
|
resourceType: 'PRODUCT',
|
|||
|
|
resourceId: productId,
|
|||
|
|
data: { delta, newStock: (product.stock || 0) + delta },
|
|||
|
|
traceId: 'stock-update-' + Date.now()
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static parseProduct(dbData: any): Product {
|
|||
|
|
return {
|
|||
|
|
...dbData,
|
|||
|
|
images: typeof dbData.images === 'string' ? JSON.parse(dbData.images) : dbData.images,
|
|||
|
|
skus: typeof dbData.skus === 'string' ? JSON.parse(dbData.skus) : dbData.skus,
|
|||
|
|
attributes: typeof dbData.attributes === 'string' ? JSON.parse(dbData.attributes) : dbData.attributes,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static formatProduct(product: Partial<Product>): any {
|
|||
|
|
const formatted: any = { ...product };
|
|||
|
|
if (product.images) formatted.images = JSON.stringify(product.images);
|
|||
|
|
if (product.skus) formatted.skus = JSON.stringify(product.skus);
|
|||
|
|
if (product.attributes) formatted.attributes = JSON.stringify(product.attributes);
|
|||
|
|
delete formatted.id;
|
|||
|
|
delete formatted.tenant_id;
|
|||
|
|
delete formatted.createdAt;
|
|||
|
|
delete formatted.updatedAt;
|
|||
|
|
return formatted;
|
|||
|
|
}
|
|||
|
|
}
|