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
+2 -1
View File
@@ -4,9 +4,10 @@ import { OrdersController } from './orders.controller';
import { CatalogModule } from '../catalog/catalog.module';
import { AccountingModule } from '../accounting/accounting.module';
import { PricingModule } from '../pricing/pricing.module';
import { SmsModule } from '../sms/sms.module';
@Module({
imports: [CatalogModule, AccountingModule, PricingModule],
imports: [CatalogModule, AccountingModule, PricingModule, SmsModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
@@ -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);
});
});
+84 -1
View File
@@ -6,6 +6,7 @@ import { LedgerService } from '../accounting/ledger.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { WageService } from '../pricing/wage.service';
import { SmsService } from '../sms/sms.service';
interface ComputedItem {
productVariantId: string;
@@ -27,6 +28,7 @@ export class OrdersService {
private readonly ledger: LedgerService,
private readonly accounting: AccountingHooksService,
private readonly wage: WageService,
private readonly sms: SmsService,
) {}
/**
@@ -122,7 +124,7 @@ export class OrdersService {
.gt(customer.goldCreditLimitGrams);
const initialStatus = isSuspendedOverCreditLimit ? OrderStatus.SUSPENDED : OrderStatus.PLACED;
return this.prisma.$transaction(async (tx) => {
const placed = await this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: {
orderNumber: this.generateOrderNumber(),
@@ -162,6 +164,87 @@ export class OrdersService {
return order;
});
// Legacy sent these after the order was committed
// (ApiMobileController.php:733-766), so a rolled-back order never texts
// anyone. Fire-and-forget per D20.
this.notifyOrderPlaced(placed.orderNumber, customer, totalGoldWeightGrams, isSuspendedOverCreditLimit);
return placed;
}
/**
* The admins who are told a new order was placed.
*
* Legacy hardcoded four numbers (ApiMobileController.php:740-743); this reads
* a comma-separated env var, as the complete-profile notifier already does.
* Unset means nobody is notified and the order itself still goes through.
*/
private adminMobiles(): string[] {
return (process.env.ORDER_ADMIN_MOBILES ?? '')
.split(',')
.map((m) => m.trim())
.filter(Boolean);
}
/**
* Legacy ApiMobileController::setOrder:733-766, brand changed to منتزر.
*
* Three audiences: the admins, the customer, and the customer's sales expert.
* Legacy's fourth message — «در سیستم ویرایش شد» (:734-737) — is
* «عمداً تکرار نشد»: no target path lets a customer edit a placed order, so
* that branch could never fire.
*
* `Notify::store` sat beside each of these in legacy (Firebase push); that is
* PUSH-01, deferred to the PWA port.
*/
private notifyOrderPlaced(
orderNumber: string,
customer: { mobile: string; firstName: string | null; lastName: string | null; referredByAgentId: string | null },
totalGoldWeightGrams: Prisma.Decimal,
isSuspendedOverCreditLimit: boolean,
) {
const fire = (to: string, body: string) => {
void this.sms.send(to, body).catch((e: unknown) => {
this.logger.error(`Order SMS to ${to} failed: ${String(e)}`);
});
};
const name = `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim();
// Legacy printed Utility::round($allGram) — two decimals, trailing zeros
// dropped, which is what Decimal's own string form gives.
const weight = totalGoldWeightGrams.toDecimalPlaces(2).toString();
const adminMessage = `منتزر\nسفارشی توسط ${name}\n به وزن ${weight}\nدر سیستم ثبت شد.`;
for (const admin of this.adminMobiles()) fire(admin, adminMessage);
fire(
customer.mobile,
isSuspendedOverCreditLimit
? `سفارش شما به شماره ${orderNumber} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. منتزر`
: `سفارش شما به شماره ${orderNumber} به ثبت رسید. منتزر`,
);
if (customer.referredByAgentId) {
void this.notifySalesAgent(customer.referredByAgentId, name, orderNumber, weight);
}
}
/** Legacy :762-765 — the referring expert is texted too. */
private async notifySalesAgent(agentId: string, customerName: string, orderNumber: string, weight: string) {
try {
const agent = await this.prisma.adminUser.findUnique({
where: { id: agentId },
select: { mobile: true },
});
if (!agent?.mobile) return;
await this.sms.send(
agent.mobile,
`منتزر\nسفارشی توسط ${customerName} به شماره ${orderNumber}و به وزن ${weight} به ثبت رسید.`,
);
} catch (e: unknown) {
this.logger.error(`Order SMS to sales agent ${agentId} failed: ${String(e)}`);
}
}
async updateStatus(orderId: string, newStatus: OrderStatus, actorAdminUserId?: string, note?: string) {