2026-09-22 07:09:33 +03:30
|
|
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
2026-09-13 21:02:25 +03:30
|
|
|
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
2026-09-09 06:46:27 +03:30
|
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
|
|
|
import { CatalogService } from '../catalog/catalog.service';
|
|
|
|
|
import { LedgerService } from '../accounting/ledger.service';
|
2026-10-06 16:48:36 +03:30
|
|
|
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
2026-09-09 06:46:27 +03:30
|
|
|
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 {
|
2026-09-22 07:09:33 +03:30
|
|
|
private readonly logger = new Logger(OrdersService.name);
|
|
|
|
|
|
2026-09-09 06:46:27 +03:30
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly catalog: CatalogService,
|
|
|
|
|
private readonly ledger: LedgerService,
|
2026-10-06 16:48:36 +03:30
|
|
|
private readonly accounting: AccountingHooksService,
|
2026-09-09 06:46:27 +03:30
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Places a stock order. Per line item:
|
|
|
|
|
* w = rashWeightGrams * (1 + (wagePercent - discountPercent) / 100)
|
|
|
|
|
* mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here —
|
2026-10-08 10:57:38 +03:30
|
|
|
* only while the order is delivered (see updateStatus / syncOrderDebt).
|
2026-09-09 06:46:27 +03:30
|
|
|
*/
|
2026-09-22 07:09:33 +03:30
|
|
|
/**
|
|
|
|
|
* @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.`,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-09 06:46:27 +03:30
|
|
|
|
|
|
|
|
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(),
|
2026-09-22 07:09:33 +03:30
|
|
|
customerId,
|
2026-09-09 06:46:27 +03:30
|
|
|
branchId: dto.branchId,
|
2026-09-13 21:02:25 +03:30
|
|
|
channel: dto.channel ?? OrderChannel.RETAIL,
|
2026-09-09 06:46:27 +03:30
|
|
|
status: initialStatus,
|
|
|
|
|
totalGoldWeightGrams,
|
|
|
|
|
wageWeightGrams,
|
|
|
|
|
flatRialCharge,
|
|
|
|
|
isSuspendedOverCreditLimit,
|
2026-10-08 10:40:21 +03:30
|
|
|
// 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,
|
2026-09-09 06:46:27 +03:30
|
|
|
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 },
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-06 16:48:36 +03:30
|
|
|
// D27 — the document rule may say «at order placement».
|
|
|
|
|
await this.accounting.orderStatusReached(tx, order.id, initialStatus);
|
|
|
|
|
|
2026-09-09 06:46:27 +03:30
|
|
|
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 },
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-08 10:57:38 +03:30
|
|
|
// 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);
|
2026-09-09 06:46:27 +03:30
|
|
|
|
2026-10-06 16:48:36 +03:30
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 06:46:27 +03:30
|
|
|
return updated;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 07:09:33 +03:30
|
|
|
/** Unscoped read. Only reachable from the admin-guarded route. */
|
2026-09-09 06:46:27 +03:30
|
|
|
getById(orderId: string) {
|
|
|
|
|
return this.prisma.order.findUnique({
|
|
|
|
|
where: { id: orderId },
|
|
|
|
|
include: { items: true, statusEvents: true, ledgerEntries: true },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 07:09:33 +03:30
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 06:46:27 +03:30
|
|
|
private generateOrderNumber(): string {
|
|
|
|
|
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
|
|
|
return `ORD-${Date.now()}-${random}`;
|
|
|
|
|
}
|
|
|
|
|
}
|