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:
co-authored by
Claude Opus 5
parent
759625d2a1
commit
83e5d2868b
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user