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:
@@ -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('معلق');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user