Listing_SuggestPrice/backend/app/services/sync_service.ts

137 lines
5.0 KiB
TypeScript

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<SyncSummary> {
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 }
}
}