Files
tehran_taj/apps/api/src/modules/admin-orders/admin-orders.service.ts
T
s.esmaeiliandClaude Sonnet 5.5 06c8a5f434 feat(auth): split panel admins out of AdminUser into their own Admin table
AdminUser held both panel staff and sales experts, so one mobile could not be
both. Staff now live in "Admin" (panel only); sales experts stay in "AdminUser"
untouched. Uniqueness is per table.

- migration 20261026120000_split_admin_table: additive only. Creates "Admin",
  copies non-expert rows with their ids, re-points the "who did this" foreign
  keys (ledger, order status, sms, notification, upload, allocation, workshop,
  admin session) to "Admin" without moving data. Never writes to "AdminUser".
- login, JWT, SSO, branch/workshop supervisors and the users grid use "Admin";
  SALES_EXPERT create/edit still goes through admin/users into "AdminUser".
- an admin cannot be turned into an expert or the reverse (different tables).
- manual/: optional phase-2 script to drop the dormant admin copies from
  "AdminUser", and a read-only expert-login diagnostic.
- docs/ADMIN-TABLE-SPLIT.md: what changed, deploy checklist, rollback.

Tested on a copy with 12,000 experts: expert rows byte-identical (md5) before
and after; full migration chain applies and matches schema.prisma.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:52:33 +03:30

422 lines
16 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 { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { OrdersService } from '../orders/orders.service';
import { LedgerService } from '../accounting/ledger.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,
private readonly accounting: AccountingHooksService,
private readonly ledger: LedgerService,
) {}
/** 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.firstName ?? o.customer.fullName,
lname: o.customer.lastName ?? '',
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(async (tx) => {
// D27 — a temporary document may already be in the books (the rule can
// post it at placement); withdraw it like a cancellation.
await this.accounting.orderCancelled(tx, id);
await tx.orderStatusEvent.deleteMany({ where: { orderId: id } });
await tx.orderItem.deleteMany({ where: { orderId: id } });
await tx.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: { actorAdmin: 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 keeps the order's debt in line with its status
* (syncOrderDebt). 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.
*
* Any status may be removed, as in legacy (MajorOrderController.php:180-203),
* which stepped `sts` back and then RECOMPUTED the debt (updateRemain). The
* rollback below does the same through syncOrderDebt, so taking an order
* back below «تحویل به نماینده» takes its debt off, and nothing is left
* behind. (This used to be refused, on the belief that legacy left the debt
* in place; legacy recomputed it.)
*/
private async removeStatusEvent(orderId: string, status: OrderStatus) {
const event = await this.prisma.orderStatusEvent.findFirst({
where: { orderId, toStatus: status },
orderBy: { createdAt: 'desc' },
});
if (!event) throw new NotFoundException('وضعیت مورد نظر یافت نشد');
// One transaction: the event, the status and the debt move together or not at all.
return this.prisma.$transaction(async (tx) => {
await tx.orderStatusEvent.delete({ where: { id: event.id } });
// Roll the order back to whatever status is now the latest recorded one.
const latest = await tx.orderStatusEvent.findFirst({
where: { orderId },
orderBy: { createdAt: 'desc' },
});
const rolledBackTo = latest?.toStatus ?? OrderStatus.PLACED;
const updated = await tx.order.update({ where: { id: orderId }, data: { status: rolledBackTo } });
// Legacy updateRemain after the step back: the debt follows the status.
await this.ledger.syncOrderDebt(orderId, tx);
// D27 — rolled back below the document rule: the پیش‌فاکتور is withdrawn.
await this.accounting.orderStatusReached(tx, orderId, rolledBackTo);
return updated;
});
}
}