import { 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 { CreateOrderDto } from './dto/create-order.dto'; 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, ) {} /** * Places a stock order. Per line item: * w = rashWeightGrams * (1 + (wagePercent - discountPercent) / 100) * mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here — * only once the order reaches DELIVERED_TO_AGENT (see updateStatus). */ /** * @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) { const customer = await this.prisma.customer.findUnique({ where: { id: customerId } }); if (!customer) throw new NotFoundException(`Customer ${customerId} not found`); // 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.`, ); } 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 wagePercent = new Prisma.Decimal(item.wagePercent); const discountPercent = new Prisma.Decimal(item.discountPercent ?? 0); 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, flatRialCharge: new Prisma.Decimal(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; return this.prisma.$transaction(async (tx) => { const order = await tx.order.create({ data: { orderNumber: this.generateOrderNumber(), customerId, branchId: dto.branchId, channel: dto.channel ?? OrderChannel.RETAIL, status: initialStatus, totalGoldWeightGrams, wageWeightGrams, flatRialCharge, isSuspendedOverCreditLimit, 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 }, }); return order; }); } 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 recognition point — mirrors legacy sts>4, which first becomes true // at sts=5 ("تحویل مسئول فروش" / delivered to agent), not sts=6 (delivered // to customer). Only fires once: a second transition into this status // (e.g. a data-fix re-save) would double-post debt, so guard on the order // not already having deliveredAt. if (newStatus === OrderStatus.DELIVERED_TO_AGENT && !order.deliveredAt) { await this.ledger.postOrderDebt(orderId, tx); } 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; } private generateOrderNumber(): string { const random = Math.random().toString(36).slice(2, 8).toUpperCase(); return `ORD-${Date.now()}-${random}`; } }