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