Files
hedena_gold/apps/api/src/modules/orders/orders.service.ts
T
s.esmaeiliandClaude Sonnet 5.5 06c8a5f434 feat(auth): split panel admins out of AdminUser into their own Admin table
AdminUser held both panel staff and sales experts, so one mobile could not be
both. Staff now live in "Admin" (panel only); sales experts stay in "AdminUser"
untouched. Uniqueness is per table.

- migration 20261026120000_split_admin_table: additive only. Creates "Admin",
  copies non-expert rows with their ids, re-points the "who did this" foreign
  keys (ledger, order status, sms, notification, upload, allocation, workshop,
  admin session) to "Admin" without moving data. Never writes to "AdminUser".
- login, JWT, SSO, branch/workshop supervisors and the users grid use "Admin";
  SALES_EXPERT create/edit still goes through admin/users into "AdminUser".
- an admin cannot be turned into an expert or the reverse (different tables).
- manual/: optional phase-2 script to drop the dormant admin copies from
  "AdminUser", and a read-only expert-login diagnostic.
- docs/ADMIN-TABLE-SPLIT.md: what changed, deploy checklist, rollback.

Tested on a copy with 12,000 experts: expert rows byte-identical (md5) before
and after; full migration chain applies and matches schema.prisma.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:52:33 +03:30

397 lines
17 KiB
TypeScript

