import { Injectable } from '@angular/core'; import { Observable, from, of, firstValueFrom } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { DataStoreService } from '../../../app/core/services/data-store.service'; import { PermissionService, StoreProductFilters } from './permission.service'; export interface ProductDimension { asin: string; parentAsin?: string; sellerSku?: string; productSku?: string; designerId?: string; designerName?: string; developerId?: string; developerName?: string; operatorId?: string; operatorName?: string; userAccount?: string; site?: string; departCode?: string; accessUserId?: string; itemStatus?: number; fulfillmentType?: string; fnsku?: string; sellerId?: string; listingColor?: string; listingSize?: string; listingVariation?: string; imageUrl?: string; smallImageUrl?: string; listingImageUrl?: string; listingSmallImageUrl?: string; photo?: any; listingOpenDateSite?: string | Date; firstOrderDateSite?: string | Date; eccangPrice?: number; eccangSalePrice?: number; eccangListingPrice?: number; eccangRegularPrice?: number; ratings?: number; ratingsCount?: number; rank?: number; category?: string[]; listingSalesVolumeOfMonth?: number; } export interface SiteDimensionOption { value: string; label: string; } export type ProductDimensionFields = Omit; interface AccessibleOptionSnapshot { designerName: string[]; operatorName: string[]; developerName: string[]; fulfillmentType: string[]; site: string[]; } @Injectable({ providedIn: 'root' }) export class ProductDimensionService { private readonly batchSize = 80; private readonly ownProductClasses = new Set(['ProductDetail', 'AsinSkuMapping', 'Product']); private dimensionCache = new Map(); private optionCache = new Map(); private siteOptionCache: SiteDimensionOption[] | null = null; private accessibleOptionCache: { ts: number; snapshot: AccessibleOptionSnapshot } | null = null; private accessibleOptionPromise: Promise | null = null; private readonly accessibleOptionTtlMs = 60_000; constructor(private permissionService: PermissionService, private dataStore: DataStoreService) {} private static readonly SITE_LABELS: Record = { us: '美国站', uk: '英国站', de: '德国站', fr: '法国站', es: '西班牙站', it: '意大利站', jp: '日本站', ca: '加拿大站', mx: '墨西哥站', br: '巴西站', in: '印度站', au: '澳大利亚站', ae: '阿联酋站', sa: '沙特站' }; private static readonly SITE_ALIASES: Record = { us: ['us', 'usa', '美国', '美国站', 'united states', 'amazon.com', 'atvpdkikx0der', '1'], uk: ['uk', 'gb', '英国', '英国站', 'united kingdom', 'amazon.co.uk', 'a1f83g8c2aro7p', '2'], de: ['de', '德国', '德国站', 'germany', 'amazon.de', 'a1pa6795ukmfr9', '3'], fr: ['fr', '法国', '法国站', 'france', 'amazon.fr', 'a13v1ib3viyzzh', '4'], in: ['in', 'india', '印度', '印度站', 'amazon.in', 'a21tjruun4kgv', '5'], ca: ['ca', 'canada', '加拿大', '加拿大站', 'amazon.ca', 'a2euq1wtgctbg2', '6'], jp: ['jp', '日本', '日本站', 'japan', 'amazon.co.jp', 'a1vc38t7yxb528', '7'], es: ['es', '西班牙', '西班牙站', 'spain', 'amazon.es', 'a1rkkupihcs9hs', '8'], it: ['it', '意大利', '意大利站', 'italy', 'amazon.it', 'apj6jra9ng5v4', '9'], mx: ['mx', 'mexico', '墨西哥', '墨西哥站', 'amazon.com.mx', 'a1am78c64um0y8', '10'], ae: ['ae', 'uae', '阿联酋', '阿联酋站', 'amazon.ae', 'a2vigq35rcs4ug', '11'], au: ['au', 'australia', '澳大利亚', '澳大利亚站', 'amazon.com.au', 'a39ibj37trp1c6', '12'], br: ['br', 'brazil', '巴西', '巴西站', 'amazon.com.br', 'a2q3y263d00kwc', '13'], sa: ['sa', 'ksa', '沙特', '沙特站', 'saudi arabia', 'amazon.sa', 'a17e79c6d8dwnp', '14'] }; getByAsins(asins: string[]): Observable> { const normalized = this.normalizeAsins(asins); if (!normalized.length) return of(new Map()); const missing = normalized.filter(asin => !this.dimensionCache.has(asin)); if (!missing.length) { return of(this.pickCached(normalized)); } return from(this.fetchDimensions(missing)).pipe( map(() => this.pickCached(normalized)), catchError(err => { console.warn('[ProductDimension] 获取产品维度失败:', err?.message || err); return of(this.pickCached(normalized)); }) ); } enrichRows>(rows: T[], asinGetter?: (row: T) => string): Observable> { if (!rows.length) return of([]); const getAsin = asinGetter || ((row: T) => row['asin'] || row['Asin'] || ''); const asins = rows.map(row => getAsin(row)).filter(Boolean); return this.getByAsins(asins).pipe( map(dimensionMap => rows.map(row => { const asin = this.normalizeAsin(getAsin(row)); const dimension = dimensionMap.get(asin); return dimension ? { ...row, ...this.toFields(dimension) } : row; })) ); } getDesignerOptions(): Observable { return this.getStringOptions('designerName'); } getOperatorOptions(): Observable { return this.getStringOptions('operatorName'); } getDeveloperOptions(): Observable { return this.getStringOptions('developerName'); } getFulfillmentTypeOptions(): Observable { return this.getStringOptions('fulfillmentType'); } getSiteOptions(): Observable { if (this.siteOptionCache) return of(this.siteOptionCache); return from(this.fetchSiteOptions()).pipe( map(options => { this.siteOptionCache = options; return options; }), catchError(err => { console.warn('[ProductDimension] 获取站点选项失败:', err?.message || err); return of([]); }) ); } clearCache(): void { this.dimensionCache.clear(); this.optionCache.clear(); this.siteOptionCache = null; this.accessibleOptionCache = null; this.accessibleOptionPromise = null; } private async fetchDimensions(asins: string[]): Promise { try { const dimensions = await this.permissionService.getProductDimensions(asins); const keys = Object.keys(dimensions || {}); if (keys.length) { keys.forEach(rawAsin => { const asin = this.normalizeAsin(rawAsin); const dimension = dimensions[rawAsin] as ProductDimension; if (asin && dimension) { this.dimensionCache.set(asin, { ...dimension, asin }); } }); return; } } catch (err: any) { console.warn('[ProductDimension] 获取产品维度失败:', err?.message || err); } await this.fetchDimensionsLegacy(asins); } private async fetchDimensionsLegacy(asins: string[]): Promise { const detailMap = await this.fetchProductDetails(asins); const fallbackMap = await this.fetchAsinSkuMappings(asins); for (const asin of asins) { const dimension = this.mergeDimensions(detailMap.get(asin), fallbackMap.get(asin)); if (dimension) this.dimensionCache.set(asin, dimension); } } private async fetchProductDetails(asins: string[]): Promise> { const result = new Map(); for (let i = 0; i < asins.length; i += this.batchSize) { const batch = asins.slice(i, i + this.batchSize); const rows = await firstValueFrom(this.dataStore.query('ProductDetail', { asin: batch }, { limit: batch.length })); const filteredRows = await this.permissionService.filterOwnProductRows( rows.map((row: any) => row.toJSON ? row.toJSON() : row) ); filteredRows.forEach((data: any) => { const dimension = this.fromProductDetail(data); if (dimension.asin) result.set(dimension.asin, dimension); }); } return result; } private async fetchAsinSkuMappings(asins: string[]): Promise> { const grouped = new Map(); for (let i = 0; i < asins.length; i += this.batchSize) { const batch = asins.slice(i, i + this.batchSize); const rows = await firstValueFrom(this.dataStore.query('AsinSkuMapping', { asin: batch }, { limit: Math.min(300, Math.max(batch.length * 5, batch.length)) })); const filteredRows = await this.permissionService.filterOwnProductRows( rows.map((row: any) => row.toJSON ? row.toJSON() : row) ); filteredRows.forEach((data: any) => { const asin = this.normalizeAsin(data.asin); if (!asin) return; const list = grouped.get(asin) || []; list.push(data); grouped.set(asin, list); }); } const result = new Map(); grouped.forEach((rows, asin) => { const best = this.pickBestMapping(rows); if (best) result.set(asin, this.fromAsinSkuMapping(best)); }); return result; } private getStringOptions(field: string): Observable { const cached = this.optionCache.get(field); if (cached) return of(cached); return from(this.fetchStringOptions(field)).pipe( map(options => { this.optionCache.set(field, options); return options; }), catchError(err => { console.warn(`[ProductDimension] 获取 ${field} 选项失败:`, err?.message || err); return of([]); }) ); } private async fetchStringOptions(field: string): Promise { let fromSnapshot: string[] = []; try { const snapshot = await this.getAccessibleOptionSnapshot(); fromSnapshot = snapshot[field as keyof AccessibleOptionSnapshot] || []; } catch (err: any) { console.warn(`[ProductDimension] 可访问产品选项快照失败,回退表查询 ${field}:`, err?.message || err); } if (field !== 'designerName' && fromSnapshot.length) return fromSnapshot; const [fromDetail, fromMapping] = await Promise.all([ this.fetchDistinctStringValues('ProductDetail', field).catch(err => { console.warn(`[ProductDimension] ProductDetail ${field} options failed:`, err?.message || err); return [] as string[]; }), this.fetchDistinctStringValues('AsinSkuMapping', field).catch(err => { console.warn(`[ProductDimension] AsinSkuMapping ${field} options failed:`, err?.message || err); return [] as string[]; }) ]); return Array.from(new Set([...fromSnapshot, ...fromDetail, ...fromMapping])) .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); } private async fetchSiteOptions(): Promise { try { const snapshot = await this.getAccessibleOptionSnapshot(); const fromSnapshot = this.buildSiteOptions(snapshot.site); if (fromSnapshot.length) return fromSnapshot; } catch (err: any) { console.warn('[ProductDimension] 可访问产品站点快照失败,回退表查询:', err?.message || err); } const primarySources = [ { className: 'ProductDetail', fields: [ 'site', 'Site', 'marketplace', 'Marketplace', 'marketplaceId', 'MarketplaceId', 'country', 'Country', 'countryCode', 'CountryCode' ] }, { className: 'AmazonProduct', fields: [ 'site', 'Site', 'marketplace', 'Marketplace', 'marketplaceId', 'MarketplaceId', 'domain', 'Domain', 'country', 'Country', 'countryCode', 'CountryCode' ] } ]; const fallbackSource = { className: 'AsinSkuMapping', fields: [ 'site', 'Site', 'marketplace', 'Marketplace', 'marketplaceId', 'MarketplaceId', 'country', 'Country', 'countryCode', 'CountryCode' ] }; let values = await this.fetchSiteValuesFromSources(primarySources); if (!values.length) { values = await this.fetchSiteValuesFromSources([fallbackSource]); } return this.buildSiteOptions(values); } private async getAccessibleOptionSnapshot(): Promise { if (this.accessibleOptionCache && Date.now() - this.accessibleOptionCache.ts < this.accessibleOptionTtlMs) { return this.accessibleOptionCache.snapshot; } if (this.accessibleOptionPromise) return this.accessibleOptionPromise; this.accessibleOptionPromise = this.permissionService .listAccessibleStoreProducts(this.emptyStoreProductFilters(), 1, 1000) .then(result => { const products = result.products || []; const optionValues = (options: any[] | undefined) => (options || []) .map(option => this.cleanText(option?.value ?? option?.label ?? option)) .filter(Boolean); const productValues = (field: string) => products .map(product => this.cleanText((product as Record)[field])) .filter(Boolean); const snapshot: AccessibleOptionSnapshot = { designerName: this.uniqueSorted([...optionValues(result.options?.designers), ...productValues('designerName')]), operatorName: this.uniqueSorted([...optionValues(result.options?.operators), ...productValues('operatorName')]), developerName: this.uniqueSorted(productValues('developerName')), fulfillmentType: this.uniqueSorted([...optionValues(result.options?.fulfillments), ...productValues('fulfillmentType')]), site: this.uniqueSorted([...optionValues(result.options?.sites), ...productValues('site')]) }; this.accessibleOptionCache = { ts: Date.now(), snapshot }; return snapshot; }) .finally(() => { this.accessibleOptionPromise = null; }); return this.accessibleOptionPromise; } private buildSiteOptions(values: string[]): SiteDimensionOption[] { const optionMap = new Map(); values.forEach(raw => { const value = this.normalizeSiteCode(raw); if (!value) return; optionMap.set(value, { value, label: ProductDimensionService.SITE_LABELS[value] || String(raw || value).trim() }); }); const order = ['us', 'uk', 'de', 'fr', 'es', 'it', 'jp', 'ca', 'mx', 'br', 'in', 'au', 'ae', 'sa']; return Array.from(optionMap.values()).sort((a, b) => { const ai = order.indexOf(a.value); const bi = order.indexOf(b.value); if (ai !== -1 || bi !== -1) return (ai === -1 ? Number.MAX_SAFE_INTEGER : ai) - (bi === -1 ? Number.MAX_SAFE_INTEGER : bi); return a.label.localeCompare(b.label, 'zh-Hans-CN'); }); } private uniqueSorted(values: string[]): string[] { return Array.from(new Set(values.map(value => this.cleanText(value)).filter(Boolean))) .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); } private emptyStoreProductFilters(): StoreProductFilters { return { asin: '', sellerSku: '', site: '', userAccount: '', itemName: '', itemStatus: '', fulfillmentType: '', designerName: '', operatorName: '' }; } private async fetchSiteValuesFromSources(sources: Array<{ className: string; fields: string[] }>): Promise { const tasks = sources.map(source => this.fetchSiteValuesFromSource(source).catch(err => { console.warn(`[ProductDimension] 获取 ${source.className} 站点字段失败:`, err?.message || err); return []; })); return (await Promise.all(tasks)).flat(); } private async fetchSiteValuesFromSource(source: { className: string; fields: string[] }): Promise { const rows = await firstValueFrom(this.dataStore.query(source.className, {}, { select: source.fields, limit: 5000 })); const readableRows = this.ownProductClasses.has(source.className) ? await this.permissionService.filterOwnProductRows(rows.map((row: any) => row.toJSON ? row.toJSON() : row)) : rows.map((row: any) => row.toJSON ? row.toJSON() : row); const values = new Set(); readableRows.forEach((row: any) => { source.fields.forEach(field => { const raw = row[field]; const value = String(raw || '').trim(); if (value && value !== '-') values.add(value); }); }); return Array.from(values).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); } private normalizeSiteCode(value: any): string { const normalized = String(value || '').trim().toLowerCase(); if (!normalized || normalized === '-') return ''; for (const [code, aliases] of Object.entries(ProductDimensionService.SITE_ALIASES)) { if (aliases.includes(normalized)) return code; } return normalized; } private async fetchDistinctStringValues(className: string, field: string): Promise { const rows = await firstValueFrom(this.dataStore.query(className, { [field]: { $exists: true } }, { select: [field], limit: 5000 })); const readableRows = this.ownProductClasses.has(className) ? await this.permissionService.filterOwnProductRows(rows.map((row: any) => row.toJSON ? row.toJSON() : row)) : rows.map((row: any) => row.toJSON ? row.toJSON() : row); const values = new Set(); readableRows.forEach((row: any) => { const value = this.cleanText(row[field]); if (value) values.add(value); }); return Array.from(values).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); } private fromProductDetail(data: any): ProductDimension { return { asin: this.normalizeAsin(data.asin), parentAsin: this.cleanText(data.parentAsin), sellerSku: this.cleanText(data.sellerSku), productSku: this.cleanText(data.productSku), designerId: this.cleanText(data.designerId), designerName: this.cleanText(data.designerName), developerId: this.cleanText(data.developerId), developerName: this.cleanText(data.developerName), operatorId: this.cleanText(data.operatorId), operatorName: this.cleanText(data.operatorName), userAccount: this.cleanText(data.userAccount), site: this.cleanText(data.site), departCode: this.cleanText(data.departCode), accessUserId: this.cleanText(data.accessUserId), itemStatus: Number(data.itemStatus) || 0, fulfillmentType: this.cleanText(data.fulfillmentType), fnsku: this.cleanText(data.fnsku), sellerId: this.cleanText(data.sellerId), listingColor: this.cleanText(data.listingColor), listingSize: this.cleanText(data.listingSize), listingVariation: this.cleanText(data.listingVariation), imageUrl: this.cleanText(data.imageUrl), smallImageUrl: this.cleanText(data.smallImageUrl), listingImageUrl: this.cleanText(data.listingImageUrl), listingSmallImageUrl: this.cleanText(data.listingSmallImageUrl), photo: data.photo || '', listingOpenDateSite: data.listingOpenDateSite || '', firstOrderDateSite: data.firstOrderDateSite || '', eccangPrice: Number(data.eccangPrice) || 0, eccangSalePrice: Number(data.eccangSalePrice) || 0, eccangListingPrice: Number(data.eccangListingPrice) || 0, eccangRegularPrice: Number(data.eccangRegularPrice) || 0, ratings: Number(data.ratings) || 0, ratingsCount: Number(data.ratingsCount) || 0, rank: Number(data.rank) || 0, category: Array.isArray(data.category) ? data.category : [], listingSalesVolumeOfMonth: Number(data.listingSalesVolumeOfMonth) || 0 }; } private fromAsinSkuMapping(data: any): ProductDimension { return { asin: this.normalizeAsin(data.asin), parentAsin: this.cleanText(data.parentAsin), sellerSku: this.cleanText(data.sellerSku), productSku: this.cleanText(data.productSku), designerId: this.cleanText(data.designerId), designerName: this.cleanText(data.designerName), developerId: this.cleanText(data.developerId), developerName: this.cleanText(data.developerName), operatorId: this.cleanText(data.operatorId), operatorName: this.cleanText(data.operatorName), userAccount: this.cleanText(data.userAccount), site: this.cleanText(data.site), departCode: this.cleanText(data.departCode), accessUserId: this.cleanText(data.accessUserId), itemStatus: Number(data.itemStatus) || 0, fulfillmentType: this.cleanText(data.fulfillmentType), fnsku: this.cleanText(data.fnsku), sellerId: this.cleanText(data.sellerId), listingColor: this.cleanText(data.color), listingSize: this.cleanText(data.size), listingVariation: this.cleanText(data.variation), imageUrl: this.cleanText(data.imageUrl), smallImageUrl: this.cleanText(data.smallImageUrl), listingImageUrl: this.cleanText(data.imageUrl), listingSmallImageUrl: this.cleanText(data.smallImageUrl), photo: data.photo || '', listingOpenDateSite: data.openDateSite || '', firstOrderDateSite: data.firstOrderDateSite || '', eccangPrice: Number(data.price) || 0, eccangSalePrice: Number(data.salePrice) || 0, eccangListingPrice: Number(data.listingPrice) || 0, eccangRegularPrice: Number(data.regularPrice) || 0 }; } private toFields(dimension: ProductDimension): ProductDimensionFields { const { asin, ...fields } = dimension; return Object.entries(fields).reduce((acc, [key, value]) => { if (['category', 'ratings', 'ratingsCount', 'rank', 'listingSalesVolumeOfMonth'].includes(key)) return acc; if (value === undefined || value === null) return acc; if (typeof value === 'string' && !this.isValidText(value)) return acc; if (Array.isArray(value) && !value.length) return acc; if (typeof value === 'number' && value === 0) return acc; (acc as Record)[key] = value; return acc; }, {} as ProductDimensionFields); } private pickCached(asins: string[]): Map { const result = new Map(); asins.forEach(asin => { const dimension = this.dimensionCache.get(asin); if (dimension) result.set(asin, dimension); }); return result; } private pickBestMapping(rows: any[]): any | null { return rows.slice().sort((a, b) => { const activeA = Number(a.itemStatus) === 1 ? 0 : 1; const activeB = Number(b.itemStatus) === 1 ? 0 : 1; if (activeA !== activeB) return activeA - activeB; const completeA = this.completenessScore(a); const completeB = this.completenessScore(b); if (completeA !== completeB) return completeB - completeA; const timeA = new Date(a.syncedAt || a.updatedAt || 0).getTime(); const timeB = new Date(b.syncedAt || b.updatedAt || 0).getTime(); return timeB - timeA; })[0] || null; } private completenessScore(row: any): number { return ['sellerSku', 'productSku', 'designerName', 'developerName', 'operatorName', 'fulfillmentType', 'color', 'size'] .reduce((score, key) => { return this.isValidText(row?.[key]) ? score + 1 : score; }, 0); } private mergeDimensions(detail?: ProductDimension, fallback?: ProductDimension): ProductDimension | undefined { if (!detail) return fallback; if (!fallback) return detail; return { ...detail, sellerSku: this.pickText(detail.sellerSku, fallback.sellerSku), productSku: this.pickText(detail.productSku, fallback.productSku), designerId: this.pickText(detail.designerId, fallback.designerId), designerName: this.pickText(detail.designerName, fallback.designerName), developerId: this.pickText(detail.developerId, fallback.developerId), developerName: this.pickText(detail.developerName, fallback.developerName), operatorId: this.pickText(detail.operatorId, fallback.operatorId), operatorName: this.pickText(detail.operatorName, fallback.operatorName), fulfillmentType: this.pickText(detail.fulfillmentType, fallback.fulfillmentType), listingColor: this.pickText(detail.listingColor, fallback.listingColor), listingSize: this.pickText(detail.listingSize, fallback.listingSize) }; } private pickText(primary: any, fallback: any): string { const p = this.cleanText(primary); return p || this.cleanText(fallback); } private cleanText(value: any): string { const text = String(value || '').trim(); return this.isValidText(text) ? text : ''; } private isValidText(value: any): boolean { const text = String(value || '').trim(); if (!text) return false; const normalized = text.toLowerCase(); return !['-', '—', '–', '未分配', '未分配设计师', 'unknown', 'null', 'undefined', 'n/a', 'na'].includes(normalized); } private normalizeAsins(asins: string[]): string[] { return Array.from(new Set(asins.map(asin => this.normalizeAsin(asin)).filter(Boolean))); } private normalizeAsin(asin: string): string { return String(asin || '').trim().toUpperCase(); } }