import logger from '@adonisjs/core/services/logger' import ProductService from '#services/product_service' import LogService from '#services/log_service' import NotificationService from '#services/notification_service' import ErpService, { type ErpProductItem } from '#services/erp_service' import { enqueueProductUpserts } from '#services/queue_service' export interface SyncSummary { /** Tổng số bản ghi ERP tự báo cáo (field `total`) — CHỈ tham khảo, không tin cậy. */ total: number /** Số item fetch về từ ERP. */ fetched: number /** Số item hợp lệ đã đẩy lên queue. */ enqueued: number /** Số item bị bỏ qua (sku rỗng / rác). */ skipped: number /** Số lần gọi ERP (số page). */ pages: number startedAt: string finishedAt?: string } interface SyncOptions { /** Bước phân trang (giá trị `skip` tăng mỗi lần). Mặc định 100. */ pageSize?: number /** Trần số trang để chặn lặp vô hạn nếu ERP không bao giờ trả rỗng. Mặc định 1000. */ maxPages?: number } /** * Service đồng bộ sản phẩm từ ERP qua BullMQ. * * Luồng: * 1. `syncFromErp` (chạy trong job `erp`) — orchestrator: quét toàn bộ ERP * theo phân trang và fan-out mỗi sản phẩm thành 1 job `upsert`. * 2. Worker xử lý job `upsert` gọi `upsertProduct`. Nếu lỗi, BullMQ tự retry * theo `attempts` + exponential backoff -> sản phẩm lỗi được sync lại sau * cùng (sau backoff), không cần hàng đợi retry thủ công. * * LƯU Ý PHÂN TRANG (ERP /api/products/instock): * - ERP BỎ QUA `limit` (trả số item/trang tùy ý, vd 195/309), và `total` báo * về KHÔNG khớp số bản ghi thật -> KHÔNG dùng `total` làm điều kiện dừng. * - `skip` hoạt động như cursor bước `pageSize`: skip=0 và skip=100 cho 2 block * RỜI NHAU. Vì vậy tăng `skip += pageSize` và lặp tới khi trang trả về RỖNG. */ export default class SyncService { static async syncFromErp(username = 'system', options: SyncOptions = {}): Promise { const pageSize = options.pageSize ?? 100 const maxPages = options.maxPages ?? 1000 const summary: SyncSummary = { total: 0, fetched: 0, enqueued: 0, skipped: 0, pages: 0, startedAt: new Date().toISOString(), } let skip = 0 while (summary.pages < maxPages) { const page = await ErpService.getProductsForSync({ limit: pageSize, skip, order: '', where: { sku: '', warehouse: '', condition: '' }, }) summary.pages++ if (Number.isFinite(page.total)) summary.total = page.total // Điều kiện dừng TIN CẬY: ERP hết dữ liệu (trả trang rỗng). if (page.items.length === 0) break summary.fetched += page.items.length // Bỏ qua item rác không có sku (sku rỗng gây trùng unique key -> lỗi insert). const valid = page.items.filter((i) => i.sku && i.sku.trim() !== '' && i.condition && ['NIB', 'NOB', 'USEB'].includes(i.condition.toUpperCase())) summary.skipped += page.items.length - valid.length if (valid.length > 0) { // Fan-out: mỗi sản phẩm 1 job upsert. BullMQ lo retry khi lỗi. await enqueueProductUpserts(valid, username) summary.enqueued += valid.length } logger.info( { skip, fetched: page.items.length, valid: valid.length, reportedTotal: page.total }, 'ERP page đã enqueue' ) skip += pageSize } if (summary.pages >= maxPages) { logger.warn({ maxPages }, 'Sync ERP chạm trần maxPages — có thể chưa quét hết, kiểm tra lại') } summary.finishedAt = new Date().toISOString() await LogService.record({ username, actionName: 'Đồng bộ ERP', action: 'sync', meta: summary, }) logger.info(summary, 'Sync ERP: đã enqueue toàn bộ sản phẩm') return summary } /** * Xử lý 1 job upsert (gọi bởi worker). * Cố tình ném lỗi khi thất bại để BullMQ tự retry job. */ static async upsertProduct(item: ErpProductItem): Promise<{ sku: string; condition: string; created: boolean }> { const { product, created } = await ProductService.upsert(item) // Sản phẩm MỚI từ ERP -> báo cho người dùng xem có cần gắn cờ noListing (bỏ qua gợi ý giá) không. if (created) { await NotificationService.notify({ type: 'news', title: 'Sản phẩm mới từ ERP', message: `SKU ${product.sku} (${product.condition}) vừa được đồng bộ. Kiểm tra xem có cần gắn cờ "Không gợi ý giá" cho sản phẩm này không.`, meta: { productId: product.id, sku: product.sku, condition: product.condition, warehouse: product.warehouse, }, }) } return { sku: item.sku, condition: item.condition, created } } }