Files
tehran_taj/apps/api/src/modules/orders/orders.service.ts
T
s.esmaeiliandCursor afb068c643 feat: paginate customer orders and polish the dashboard hero
Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-17 13:11:42 +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, 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);
}
}