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:
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user