chore: migrate to pnpm workspaces monorepo with Turborepo
- Restructure directories: apps/api, apps/admin, apps/website - Add root pnpm-workspace.yaml, turbo.json, .prettierrc, .gitignore - Rename packages to @inkreach/api, @inkreach/admin, @inkreach/website - Add shared packages: packages/tsconfig, packages/shared-types - Add pnpm.onlyBuiltDependencies for native builds - Update docs: README.md, structs.md - All three projects build successfully
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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<void> {
|
||||
try {
|
||||
await this.syncCategories();
|
||||
await this.syncProducts();
|
||||
} catch (err) {
|
||||
this.logger.error('Hourly cron sync failed', err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async syncCategories(): Promise<CategorySyncResult> {
|
||||
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<ProductSyncResult> {
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user