import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CatalogService } from '../catalog/catalog.service';
import { LedgerService } from '../accounting/ledger.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { CreateOrderDto, CreateOrderItemInputDto } from './dto/create-order.dto';
import { WageService } from '../pricing/wage.service';
import { SmsService } from '../sms/sms.service';
import { brandName } from '../../common/brand';
interface ComputedItem {
productVariantId: string;
quantity: number;
rashWeightGramsSnapshot: Prisma.Decimal;
wagePercentSnapshot: Prisma.Decimal;
discountPercentSnapshot: Prisma.Decimal;
computedWeightGrams: Prisma.Decimal;
flatRialCharge: Prisma.Decimal;
}
@Injectable()
export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly catalog: CatalogService,
private readonly ledger: LedgerService,
private readonly accounting: AccountingHooksService,
private readonly wage: WageService,
private readonly sms: SmsService,
) {}
/**
* Places a stock order. Per line item:
* w = rashWeightGrams * (1 + (wagePercent - discountPercent) / 100)
* mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here —
* only while the order is delivered (see updateStatus / syncOrderDebt).
*/
/**
* @param customerId taken from the caller's token by the controller, NEVER
* from the request body. The body's `customerId` is ignored; see the note
* on CreateOrderDto.
*/
async createOrder(
dto: CreateOrderDto,
customerId: string,
opts: {
placedBy: 'customer' | 'operator';
/**
* Set when a sales expert placed this through «ورود به حساب مشتری». The
* order is the customer's in every other respect; this only records who
* actually pressed the button, which the reference lost entirely by
* lending out the customer's own token.
*/
actingAsExpertId?: string;
} = { placedBy: 'operator' },
) {
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
// MON-05 (owner ruling 2026-10-08): «معلق کردن سفارش» stops the customer
// ordering from the app. Legacy stored the switch (customers.sts) but no
// code read it, so it stopped nothing. An operator placing an order FOR
// the customer from the panel is not blocked — that is how a suspended
// account is still served.
if (opts.placedBy === 'customer' && !customer.canPlaceOrders) {
throw new ForbiddenException('ثبت سفارش برای حساب شما فعلاً غیرفعال است. لطفاً با پشتیبانی تماس بگیرید.');
}
// The apps still send customerId in the body (removed from them in Phase 2).
// It is not used for anything; a mismatch means either a stale client or an
// attempt to order on someone else's account, and both are worth seeing.
if (dto.customerId && dto.customerId !== customerId) {
this.logger.warn(
`Order body carried customerId=${dto.customerId} but the token is ${customerId}; ` +
`the token wins.`,
);
}
// MON-08 (owner ruling 2026-10-08): an app order is priced by the server's
// making-charge formula (pricing/wage-formula.ts — legacy's by default), the
// same figure the app showed. Whatever the app sends for wage, discount or
// rial charge is ignored, so a tampered request cannot set its own price.
// An operator ordering from the panel keeps the numbers they entered, as
// legacy's order builder did.
const quotes =
opts.placedBy === 'customer'
? await this.wage.quote(customerId, dto.items.map((i) => i.productVariantId))
: null;
const computedItems: ComputedItem[] = await Promise.all(
dto.items.map(async (item) => {
const variant = await this.catalog.getVariantOrThrow(item.productVariantId);
const rashWeight = new Prisma.Decimal(variant.rashWeightGrams);
const quote = quotes?.get(variant.id);
if (quotes && !quote) throw new NotFoundException(`Variant ${variant.id} not found`);
const wagePercent = new Prisma.Decimal(quote ? quote.wagePercentSnapshot : item.wagePercent);
const discountPercent = new Prisma.Decimal(quote ? quote.discountPercentSnapshot : (item.discountPercent ?? 0));
if (quote) this.warnOnPriceMismatch(variant.id, item, wagePercent, discountPercent);
const netPercent = wagePercent.sub(discountPercent);
const computedWeightGrams = rashWeight.mul(netPercent.div(100).add(1));
return {
productVariantId: variant.id,
quantity: item.quantity,
rashWeightGramsSnapshot: rashWeight,
wagePercentSnapshot: wagePercent,
discountPercentSnapshot: discountPercent,
computedWeightGrams,
// Legacy app: rial_pay is always 0 (cart.vue); only an operator sets one.
flatRialCharge: new Prisma.Decimal(quotes ? 0 : (item.flatRialCharge ?? 0)),
};
}),
);
const totalGoldWeightGrams = computedItems.reduce(
(sum, item) => sum.add(item.computedWeightGrams.mul(item.quantity)),
new Prisma.Decimal(0),
);
const totalRashWeightGrams = computedItems.reduce(
(sum, item) => sum.add(item.rashWeightGramsSnapshot.mul(item.quantity)),
new Prisma.Decimal(0),
);
const wageWeightGrams = totalGoldWeightGrams.sub(totalRashWeightGrams);
const flatRialCharge = computedItems.reduce(
(sum, item) => sum.add(item.flatRialCharge.mul(item.quantity)),
new Prisma.Decimal(0),
);
// legacy: inValidOrder = (allGram + customer.remain) > customer.gram_level
const isSuspendedOverCreditLimit = totalGoldWeightGrams
.add(customer.goldDebtGrams)
.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: await this.nextOrderNumber(tx),
customerId,
branchId: dto.branchId,
channel,
status: initialStatus,
totalGoldWeightGrams,
wageWeightGrams,
flatRialCharge,
isSuspendedOverCreditLimit,
// Legacy `'reagent_id' => auth()->user()->reagent_id`
// (ApiMobileController.php:601): the customer's expert at THIS
// moment, fixed on the order so a later change of expert never moves
// past sales (owner ruling 2026-10-08).
salesAgentId: customer.referredByAgentId,
items: {
create: computedItems.map((item) => ({
productVariantId: item.productVariantId,
quantity: item.quantity,
rashWeightGramsSnapshot: item.rashWeightGramsSnapshot,
wagePercentSnapshot: item.wagePercentSnapshot,
discountPercentSnapshot: item.discountPercentSnapshot,
computedWeightGrams: item.computedWeightGrams,
flatRialCharge: item.flatRialCharge,
})),
},
statusEvents: {
create: { toStatus: initialStatus, note: isSuspendedOverCreditLimit ? 'Credit limit exceeded' : undefined },
},
},
include: { items: true, statusEvents: true },
});
// D27 — the document rule may say «at order placement».
await this.accounting.orderStatusReached(tx, order.id, initialStatus);
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.
// The audit trail for «ورود به حساب مشتری». Deliberately logged after the
// commit, so it only ever claims an order that exists.
if (opts.actingAsExpertId) {
this.logger.log(
`Order ${placed.orderNumber} for customer ${customerId} was placed by ` +
`sales expert ${opts.actingAsExpertId} acting as them.`,
);
}
this.notifyOrderPlaced(placed.orderNumber, customer, totalGoldWeightGrams, isSuspendedOverCreditLimit);
return placed;
}
/**
* MON-08 step 2: the server's own figures are what the order is charged, but
* a client that sent something else is worth seeing. An ordinary app sends
* exactly what the server derives (DECISIONS §"Report — MON-08"), so a
* mismatch means a stale app version, a promotion that expired between the
* product page and checkout, or a tampered request.
*
* `flatRialCharge` is included because the app hardcodes 0 (cart.vue:179) and
* only an operator ever sets one — anything non-zero from a customer is a
* client that should not be sending it.
*/
private warnOnPriceMismatch(
variantId: string,
sent: CreateOrderItemInputDto,
wagePercent: Prisma.Decimal,
discountPercent: Prisma.Decimal,
) {
const differs: string[] = [];
if (!wagePercent.eq(sent.wagePercent)) {
differs.push(`wagePercent sent=${sent.wagePercent} used=${wagePercent.toString()}`);
}
if (!discountPercent.eq(sent.discountPercent ?? 0)) {
differs.push(`discountPercent sent=${sent.discountPercent ?? 0} used=${discountPercent.toString()}`);
}
if ((sent.flatRialCharge ?? 0) !== 0) {
differs.push(`flatRialCharge sent=${sent.flatRialCharge} used=0`);
}
if (differs.length) {
this.logger.warn(
`Order pricing for variant ${variantId}: the server's figures are used, ` +
`the client's are ignored — ${differs.join('; ')}`,
);
}
}
/**
* 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 read from BRAND_NAME.
*
* 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 = `${brandName()}\nسفارشی توسط ${name}\n به وزن ${weight}\nدر سیستم ثبت شد.`;
for (const admin of this.adminMobiles()) fire(admin, adminMessage);
fire(
customer.mobile,
isSuspendedOverCreditLimit
? `سفارش شما به شماره ${orderNumber} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. ${brandName()}`
: `سفارش شما به شماره ${orderNumber} به ثبت رسید. ${brandName()}`,
);
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,
`${brandName()}\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) {
return this.prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new NotFoundException(`Order ${orderId} not found`);
const updated = await tx.order.update({
where: { id: orderId },
data: {
status: newStatus,
deliveredAt: newStatus === OrderStatus.DELIVERED_TO_CUSTOMER ? new Date() : order.deliveredAt,
},
});
await tx.orderStatusEvent.create({
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminId: actorAdminUserId },
});
// Debt follows the status, as legacy's recomputed `sts > 4` did: owed
// while delivered (to the expert or the customer), not otherwise, never
// twice. See LedgerService.syncOrderDebt (ACC-03 / MON-04).
await this.ledger.syncOrderDebt(orderId, tx);
// D27 — the accounting document (پیش‌فاکتور) follows the owner's rule on
// which status posts it, queued in this same transaction.
if (newStatus === OrderStatus.CANCELLED && order.status !== OrderStatus.CANCELLED) {
await this.accounting.orderCancelled(tx, orderId);
} else if (newStatus !== order.status) {
await this.accounting.orderStatusReached(tx, orderId, newStatus);
}
return updated;
});
}
/** Unscoped read. Only reachable from the admin-guarded route. */
getById(orderId: string) {
return this.prisma.order.findUnique({
where: { id: orderId },
include: { items: true, statusEvents: true, ledgerEntries: true },
});
}
/**
* A customer reading one of their own orders.
*
* Someone else's order is reported as 404, not 403: a 403 would confirm the
* id exists, which is the same enumeration leak the id-addressed customer
* routes had.
*/
async getOwnedById(orderId: string, customerId: string) {
const order = await this.prisma.order.findFirst({
where: { id: orderId, customerId },
include: { items: true, statusEvents: true, ledgerEntries: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
return order;
}
/**
* 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);
}
}