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>
397 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|