The user declined to defer this to Phase 2, correctly: a public POST /orders with customerId in the body let anyone place an order on any customer's account, and an order lands on that customer's gold debt and ledger. POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the customer id as a separate argument that the controller fills from the token; the body's customerId is ignored and a mismatch is logged. The field stays optional in the DTO and in shared-types only so both apps keep validating while they still send it -- they drop it in Phase 2. The admin builder is unaffected: it legitimately places orders for a chosen customer and passes that id explicitly, behind the admin guard. GET /orders/:id is scoped to the owner. A foreign or nonexistent id both return 404, never 403, so ids stay unenumerable. Operators read through the new GET /orders/admin/:id behind rid order.list, declared before the :id route because Nest matches in declaration order. Both apps' CartView now gates on session.isLoggedIn rather than a remembered customerId, which is no longer a credential. OTP audit (requested): no master code, no bypass, no hardcoded code anywhere; one code path issues a token and only after bcrypt.compare. No SMS provider is wired up -- in development the code is logged once, guarded by NODE_ENV !== 'production'. Fixed while auditing (SEC-11): deliver() persisted the real message text, code included, into SmsMessage.body, which the admin SMS grid lists and full-text searches. Any operator with the sms rid could read live codes, defeating the hashing of codeHash. The stored row is now a redacted audit line. Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken from the client on order creation. The reference is worse -- it takes rash_weight from the client too -- so this is already a partial improvement; logged rather than changed, per D4. Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body customerId being ignored in favour of the token. Typecheck clean on api, user-app, user-app-v2 and shared-types. [ref: henza_server ApiMobileController.php:533,536-545,615-632] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { TableService } from '../admin-table/table.service';
|
|
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
|
import { OrdersService } from '../orders/orders.service';
|
|
import type { UpdateOrderStatusDto } from './update-status.dto';
|
|
|
|
/**
|
|
* Farsi status labels, matching legacy's `sts_str` column.
|
|
*
|
|
* Legacy stored status as an integer `sts` and the server derived the string via
|
|
* Order::$SELECT_STS_STR. The new schema uses an enum, so the mapping is explicit.
|
|
* Debt is recognised at sts>4 (DELIVERED_TO_AGENT) — see the schema notes.
|
|
*/
|
|
const STATUS_LABELS: Record<OrderStatus, string> = {
|
|
SUSPENDED: 'معلق (سقف اعتبار)',
|
|
PLACED: 'ثبت شده',
|
|
ADMIN_APPROVED: 'تایید مدیر',
|
|
SOURCING: 'در حال تامین',
|
|
PREPARING: 'در حال آمادهسازی',
|
|
DELIVERED_TO_AGENT: 'تحویل به نماینده',
|
|
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
|
|
CANCELLED: 'لغو شده',
|
|
};
|
|
|
|
/** Order of the status timeline shown in the legacy "وضعیت سفارش" modal. */
|
|
const STATUS_FLOW: OrderStatus[] = [
|
|
OrderStatus.PLACED,
|
|
OrderStatus.ADMIN_APPROVED,
|
|
OrderStatus.SOURCING,
|
|
OrderStatus.PREPARING,
|
|
OrderStatus.DELIVERED_TO_AGENT,
|
|
OrderStatus.DELIVERED_TO_CUSTOMER,
|
|
];
|
|
|
|
@Injectable()
|
|
export class AdminOrdersService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly table: TableService,
|
|
private readonly orders: OrdersService,
|
|
) {}
|
|
|
|
/** Fields the XTable filter bar and sorter may reference. */
|
|
private static readonly ALLOWED = [
|
|
'id',
|
|
'orderNumber',
|
|
'status',
|
|
'createdAt',
|
|
'updatedAt',
|
|
'totalGoldWeightGrams',
|
|
'flatRialCharge',
|
|
'isSuspendedOverCreditLimit',
|
|
'customer.fullName',
|
|
'customer.mobile',
|
|
'branch.name',
|
|
];
|
|
|
|
/**
|
|
* Legacy `POST single-order` — the order list grid.
|
|
*
|
|
* `branchId` narrows the result to one branch, which is how the branch-mode
|
|
* build of the panel scopes its list.
|
|
*/
|
|
async list(query: TableQueryDto, branchId?: string, channel: OrderChannel = OrderChannel.RETAIL) {
|
|
this.table.assertNotExport(query);
|
|
|
|
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
|
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
|
|
allowed: AdminOrdersService.ALLOWED,
|
|
defaultOrderBy: { createdAt: 'desc' },
|
|
});
|
|
|
|
const scoped: Prisma.OrderWhereInput = {
|
|
AND: [
|
|
where as Prisma.OrderWhereInput,
|
|
{ channel },
|
|
...(branchId ? [{ branchId }] : []),
|
|
],
|
|
};
|
|
|
|
const [rows, total] = await Promise.all([
|
|
this.prisma.order.findMany({
|
|
where: scoped,
|
|
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
|
|
skip,
|
|
take,
|
|
include: {
|
|
customer: true,
|
|
branch: true,
|
|
items: { include: { productVariant: { include: { product: true } } } },
|
|
},
|
|
}),
|
|
this.prisma.order.count({ where: scoped }),
|
|
]);
|
|
|
|
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
|
}
|
|
|
|
/**
|
|
* Shapes a row the way the ported list view reads it.
|
|
*
|
|
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`)
|
|
* so the ported template bindings keep working unchanged.
|
|
*/
|
|
private toRow(o: Prisma.OrderGetPayload<{
|
|
include: {
|
|
customer: true;
|
|
branch: true;
|
|
items: { include: { productVariant: { include: { product: true } } } };
|
|
};
|
|
}>) {
|
|
return {
|
|
id: o.id,
|
|
orderNumber: o.orderNumber,
|
|
status: o.status,
|
|
sts_str: STATUS_LABELS[o.status],
|
|
caj: o.createdAt,
|
|
uaj: o.updatedAt,
|
|
total_price: o.flatRialCharge,
|
|
totalGoldWeightGrams: o.totalGoldWeightGrams,
|
|
wageWeightGrams: o.wageWeightGrams,
|
|
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
|
|
deliveredAt: o.deliveredAt,
|
|
branch_id: o.branchId,
|
|
branch: o.branch ? { id: o.branch.id, name: o.branch.name } : null,
|
|
customer: {
|
|
id: o.customer.id,
|
|
// Legacy split names into fname/lname; the new schema has one fullName.
|
|
// Both are exposed so ported templates binding either keep working.
|
|
fname: o.customer.fullName,
|
|
lname: '',
|
|
fullName: o.customer.fullName,
|
|
phone: o.customer.mobile,
|
|
},
|
|
items: o.items.map((it) => ({
|
|
id: it.id,
|
|
title: it.productVariant.product.title,
|
|
variant_title: it.productVariant.title,
|
|
qty: it.quantity,
|
|
grams: it.computedWeightGrams,
|
|
rash: it.rashWeightGramsSnapshot,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Legacy `POST major/order/table` — the wholesale (عمده) order grid.
|
|
*
|
|
* Legacy kept the two sales channels in SEPARATE tables: `orders_orders`
|
|
* (SingleCustomerOrders, served by AdminController::getSingleOrders) and
|
|
* `orders` (Order, served by MajorOrderController::index). Neither controller
|
|
* applied a type filter because the table itself was the discriminator, so the
|
|
* two lists never overlapped.
|
|
*
|
|
* The redesigned schema has one Order model, so `Order.channel` carries that
|
|
* distinction instead and each list filters on it.
|
|
*/
|
|
async listWholesale(query: TableQueryDto, branchId?: string) {
|
|
return this.list(query, branchId, OrderChannel.WHOLESALE);
|
|
}
|
|
|
|
/**
|
|
* Legacy `POST order-tamin` — MajorOrderController::getTaminOrders.
|
|
*
|
|
* Legacy narrowed to orders whose activity contained Order::$STS[3], i.e. those
|
|
* that have reached the sourcing stage. The equivalent here is a status event
|
|
* recording a transition into SOURCING.
|
|
*/
|
|
async listTamin(query: TableQueryDto, branchId?: string) {
|
|
this.table.assertNotExport(query);
|
|
|
|
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
|
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
|
|
allowed: AdminOrdersService.ALLOWED,
|
|
defaultOrderBy: { createdAt: 'desc' },
|
|
});
|
|
|
|
const conditions: Prisma.OrderWhereInput[] = [
|
|
where as Prisma.OrderWhereInput,
|
|
{ statusEvents: { some: { toStatus: OrderStatus.SOURCING } } },
|
|
];
|
|
if (branchId) conditions.push({ branchId });
|
|
|
|
const scoped: Prisma.OrderWhereInput = { AND: conditions };
|
|
|
|
const [rows, total] = await Promise.all([
|
|
this.prisma.order.findMany({
|
|
where: scoped,
|
|
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
|
|
skip,
|
|
take,
|
|
include: {
|
|
customer: true,
|
|
branch: true,
|
|
items: { include: { productVariant: { include: { product: true } } } },
|
|
},
|
|
}),
|
|
this.prisma.order.count({ where: scoped }),
|
|
]);
|
|
|
|
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
|
}
|
|
|
|
/** Legacy `POST delete-order` — remove a wholesale order. */
|
|
async remove(id: string) {
|
|
const order = await this.prisma.order.findUnique({
|
|
where: { id },
|
|
include: { ledgerEntries: true },
|
|
});
|
|
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
|
|
|
// Refuses once debt has been posted: deleting the order would orphan the
|
|
// ledger entries and leave the customer permanently in debt. Legacy had no
|
|
// such guard. Recorded in docs/ADMIN-PORT-PLAN.md.
|
|
if (order.ledgerEntries.length > 0) {
|
|
throw new BadRequestException(
|
|
'حذف این سفارش ممکن نیست، چون برای آن سند حسابداری ثبت شده است.',
|
|
);
|
|
}
|
|
|
|
await this.prisma.$transaction([
|
|
this.prisma.orderStatusEvent.deleteMany({ where: { orderId: id } }),
|
|
this.prisma.orderItem.deleteMany({ where: { orderId: id } }),
|
|
this.prisma.order.delete({ where: { id } }),
|
|
]);
|
|
return { success: true };
|
|
}
|
|
|
|
/** Legacy `GET order/:id` detail, including the status timeline. */
|
|
async findOne(id: string) {
|
|
const order = await this.prisma.order.findUnique({
|
|
where: { id },
|
|
include: {
|
|
customer: true,
|
|
branch: true,
|
|
items: { include: { productVariant: { include: { product: true } } } },
|
|
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } },
|
|
},
|
|
});
|
|
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
|
|
|
const reached = new Set(order.statusEvents.map((e) => e.toStatus));
|
|
|
|
return {
|
|
...this.toRow(order),
|
|
// legacy `activity`: one entry per step, `proceed` marking those reached
|
|
activity: STATUS_FLOW.map((s) => {
|
|
const event = order.statusEvents.find((e) => e.toStatus === s);
|
|
return {
|
|
status: s,
|
|
title: STATUS_LABELS[s],
|
|
proceed: reached.has(s),
|
|
uaj: event?.createdAt ?? null,
|
|
note: event?.note ?? '',
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** Farsi labels for the status dropdowns. */
|
|
statusOptions() {
|
|
return Object.entries(STATUS_LABELS).map(([value, title]) => ({ value, title }));
|
|
}
|
|
|
|
/**
|
|
* Legacy `GET order/type` — the "نوع فروش" dropdown.
|
|
* Legacy Order::$TYPES = [1 => 'ثبت سفارش توسط مسئول فروش', 2 => 'ثبت مشتری'].
|
|
*/
|
|
typeOptions() {
|
|
return { 1: 'ثبت سفارش توسط مسئول فروش', 2: 'ثبت مشتری' };
|
|
}
|
|
|
|
/**
|
|
* Legacy `POST /order` from views/order/create.vue — the admin order builder.
|
|
*
|
|
* Delegates to OrdersService.createOrder, which owns the weight formula
|
|
* w = rash * (1 + (wagePercent - discountPercent)/100)
|
|
* and the credit-limit gate that suspends rather than rejects an over-limit
|
|
* order. Reimplementing either here would let the two paths drift.
|
|
*
|
|
* Legacy also posted total_price / total_weight / gold_price_original computed
|
|
* in the BROWSER. Those are ignored: the server recomputes them from the
|
|
* variant snapshots, so a tampered or stale client total cannot alter a debt.
|
|
*/
|
|
async createFromAdmin(
|
|
payload: {
|
|
customer_id: string;
|
|
order_items: {
|
|
vid: string;
|
|
qty: number;
|
|
percent?: number;
|
|
discount?: number;
|
|
rial_input?: number;
|
|
}[];
|
|
sts?: number | string;
|
|
note?: string;
|
|
},
|
|
actorAdminUserId: string,
|
|
) {
|
|
if (!payload.customer_id) throw new BadRequestException('مشتری انتخاب نشده است');
|
|
if (!Array.isArray(payload.order_items) || payload.order_items.length === 0) {
|
|
throw new BadRequestException('سبد سفارش خالی است');
|
|
}
|
|
|
|
const items = await Promise.all(
|
|
payload.order_items.map(async (it) => {
|
|
const variant = await this.prisma.productVariant.findUnique({ where: { id: it.vid } });
|
|
if (!variant) throw new NotFoundException(`تنوع محصول یافت نشد: ${it.vid}`);
|
|
return {
|
|
productVariantId: it.vid,
|
|
quantity: Number(it.qty) || 1,
|
|
// Fall back to the variant's own default wage when the builder did not
|
|
// negotiate a different one.
|
|
wagePercent: Number(it.percent ?? variant.defaultWagePercent),
|
|
discountPercent: Number(it.discount ?? 0),
|
|
flatRialCharge: Number(it.rial_input ?? 0),
|
|
};
|
|
}),
|
|
);
|
|
|
|
// The customer id is now a separate argument rather than a body field, so
|
|
// the customer-facing route can take it from the caller's token (SEC-02).
|
|
// This path is different: an operator legitimately places an order FOR a
|
|
// customer, so it passes the one the builder selected. The route itself is
|
|
// admin-guarded, which is what makes that safe.
|
|
const order = await this.orders.createOrder(
|
|
{
|
|
items,
|
|
// The admin builder serves the omde (major/*) flow, so orders it creates
|
|
// belong to the wholesale channel — legacy wrote them to the `orders`
|
|
// table, which MajorOrderController::index listed.
|
|
channel: OrderChannel.WHOLESALE,
|
|
},
|
|
payload.customer_id,
|
|
);
|
|
|
|
// legacy `sts`: advance the new order to the chosen delivery status.
|
|
const target = this.statusFromLegacyCode(payload.sts);
|
|
if (target && target !== OrderStatus.PLACED) {
|
|
await this.orders.updateStatus(order.id, target, actorAdminUserId, payload.note);
|
|
}
|
|
|
|
return this.findOne(order.id);
|
|
}
|
|
|
|
/**
|
|
* Legacy delivery-status codes from create.vue's `sts` map:
|
|
* 1 ثبت سفارش | 2 تایید مدیر سیستم | 3 در حال تامین
|
|
* 4 در حال آماده سازی | 5 ارسال | 6 تحویل مشتری
|
|
*/
|
|
private statusFromLegacyCode(code?: number | string): OrderStatus | undefined {
|
|
const map: Record<number, OrderStatus> = {
|
|
1: OrderStatus.PLACED,
|
|
2: OrderStatus.ADMIN_APPROVED,
|
|
3: OrderStatus.SOURCING,
|
|
4: OrderStatus.PREPARING,
|
|
5: OrderStatus.DELIVERED_TO_AGENT,
|
|
6: OrderStatus.DELIVERED_TO_CUSTOMER,
|
|
};
|
|
return code === undefined ? undefined : map[Number(code)];
|
|
}
|
|
|
|
/**
|
|
* Legacy `POST single/order-sts/edit` — advance or remove a status.
|
|
*
|
|
* Setting a status delegates to OrdersService.updateStatus rather than writing
|
|
* the row here, because that method also posts the gold debt to the ledger at
|
|
* DELIVERED_TO_AGENT. Duplicating the update would silently skip that.
|
|
*/
|
|
async setStatus(dto: UpdateOrderStatusDto, actorAdminUserId: string) {
|
|
if (dto.remove) {
|
|
return this.removeStatusEvent(dto.orderId, dto.status);
|
|
}
|
|
return this.orders.updateStatus(dto.orderId, dto.status, actorAdminUserId, dto.note);
|
|
}
|
|
|
|
/**
|
|
* Legacy's `remove: true` branch: drop a recorded status event.
|
|
*
|
|
* Deliberately refuses to remove DELIVERED_TO_AGENT. In legacy, deleting that
|
|
* row left the posted debt ledger entries behind, so the customer stayed in
|
|
* debt for an order no longer marked delivered. Rather than reproduce a
|
|
* data-corrupting path, this rejects and explains — recorded in
|
|
* docs/ADMIN-PORT-PLAN.md.
|
|
*/
|
|
private async removeStatusEvent(orderId: string, status: OrderStatus) {
|
|
if (status === OrderStatus.DELIVERED_TO_AGENT) {
|
|
throw new BadRequestException(
|
|
'حذف وضعیت «تحویل به نماینده» ممکن نیست، چون بدهی طلا در همین مرحله ثبت شده است.',
|
|
);
|
|
}
|
|
|
|
const event = await this.prisma.orderStatusEvent.findFirst({
|
|
where: { orderId, toStatus: status },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
if (!event) throw new NotFoundException('وضعیت مورد نظر یافت نشد');
|
|
|
|
await this.prisma.orderStatusEvent.delete({ where: { id: event.id } });
|
|
|
|
// Roll the order back to whatever status is now the latest recorded one.
|
|
const latest = await this.prisma.orderStatusEvent.findFirst({
|
|
where: { orderId },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
return this.prisma.order.update({
|
|
where: { id: orderId },
|
|
data: { status: latest?.toStatus ?? OrderStatus.PLACED },
|
|
});
|
|
}
|
|
}
|