diff --git a/apps/admin-panel/src/views/manage/accounting.vue b/apps/admin-panel/src/views/manage/accounting.vue
index 536a5e5..f524c13 100644
--- a/apps/admin-panel/src/views/manage/accounting.vue
+++ b/apps/admin-panel/src/views/manage/accounting.vue
@@ -119,42 +119,110 @@
اسناد سفارش همیشه «موقت» ثبت میشوند و تا حسابدار نهایی نکند روی ماندهی حسابداری اثر ندارند.
- سقف اعتبار سفارش همچنان با دفتر داخلی اپ چک میشود.
+ سقف اعتبار سفارش همچنان با دفتر داخلی منتزر چک میشود.
ذخیره
+
+
+
+ در این صفحه «منتزر» یعنی همین سامانه: اپ مشتری (جایی که مشتری ثبتنام، احراز هویت و سفارش میکند و ماندهاش را میبیند) و
+ پنل مدیریت (همین پنل، جایی که محصول و دستهبندی تعریف میشود). «حسابداری» یعنی نرمافزار حسابداری مغازه (تهحساب یا زرین).
+
+
+ منتزر و حسابداری هر کدام گروهبندی خودشان را دارند. این تب میگوید هر چیز در منتزر با کدام گروه در حسابداری متناظر است؛
+ خودِ مشتریها و محصولها اینجا وصل نمیشوند (آنها در تب «لینکها» دیده میشوند).
+
+
+ - تنظیمات تب «اتصال» را ذخیره کنید، بعد «دریافت گروهها از حسابداری» را بزنید تا فهرستها از حسابداری پر شوند.
+ -
+ گروههای حسابی که مشتری منتزر حساب میشوند: در حسابداری صندوق، کارمند و سرمایهگذار هم «حساب» هستند. فقط گروههایی را انتخاب کنید که
+ واقعاً مشتریاند؛ سینک مشتری فقط حسابهای این گروهها را با شماره موبایل به کاربران اپ مشتری وصل میکند. اگر خالی بماند سینک مشتری اجرا نمیشود.
+
+ -
+ گروه مشتری جدید: وقتی کاربری در اپ مشتری احراز هویت شود و در حسابداری حساب نداشته باشد، حسابش در این گروه ساخته میشود.
+ اگر تعیین نشود، ساخت حساب در «لاگ ارسال» با وضعیت «نیاز به بررسی» میماند.
+
+ -
+ عیار پایه: عیار وزنهایی که در منتزر ثبت میشود (وزن محصولها و سفارشها؛ معمولاً ۷۵۰ = ۱۸ عیار). در سند سفارش، وزن با همین عیار به
+ حسابداری میرود؛ در تهحساب اجرت هم روی آن اعمال میشود (مثلاً ۷۵۰ با اجرت ۱۰٪ میشود ۸۲۵).
+
+ -
+ کارساخته (فقط زرین): وقتی در پنل مدیریت منتزر محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته میشود. «نوع» تعیین میکند
+ گرمی است یا تعدادی، و «گروه ساخته» جای آن در زرین است. در جدول پایین برای هر دستهبندی محصولِ منتزر میتوانید گروه جدا بدهید؛ دستهای که
+ «پیشفرض» بماند در «گروه ساختهی پیشفرض» میرود. اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی میماند.
+
+
+
+ تغییر این تنظیمات فقط روی موارد بعدی اثر دارد؛ حسابها و کارساختههایی که قبلاً ساخته شدهاند جابهجا نمیشوند. در آخر «ذخیره» را بزنید.
+
+
+
دریافت گروهها از حسابداری
{{ e }}
+ حساب مشتریها
+
+
+
+ هر حسابی که در گروههای انتخابشده باشد و موبایلش با یک کاربر اپ مشتری منتزر یکی باشد، به آن کاربر وصل میشود و ماندهاش در اپ مشتری
+ نمایش داده میشود. صندوق، کارمند و سرمایهگذار را انتخاب نکنید.
+
+
+ مثال: «بنکدار» و «مغازهدار» را در فیلد اول انتخاب کنید و در فیلد دوم «مغازهدار» را بگذارید. در این حالت کسی که در اپ مشتری ثبتنام و
+ احراز هویت کند و در حسابداری حساب نداشته باشد، در گروه «مغازهدار» برایش حساب ساخته میشود.
+
+
+
کارساخته (زرین)
+
+
+
+ زرین برای ساختن کارساخته دو چیز لازم دارد: نوع (گرمی یا تعدادی) و گروه ساخته (کارساخته در کدام پوشهی زرین قرار بگیرد).
+ این بخش این دو را بهجای شما انتخاب میکند تا لازم نباشد برای هر محصول جداگانه در زرین کاری انجام دهید.
+
+
+ ستون «دستهبندی محصول در منتزر» همان دستهبندیهایی است که در پنل مدیریت برای محصولات تعریف کردهاید. مثال: اگر روبهروی «انگشتر»
+ گروه «انگشتر» زرین را بگذارید، هر محصول تازه در دستهی انگشتر منتزر، در گروه انگشترِ زرین ساخته میشود.
+ دستههایی که «پیشفرض» بمانند در «گروه ساختهی پیشفرض» میروند.
+
+
+ اگر نوع یا گروه خالی باشد، محصول در منتزر ساخته میشود ولی ساخت کارساخته در «لاگ ارسال» منتظر میماند تا آن را تعیین کنید و «تلاش دوباره» بزنید.
+ محصولهایی که از قبل به کارساخته وصل شدهاند تغییری نمیکنند.
+
+
+
-
+
@@ -165,6 +233,9 @@
تهحساب در API خود ساخت کارساخته ندارد: نام کار با اولین سند سفارش در تهحساب ساخته میشود و از آن به بعد به محصول لینک میشود.
+
+ اگر فهرستی خالی است، یعنی دریافت آن از حسابداری خطا داده (پیام قرمز بالای صفحه). اتصال را بررسی کنید و دوباره «دریافت گروهها» را بزنید.
+
ذخیره
@@ -272,7 +343,7 @@
>
-
+
{{ ORIGIN[record.origin] ?? record.origin }}
@@ -367,7 +438,7 @@ const ISSUE: Record = {
const ORIGIN: Record = {
LINKED: 'جفت با موبایل/نام',
IMPORTED: 'وارد شده از حسابداری',
- PUSHED: 'ساخته شده از اپ',
+ PUSHED: 'ساخته شده از منتزر',
MANUAL: 'دستی',
};
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 712ee87..0d6de5c 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -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,
diff --git a/apps/api/src/modules/accounting-sync/accounting-config.service.ts b/apps/api/src/modules/accounting-sync/accounting-config.service.ts
index b8aff8e..3f8f4af 100644
--- a/apps/api/src/modules/accounting-sync/accounting-config.service.ts
+++ b/apps/api/src/modules/accounting-sync/accounting-config.service.ts
@@ -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 };
}
diff --git a/apps/api/src/modules/accounting-sync/accounting-hooks.service.ts b/apps/api/src/modules/accounting-sync/accounting-hooks.service.ts
index 7234ccd..43508d8 100644
--- a/apps/api/src/modules/accounting-sync/accounting-hooks.service.ts
+++ b/apps/api/src/modules/accounting-sync/accounting-hooks.service.ts
@@ -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);
+ };
}
}
diff --git a/apps/api/src/modules/accounting-sync/providers/zarrin.provider.spec.ts b/apps/api/src/modules/accounting-sync/providers/zarrin.provider.spec.ts
index 794b569..fc86815 100644
--- a/apps/api/src/modules/accounting-sync/providers/zarrin.provider.spec.ts
+++ b/apps/api/src/modules/accounting-sync/providers/zarrin.provider.spec.ts
@@ -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('Server Error
kind=listGorooheHesabha&token=SECRET failed
', 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 () => {
diff --git a/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts b/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts
index cd3d4a6..209de7d 100644
--- a/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts
+++ b/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts
@@ -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 | 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[] {
if (Array.isArray(json)) return json as Record[];
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 {
+ 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>();
- 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[];
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) {
diff --git a/apps/api/src/modules/catalog/catalog-cache.module.ts b/apps/api/src/modules/catalog/catalog-cache.module.ts
new file mode 100644
index 0000000..0f59164
--- /dev/null
+++ b/apps/api/src/modules/catalog/catalog-cache.module.ts
@@ -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 {}
diff --git a/apps/api/src/modules/catalog/catalog-cache.service.spec.ts b/apps/api/src/modules/catalog/catalog-cache.service.spec.ts
new file mode 100644
index 0000000..50c59eb
--- /dev/null
+++ b/apps/api/src/modules/catalog/catalog-cache.service.spec.ts
@@ -0,0 +1,97 @@
+import { CATALOG_CACHE_MS, CatalogCacheService } from './catalog-cache.service';
+
+type Middleware = (params: Record, next: (p: unknown) => Promise) => Promise;
+
+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) => 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((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((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);
+ });
+});
diff --git a/apps/api/src/modules/catalog/catalog-cache.service.ts b/apps/api/src/modules/catalog/catalog-cache.service.ts
new file mode 100644
index 0000000..584b280
--- /dev/null
+++ b/apps/api/src/modules/catalog/catalog-cache.service.ts
@@ -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 | 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(build: () => Promise): Promise {
+ 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;
+
+ 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;
+ }
+}
diff --git a/apps/api/src/modules/catalog/catalog.service.ts b/apps/api/src/modules/catalog/catalog.service.ts
index d0cdeca..03565ce 100644
--- a/apps/api/src/modules/catalog/catalog.service.ts
+++ b/apps/api/src/modules/catalog/catalog.service.ts
@@ -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;
diff --git a/apps/user-app-v2/src/api/cachedReads.ts b/apps/user-app-v2/src/api/cachedReads.ts
new file mode 100644
index 0000000..55b238d
--- /dev/null
+++ b/apps/user-app-v2/src/api/cachedReads.ts
@@ -0,0 +1,18 @@
+// Cached versions of the reads screens repeat on every visit. Each one goes
+// through queryCache under its own key; writes stay on `api` directly.
+import { api } from './client'
+import { KEYS, useQueryCache, type FetchOptions } from '../stores/queryCache'
+
+export function useCachedReads() {
+ const cache = useQueryCache()
+ return {
+ me: (o?: FetchOptions) => cache.fetch(KEYS.profile, api.getMe, o),
+ myOrders: (o?: FetchOptions) => cache.fetch(KEYS.orders, api.getMyOrders, o),
+ accountingBalance: (o?: FetchOptions) => cache.fetch(KEYS.balance, api.getAccountingBalance, o),
+ myLedgerEntries: (o?: FetchOptions) => cache.fetch(KEYS.ledger, api.getMyLedgerEntries, o),
+ goldPrice: (o?: FetchOptions) => cache.fetch(KEYS.goldPrice, () => api.getLatestGoldPrice(), o),
+ homeSlider: (o?: FetchOptions) => cache.fetch(KEYS.homeSlider, api.getHomeSlider, o),
+ peek: cache.peek,
+ invalidate: cache.invalidate,
+ }
+}
diff --git a/apps/user-app-v2/src/composables/useOrderItemProduct.ts b/apps/user-app-v2/src/composables/useOrderItemProduct.ts
index c5f099c..19a6d9f 100644
--- a/apps/user-app-v2/src/composables/useOrderItemProduct.ts
+++ b/apps/user-app-v2/src/composables/useOrderItemProduct.ts
@@ -1,39 +1,16 @@
// The real GET /customers/:id/orders endpoint returns OrderItem rows with
// only `productVariantId` (no nested product/variant title) — this joins
-// against the already-existing GET /catalog/products list client-side rather
+// against the shared catalogue (stores/catalogStore.ts) client-side rather
// than touching apps/api to add an include.
-import { ref } from 'vue'
-import { api } from '../api/client'
+import { useCatalogStore, type VariantLookup } from '../stores/catalogStore'
-export interface VariantLookup {
- productTitle: string
- variantTitle: string
- imageUrl?: string
-}
-
-const cache = ref