feat(orders): send the order-placement SMS and close Phase 1

The reference texted three audiences on every placed order
(henza_server Api/ApiMobileController::setOrder:733-766). None of it was
wired here — OrdersService had no SmsService at all, so a customer could
place an order and nobody was told: not the customer, not the admins, not
the referring sales expert.

Now, after the transaction commits (so a rolled-back order texts nobody)
and without ever being awaited (D20 — a dead gateway must not fail an
order):
  - every mobile in ORDER_ADMIN_MOBILES, with the customer's name and
    the rounded gold weight;
  - the customer, with distinct text for a normal and an over-limit
    suspended order;
  - the referring sales expert, looked up on AdminUser (the target's
    referring expert is an admin, not a customer as in the reference).

The reference's four hardcoded personal numbers stay out of git, behind
the same env-var pattern as PROFILE_ADMIN_MOBILES.

The «در سیستم ویرایش شد» variant was deliberately not ported: the target
has no order-edit route, so it would be dead code — «عمداً تکرار نشد».

Also fills §2 of the admin matrix, the D1 deliverable that had been
(pending) since the phase opened: 163 rid-guarded routes, 68 distinct
rids, 176 grantable tree keys, and a per-module map to the reference
controllers. Only two enforced rids are ungrantable from the permission
screen (newsletters, persons.list.sellers.view) — filed as PERM-02,
awaiting an owner ruling because it changes the permission vocabulary.

Phase 1 (admin parity) is at its gate. api: 283/283 tests, 26 suites.

پنل: پیامک ثبت سفارش برای مشتری، ادمین‌ها و کارشناس فروش فعال شد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-12 10:24:46 +03:30
co-authored by Claude Opus 5
parent 759625d2a1
commit 83e5d2868b
8 changed files with 351 additions and 19 deletions
@@ -1,6 +1,9 @@
import { Prisma } from '@prisma/client';
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 })) };
/**
* 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
@@ -26,12 +29,13 @@ describe('OrdersService.createOrder — sales expert credit', () => {
goldCreditLimitGrams: new Prisma.Decimal(1000),
})),
},
adminUser: { findUnique: jest.fn(async () => null) },
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const accounting = { orderStatusReached: jest.fn() };
const wage = { quote: jest.fn() };
const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never);
const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never, smsStub as never);
return { service, created };
}
@@ -72,7 +76,7 @@ describe('OrdersService.createOrder — order ban', () => {
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never);
return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never);
}
const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never;
@@ -99,7 +103,7 @@ describe('OrdersService.createOrder — server-side making charge', () => {
$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);
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 };
}
// A tampered request: zero making charge, a 50% discount and a rial charge.
@@ -119,3 +123,91 @@ describe('OrdersService.createOrder — server-side making charge', () => {
expect(created[0].flatRialCharge.toString()).toBe('999');
});
});
/**
* Legacy ApiMobileController::setOrder:733-766 texted three audiences on every
* placed order. None of it was wired in the target until now.
*/
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 prisma = {
customer: {
findUnique: jest.fn(async () => ({
id: 'c1',
mobile: '09120000001',
firstName: 'علی',
lastName: 'رضایی',
canPlaceOrders: true,
referredByAgentId: opts.agentId ?? null,
goldDebtGrams: new Prisma.Decimal(0),
goldCreditLimitGrams: new Prisma.Decimal(opts.overLimit ? 1 : 1000),
})),
},
adminUser: { findUnique: jest.fn(async () => ({ mobile: opts.agentMobile ?? null })) },
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const sms = { send: jest.fn(async (to: string, body: string) => { sent.push({ to, body }); return { ok: true }; }) };
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, sms as never);
return { service, sent };
}
const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never;
// SMS is deliberately not awaited (D20), so let the microtasks drain.
const settle = () => new Promise((r) => setImmediate(r));
afterEach(() => { delete process.env.ORDER_ADMIN_MOBILES; });
it('texts the customer that the order was placed', async () => {
const { service, sent } = build();
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toCustomer = sent.find((s) => s.to === '09120000001');
expect(toCustomer?.body).toContain('به ثبت رسید. منتزر');
expect(toCustomer?.body).not.toContain('معلق');
});
it('tells the customer an over-limit order was suspended', async () => {
const { service, sent } = build({ overLimit: true });
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toCustomer = sent.find((s) => s.to === '09120000001');
expect(toCustomer?.body).toContain('عبور از سقف مجاز گرمی در حالت معلق قرار گرفت');
});
it('texts every configured admin with the name and weight', async () => {
process.env.ORDER_ADMIN_MOBILES = '09121111111, 09122222222';
const { service, sent } = build();
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const admins = sent.filter((s) => s.to.startsWith('0912') && s.to !== '09120000001');
expect(admins.map((a) => a.to)).toEqual(['09121111111', '09122222222']);
expect(admins[0].body).toContain('علی رضایی');
expect(admins[0].body).toContain('11');
});
it('texts nobody extra when no admin numbers are configured', async () => {
const { service, sent } = build();
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
expect(sent).toHaveLength(1);
expect(sent[0].to).toBe('09120000001');
});
it("texts the customer's sales expert", async () => {
const { service, sent } = build({ agentId: 'agent-A', agentMobile: '09123333333' });
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toAgent = sent.find((s) => s.to === '09123333333');
expect(toAgent?.body).toContain('سفارشی توسط علی رضایی');
});
it('places the order even when the expert has no mobile', async () => {
const { service, sent } = build({ agentId: 'agent-A', agentMobile: null });
await expect(service.createOrder(dto, 'c1', { placedBy: 'customer' })).resolves.toMatchObject({ id: 'o1' });
await settle();
expect(sent).toHaveLength(1);
});
});