feat: paginate customer orders and polish the dashboard hero

Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-17 13:11:42 +03:30
co-authored by Cursor
parent f550172567
commit afb068c643
89 changed files with 4518 additions and 1325 deletions
@@ -0,0 +1,14 @@
-- App (B2B) orders were stored as RETAIL because createOrder defaulted
-- channel to RETAIL and the apps never sent one. Legacy's mobile setOrder
-- wrote to the wholesale `orders` table.
--
-- Flip mis-filed rows: any RETAIL order whose customer is NOT a تک
-- (CustomerKind.RETAIL) belongs on the omde list. Genuine retail-list
-- orders for CustomerKind.RETAIL stay RETAIL.
UPDATE "Order" AS o
SET channel = 'WHOLESALE'
FROM "Customer" AS c
WHERE o."customerId" = c.id
AND o.channel = 'RETAIL'
AND c.kind <> 'RETAIL';
@@ -0,0 +1,31 @@
-- Short global customer-facing order numbers (SMS / app), replacing
-- ORD-{timestamp}-{random}. Pure digits, starting at 100.
-- Existing ORD-* / MOCK-ORD-* rows stay as-is; the sequence only advances
-- past any already-numeric orderNumber so new allocations never collide.
DROP SEQUENCE IF EXISTS order_number_seq;
CREATE SEQUENCE order_number_seq
AS BIGINT
INCREMENT BY 1
MINVALUE 1
START WITH 100
NO CYCLE;
DO $$
DECLARE
max_numeric bigint;
BEGIN
SELECT MAX("orderNumber"::bigint)
INTO max_numeric
FROM "Order"
WHERE "orderNumber" ~ '^[0-9]+$';
IF max_numeric IS NULL THEN
-- next nextval() returns 100
PERFORM setval('order_number_seq', 100, false);
ELSE
-- next nextval() returns max_numeric + 1
PERFORM setval('order_number_seq', max_numeric, true);
END IF;
END $$;
+2 -2
View File
@@ -389,8 +389,8 @@ async function main() {
await prisma.brand.updateMany({ where: { id: 'brand-henza' }, data: { id: 'brand-montzar' } });
const brandMain = await prisma.brand.upsert({
where: { id: 'brand-montzar' },
update: { title: 'زرتاج طلا' },
create: { id: 'brand-montzar', title: 'زرتاج طلا' },
update: { title: 'تهران تاج طلا' },
create: { id: 'brand-montzar', title: 'تهران تاج طلا' },
});
const brandArtisan = await prisma.brand.upsert({
where: { id: 'brand-artisan' },
@@ -0,0 +1,16 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const row = await prisma.setting.findUnique({ where: { key: 'accounting.config' } })
console.log('accounting.config:', row ? JSON.stringify(row.value, null, 2) : 'MISSING')
const links = await prisma.accountingLink.count({ where: { entityType: 'PRODUCT' } })
console.log('PRODUCT accounting links:', links)
await prisma.$disconnect()
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const a = await prisma.$queryRaw<Array<{ n: bigint }>>`SELECT nextval('order_number_seq') AS n`
const b = await prisma.$queryRaw<Array<{ n: bigint }>>`SELECT nextval('order_number_seq') AS n`
console.log(JSON.stringify({ first: String(a[0]!.n), second: String(b[0]!.n) }))
await prisma.$disconnect()
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+27
View File
@@ -0,0 +1,27 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const counts = await prisma.order.groupBy({
by: ['status'],
_count: { _all: true },
where: { orderNumber: { startsWith: 'MOCK-ORD' } },
})
const total = counts.reduce((s, c) => s + c._count._all, 0)
const sample = await prisma.order.findFirst({
where: { orderNumber: { startsWith: 'MOCK-ORD' } },
select: {
orderNumber: true,
status: true,
customer: { select: { mobile: true, fullName: true } },
},
})
console.log(JSON.stringify({ total, byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])), sample }, null, 2))
await prisma.$disconnect()
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Legacy system (MySQL `oapi`) → zartaj (PostgreSQL) data import.
* Legacy system (MySQL `oapi`) → tehran-taj (PostgreSQL) data import.
*
* The rules live in docs/DATA-MIGRATION-MAP.md; every step cites its section.
*
@@ -56,7 +56,7 @@ export class Report {
}
render(header: string[]): string {
const out: string[] = ['# legacy (oapi) → zartaj import report', '', ...header, ''];
const out: string[] = ['# legacy (oapi) → tehran-taj import report', '', ...header, ''];
for (const [step, s] of this.sections) {
out.push(`## ${step}`, '');
if (s.counts.size) {
+53
View File
@@ -0,0 +1,53 @@
/**
* Move all MOCK-ORD-* orders to a target customer mobile.
* Run: npx tsx scripts/reassign-mock-orders.ts
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const TARGET_MOBILE = process.env.MOCK_ORDERS_MOBILE?.trim() || '09382507041'
const TAG = 'MOCK-ORD'
async function main() {
const customer = await prisma.customer.findUnique({
where: { mobile: TARGET_MOBILE },
select: { id: true, mobile: true, fullName: true },
})
if (!customer) {
throw new Error(`Customer not found for mobile=${TARGET_MOBILE}`)
}
const before = await prisma.order.count({ where: { orderNumber: { startsWith: TAG } } })
const result = await prisma.order.updateMany({
where: { orderNumber: { startsWith: TAG } },
data: { customerId: customer.id },
})
const byStatus = await prisma.order.groupBy({
by: ['status'],
_count: { _all: true },
where: { orderNumber: { startsWith: TAG }, customerId: customer.id },
})
console.log(
JSON.stringify(
{
target: { mobile: customer.mobile, fullName: customer.fullName },
matched: before,
updated: result.count,
byStatus: Object.fromEntries(byStatus.map((c) => [c.status, c._count._all])),
},
null,
2,
),
)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+199
View File
@@ -0,0 +1,199 @@
/**
* Dev helper: seed >15 mock orders per OrderStatus so the Orders UI can be inspected.
* Run: npx tsx scripts/seed-mock-orders.ts
*/
import {
OrderChannel,
OrderStatus,
Prisma,
PrismaClient,
} from '@prisma/client'
const prisma = new PrismaClient()
const STATUSES: OrderStatus[] = [
OrderStatus.SUSPENDED,
OrderStatus.PLACED,
OrderStatus.ADMIN_APPROVED,
OrderStatus.SOURCING,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_AGENT,
OrderStatus.DELIVERED_TO_CUSTOMER,
OrderStatus.CANCELLED,
]
const PER_STATUS = 18
const TAG = 'MOCK-ORD'
function d(n: number | string) {
return new Prisma.Decimal(n)
}
function orderNumber(status: OrderStatus, i: number) {
// Unique, easy to spot/delete later
return `${TAG}-${status.slice(0, 4)}-${String(i).padStart(3, '0')}-${Date.now().toString(36)}`
}
async function main() {
const mobileOverride = process.env.MOCK_ORDERS_MOBILE?.trim()
const customer = mobileOverride
? await prisma.customer.findUnique({
where: { mobile: mobileOverride },
select: { id: true, mobile: true, fullName: true, referredByAgentId: true },
})
: await prisma.customer.findFirst({
orderBy: { updatedAt: 'desc' },
select: { id: true, mobile: true, fullName: true, referredByAgentId: true },
})
if (!customer) {
throw new Error(
mobileOverride
? `No customer with mobile=${mobileOverride}`
: 'No customers in DB — create/login a customer first',
)
}
const variants = await prisma.productVariant.findMany({
take: 30,
where: { isActive: true },
include: { product: { select: { title: true } } },
})
if (variants.length === 0) {
throw new Error('No product variants in DB — seed catalog first')
}
const branch = await prisma.branch.findFirst({ select: { id: true, name: true } })
console.log(
`Seeding ${PER_STATUS} orders × ${STATUSES.length} statuses for customer ${customer.mobile} (${customer.fullName}), ${variants.length} variants` +
(branch ? `, branch=${branch.name}` : ''),
)
let created = 0
for (const status of STATUSES) {
for (let i = 1; i <= PER_STATUS; i++) {
const variant = variants[i % variants.length]!
const qty = 1 + (i % 3)
const rash = Number(variant.rashWeightGrams ?? 1.25)
const wagePct = Number(variant.defaultWagePercent ?? 7)
const discountPct = 0
const computed = rash * (1 + (wagePct - discountPct) / 100)
const totalGold = computed * qty
const wageWeight = totalGold - rash * qty
const flatRial = i % 4 === 0 ? 150_000 * i : 0
const daysAgo = (STATUSES.indexOf(status) * PER_STATUS + i) % 40
const createdAt = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000 - i * 60_000)
const deliveredAt =
status === OrderStatus.DELIVERED_TO_CUSTOMER
? new Date(createdAt.getTime() + 2 * 24 * 60 * 60 * 1000)
: null
// 1–2 line items so list/detail both look realistic
const second = variants[(i + 3) % variants.length]!
const useSecond = i % 3 === 0
const order = await prisma.order.create({
data: {
orderNumber: orderNumber(status, i),
customerId: customer.id,
branchId: branch?.id ?? null,
channel: i % 5 === 0 ? OrderChannel.WHOLESALE : OrderChannel.RETAIL,
status,
totalGoldWeightGrams: d(
(useSecond
? totalGold + Number(second.rashWeightGrams) * (1 + Number(second.defaultWagePercent) / 100)
: totalGold
).toFixed(4),
),
wageWeightGrams: d(Math.max(0, wageWeight).toFixed(4)),
flatRialCharge: d(flatRial),
isSuspendedOverCreditLimit: status === OrderStatus.SUSPENDED,
deliveredAt,
salesAgentId: customer.referredByAgentId ?? null,
createdAt,
updatedAt: createdAt,
items: {
create: [
{
productVariantId: variant.id,
quantity: qty,
rashWeightGramsSnapshot: d(rash.toFixed(4)),
wagePercentSnapshot: d(wagePct.toFixed(3)),
discountPercentSnapshot: d(discountPct.toFixed(3)),
computedWeightGrams: d(computed.toFixed(4)),
flatRialCharge: d(flatRial),
},
...(useSecond
? [
{
productVariantId: second.id,
quantity: 1,
rashWeightGramsSnapshot: d(Number(second.rashWeightGrams).toFixed(4)),
wagePercentSnapshot: d(Number(second.defaultWagePercent).toFixed(3)),
discountPercentSnapshot: d(0),
computedWeightGrams: d(
(
Number(second.rashWeightGrams) *
(1 + Number(second.defaultWagePercent) / 100)
).toFixed(4),
),
flatRialCharge: d(0),
},
]
: []),
],
},
statusEvents: {
create: [
{
fromStatus: null,
toStatus: OrderStatus.PLACED,
note: 'mock seed: placed',
createdAt,
},
...(status !== OrderStatus.PLACED
? [
{
fromStatus: OrderStatus.PLACED,
toStatus: status,
note: `mock seed: set to ${status}`,
createdAt: new Date(createdAt.getTime() + 60_000),
},
]
: []),
],
},
},
})
created++
if (i === 1) {
console.log(` ${status}: first=${order.orderNumber}`)
}
}
console.log(` ${status}: +${PER_STATUS}`)
}
const counts = await prisma.order.groupBy({
by: ['status'],
_count: { _all: true },
where: { orderNumber: { startsWith: TAG } },
})
console.log(`Done. Created ${created} mock orders (tag ${TAG}).`)
console.log(
'Mock counts:',
Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
)
console.log(`Login as mobile ${customer.mobile} to see them in the app.`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+1 -1
View File
@@ -8,7 +8,7 @@ import { envOr } from './env';
* docs/BRANDING.md for the full list.
*/
export function brandName(): string {
return envOr('BRAND_NAME', 'زرتاج');
return envOr('BRAND_NAME', 'تهران تاج');
}
export function brandSiteUrl(): string {
@@ -367,21 +367,26 @@ export class AccountingSyncService {
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
});
const stale = !c?.accountingBalanceAt || Date.now() - c.accountingBalanceAt.getTime() > BALANCE_FRESH_MS;
if (stale) {
try {
const [b] = await a.provider.getBalances(
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
[link.externalCode],
// Already have a stored figure: hand it back immediately and refresh in the
// background. Waiting on Tahesab/Zarrin here blocked GET /customer/me for up
// to BALANCE_ON_DEMAND_TIMEOUT_MS on almost every dashboard open (freshness
// is only a few seconds), so the app painted the guest invite while the
// token was still valid.
if (c?.accountingBalanceAt && stale) {
void this.refreshStoredBalance(a, customerId, link.externalCode).catch((err) => {
this.logger.warn(
`Background balance refresh for ${customerId} failed: ${(err as Error).message}`,
);
if (b) {
c = await this.prisma.customer.update({
where: { id: customerId },
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: new Date() },
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
});
}
});
} else if (stale) {
// First read — nothing to show yet, so wait (with the short timeout).
try {
c = (await this.refreshStoredBalance(a, customerId, link.externalCode)) ?? c;
} catch (err) {
this.logger.warn(`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
this.logger.warn(
`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`,
);
}
}
if (!c?.accountingBalanceAt) return null;
@@ -393,6 +398,32 @@ export class AccountingSyncService {
};
}
/** Pull one linked customer's balance from the provider and persist it. */
private async refreshStoredBalance(
a: ActiveProvider,
customerId: string,
externalCode: string,
) {
const [b] = await a.provider.getBalances(
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
[externalCode],
);
if (!b) return null;
return this.prisma.customer.update({
where: { id: customerId },
data: {
accountingGoldBalance: b.goldGrams,
accountingRialBalance: b.rial,
accountingBalanceAt: new Date(),
},
select: {
accountingGoldBalance: true,
accountingRialBalance: true,
accountingBalanceAt: true,
},
});
}
// ════════════════════════════════════════════════════════════════════
// Real-time: change poll + live reads
@@ -133,6 +133,12 @@ describe('CatalogService — paging and filtering', () => {
});
});
it('accepts several brands as a comma-separated brandId', async () => {
const { service, findMany } = setup();
await service.findProducts({ page: 1, brandId: 'b1,b2' });
expect(lastWhere(findMany).brandId).toEqual({ in: ['b1', 'b2'] });
});
it('matches a gram range against the variants, as the app did', async () => {
// A product qualifies when ANY of its variants is in range
// (SearchView.vue:189-195), not when all of them are.
@@ -143,7 +143,17 @@ export class CatalogService {
where.title = { contains: query.search.trim(), mode: 'insensitive' };
}
if (query.categoryId) where.categoryId = query.categoryId;
if (query.brandId) where.brandId = query.brandId;
// Apps' filter sheet allows several brands at once. Accept a single id or
// a comma-separated list (`?brandId=a,b`) so Search does not have to fake
// multi-brand by loading the whole catalogue.
if (query.brandId) {
const brandIds = query.brandId
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (brandIds.length === 1) where.brandId = brandIds[0];
else if (brandIds.length > 1) where.brandId = { in: brandIds };
}
if (query.collectionId) where.collections = { some: { collectionId: query.collectionId } };
// A product is in range when at least ONE of its variants is, which is what
@@ -97,8 +97,19 @@ export class CustomerAuthController {
@CustomerAuth()
@Get('me/orders')
myOrders(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.getOrders(me.id);
myOrders(
@CurrentCustomer() me: AuthenticatedCustomer,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('status') status?: string,
@Query('q') q?: string,
) {
return this.customers.getOrders(me.id, {
page: Number(page) || 1,
limit: limit !== undefined && limit !== '' ? Number(limit) : undefined,
status,
q,
});
}
@CustomerAuth()
@@ -1,8 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
/** Default page size for GET /customer/me/orders (mobile infinite scroll). */
const ORDERS_PAGE_LIMIT = 20;
const ORDERS_PAGE_LIMIT_MAX = 50;
const ORDER_STATUSES = new Set<string>(Object.values(OrderStatus));
export interface GetOrdersQuery {
page?: number;
limit?: number;
/** One OrderStatus value, or omit / ALL for every status. */
status?: string;
/** Substring match on `orderNumber`. */
q?: string;
}
// passwordHash must never leave the API — every customer read goes through this.
const PUBLIC_CUSTOMER_SELECT = {
id: true,
@@ -36,6 +51,8 @@ const PUBLIC_CUSTOMER_SELECT = {
*/
@Injectable()
export class CustomersService {
private readonly logger = new Logger(CustomersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly accounting: AccountingSyncService,
@@ -51,52 +68,105 @@ export class CustomersService {
// D27 — with the accounting integration on, the customer sees the balance
// the accounting software holds. The apps only display these two fields;
// the credit-limit check runs server-side on the internal ledger, untouched.
const accounting = await this.accounting.customerBalance(customerId);
if (accounting) {
return {
...customer,
goldDebtGrams: accounting.gold,
fiatDebtRial: accounting.rial,
balanceSource: 'accounting' as const,
balanceAt: accounting.at,
};
//
// Accounting must never take down /customer/me: the dashboard treats a
// failed me() as "no customer" and shows the guest invite while the token
// (and logout) still work — orders keep loading because they skip this path.
try {
const accounting = await this.accounting.customerBalance(customerId);
if (accounting) {
return {
...customer,
goldDebtGrams: accounting.gold,
fiatDebtRial: accounting.rial,
balanceSource: 'accounting' as const,
balanceAt: accounting.at,
};
}
} catch (err) {
this.logger.warn(
`Balance overlay for ${customerId} failed; serving app ledger: ${(err as Error).message}`,
);
}
return { ...customer, balanceSource: 'app' as const, balanceAt: null };
}
getOrders(customerId: string) {
return this.prisma.order.findMany({
where: { customerId },
include: {
items: {
// Each item carried only `productVariantId`, so the apps looked the
// title up by loading the ENTIRE catalogue and joining in the browser
// (composables/useOrderItemProduct.ts). With ~16k products that is
// the wrong trade for a few row labels, and it also failed for an
// order whose product has since left the storefront — the catalogue
// hides it, so the row rendered blank.
//
// A nested read here is one join and always answers, including for
// products that are no longer on sale.
include: {
productVariant: {
select: {
id: true,
title: true,
product: {
select: {
id: true,
title: true,
images: { orderBy: { sortOrder: 'asc' }, take: 1, select: { url: true } },
},
/**
* Paginated order history for the signed-in customer.
*
* Shape matches the report endpoints (`result` / `count` / `page` / `limit`)
* so the apps can infinite-scroll the same way. Status and search are applied
* server-side — client-side filtering after a page slice would miss matches
* that sit on later pages.
*/
async getOrders(customerId: string, query: GetOrdersQuery = {}) {
const page = Math.max(1, Math.floor(query.page ?? 1) || 1);
const limit = Math.min(
ORDERS_PAGE_LIMIT_MAX,
Math.max(1, Math.floor(query.limit ?? ORDERS_PAGE_LIMIT) || ORDERS_PAGE_LIMIT),
);
const where: Prisma.OrderWhereInput = { customerId };
if (query.status && query.status !== 'ALL' && ORDER_STATUSES.has(query.status)) {
where.status = query.status as OrderStatus;
}
const q = query.q?.trim();
if (q) {
where.orderNumber = { contains: q, mode: 'insensitive' };
}
const include = {
items: {
// Each item carried only `productVariantId`, so the apps looked the
// title up by loading the ENTIRE catalogue and joining in the browser
// (composables/useOrderItemProduct.ts). With ~16k products that is
// the wrong trade for a few row labels, and it also failed for an
// order whose product has since left the storefront — the catalogue
// hides it, so the row rendered blank.
//
// A nested read here is one join and always answers, including for
// products that are no longer on sale.
include: {
productVariant: {
select: {
id: true,
title: true,
product: {
select: {
id: true,
title: true,
images: { orderBy: { sortOrder: 'asc' as const }, take: 1, select: { url: true } },
},
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
// Chronological status changes for the order-detail timeline in the app.
statusEvents: {
orderBy: { createdAt: 'asc' as const },
select: {
id: true,
fromStatus: true,
toStatus: true,
note: true,
createdAt: true,
},
},
};
const [result, count] = await Promise.all([
this.prisma.order.findMany({
where,
include,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.order.count({ where }),
]);
return { result, count, page, limit };
}
getLedgerEntries(customerId: string) {
@@ -0,0 +1,71 @@
import { NotFoundException } from '@nestjs/common';
import { CustomersService } from './customers.service';
describe('CustomersService.getOrThrow', () => {
const base = {
id: 'c1',
fullName: 'Honar',
mobile: '09382507041',
nationalCode: null,
cityId: null,
referredByAgentId: null,
goldCreditLimitGrams: '100',
goldDebtGrams: '1.5',
fiatDebtRial: '2000',
verificationStep: 'VERIFIED',
nationalIdImageUrl: null,
businessLicenseUrl: null,
shopImageUrl: null,
isActive: true,
isArchived: false,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
it('falls back to the app ledger when accounting throws', async () => {
const prisma = {
customer: { findUnique: jest.fn().mockResolvedValue(base) },
};
const accounting = {
customerBalance: jest.fn().mockRejectedValue(new Error('tahesab down')),
};
const service = new CustomersService(prisma as never, accounting as never);
await expect(service.getOrThrow('c1')).resolves.toMatchObject({
id: 'c1',
goldDebtGrams: '1.5',
fiatDebtRial: '2000',
balanceSource: 'app',
balanceAt: null,
});
});
it('overlays the accounting balance when available', async () => {
const prisma = {
customer: { findUnique: jest.fn().mockResolvedValue(base) },
};
const accounting = {
customerBalance: jest.fn().mockResolvedValue({
gold: '3.25',
rial: '9000',
at: new Date('2026-10-17'),
provider: 'ته‌حساب',
}),
};
const service = new CustomersService(prisma as never, accounting as never);
await expect(service.getOrThrow('c1')).resolves.toMatchObject({
goldDebtGrams: '3.25',
fiatDebtRial: '9000',
balanceSource: 'accounting',
});
});
it('404s when the customer is missing', async () => {
const prisma = {
customer: { findUnique: jest.fn().mockResolvedValue(null) },
};
const service = new CustomersService(prisma as never, { customerBalance: jest.fn() } as never);
await expect(service.getOrThrow('missing')).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -16,10 +16,11 @@ import { CustomersService } from './customers.service';
describe('CustomersService.getOrders — items carry their product', () => {
function setup() {
const findMany = jest.fn().mockResolvedValue([]);
const prisma = { order: { findMany } };
const count = jest.fn().mockResolvedValue(0);
const prisma = { order: { findMany, count } };
// getOrders touches neither the accounting sync nor anything on it.
const service = new CustomersService(prisma as never, {} as never);
return { service, findMany };
return { service, findMany, count };
}
it('includes the variant, its product and the first image', async () => {
@@ -39,6 +40,22 @@ describe('CustomersService.getOrders — items carry their product', () => {
});
});
it('includes status events oldest-first for the detail timeline', async () => {
const { service, findMany } = setup();
await service.getOrders('c1');
expect(findMany.mock.calls[0][0].include.statusEvents).toMatchObject({
orderBy: { createdAt: 'asc' },
select: {
id: true,
fromStatus: true,
toStatus: true,
note: true,
createdAt: true,
},
});
});
it('is scoped to the caller and newest first', async () => {
// Guards the two properties that were already here: one customer must never
// receive another's orders, and the list is reverse-chronological.
@@ -59,4 +76,34 @@ describe('CustomersService.getOrders — items carry their product', () => {
const product = findMany.mock.calls[0][0].include.items.include.productVariant.select.product;
expect(product.where).toBeUndefined();
});
it('pages with skip/take and returns count', async () => {
const { service, findMany, count } = setup();
findMany.mockResolvedValue([{ id: 'o1' }]);
count.mockResolvedValue(41);
await expect(service.getOrders('c1', { page: 2, limit: 20 })).resolves.toEqual({
result: [{ id: 'o1' }],
count: 41,
page: 2,
limit: 20,
});
expect(findMany.mock.calls[0][0]).toMatchObject({ skip: 20, take: 20 });
});
it('filters by status and orderNumber on the server', async () => {
const { service, findMany } = setup();
await service.getOrders('c1', { status: 'PLACED', q: '12' });
expect(findMany.mock.calls[0][0].where).toEqual({
customerId: 'c1',
status: 'PLACED',
orderNumber: { contains: '12', mode: 'insensitive' },
});
});
it('ignores an unknown status instead of throwing', async () => {
const { service, findMany } = setup();
await service.getOrders('c1', { status: 'NOT_A_STATUS' });
expect(findMany.mock.calls[0][0].where).toEqual({ customerId: 'c1' });
});
});
+2 -2
View File
@@ -24,7 +24,7 @@ describe('IamService', () => {
IAM_ISSUER: 'https://auth.test.local',
IAM_CLIENT_ID: 'goldbank_api',
IAM_CLIENT_SECRET: 'server-secret',
IAM_TENANT_SLUG: 'zartaj',
IAM_TENANT_SLUG: 'tehrantaj',
IAM_LOGIN_DELEGATION: 'fallback',
...overrides,
};
@@ -110,7 +110,7 @@ describe('IamService', () => {
expect(body.identifier).toBe('09120000000');
expect(body.client_id).toBe('goldbank_api');
expect(body.client_secret).toBe('server-secret');
expect(body.tenant_slug).toBe('zartaj');
expect(body.tenant_slug).toBe('tehrantaj');
// Without this the IAM would rate-limit on this server's IP and lock
// out every admin after a handful of attempts.
expect(body.client_ip).toBe('203.0.113.7');
@@ -47,7 +47,11 @@ export class CreateOrderDto {
/**
* Which sales channel placed the order. Legacy had no such field because the
* two channels lived in separate tables (orders vs orders_orders); the one
* Order model carries the distinction here. Defaults to RETAIL.
* Order model carries the distinction here.
*
* On the customer (app) path the server forces WHOLESALE and ignores this
* field. Operators may set it; admin MakeOrder always sends WHOLESALE.
* Default when an operator omits it remains RETAIL (retail list builder).
*/
@IsOptional()
@IsEnum(OrderChannel)
@@ -5,6 +5,9 @@ import { OrdersService } from './orders.service';
/** Order SMS is fire-and-forget (D20); these suites only assert order data. */
const smsStub = { send: jest.fn(async () => ({ ok: true })) };
/** Stands in for Postgres `nextval('order_number_seq')` inside the create tx. */
const nextOrderNumberRaw = () => jest.fn(async () => [{ n: 100n }]);
/**
* Owner ruling 2026-10-08: an order is credited to the customer's expert AT
* THE MOMENT IT IS PLACED (legacy `orders.reagent_id`), and a later change of
@@ -20,6 +23,7 @@ describe('OrdersService.createOrder — sales expert credit', () => {
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
@@ -48,6 +52,12 @@ describe('OrdersService.createOrder — sales expert credit', () => {
expect(created[0].salesAgentId).toBe('agent-A');
});
it('assigns a short numeric orderNumber from the sequence', async () => {
const { service, created } = build(null);
await service.createOrder(dto, 'c1');
expect(created[0].orderNumber).toBe('100');
});
it('records no expert for a customer without one', async () => {
const { service, created } = build(null);
await service.createOrder(dto, 'c1');
@@ -63,7 +73,10 @@ const wageStub = {
/** MON-05 (owner ruling 2026-10-08): «عدم سفارش» stops app orders only. */
describe('OrdersService.createOrder — order ban', () => {
function build(canPlaceOrders: boolean) {
const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) } };
const tx = {
order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) },
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
@@ -98,7 +111,15 @@ describe('OrdersService.createOrder — order ban', () => {
describe('OrdersService.createOrder — server-side making charge', () => {
function build() {
const created: Record<string, any>[] = [];
const tx = { order: { create: jest.fn(async ({ data }: { data: Record<string, any> }) => { created.push(data); return { id: 'o1', ...data }; }) } };
const tx = {
order: {
create: jest.fn(async ({ data }: { data: Record<string, any> }) => {
created.push(data);
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000) })) },
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
@@ -200,6 +221,75 @@ describe('OrdersService.createOrder — server-side making charge', () => {
});
});
/** App orders are B2B wholesale; RETAIL is only for the admin retail list. */
describe('OrdersService.createOrder — channel', () => {
function build() {
const created: Record<string, any>[] = [];
const tx = {
order: {
create: jest.fn(async ({ data }: { data: Record<string, any> }) => {
created.push(data);
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
id: 'c1',
canPlaceOrders: true,
referredByAgentId: null,
goldDebtGrams: new Prisma.Decimal(0),
goldCreditLimitGrams: new Prisma.Decimal(1000),
})),
},
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const service = new OrdersService(
prisma as never,
catalog as never,
{} as never,
{ orderStatusReached: jest.fn() } as never,
wageStub as never,
smsStub as never,
);
return { service, created };
}
const items = [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }];
it('stores an app order as WHOLESALE even when the body omits channel', async () => {
const { service, created } = build();
await service.createOrder({ items } as never, 'c1', { placedBy: 'customer' });
expect(created[0].channel).toBe('WHOLESALE');
});
it('ignores a RETAIL channel sent by the app', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
const { service, created } = build();
await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'customer' });
expect(created[0].channel).toBe('WHOLESALE');
expect(warn.mock.calls.map((c) => String(c[0])).join('\n')).toContain('forcing WHOLESALE');
} finally {
warn.mockRestore();
}
});
it('keeps an operator-supplied RETAIL channel', async () => {
const { service, created } = build();
await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'operator' });
expect(created[0].channel).toBe('RETAIL');
});
it('defaults an operator order without channel to RETAIL', async () => {
const { service, created } = build();
await service.createOrder({ items } as never, 'c1', { placedBy: 'operator' });
expect(created[0].channel).toBe('RETAIL');
});
});
/**
* Legacy ApiMobileController::setOrder:733-766 texted three audiences on every
* placed order. None of it was wired in the target until now.
@@ -207,7 +297,10 @@ describe('OrdersService.createOrder — server-side making charge', () => {
describe('OrdersService.createOrder — order SMS', () => {
function build(opts: { overLimit?: boolean; agentId?: string | null; agentMobile?: string | null } = {}) {
const sent: { to: string; body: string }[] = [];
const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) } };
const tx = {
order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) },
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
@@ -241,7 +334,7 @@ describe('OrdersService.createOrder — order SMS', () => {
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toCustomer = sent.find((s) => s.to === '09120000001');
expect(toCustomer?.body).toContain('به ثبت رسید. زرتاج');
expect(toCustomer?.body).toContain('به ثبت رسید. تهران تاج');
expect(toCustomer?.body).not.toContain('معلق');
});
+34 -5
View File
@@ -135,13 +135,29 @@ export class OrdersService {
.gt(customer.goldCreditLimitGrams);
const initialStatus = isSuspendedOverCreditLimit ? OrderStatus.SUSPENDED : OrderStatus.PLACED;
// App (B2B) orders are always WHOLESALE. Legacy wrote them to `orders`,
// not `orders_orders` (RETAIL). The DTO default was RETAIL and the apps
// never sent `channel`, so every app order landed on the retail list.
// Operators still pick the channel (admin MakeOrder forces WHOLESALE;
// the retail list builder can pass RETAIL). A client-supplied channel on
// the customer path is ignored — same posture as MON-08 pricing.
const channel =
opts.placedBy === 'customer'
? OrderChannel.WHOLESALE
: (dto.channel ?? OrderChannel.RETAIL);
if (opts.placedBy === 'customer' && dto.channel && dto.channel !== OrderChannel.WHOLESALE) {
this.logger.warn(
`Order body carried channel=${dto.channel} from the app; forcing WHOLESALE.`,
);
}
const placed = await this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: {
orderNumber: this.generateOrderNumber(),
orderNumber: await this.nextOrderNumber(tx),
customerId,
branchId: dto.branchId,
channel: dto.channel ?? OrderChannel.RETAIL,
channel,
status: initialStatus,
totalGoldWeightGrams,
wageWeightGrams,
@@ -360,8 +376,21 @@ export class OrdersService {
return order;
}
private generateOrderNumber(): string {
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `ORD-${Date.now()}-${random}`;
/**
* Short global sequential number for the customer, SMS and search
* (`100`, `101`, …). Allocated from Postgres `order_number_seq` inside the
* placement transaction so concurrent creates cannot collide. Legacy used
* the auto-increment order id the same way; the old `ORD-{ts}-{rand}` form
* was too long for SMS and the list UI.
*/
private async nextOrderNumber(tx: Prisma.TransactionClient): Promise<string> {
const rows = await tx.$queryRaw<Array<{ n: bigint | number | string }>>`
SELECT nextval('order_number_seq') AS n
`;
const n = rows[0]?.n;
if (n === undefined || n === null) {
throw new Error('order_number_seq returned no value');
}
return String(n);
}
}
+3 -3
View File
@@ -310,18 +310,18 @@ describe('SmsService.send', () => {
/**
* The OTP text keeps the reference's shape (ApiMobileController.php:47) with
* the brand زرتاج — the first line and the trailing token are the Android SMS
* the brand تهران تاج — the first line and the trailing token are the Android SMS
* Retriever hash, and autofill breaks if either changes.
*/
describe('OTP message template', () => {
const build = (code: string) =>
`<#> code is ${code}\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج ${code} می باشد\n` +
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن تهران تاج ${code} می باشد\n` +
`aMo6iFyUQaY`;
it('matches the expected text byte for byte', () => {
expect(build('1234')).toBe(
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج 1234 می باشد\naMo6iFyUQaY',
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن تهران تاج 1234 می باشد\naMo6iFyUQaY',
);
});