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
+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) {