import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { SdsClientService, SdsCategoryTreeNode, SdsProduct } from './sds-client.service'; export interface CategorySyncResult { inserted: number; updated: number; total: number; } export interface ProductSyncResult { inserted: number; updated: number; total: number; leafCategories: number; } @Injectable() export class SyncService { private readonly logger = new Logger(SyncService.name); private running = { categories: false, products: false }; constructor( private readonly prisma: PrismaService, private readonly sds: SdsClientService, ) {} /** * 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 { try { await this.syncCategories(); await this.syncProducts(); } catch (err) { this.logger.error('Hourly cron sync failed', err as Error); } } async syncCategories(): Promise { 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`); let inserted = 0; let updated = 0; for (const node of flat) { const existing = await this.prisma.category.findUnique({ where: { sdsCategoryId: node.sdsId }, }); if (!existing) { await this.prisma.category.create({ data: { sdsCategoryId: node.sdsId, categoryName: node.name, categoryIcon: node.icon ?? null, }, }); inserted++; } else { await this.prisma.category.update({ where: { id: existing.id }, data: { categoryName: node.name, categoryIcon: node.icon ?? null, }, }); updated++; } } // Second pass: wire up parents by sdsCategoryId. for (const node of flat) { if (!node.parentSdsId) continue; const child = await this.prisma.category.findUnique({ where: { sdsCategoryId: node.sdsId }, }); const parent = await this.prisma.category.findUnique({ where: { sdsCategoryId: node.parentSdsId }, }); if (child && parent && child.parentCategoryId !== parent.id) { await this.prisma.category.update({ where: { id: child.id }, data: { parentCategoryId: parent.id }, }); } } await this.prisma.syncLog.update({ where: { id: log.id }, data: { status: 'SUCCESS', finishedAt: new Date(), message: `inserted=${inserted} updated=${updated} total=${flat.length}`, }, }); return { inserted, updated, total: flat.length }; } 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 { 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; 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) { 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; } } } await this.prisma.syncLog.update({ where: { id: log.id }, data: { status: 'SUCCESS', finishedAt: new Date(), message: `inserted=${inserted} updated=${updated} total=${total} leafCategories=${leafRows.length}`, }, }); return { inserted, updated, total, leafCategories: leafRows.length }; } 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, }); } /** * 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); } } const data: Prisma.OriginGoodUncheckedUpdateInput = { sdsCategoryId, goodName, goodImage, goodPrice, }; if (!existing) { await this.prisma.originGood.create({ data: { sdsGoodId, sdsCategoryId, goodName, goodImage, goodPrice, }, }); return 'inserted'; } await this.prisma.originGood.update({ where: { id: existing.id }, data, }); return 'updated'; } }