product-dimension.service.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  1. import { Injectable } from '@angular/core';
  2. import { Observable, from, of, firstValueFrom } from 'rxjs';
  3. import { catchError, map } from 'rxjs/operators';
  4. import { DataStoreService } from '../../../app/core/services/data-store.service';
  5. import { PermissionService, StoreProductFilters } from './permission.service';
  6. export interface ProductDimension {
  7. asin: string;
  8. parentAsin?: string;
  9. sellerSku?: string;
  10. productSku?: string;
  11. designerId?: string;
  12. designerName?: string;
  13. developerId?: string;
  14. developerName?: string;
  15. operatorId?: string;
  16. operatorName?: string;
  17. userAccount?: string;
  18. site?: string;
  19. departCode?: string;
  20. accessUserId?: string;
  21. itemStatus?: number;
  22. fulfillmentType?: string;
  23. fnsku?: string;
  24. sellerId?: string;
  25. listingColor?: string;
  26. listingSize?: string;
  27. listingVariation?: string;
  28. imageUrl?: string;
  29. smallImageUrl?: string;
  30. listingImageUrl?: string;
  31. listingSmallImageUrl?: string;
  32. photo?: any;
  33. listingOpenDateSite?: string | Date;
  34. firstOrderDateSite?: string | Date;
  35. eccangPrice?: number;
  36. eccangSalePrice?: number;
  37. eccangListingPrice?: number;
  38. eccangRegularPrice?: number;
  39. ratings?: number;
  40. ratingsCount?: number;
  41. rank?: number;
  42. category?: string[];
  43. listingSalesVolumeOfMonth?: number;
  44. }
  45. export interface SiteDimensionOption {
  46. value: string;
  47. label: string;
  48. }
  49. export type ProductDimensionFields = Omit<ProductDimension, 'asin'>;
  50. interface AccessibleOptionSnapshot {
  51. designerName: string[];
  52. operatorName: string[];
  53. developerName: string[];
  54. fulfillmentType: string[];
  55. site: string[];
  56. }
  57. @Injectable({
  58. providedIn: 'root'
  59. })
  60. export class ProductDimensionService {
  61. private readonly batchSize = 80;
  62. private readonly ownProductClasses = new Set(['ProductDetail', 'AsinSkuMapping', 'Product']);
  63. private dimensionCache = new Map<string, ProductDimension>();
  64. private optionCache = new Map<string, string[]>();
  65. private siteOptionCache: SiteDimensionOption[] | null = null;
  66. private accessibleOptionCache: { ts: number; snapshot: AccessibleOptionSnapshot } | null = null;
  67. private accessibleOptionPromise: Promise<AccessibleOptionSnapshot> | null = null;
  68. private readonly accessibleOptionTtlMs = 60_000;
  69. constructor(private permissionService: PermissionService, private dataStore: DataStoreService) {}
  70. private static readonly SITE_LABELS: Record<string, string> = {
  71. us: '美国站',
  72. uk: '英国站',
  73. de: '德国站',
  74. fr: '法国站',
  75. es: '西班牙站',
  76. it: '意大利站',
  77. jp: '日本站',
  78. ca: '加拿大站',
  79. mx: '墨西哥站',
  80. br: '巴西站',
  81. in: '印度站',
  82. au: '澳大利亚站',
  83. ae: '阿联酋站',
  84. sa: '沙特站'
  85. };
  86. private static readonly SITE_ALIASES: Record<string, string[]> = {
  87. us: ['us', 'usa', '美国', '美国站', 'united states', 'amazon.com', 'atvpdkikx0der', '1'],
  88. uk: ['uk', 'gb', '英国', '英国站', 'united kingdom', 'amazon.co.uk', 'a1f83g8c2aro7p', '2'],
  89. de: ['de', '德国', '德国站', 'germany', 'amazon.de', 'a1pa6795ukmfr9', '3'],
  90. fr: ['fr', '法国', '法国站', 'france', 'amazon.fr', 'a13v1ib3viyzzh', '4'],
  91. in: ['in', 'india', '印度', '印度站', 'amazon.in', 'a21tjruun4kgv', '5'],
  92. ca: ['ca', 'canada', '加拿大', '加拿大站', 'amazon.ca', 'a2euq1wtgctbg2', '6'],
  93. jp: ['jp', '日本', '日本站', 'japan', 'amazon.co.jp', 'a1vc38t7yxb528', '7'],
  94. es: ['es', '西班牙', '西班牙站', 'spain', 'amazon.es', 'a1rkkupihcs9hs', '8'],
  95. it: ['it', '意大利', '意大利站', 'italy', 'amazon.it', 'apj6jra9ng5v4', '9'],
  96. mx: ['mx', 'mexico', '墨西哥', '墨西哥站', 'amazon.com.mx', 'a1am78c64um0y8', '10'],
  97. ae: ['ae', 'uae', '阿联酋', '阿联酋站', 'amazon.ae', 'a2vigq35rcs4ug', '11'],
  98. au: ['au', 'australia', '澳大利亚', '澳大利亚站', 'amazon.com.au', 'a39ibj37trp1c6', '12'],
  99. br: ['br', 'brazil', '巴西', '巴西站', 'amazon.com.br', 'a2q3y263d00kwc', '13'],
  100. sa: ['sa', 'ksa', '沙特', '沙特站', 'saudi arabia', 'amazon.sa', 'a17e79c6d8dwnp', '14']
  101. };
  102. getByAsins(asins: string[]): Observable<Map<string, ProductDimension>> {
  103. const normalized = this.normalizeAsins(asins);
  104. if (!normalized.length) return of(new Map<string, ProductDimension>());
  105. const missing = normalized.filter(asin => !this.dimensionCache.has(asin));
  106. if (!missing.length) {
  107. return of(this.pickCached(normalized));
  108. }
  109. return from(this.fetchDimensions(missing)).pipe(
  110. map(() => this.pickCached(normalized)),
  111. catchError(err => {
  112. console.warn('[ProductDimension] 获取产品维度失败:', err?.message || err);
  113. return of(this.pickCached(normalized));
  114. })
  115. );
  116. }
  117. enrichRows<T extends Record<string, any>>(rows: T[], asinGetter?: (row: T) => string): Observable<Array<T & ProductDimensionFields>> {
  118. if (!rows.length) return of([]);
  119. const getAsin = asinGetter || ((row: T) => row['asin'] || row['Asin'] || '');
  120. const asins = rows.map(row => getAsin(row)).filter(Boolean);
  121. return this.getByAsins(asins).pipe(
  122. map(dimensionMap => rows.map(row => {
  123. const asin = this.normalizeAsin(getAsin(row));
  124. const dimension = dimensionMap.get(asin);
  125. return dimension ? { ...row, ...this.toFields(dimension) } : row;
  126. }))
  127. );
  128. }
  129. getDesignerOptions(): Observable<string[]> {
  130. return this.getStringOptions('designerName');
  131. }
  132. getOperatorOptions(): Observable<string[]> {
  133. return this.getStringOptions('operatorName');
  134. }
  135. getDeveloperOptions(): Observable<string[]> {
  136. return this.getStringOptions('developerName');
  137. }
  138. getFulfillmentTypeOptions(): Observable<string[]> {
  139. return this.getStringOptions('fulfillmentType');
  140. }
  141. getSiteOptions(): Observable<SiteDimensionOption[]> {
  142. if (this.siteOptionCache) return of(this.siteOptionCache);
  143. return from(this.fetchSiteOptions()).pipe(
  144. map(options => {
  145. this.siteOptionCache = options;
  146. return options;
  147. }),
  148. catchError(err => {
  149. console.warn('[ProductDimension] 获取站点选项失败:', err?.message || err);
  150. return of([]);
  151. })
  152. );
  153. }
  154. clearCache(): void {
  155. this.dimensionCache.clear();
  156. this.optionCache.clear();
  157. this.siteOptionCache = null;
  158. this.accessibleOptionCache = null;
  159. this.accessibleOptionPromise = null;
  160. }
  161. private async fetchDimensions(asins: string[]): Promise<void> {
  162. try {
  163. const dimensions = await this.permissionService.getProductDimensions(asins);
  164. const keys = Object.keys(dimensions || {});
  165. if (keys.length) {
  166. keys.forEach(rawAsin => {
  167. const asin = this.normalizeAsin(rawAsin);
  168. const dimension = dimensions[rawAsin] as ProductDimension;
  169. if (asin && dimension) {
  170. this.dimensionCache.set(asin, { ...dimension, asin });
  171. }
  172. });
  173. return;
  174. }
  175. } catch (err: any) {
  176. console.warn('[ProductDimension] 获取产品维度失败:', err?.message || err);
  177. }
  178. await this.fetchDimensionsLegacy(asins);
  179. }
  180. private async fetchDimensionsLegacy(asins: string[]): Promise<void> {
  181. const detailMap = await this.fetchProductDetails(asins);
  182. const fallbackMap = await this.fetchAsinSkuMappings(asins);
  183. for (const asin of asins) {
  184. const dimension = this.mergeDimensions(detailMap.get(asin), fallbackMap.get(asin));
  185. if (dimension) this.dimensionCache.set(asin, dimension);
  186. }
  187. }
  188. private async fetchProductDetails(asins: string[]): Promise<Map<string, ProductDimension>> {
  189. const result = new Map<string, ProductDimension>();
  190. for (let i = 0; i < asins.length; i += this.batchSize) {
  191. const batch = asins.slice(i, i + this.batchSize);
  192. const rows = await firstValueFrom(this.dataStore.query('ProductDetail', { asin: batch }, { limit: batch.length }));
  193. const filteredRows = await this.permissionService.filterOwnProductRows(
  194. rows.map((row: any) => row.toJSON ? row.toJSON() : row)
  195. );
  196. filteredRows.forEach((data: any) => {
  197. const dimension = this.fromProductDetail(data);
  198. if (dimension.asin) result.set(dimension.asin, dimension);
  199. });
  200. }
  201. return result;
  202. }
  203. private async fetchAsinSkuMappings(asins: string[]): Promise<Map<string, ProductDimension>> {
  204. const grouped = new Map<string, any[]>();
  205. for (let i = 0; i < asins.length; i += this.batchSize) {
  206. const batch = asins.slice(i, i + this.batchSize);
  207. const rows = await firstValueFrom(this.dataStore.query('AsinSkuMapping', { asin: batch }, { limit: Math.min(300, Math.max(batch.length * 5, batch.length)) }));
  208. const filteredRows = await this.permissionService.filterOwnProductRows(
  209. rows.map((row: any) => row.toJSON ? row.toJSON() : row)
  210. );
  211. filteredRows.forEach((data: any) => {
  212. const asin = this.normalizeAsin(data.asin);
  213. if (!asin) return;
  214. const list = grouped.get(asin) || [];
  215. list.push(data);
  216. grouped.set(asin, list);
  217. });
  218. }
  219. const result = new Map<string, ProductDimension>();
  220. grouped.forEach((rows, asin) => {
  221. const best = this.pickBestMapping(rows);
  222. if (best) result.set(asin, this.fromAsinSkuMapping(best));
  223. });
  224. return result;
  225. }
  226. private getStringOptions(field: string): Observable<string[]> {
  227. const cached = this.optionCache.get(field);
  228. if (cached) return of(cached);
  229. return from(this.fetchStringOptions(field)).pipe(
  230. map(options => {
  231. this.optionCache.set(field, options);
  232. return options;
  233. }),
  234. catchError(err => {
  235. console.warn(`[ProductDimension] 获取 ${field} 选项失败:`, err?.message || err);
  236. return of([]);
  237. })
  238. );
  239. }
  240. private async fetchStringOptions(field: string): Promise<string[]> {
  241. let fromSnapshot: string[] = [];
  242. try {
  243. const snapshot = await this.getAccessibleOptionSnapshot();
  244. fromSnapshot = snapshot[field as keyof AccessibleOptionSnapshot] || [];
  245. } catch (err: any) {
  246. console.warn(`[ProductDimension] 可访问产品选项快照失败,回退表查询 ${field}:`, err?.message || err);
  247. }
  248. if (field !== 'designerName' && fromSnapshot.length) return fromSnapshot;
  249. const [fromDetail, fromMapping] = await Promise.all([
  250. this.fetchDistinctStringValues('ProductDetail', field).catch(err => {
  251. console.warn(`[ProductDimension] ProductDetail ${field} options failed:`, err?.message || err);
  252. return [] as string[];
  253. }),
  254. this.fetchDistinctStringValues('AsinSkuMapping', field).catch(err => {
  255. console.warn(`[ProductDimension] AsinSkuMapping ${field} options failed:`, err?.message || err);
  256. return [] as string[];
  257. })
  258. ]);
  259. return Array.from(new Set([...fromSnapshot, ...fromDetail, ...fromMapping]))
  260. .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
  261. }
  262. private async fetchSiteOptions(): Promise<SiteDimensionOption[]> {
  263. try {
  264. const snapshot = await this.getAccessibleOptionSnapshot();
  265. const fromSnapshot = this.buildSiteOptions(snapshot.site);
  266. if (fromSnapshot.length) return fromSnapshot;
  267. } catch (err: any) {
  268. console.warn('[ProductDimension] 可访问产品站点快照失败,回退表查询:', err?.message || err);
  269. }
  270. const primarySources = [
  271. {
  272. className: 'ProductDetail',
  273. fields: [
  274. 'site', 'Site',
  275. 'marketplace', 'Marketplace',
  276. 'marketplaceId', 'MarketplaceId',
  277. 'country', 'Country',
  278. 'countryCode', 'CountryCode'
  279. ]
  280. },
  281. {
  282. className: 'AmazonProduct',
  283. fields: [
  284. 'site', 'Site',
  285. 'marketplace', 'Marketplace',
  286. 'marketplaceId', 'MarketplaceId',
  287. 'domain', 'Domain',
  288. 'country', 'Country',
  289. 'countryCode', 'CountryCode'
  290. ]
  291. }
  292. ];
  293. const fallbackSource = {
  294. className: 'AsinSkuMapping',
  295. fields: [
  296. 'site', 'Site',
  297. 'marketplace', 'Marketplace',
  298. 'marketplaceId', 'MarketplaceId',
  299. 'country', 'Country',
  300. 'countryCode', 'CountryCode'
  301. ]
  302. };
  303. let values = await this.fetchSiteValuesFromSources(primarySources);
  304. if (!values.length) {
  305. values = await this.fetchSiteValuesFromSources([fallbackSource]);
  306. }
  307. return this.buildSiteOptions(values);
  308. }
  309. private async getAccessibleOptionSnapshot(): Promise<AccessibleOptionSnapshot> {
  310. if (this.accessibleOptionCache && Date.now() - this.accessibleOptionCache.ts < this.accessibleOptionTtlMs) {
  311. return this.accessibleOptionCache.snapshot;
  312. }
  313. if (this.accessibleOptionPromise) return this.accessibleOptionPromise;
  314. this.accessibleOptionPromise = this.permissionService
  315. .listAccessibleStoreProducts(this.emptyStoreProductFilters(), 1, 1000)
  316. .then(result => {
  317. const products = result.products || [];
  318. const optionValues = (options: any[] | undefined) => (options || [])
  319. .map(option => this.cleanText(option?.value ?? option?.label ?? option))
  320. .filter(Boolean);
  321. const productValues = (field: string) => products
  322. .map(product => this.cleanText((product as Record<string, any>)[field]))
  323. .filter(Boolean);
  324. const snapshot: AccessibleOptionSnapshot = {
  325. designerName: this.uniqueSorted([...optionValues(result.options?.designers), ...productValues('designerName')]),
  326. operatorName: this.uniqueSorted([...optionValues(result.options?.operators), ...productValues('operatorName')]),
  327. developerName: this.uniqueSorted(productValues('developerName')),
  328. fulfillmentType: this.uniqueSorted([...optionValues(result.options?.fulfillments), ...productValues('fulfillmentType')]),
  329. site: this.uniqueSorted([...optionValues(result.options?.sites), ...productValues('site')])
  330. };
  331. this.accessibleOptionCache = { ts: Date.now(), snapshot };
  332. return snapshot;
  333. })
  334. .finally(() => {
  335. this.accessibleOptionPromise = null;
  336. });
  337. return this.accessibleOptionPromise;
  338. }
  339. private buildSiteOptions(values: string[]): SiteDimensionOption[] {
  340. const optionMap = new Map<string, SiteDimensionOption>();
  341. values.forEach(raw => {
  342. const value = this.normalizeSiteCode(raw);
  343. if (!value) return;
  344. optionMap.set(value, {
  345. value,
  346. label: ProductDimensionService.SITE_LABELS[value] || String(raw || value).trim()
  347. });
  348. });
  349. const order = ['us', 'uk', 'de', 'fr', 'es', 'it', 'jp', 'ca', 'mx', 'br', 'in', 'au', 'ae', 'sa'];
  350. return Array.from(optionMap.values()).sort((a, b) => {
  351. const ai = order.indexOf(a.value);
  352. const bi = order.indexOf(b.value);
  353. if (ai !== -1 || bi !== -1) return (ai === -1 ? Number.MAX_SAFE_INTEGER : ai) - (bi === -1 ? Number.MAX_SAFE_INTEGER : bi);
  354. return a.label.localeCompare(b.label, 'zh-Hans-CN');
  355. });
  356. }
  357. private uniqueSorted(values: string[]): string[] {
  358. return Array.from(new Set(values.map(value => this.cleanText(value)).filter(Boolean)))
  359. .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
  360. }
  361. private emptyStoreProductFilters(): StoreProductFilters {
  362. return {
  363. asin: '',
  364. sellerSku: '',
  365. site: '',
  366. userAccount: '',
  367. itemName: '',
  368. itemStatus: '',
  369. fulfillmentType: '',
  370. designerName: '',
  371. operatorName: ''
  372. };
  373. }
  374. private async fetchSiteValuesFromSources(sources: Array<{ className: string; fields: string[] }>): Promise<string[]> {
  375. const tasks = sources.map(source => this.fetchSiteValuesFromSource(source).catch(err => {
  376. console.warn(`[ProductDimension] 获取 ${source.className} 站点字段失败:`, err?.message || err);
  377. return [];
  378. }));
  379. return (await Promise.all(tasks)).flat();
  380. }
  381. private async fetchSiteValuesFromSource(source: { className: string; fields: string[] }): Promise<string[]> {
  382. const rows = await firstValueFrom(this.dataStore.query(source.className, {}, { select: source.fields, limit: 5000 }));
  383. const readableRows = this.ownProductClasses.has(source.className)
  384. ? await this.permissionService.filterOwnProductRows(rows.map((row: any) => row.toJSON ? row.toJSON() : row))
  385. : rows.map((row: any) => row.toJSON ? row.toJSON() : row);
  386. const values = new Set<string>();
  387. readableRows.forEach((row: any) => {
  388. source.fields.forEach(field => {
  389. const raw = row[field];
  390. const value = String(raw || '').trim();
  391. if (value && value !== '-') values.add(value);
  392. });
  393. });
  394. return Array.from(values).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
  395. }
  396. private normalizeSiteCode(value: any): string {
  397. const normalized = String(value || '').trim().toLowerCase();
  398. if (!normalized || normalized === '-') return '';
  399. for (const [code, aliases] of Object.entries(ProductDimensionService.SITE_ALIASES)) {
  400. if (aliases.includes(normalized)) return code;
  401. }
  402. return normalized;
  403. }
  404. private async fetchDistinctStringValues(className: string, field: string): Promise<string[]> {
  405. const rows = await firstValueFrom(this.dataStore.query(className, { [field]: { $exists: true } }, { select: [field], limit: 5000 }));
  406. const readableRows = this.ownProductClasses.has(className)
  407. ? await this.permissionService.filterOwnProductRows(rows.map((row: any) => row.toJSON ? row.toJSON() : row))
  408. : rows.map((row: any) => row.toJSON ? row.toJSON() : row);
  409. const values = new Set<string>();
  410. readableRows.forEach((row: any) => {
  411. const value = this.cleanText(row[field]);
  412. if (value) values.add(value);
  413. });
  414. return Array.from(values).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
  415. }
  416. private fromProductDetail(data: any): ProductDimension {
  417. return {
  418. asin: this.normalizeAsin(data.asin),
  419. parentAsin: this.cleanText(data.parentAsin),
  420. sellerSku: this.cleanText(data.sellerSku),
  421. productSku: this.cleanText(data.productSku),
  422. designerId: this.cleanText(data.designerId),
  423. designerName: this.cleanText(data.designerName),
  424. developerId: this.cleanText(data.developerId),
  425. developerName: this.cleanText(data.developerName),
  426. operatorId: this.cleanText(data.operatorId),
  427. operatorName: this.cleanText(data.operatorName),
  428. userAccount: this.cleanText(data.userAccount),
  429. site: this.cleanText(data.site),
  430. departCode: this.cleanText(data.departCode),
  431. accessUserId: this.cleanText(data.accessUserId),
  432. itemStatus: Number(data.itemStatus) || 0,
  433. fulfillmentType: this.cleanText(data.fulfillmentType),
  434. fnsku: this.cleanText(data.fnsku),
  435. sellerId: this.cleanText(data.sellerId),
  436. listingColor: this.cleanText(data.listingColor),
  437. listingSize: this.cleanText(data.listingSize),
  438. listingVariation: this.cleanText(data.listingVariation),
  439. imageUrl: this.cleanText(data.imageUrl),
  440. smallImageUrl: this.cleanText(data.smallImageUrl),
  441. listingImageUrl: this.cleanText(data.listingImageUrl),
  442. listingSmallImageUrl: this.cleanText(data.listingSmallImageUrl),
  443. photo: data.photo || '',
  444. listingOpenDateSite: data.listingOpenDateSite || '',
  445. firstOrderDateSite: data.firstOrderDateSite || '',
  446. eccangPrice: Number(data.eccangPrice) || 0,
  447. eccangSalePrice: Number(data.eccangSalePrice) || 0,
  448. eccangListingPrice: Number(data.eccangListingPrice) || 0,
  449. eccangRegularPrice: Number(data.eccangRegularPrice) || 0,
  450. ratings: Number(data.ratings) || 0,
  451. ratingsCount: Number(data.ratingsCount) || 0,
  452. rank: Number(data.rank) || 0,
  453. category: Array.isArray(data.category) ? data.category : [],
  454. listingSalesVolumeOfMonth: Number(data.listingSalesVolumeOfMonth) || 0
  455. };
  456. }
  457. private fromAsinSkuMapping(data: any): ProductDimension {
  458. return {
  459. asin: this.normalizeAsin(data.asin),
  460. parentAsin: this.cleanText(data.parentAsin),
  461. sellerSku: this.cleanText(data.sellerSku),
  462. productSku: this.cleanText(data.productSku),
  463. designerId: this.cleanText(data.designerId),
  464. designerName: this.cleanText(data.designerName),
  465. developerId: this.cleanText(data.developerId),
  466. developerName: this.cleanText(data.developerName),
  467. operatorId: this.cleanText(data.operatorId),
  468. operatorName: this.cleanText(data.operatorName),
  469. userAccount: this.cleanText(data.userAccount),
  470. site: this.cleanText(data.site),
  471. departCode: this.cleanText(data.departCode),
  472. accessUserId: this.cleanText(data.accessUserId),
  473. itemStatus: Number(data.itemStatus) || 0,
  474. fulfillmentType: this.cleanText(data.fulfillmentType),
  475. fnsku: this.cleanText(data.fnsku),
  476. sellerId: this.cleanText(data.sellerId),
  477. listingColor: this.cleanText(data.color),
  478. listingSize: this.cleanText(data.size),
  479. listingVariation: this.cleanText(data.variation),
  480. imageUrl: this.cleanText(data.imageUrl),
  481. smallImageUrl: this.cleanText(data.smallImageUrl),
  482. listingImageUrl: this.cleanText(data.imageUrl),
  483. listingSmallImageUrl: this.cleanText(data.smallImageUrl),
  484. photo: data.photo || '',
  485. listingOpenDateSite: data.openDateSite || '',
  486. firstOrderDateSite: data.firstOrderDateSite || '',
  487. eccangPrice: Number(data.price) || 0,
  488. eccangSalePrice: Number(data.salePrice) || 0,
  489. eccangListingPrice: Number(data.listingPrice) || 0,
  490. eccangRegularPrice: Number(data.regularPrice) || 0
  491. };
  492. }
  493. private toFields(dimension: ProductDimension): ProductDimensionFields {
  494. const { asin, ...fields } = dimension;
  495. return Object.entries(fields).reduce((acc, [key, value]) => {
  496. if (['category', 'ratings', 'ratingsCount', 'rank', 'listingSalesVolumeOfMonth'].includes(key)) return acc;
  497. if (value === undefined || value === null) return acc;
  498. if (typeof value === 'string' && !this.isValidText(value)) return acc;
  499. if (Array.isArray(value) && !value.length) return acc;
  500. if (typeof value === 'number' && value === 0) return acc;
  501. (acc as Record<string, any>)[key] = value;
  502. return acc;
  503. }, {} as ProductDimensionFields);
  504. }
  505. private pickCached(asins: string[]): Map<string, ProductDimension> {
  506. const result = new Map<string, ProductDimension>();
  507. asins.forEach(asin => {
  508. const dimension = this.dimensionCache.get(asin);
  509. if (dimension) result.set(asin, dimension);
  510. });
  511. return result;
  512. }
  513. private pickBestMapping(rows: any[]): any | null {
  514. return rows.slice().sort((a, b) => {
  515. const activeA = Number(a.itemStatus) === 1 ? 0 : 1;
  516. const activeB = Number(b.itemStatus) === 1 ? 0 : 1;
  517. if (activeA !== activeB) return activeA - activeB;
  518. const completeA = this.completenessScore(a);
  519. const completeB = this.completenessScore(b);
  520. if (completeA !== completeB) return completeB - completeA;
  521. const timeA = new Date(a.syncedAt || a.updatedAt || 0).getTime();
  522. const timeB = new Date(b.syncedAt || b.updatedAt || 0).getTime();
  523. return timeB - timeA;
  524. })[0] || null;
  525. }
  526. private completenessScore(row: any): number {
  527. return ['sellerSku', 'productSku', 'designerName', 'developerName', 'operatorName', 'fulfillmentType', 'color', 'size']
  528. .reduce((score, key) => {
  529. return this.isValidText(row?.[key]) ? score + 1 : score;
  530. }, 0);
  531. }
  532. private mergeDimensions(detail?: ProductDimension, fallback?: ProductDimension): ProductDimension | undefined {
  533. if (!detail) return fallback;
  534. if (!fallback) return detail;
  535. return {
  536. ...detail,
  537. sellerSku: this.pickText(detail.sellerSku, fallback.sellerSku),
  538. productSku: this.pickText(detail.productSku, fallback.productSku),
  539. designerId: this.pickText(detail.designerId, fallback.designerId),
  540. designerName: this.pickText(detail.designerName, fallback.designerName),
  541. developerId: this.pickText(detail.developerId, fallback.developerId),
  542. developerName: this.pickText(detail.developerName, fallback.developerName),
  543. operatorId: this.pickText(detail.operatorId, fallback.operatorId),
  544. operatorName: this.pickText(detail.operatorName, fallback.operatorName),
  545. fulfillmentType: this.pickText(detail.fulfillmentType, fallback.fulfillmentType),
  546. listingColor: this.pickText(detail.listingColor, fallback.listingColor),
  547. listingSize: this.pickText(detail.listingSize, fallback.listingSize)
  548. };
  549. }
  550. private pickText(primary: any, fallback: any): string {
  551. const p = this.cleanText(primary);
  552. return p || this.cleanText(fallback);
  553. }
  554. private cleanText(value: any): string {
  555. const text = String(value || '').trim();
  556. return this.isValidText(text) ? text : '';
  557. }
  558. private isValidText(value: any): boolean {
  559. const text = String(value || '').trim();
  560. if (!text) return false;
  561. const normalized = text.toLowerCase();
  562. return !['-', '—', '–', '未分配', '未分配设计师', 'unknown', 'null', 'undefined', 'n/a', 'na'].includes(normalized);
  563. }
  564. private normalizeAsins(asins: string[]): string[] {
  565. return Array.from(new Set(asins.map(asin => this.normalizeAsin(asin)).filter(Boolean)));
  566. }
  567. private normalizeAsin(asin: string): string {
  568. return String(asin || '').trim().toUpperCase();
  569. }
  570. }