feat: catalog read cache (api + user apps) and clearer Zarrin accounting errors
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
import { CatalogCacheModule } from './modules/catalog/catalog-cache.module';
|
||||
import { CustomersModule } from './modules/customers/customers.module';
|
||||
import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module';
|
||||
import { SalesExpertAppModule } from './modules/sales-expert-app/sales-expert-app.module';
|
||||
@@ -41,6 +42,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
|
||||
IamModule,
|
||||
AdminSessionModule,
|
||||
PrismaModule,
|
||||
CatalogCacheModule,
|
||||
AuthModule,
|
||||
AdminTableModule,
|
||||
AdminOrdersModule,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { ACCOUNTING_PROVIDERS, AccountingProvider, ProviderName, ProviderSettings, isProviderName } from './providers';
|
||||
import { openSecret, sealSecret, secretKeySource } from './secret-box';
|
||||
import { parsePemCertificates } from './accounting-http';
|
||||
import { CatalogCacheService } from '../catalog/catalog-cache.service';
|
||||
|
||||
/** The first certificate of a pasted bundle, for display; null when none parses. */
|
||||
function describeCertificate(pem: string | null) {
|
||||
@@ -59,7 +60,10 @@ export class AccountingConfigService implements OnModuleInit {
|
||||
private readonly logger = new Logger('Accounting');
|
||||
private cache: { at: number; config: AccountingConfig } | null = null;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalogCache: CatalogCacheService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (secretKeySource() !== 'ACCOUNTING_SECRET_KEY') {
|
||||
@@ -203,6 +207,8 @@ export class AccountingConfigService implements OnModuleInit {
|
||||
create: { key: ACCOUNTING_CONFIG_KEY, value: next as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
this.cache = null;
|
||||
// The provider and product-sync switch decide which products read as out of stock.
|
||||
this.catalogCache.invalidate();
|
||||
return { providerChanged: current.provider !== next.provider };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ import { ACCOUNTING_PROVIDERS } from './providers';
|
||||
|
||||
type Tx = Prisma.TransactionClient | PrismaService;
|
||||
|
||||
type StockFields = {
|
||||
accountingStockAt: Date | null;
|
||||
accountingStockWeight: Prisma.Decimal | null;
|
||||
accountingStockCount: Prisma.Decimal | null;
|
||||
};
|
||||
|
||||
const OPEN: AccountingOutboxStatus[] = ['PENDING', 'SENDING'];
|
||||
|
||||
/**
|
||||
@@ -150,15 +156,21 @@ export class AccountingHooksService {
|
||||
* meaningful while the product sync is on; stock fields of products that are
|
||||
* not linked under the active provider are cleared by every product pull.
|
||||
*/
|
||||
async stockBlocked(product: {
|
||||
accountingStockAt: Date | null;
|
||||
accountingStockWeight: Prisma.Decimal | null;
|
||||
accountingStockCount: Prisma.Decimal | null;
|
||||
}) {
|
||||
if (!product.accountingStockAt) return false;
|
||||
async stockBlocked(product: StockFields) {
|
||||
return (await this.stockBlocker())(product);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rule as stockBlocked, with the configuration read once — for
|
||||
* checking a whole list without an await per product.
|
||||
*/
|
||||
async stockBlocker(): Promise<(product: StockFields) => boolean> {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.products) return false;
|
||||
const stock = product.accountingStockWeight ?? product.accountingStockCount;
|
||||
return stock !== null && new Prisma.Decimal(stock).lte(0);
|
||||
const active = c.provider !== 'none' && c.features.products;
|
||||
return (product) => {
|
||||
if (!active || !product.accountingStockAt) return false;
|
||||
const stock = product.accountingStockWeight ?? product.accountingStockCount;
|
||||
return stock !== null && new Prisma.Decimal(stock).lte(0);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,17 @@ describe('wire format', () => {
|
||||
await expect(z.listCustomerGroups(s)).rejects.toThrow('دسترسی مجاز نیست');
|
||||
});
|
||||
|
||||
it('shows the server reason on a non-2xx reply, without the token', async () => {
|
||||
http.mockReturnValueOnce(reply('<html><body><h1>Server Error</h1><p>kind=listGorooheHesabha&token=SECRET failed</p></body></html>', 500));
|
||||
const err = await z.listCustomerGroups(s).catch((e) => e);
|
||||
expect(err.message).toContain('HTTP 500 — Server Error');
|
||||
expect(err.message).toContain('failed');
|
||||
expect(err.message).not.toContain('SECRET');
|
||||
|
||||
http.mockReturnValueOnce(reply({ state: false, msg: 'شناسه نامعتبر' }, 400));
|
||||
await expect(z.listCustomerGroups(s)).rejects.toThrow('HTTP 400 — شناسه نامعتبر');
|
||||
});
|
||||
|
||||
it('lists the different envelopes Zarrin uses', () => {
|
||||
const { listOf, fullJalali } = __zarrinInternals;
|
||||
expect(listOf({ data: [1] })).toEqual([1]);
|
||||
@@ -70,17 +81,76 @@ describe('wire format', () => {
|
||||
});
|
||||
|
||||
describe('customers', () => {
|
||||
it('pages with Get100Account and fills groups from listhesabha', async () => {
|
||||
const page = Array.from({ length: 100 }, (_, i) => ({ code: 81 + i, name: 'n', namKhanevadegi: `f${i}`, mobile: i === 0 ? '09121112233' : '' }));
|
||||
http
|
||||
.mockReturnValueOnce(reply({ state: true, msg: '', date: page }))
|
||||
.mockReturnValueOnce(reply({ data: [{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' }] }));
|
||||
it('lists every account with its group from one listhesabha call', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply({
|
||||
data: [
|
||||
{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' },
|
||||
{ id: '82', nam: 'آرین یوسفی', gorooh: 'سازنده (تامین کننده)', tell: '', mobile: '', codeMeli: '' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const res = await z.listCustomers(s, null);
|
||||
expect(url(0).searchParams.get('id')).toBe('0');
|
||||
expect(url(1).searchParams.get('az')).toBe('81');
|
||||
expect(url(1).searchParams.get('ta')).toBe('180');
|
||||
expect(http).toHaveBeenCalledTimes(1);
|
||||
expect(url(0).searchParams.get('kind')).toBe('listhesabha');
|
||||
expect(url(0).searchParams.get('az')).toBe('1');
|
||||
expect(res.items).toHaveLength(2);
|
||||
expect(res.items[0]).toMatchObject({ code: '81', name: 'حسین اصغری', mobile: '09121112233', groupName: 'مشتری (خریدار)', phone: '021' });
|
||||
expect(res.next).toBe('181');
|
||||
expect(res.items[1].mobile).toBeNull();
|
||||
expect(res.next).toBeNull();
|
||||
});
|
||||
|
||||
it('finds by mobile with searchAccount, keeping exact matches only', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply({
|
||||
state: true,
|
||||
msg: '',
|
||||
date: [
|
||||
{ code: 84, name: 'سعید', namKhanevadegi: 'رجایی', mobile: '09121995971' },
|
||||
{ code: 90, name: 'x', namKhanevadegi: 'y', mobile: '09121995972' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const found = await z.findCustomersByMobile(s, '09121995971');
|
||||
expect(url(0).searchParams.get('kind')).toBe('searchAccount');
|
||||
expect(found.map((c) => c.code)).toEqual(['84']);
|
||||
expect(found[0].name).toBe('سعید رجایی');
|
||||
});
|
||||
|
||||
it('falls back to listhesabha when searchAccount is missing (bare 400)', async () => {
|
||||
http
|
||||
.mockReturnValueOnce(reply('', 400))
|
||||
.mockReturnValueOnce(reply({ data: [{ id: '84', nam: 'رجایی * سعید', gorooh: 'مشتری (خریدار)', mobile: '09121995971' }, { id: '85', nam: 'z', mobile: '' }] }));
|
||||
const found = await z.findCustomersByMobile(s, '09121995971');
|
||||
expect(url(1).searchParams.get('kind')).toBe('listhesabha');
|
||||
expect(found.map((c) => c.code)).toEqual(['84']);
|
||||
});
|
||||
|
||||
it('does not hide other errors behind the fallback', async () => {
|
||||
http.mockReturnValueOnce(reply('', 500));
|
||||
await expect(z.findCustomersByMobile(s, '09121995971')).rejects.toThrow('HTTP 500');
|
||||
});
|
||||
|
||||
it('lists karsakhte groups with goroohSakhte when the installation has it', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, detail: 'OK', data: [{ id: '26', name: 'النگو' }] }));
|
||||
expect(await z.listKarSakhteGroups!(s)).toEqual([{ id: '26', name: 'النگو' }]);
|
||||
expect(http).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('scans karsakhte groups with infogorooh when goroohSakhte is missing, skipping ids that fail', async () => {
|
||||
http.mockImplementation((req) => {
|
||||
const u = new URL(req.url);
|
||||
if (u.searchParams.get('kind') === 'goroohSakhte') return reply('', 400);
|
||||
const id = u.searchParams.get('id');
|
||||
if (id === '26') return reply({ data: [{ nam: 'النگو', code: 26 }] });
|
||||
if (id === '115') return reply({ data: [{ nam: 'سکه', code: 115 }] });
|
||||
if (id === '3') return reply('', 500);
|
||||
return reply('{\n "data": \n ]\n}');
|
||||
});
|
||||
expect(await z.listKarSakhteGroups!(s)).toEqual([
|
||||
{ id: '26', name: 'النگو' },
|
||||
{ id: '115', name: 'سکه' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the account in its group on edit', async () => {
|
||||
|
||||
@@ -40,11 +40,51 @@ import {
|
||||
const STATEMENT_PAGE = 250;
|
||||
const CHANGE_WINDOW = 50;
|
||||
const MAX_STATEMENT_PAGES = 20;
|
||||
const ALL_CODES = { az: 1, ta: 999999 };
|
||||
const GROUP_SCAN_MAX = 300;
|
||||
const GROUP_SCAN_CONCURRENCY = 10;
|
||||
|
||||
function endpoint(s: ProviderSettings) {
|
||||
return s.baseUrl.trim();
|
||||
}
|
||||
|
||||
class ZarrinHttpError extends AccountingProviderError {
|
||||
constructor(
|
||||
message: string,
|
||||
body: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message, false, body, status === 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installations lack some documented kinds (v228 has no Get100Account,
|
||||
* goroohSakhte or getinfoByPhone) and answer them with a bare 400.
|
||||
*/
|
||||
function isMissingKind(err: unknown) {
|
||||
return err instanceof ZarrinHttpError && err.status === 400;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the server said on a non-2xx reply, short enough for the admin screen.
|
||||
* Error pages may echo the request URL, so the token is scrubbed.
|
||||
*/
|
||||
function errorBodyText(body: string, token: string) {
|
||||
let text = body;
|
||||
try {
|
||||
const j = parseLooseJson(body) as any;
|
||||
const o = typeof j === 'string' && /^\s*[{[]/.test(j) ? parseLooseJson(j) : j;
|
||||
if (o && typeof o === 'object') text = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? str(o.Message) ?? str(o.title) ?? JSON.stringify(o);
|
||||
else if (typeof o === 'string') text = o;
|
||||
} catch {
|
||||
text = body.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ');
|
||||
}
|
||||
if (token) for (const t of new Set([token, encodeURIComponent(token)])) text = text.split(t).join('***');
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||||
}
|
||||
|
||||
async function call(
|
||||
s: ProviderSettings,
|
||||
kind: string,
|
||||
@@ -73,7 +113,8 @@ async function call(
|
||||
}
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new AccountingProviderError(`زرین (${kind}): HTTP ${res.status}`, false, res.body.slice(0, 500), res.status === 404);
|
||||
const reason = errorBodyText(res.body, s.token);
|
||||
throw new ZarrinHttpError(`زرین (${kind}): HTTP ${res.status}${reason ? ` — ${reason}` : ''}`, res.body.slice(0, 500), res.status);
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
@@ -96,7 +137,7 @@ async function call(
|
||||
return json as Record<string, unknown> | unknown[];
|
||||
}
|
||||
|
||||
/** Zarrin wraps lists as `data`, once as `date` (Get100Account), or not at all. */
|
||||
/** Zarrin wraps lists as `data`, sometimes as `date` (searchAccount), or not at all. */
|
||||
function listOf(json: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(json)) return json as Record<string, unknown>[];
|
||||
if (json && typeof json === 'object') {
|
||||
@@ -128,6 +169,21 @@ function fullJalali(v: unknown): string {
|
||||
return `14${m[1]}/${m[2].padStart(2, '0')}/${m[3].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function allAccounts(s: ProviderSettings): Promise<ExternalCustomer[]> {
|
||||
return listOf(await call(s, 'listhesabha', ALL_CODES))
|
||||
.filter((r) => r.id !== undefined)
|
||||
.map((r) => ({
|
||||
code: String(r.id),
|
||||
name: str(r.nam) ?? String(r.id),
|
||||
mobile: normalizeMobile(r.mobile),
|
||||
phone: str(r.tell),
|
||||
nationalCode: str(r.codeMeli),
|
||||
address: str(r.address),
|
||||
groupId: null,
|
||||
groupName: str(r.gorooh),
|
||||
}));
|
||||
}
|
||||
|
||||
function signed(s: ProviderSettings, v: Prisma.Decimal) {
|
||||
// Until checked on a real installation (plan §9) the sign is a setting.
|
||||
return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v;
|
||||
@@ -152,35 +208,10 @@ export const zarrinProvider: AccountingProvider = {
|
||||
}));
|
||||
},
|
||||
|
||||
async listCustomers(s, cursor) {
|
||||
const from = Number(cursor ?? 0) || 0;
|
||||
// Get100Account pages by code (100 accounts from `id` on) but carries no
|
||||
// group or address; listhesabha over the same code span fills those in.
|
||||
const page = listOf(await call(s, 'Get100Account', { id: from }));
|
||||
if (page.length === 0) return { items: [], next: null };
|
||||
|
||||
const codes = page.map((r) => Number(r.code)).filter(Number.isFinite);
|
||||
const lo = Math.min(...codes);
|
||||
const hi = Math.max(...codes);
|
||||
const details = new Map<string, Record<string, unknown>>();
|
||||
for (const r of listOf(await call(s, 'listhesabha', { az: lo, ta: hi }))) details.set(String(r.id), r);
|
||||
|
||||
const items: ExternalCustomer[] = page.map((r) => {
|
||||
const code = String(r.code);
|
||||
const d = details.get(code) ?? {};
|
||||
return {
|
||||
code,
|
||||
name: str(d.nam) ?? joinName(r.name, r.namKhanevadegi),
|
||||
mobile: normalizeMobile(d.mobile ?? r.mobile),
|
||||
phone: str(d.tell),
|
||||
nationalCode: str(d.codeMeli),
|
||||
address: str(d.address),
|
||||
groupId: null,
|
||||
groupName: str(d.gorooh),
|
||||
};
|
||||
});
|
||||
const next = page.length < 100 ? null : String(hi + 1);
|
||||
return { items, next };
|
||||
async listCustomers(s) {
|
||||
// listhesabha carries everything (group, mobile, address) and returns the
|
||||
// whole code span in one reply (~865 accounts in under 2 s).
|
||||
return { items: await allAccounts(s), next: null };
|
||||
},
|
||||
|
||||
async findCustomersByMobile(s, mobile) {
|
||||
@@ -188,20 +219,22 @@ export const zarrinProvider: AccountingProvider = {
|
||||
if (!target) return [];
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = listOf(await call(s, 'getinfoByPhone', { number: target }));
|
||||
// Matches on name or mobile, possibly partially; filtered exactly below.
|
||||
rows = listOf(await call(s, 'searchAccount', { search: target }));
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingProviderError && err.notFound) return [];
|
||||
throw err;
|
||||
if (!isMissingKind(err)) throw err;
|
||||
return (await allAccounts(s)).filter((c) => c.mobile === target);
|
||||
}
|
||||
return rows
|
||||
.filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target)
|
||||
.map((r) => ({
|
||||
code: String(r.code),
|
||||
name: joinName(r.nam, r.namKhanevadegi),
|
||||
name: joinName(r.name, r.namKhanevadegi),
|
||||
mobile: target,
|
||||
phone: str(r.tel),
|
||||
phone: null,
|
||||
nationalCode: null,
|
||||
address: str(r.address),
|
||||
address: null,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
}));
|
||||
@@ -333,7 +366,30 @@ export const zarrinProvider: AccountingProvider = {
|
||||
},
|
||||
|
||||
async listKarSakhteGroups(s) {
|
||||
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
|
||||
try {
|
||||
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
|
||||
} catch (err) {
|
||||
if (!isMissingKind(err)) throw err;
|
||||
}
|
||||
// Without goroohSakhte the only way is one infogorooh per id. Ids are
|
||||
// sparse and unknown ids fail (some with a 500), so failures are skipped.
|
||||
const found: { id: string; name: string }[] = [];
|
||||
let next = 1;
|
||||
const worker = async () => {
|
||||
while (next <= GROUP_SCAN_MAX) {
|
||||
const id = next++;
|
||||
try {
|
||||
for (const g of listOf(await call(s, 'infogorooh', { id }))) {
|
||||
const name = str(g.nam);
|
||||
if (name) found.push({ id: String(g.code ?? id), name });
|
||||
}
|
||||
} catch {
|
||||
// not a group
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: GROUP_SCAN_CONCURRENCY }, worker));
|
||||
return found.sort((a, b) => Number(a.id) - Number(b.id));
|
||||
},
|
||||
|
||||
async listKarSakhteTypes(s) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CatalogCacheService } from './catalog-cache.service';
|
||||
|
||||
/**
|
||||
* Global so the accounting module can invalidate the storefront catalogue
|
||||
* without importing CatalogModule (which itself depends on the accounting
|
||||
* module).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CatalogCacheService],
|
||||
exports: [CatalogCacheService],
|
||||
})
|
||||
export class CatalogCacheModule {}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { CATALOG_CACHE_MS, CatalogCacheService } from './catalog-cache.service';
|
||||
|
||||
type Middleware = (params: Record<string, unknown>, next: (p: unknown) => Promise<unknown>) => Promise<unknown>;
|
||||
|
||||
function setup() {
|
||||
let middleware: Middleware | undefined;
|
||||
const prisma = { $use: jest.fn((fn: Middleware) => (middleware = fn)) };
|
||||
const cache = new CatalogCacheService(prisma as never);
|
||||
cache.onModuleInit();
|
||||
const runQuery = (params: Record<string, unknown>) => middleware!(params, async () => 'ok');
|
||||
return { cache, runQuery };
|
||||
}
|
||||
|
||||
describe('CatalogCacheService', () => {
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('serves a fresh copy without rebuilding', async () => {
|
||||
const { cache } = setup();
|
||||
const build = jest.fn().mockResolvedValue(['a']);
|
||||
expect(await cache.get(build)).toEqual(['a']);
|
||||
expect(await cache.get(build)).toEqual(['a']);
|
||||
expect(build).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rebuilds once the TTL has passed', async () => {
|
||||
jest.useFakeTimers({ now: 1_000_000 });
|
||||
const { cache } = setup();
|
||||
const build = jest.fn().mockResolvedValueOnce(['old']).mockResolvedValueOnce(['new']);
|
||||
await cache.get(build);
|
||||
jest.setSystemTime(1_000_000 + CATALOG_CACHE_MS);
|
||||
expect(await cache.get(build)).toEqual(['new']);
|
||||
expect(build).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shares one build between concurrent callers', async () => {
|
||||
const { cache } = setup();
|
||||
let resolve!: (v: string[]) => void;
|
||||
const build = jest.fn(() => new Promise<string[]>((r) => (resolve = r)));
|
||||
const a = cache.get(build);
|
||||
const b = cache.get(build);
|
||||
resolve(['x']);
|
||||
expect(await a).toEqual(['x']);
|
||||
expect(await b).toEqual(['x']);
|
||||
expect(build).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invalidate() forces the next read to rebuild', async () => {
|
||||
const { cache } = setup();
|
||||
const build = jest.fn().mockResolvedValueOnce(['old']).mockResolvedValueOnce(['new']);
|
||||
await cache.get(build);
|
||||
cache.invalidate();
|
||||
expect(await cache.get(build)).toEqual(['new']);
|
||||
});
|
||||
|
||||
it('does not keep a build that started before an invalidation', async () => {
|
||||
const { cache } = setup();
|
||||
let resolve!: (v: string[]) => void;
|
||||
const slow = jest.fn(() => new Promise<string[]>((r) => (resolve = r)));
|
||||
const pending = cache.get(slow);
|
||||
cache.invalidate();
|
||||
resolve(['stale']);
|
||||
await pending;
|
||||
const fresh = jest.fn().mockResolvedValue(['fresh']);
|
||||
expect(await cache.get(fresh)).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('invalidates on a write to a catalogue model', async () => {
|
||||
const { cache, runQuery } = setup();
|
||||
const build = jest.fn().mockResolvedValue(['x']);
|
||||
await cache.get(build);
|
||||
await runQuery({ model: 'ProductVariant', action: 'updateMany', runInTransaction: false });
|
||||
await cache.get(build);
|
||||
expect(build).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ignores reads and writes to other models', async () => {
|
||||
const { cache, runQuery } = setup();
|
||||
const build = jest.fn().mockResolvedValue(['x']);
|
||||
await cache.get(build);
|
||||
await runQuery({ model: 'Product', action: 'findMany', runInTransaction: false });
|
||||
await runQuery({ model: 'Setting', action: 'upsert', runInTransaction: false });
|
||||
await cache.get(build);
|
||||
expect(build).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invalidates again after a transactional write has had time to commit', async () => {
|
||||
jest.useFakeTimers();
|
||||
const { cache, runQuery } = setup();
|
||||
await runQuery({ model: 'Product', action: 'update', runInTransaction: true });
|
||||
// Rebuilt from the pre-commit state before the transaction finished.
|
||||
const build = jest.fn().mockResolvedValue(['x']);
|
||||
await cache.get(build);
|
||||
jest.advanceTimersByTime(2_000);
|
||||
await cache.get(build);
|
||||
expect(build).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
/** How long a built storefront catalogue is served before it is rebuilt. */
|
||||
export const CATALOG_CACHE_MS = 30_000;
|
||||
|
||||
/** Models whose rows appear in GET /catalog/products. */
|
||||
const CATALOG_MODELS = new Set(['Product', 'ProductVariant', 'ProductImage', 'Category', 'Brand', 'Collection', 'CollectionProduct']);
|
||||
|
||||
const WRITE_ACTIONS = new Set([
|
||||
'create',
|
||||
'createMany',
|
||||
'createManyAndReturn',
|
||||
'update',
|
||||
'updateMany',
|
||||
'upsert',
|
||||
'delete',
|
||||
'deleteMany',
|
||||
]);
|
||||
|
||||
/**
|
||||
* A write inside an interactive transaction is not visible until the commit,
|
||||
* which the middleware cannot see. A second invalidation this long after the
|
||||
* write drops anything rebuilt from the pre-commit state in between.
|
||||
*/
|
||||
const TX_SETTLE_MS = 2_000;
|
||||
|
||||
/**
|
||||
* In-memory copy of the storefront catalogue (GET /catalog/products).
|
||||
*
|
||||
* Building it is one wide findMany plus a stock check per product, and every
|
||||
* app screen used to ask for it. Any Prisma write to a catalogue model — admin
|
||||
* product/category/brand/collection screens, the accounting product and stock
|
||||
* pull, wage changes — invalidates it through the middleware below, wherever
|
||||
* the write comes from. Accounting settings (which decide the stock rule) are
|
||||
* not a catalogue model; AccountingConfigService invalidates on save.
|
||||
*
|
||||
* Per process: correct while the API runs as a single instance. With several
|
||||
* instances this moves to a shared store (Redis) behind the same two methods.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CatalogCacheService implements OnModuleInit {
|
||||
private value: { at: number; data: unknown } | null = null;
|
||||
private inflight: Promise<unknown> | null = null;
|
||||
/** Bumped by invalidate(), so a build started before it is never stored. */
|
||||
private generation = 0;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.prisma.$use(async (params, next) => {
|
||||
const result = await next(params);
|
||||
if (params.model && CATALOG_MODELS.has(params.model) && WRITE_ACTIONS.has(params.action)) {
|
||||
this.invalidate();
|
||||
if (params.runInTransaction) setTimeout(() => this.invalidate(), TX_SETTLE_MS).unref?.();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async get<T>(build: () => Promise<T>): Promise<T> {
|
||||
if (this.value && Date.now() - this.value.at < CATALOG_CACHE_MS) return this.value.data as T;
|
||||
if (this.inflight) return this.inflight as Promise<T>;
|
||||
|
||||
const gen = this.generation;
|
||||
const p = build()
|
||||
.then((data) => {
|
||||
if (gen === this.generation) this.value = { at: Date.now(), data };
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.inflight === p) this.inflight = null;
|
||||
});
|
||||
this.inflight = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.generation++;
|
||||
this.value = null;
|
||||
this.inflight = null;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
import { CatalogCacheService } from './catalog-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
private readonly catalogCache: CatalogCacheService,
|
||||
) {}
|
||||
|
||||
async listProducts() {
|
||||
/** Served from CatalogCacheService; see there for what invalidates it. */
|
||||
listProducts() {
|
||||
return this.catalogCache.get(() => this.buildProductList());
|
||||
}
|
||||
|
||||
private async buildProductList() {
|
||||
const products = await this.prisma.product.findMany({
|
||||
// D27 — a product with no image is never shown in the app. Products now
|
||||
// also arrive from the accounting software, which carries no pictures;
|
||||
@@ -34,6 +41,7 @@ export class CatalogService {
|
||||
},
|
||||
});
|
||||
|
||||
const stockBlocked = await this.accounting.stockBlocker();
|
||||
const out = [];
|
||||
for (const p of products) {
|
||||
const { accountingStockWeight, accountingStockCount, accountingStockAt, ...product } = p;
|
||||
@@ -41,7 +49,7 @@ export class CatalogService {
|
||||
// qty 0, which the apps already render as «ناموجود» and refuse to add to
|
||||
// the cart unless it is a pre-order variant. The stock figures themselves
|
||||
// stay server-side; this endpoint is public.
|
||||
const blocked = await this.accounting.stockBlocked({ accountingStockWeight, accountingStockCount, accountingStockAt });
|
||||
const blocked = stockBlocked({ accountingStockWeight, accountingStockCount, accountingStockAt });
|
||||
out.push(blocked ? { ...product, variants: product.variants.map((v) => (v.isPreOrder ? v : { ...v, qty: 0 })) } : product);
|
||||
}
|
||||
return out;
|
||||
|
||||
Reference in New Issue
Block a user