feat: order revision, rial wage from variant, product tags; drop user-app-v2

Admin order edit with category chips and timeline; customer checkout charges variant rialWage; product tags; retire the unused user-app-v2 tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
saeed
2026-10-28 14:29:12 +03:30
co-authored by Cursor
parent 4e09a66fbf
commit feb5727c0e
429 changed files with 3942 additions and 30302 deletions
@@ -1,10 +1,11 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { OrderChannel, OrderRevisionStatus, 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 { OrderRevisionService } from '../orders/order-revision.service';
import { LedgerService } from '../accounting/ledger.service';
import type { UpdateOrderStatusDto } from './update-status.dto';
@@ -36,12 +37,35 @@ const STATUS_FLOW: OrderStatus[] = [
OrderStatus.DELIVERED_TO_CUSTOMER,
];
/** Variant photo preferred, else the product's first gallery image. */
const ORDER_ITEM_INCLUDE = {
productVariant: {
include: {
image: true,
product: {
include: {
images: { orderBy: { sortOrder: 'asc' as const }, take: 1 },
},
},
},
},
} satisfies Prisma.OrderItemInclude;
type OrderWithItems = Prisma.OrderGetPayload<{
include: {
customer: true;
branch: true;
items: { include: typeof ORDER_ITEM_INCLUDE };
};
}>;
@Injectable()
export class AdminOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly orders: OrdersService,
private readonly revisions: OrderRevisionService,
private readonly accounting: AccountingHooksService,
private readonly ledger: LedgerService,
) {}
@@ -93,7 +117,7 @@ export class AdminOrdersService {
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
items: { include: ORDER_ITEM_INCLUDE },
},
}),
this.prisma.order.count({ where: scoped }),
@@ -108,23 +132,23 @@ export class AdminOrdersService {
* 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 } } } };
};
}>) {
private toRow(o: OrderWithItems & { revisionStatus?: OrderRevisionStatus }) {
const awaiting = o.revisionStatus === OrderRevisionStatus.AWAITING_CUSTOMER;
return {
id: o.id,
orderNumber: o.orderNumber,
status: o.status,
sts_str: STATUS_LABELS[o.status],
revisionStatus: o.revisionStatus ?? OrderRevisionStatus.NONE,
// Legacy Omde/Tamin «تایید مشتری» column (1 = ok / 2 = waiting).
confirmed: awaiting ? 2 : 1,
confirmed_str: awaiting ? 'در انتظار تایید' : '—',
caj: o.createdAt,
uaj: o.updatedAt,
total_price: o.flatRialCharge,
totalGoldWeightGrams: o.totalGoldWeightGrams,
wageWeightGrams: o.wageWeightGrams,
flatRialCharge: o.flatRialCharge,
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
deliveredAt: o.deliveredAt,
branch_id: o.branchId,
@@ -140,11 +164,18 @@ export class AdminOrdersService {
},
items: o.items.map((it) => ({
id: it.id,
productVariantId: it.productVariantId,
title: it.productVariant.product.title,
variant_title: it.productVariant.title,
qty: it.quantity,
grams: it.computedWeightGrams,
rash: it.rashWeightGramsSnapshot,
wagePercent: it.wagePercentSnapshot,
discountPercent: it.discountPercentSnapshot,
flatRialCharge: it.flatRialCharge,
// Same preference as the customer app: variant photo, else product cover.
image: it.productVariant.image?.url ?? it.productVariant.product.images[0]?.url ?? null,
note: '',
})),
};
}
@@ -198,7 +229,7 @@ export class AdminOrdersService {
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
items: { include: ORDER_ITEM_INCLUDE },
},
}),
this.prisma.order.count({ where: scoped }),
@@ -242,16 +273,29 @@ export class AdminOrdersService {
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
items: { include: ORDER_ITEM_INCLUDE },
statusEvents: { include: { actorAdmin: true }, orderBy: { createdAt: 'asc' } },
},
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
const reached = new Set(order.statusEvents.map((e) => e.toStatus));
const pending = await this.revisions.pendingForOrder(id);
let pendingRevision = pending ? this.revisions.shapeRevision(pending) : null;
if (pendingRevision && pending?.customerDecision === 'REJECTED') {
// Surface reject for the admin decision UI.
(pendingRevision as { confirmed_str?: string }).confirmed_str = 'رد شده توسط مشتری';
}
return {
...this.toRow(order),
confirmed_str:
order.revisionStatus === OrderRevisionStatus.AWAITING_CUSTOMER
? pending?.customerDecision === 'REJECTED'
? 'رد شده توسط مشتری'
: 'در انتظار تایید'
: '—',
pendingRevision,
// legacy `activity`: one entry per step, `proceed` marking those reached
activity: STATUS_FLOW.map((s) => {
const event = order.statusEvents.find((e) => e.toStatus === s);
@@ -266,6 +310,40 @@ export class AdminOrdersService {
};
}
editableStatuses() {
return this.revisions.editableStatuses();
}
proposeEdit(
body: {
orderId: string;
note?: string;
items: {
productVariantId: string;
quantity: number;
grams?: number;
flatRialCharge?: number;
rashWeightGrams?: number;
wagePercent?: number;
discountPercent?: number;
}[];
},
adminId: string,
) {
if (!body.orderId) throw new BadRequestException('سفارش مشخص نشده است');
return this.revisions.propose(body.orderId, body.items ?? [], adminId, body.note);
}
forceApplyEdit(orderId: string, adminId: string) {
if (!orderId) throw new BadRequestException('سفارش مشخص نشده است');
return this.revisions.forceApply(orderId, adminId);
}
discardEdit(orderId: string, adminId: string) {
if (!orderId) throw new BadRequestException('سفارش مشخص نشده است');
return this.revisions.discard(orderId, adminId);
}
/** Farsi labels for the status dropdowns. */
statusOptions() {
return Object.entries(STATUS_LABELS).map(([value, title]) => ({ value, title }));