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
@@ -107,6 +107,51 @@ export class AdminOrdersController {
return this.orders.setStatus(dto, admin.id);
}
/** Stages where admin may propose an edit — from Setting `order.edit.editableStatuses`. */
@Get('edit/editable-statuses')
@RequirePermission('order.list', 'order.omde', 'order.tamin', 'order.list.view.edit')
editableStatuses() {
return this.orders.editableStatuses();
}
/** Propose a full line-item edit; customer must confirm (SMS sent). */
@Post('edit/propose')
@HttpCode(200)
@RequirePermission('order.list.view.edit', 'order.omde.view.edit', 'order.tamin.view.edit')
proposeEdit(
@Body()
body: {
orderId: string;
note?: string;
items: {
productVariantId: string;
quantity: number;
grams?: number;
flatRialCharge?: number;
rashWeightGrams?: number;
wagePercent?: number;
discountPercent?: number;
}[];
},
@CurrentAdmin() admin: AuthenticatedAdmin,
) {
return this.orders.proposeEdit(body, admin.id);
}
@Post('edit/force-apply')
@HttpCode(200)
@RequirePermission('order.list.view.edit', 'order.omde.view.edit', 'order.tamin.view.edit')
forceApplyEdit(@Body() body: { orderId: string }, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.forceApplyEdit(body.orderId, admin.id);
}
@Post('edit/discard')
@HttpCode(200)
@RequirePermission('order.list.view.edit', 'order.omde.view.edit', 'order.tamin.view.edit')
discardEdit(@Body() body: { orderId: string }, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.discardEdit(body.orderId, admin.id);
}
@Get(':id')
@RequirePermission('order.list', 'order.omde', 'order.tamin')
findOne(@Param('id') id: string) {
@@ -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 }));
@@ -91,6 +91,12 @@ export class AdminProductsController {
return this.products.brands();
}
@Get('categories')
@RequirePermission('product.list', 'order.list.view.edit', 'order.omde.view.edit', 'order.tamin.view.edit')
categories() {
return this.products.categoriesForPicker();
}
/** Brands as an XTable grid — the exclusion picker needs a selectable table. */
@Post('brands-table')
@HttpCode(200)
@@ -42,6 +42,7 @@ export class AdminProductsService {
'showType',
'isActive',
'createdAt',
'categoryId',
'brand.title',
'category.title',
// legacy filter keys
@@ -117,6 +118,7 @@ export class AdminProductsService {
show_type_str: SHOW_TYPE_LABELS[p.showType] ?? '-',
is_active: p.isActive,
description: p.description,
tags: p.tags ?? [],
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
// Legacy nested this as category.collection.title in the grid model.
category: p.category
@@ -187,6 +189,7 @@ export class AdminProductsService {
brand_id: p.brandId,
cats: p.categoryId ? [p.categoryId] : [],
ciny_ids: p.collections.map((c) => c.collectionId),
tags: p.tags ?? [],
variants_all: p.variants.map((v) => this.variantRow(v)),
// The 4 image slots the product form edits, split back out by role —
// mirrors the shape AddProduct.vue posts (indicator_id / mobile_pic /
@@ -245,7 +248,7 @@ export class AdminProductsService {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['title', 'sku'],
searchable: ['title', 'sku', 'product.title'],
allowed: ['id', 'title', 'sku', 'qty', 'rashWeightGrams', 'isActive', 'product.title'],
defaultOrderBy: { title: 'asc' },
});
@@ -411,6 +414,17 @@ export class AdminProductsService {
* for the two featured slots and one URL array each for the two galleries;
* this flattens all four into the role-tagged rows ProductImage.create wants.
*/
/** Tags posted by the product form — trimmed unique strings, empty dropped. */
private normalizeTags(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const out: string[] = [];
for (const item of raw) {
const t = String(item ?? '').trim();
if (t && !out.includes(t)) out.push(t);
}
return out;
}
private imageCreateData(data: Record<string, unknown>): { url: string; sortOrder: number; role: Prisma.ProductImageCreateInput['role'] }[] {
const rows: { url: string; sortOrder: number; role: Prisma.ProductImageCreateInput['role'] }[] = [];
@@ -459,6 +473,7 @@ export class AdminProductsService {
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
brandId: (data.brand_id as string) || null,
categoryId: cats[0] ?? null,
tags: this.normalizeTags(data.tags),
collections: cinyIds.length
? { create: cinyIds.map((collectionId) => ({ collectionId })) }
: undefined,
@@ -525,6 +540,9 @@ export class AdminProductsService {
const catId = (data.cats as string[])[0];
patch.category = catId ? { connect: { id: catId } } : { disconnect: true };
}
if (data.tags !== undefined) {
patch.tags = this.normalizeTags(data.tags);
}
await this.prisma.product.update({ where: { id }, data: patch });
@@ -728,6 +746,15 @@ export class AdminProductsService {
return this.prisma.brand.findMany({ orderBy: { title: 'asc' } });
}
/** Categories that currently have at least one active product — order picker chips. */
async categoriesForPicker() {
return this.prisma.category.findMany({
where: { products: { some: { isActive: true } } },
orderBy: { title: 'asc' },
select: { id: true, title: true },
});
}
/** Brands in the XTable envelope, for the promotion-exclusion picker. */
async brandsTable(query: TableQueryDto) {
this.table.assertNotExport(query);
@@ -98,7 +98,12 @@ describe('CatalogService — paging and filtering', () => {
const out = await service.findProducts({ page: 1, limit: 20, search: 'انگشتر' });
expect(out.total).toBe(42);
expect(count).toHaveBeenCalledWith({
where: expect.objectContaining({ title: { contains: 'انگشتر', mode: 'insensitive' } }),
where: expect.objectContaining({
OR: [
{ title: { contains: 'انگشتر', mode: 'insensitive' } },
{ productCode: { contains: 'انگشتر', mode: 'insensitive' } },
],
}),
});
});
@@ -161,6 +166,16 @@ describe('CatalogService — paging and filtering', () => {
const { service, findMany } = setup();
await service.findProducts({ page: 1, search: ' ' });
expect(lastWhere(findMany).title).toBeUndefined();
expect(lastWhere(findMany).OR).toBeUndefined();
});
it('matches productCode as well as title', async () => {
const { service, findMany } = setup();
await service.findProducts({ page: 1, search: 'HG-12' });
expect(lastWhere(findMany).OR).toEqual([
{ title: { contains: 'HG-12', mode: 'insensitive' } },
{ productCode: { contains: 'HG-12', mode: 'insensitive' } },
]);
});
});
@@ -138,9 +138,13 @@ export class CatalogService {
const where: Prisma.ProductWhereInput = { isActive: true, images: { some: {} } };
if (query.search?.trim()) {
// `contains`, as the apps' own title filter did. Case-insensitive is a
// no-op for Persian but matches how every other search in this API reads.
where.title = { contains: query.search.trim(), mode: 'insensitive' };
// Title OR product code — same as the app search box (SearchView).
// Case-insensitive is a no-op for Persian but matches other API searches.
const term = query.search.trim();
where.OR = [
{ title: { contains: term, mode: 'insensitive' } },
{ productCode: { contains: term, mode: 'insensitive' } },
];
}
if (query.categoryId) where.categoryId = query.categoryId;
// Apps' filter sheet allows several brands at once. Accept a single id or
@@ -248,6 +252,9 @@ export class CatalogService {
out.push({
...product,
// Explicit so list cards can show admin-assigned labels even if a
// future select() omits the field from the spread.
tags: product.tags ?? [],
variants: mappedVariants,
});
}
@@ -1,7 +1,8 @@
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
import { Body, Controller, Get, Headers, HttpCode, Ip, Param, Post, Query, Res } from '@nestjs/common';
import type { Response } from 'express';
import { CustomerAuthService } from './customer-auth.service';
import { CustomersService } from '../customers/customers.service';
import { OrderRevisionService } from '../orders/order-revision.service';
import { RequestOtpDto } from './dto/request-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { RegisterDto } from './dto/register.dto';
@@ -27,6 +28,7 @@ export class CustomerAuthController {
constructor(
private readonly auth: CustomerAuthService,
private readonly customers: CustomersService,
private readonly revisions: OrderRevisionService,
) {}
/** Step 1. Always answers `{ sent: true }` — it never reveals whether the number is registered. */
@@ -117,6 +119,58 @@ export class CustomerAuthController {
myLedgerEntries(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.getLedgerEntries(me.id);
}
/** Inbox: admin broadcast / direct messages for this customer. */
@CustomerAuth()
@Get('me/notifications')
myNotifications(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.getNotifications(me.id);
}
@CustomerAuth()
@Get('me/notifications/unread-count')
myUnreadNotificationCount(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.unreadNotificationCount(me.id).then((count) => ({ count }));
}
@CustomerAuth()
@Post('me/notifications/read-all')
@HttpCode(200)
markAllNotificationsRead(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.customers.markAllNotificationsRead(me.id);
}
@CustomerAuth()
@Post('me/notifications/:id/read')
@HttpCode(200)
markNotificationRead(
@CurrentCustomer() me: AuthenticatedCustomer,
@Param('id') id: string,
) {
return this.customers.markNotificationRead(me.id, id);
}
/** Accept an admin-proposed order edit. */
@CustomerAuth()
@Post('me/orders/:id/revision/confirm')
@HttpCode(200)
confirmOrderRevision(
@CurrentCustomer() me: AuthenticatedCustomer,
@Param('id') id: string,
) {
return this.revisions.confirm(id, me.id);
}
/** Reject — stays awaiting until an admin force-applies or discards. */
@CustomerAuth()
@Post('me/orders/:id/revision/reject')
@HttpCode(200)
rejectOrderRevision(
@CurrentCustomer() me: AuthenticatedCustomer,
@Param('id') id: string,
) {
return this.revisions.reject(id, me.id);
}
}
function readCookie(header: string | undefined, name: string): string | null {
@@ -3,6 +3,7 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from '../../prisma/prisma.module';
import { CustomersModule } from '../customers/customers.module';
import { OrdersModule } from '../orders/orders.module';
import { CustomerAuthController } from './customer-auth.controller';
import { CustomerAuthService } from './customer-auth.service';
import { CustomerJwtStrategy } from './customer-jwt.strategy';
@@ -17,7 +18,7 @@ import { CustomerJwtStrategy } from './customer-jwt.strategy';
* No APP_GUARD is registered — customer routes opt in with @CustomerAuth().
*/
@Module({
imports: [PrismaModule, PassportModule, JwtModule.register({}), CustomersModule],
imports: [PrismaModule, PassportModule, JwtModule.register({}), CustomersModule, OrdersModule],
controllers: [CustomerAuthController],
providers: [CustomerAuthService, CustomerJwtStrategy],
exports: [CustomerAuthService],
@@ -1,4 +1,4 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
@@ -131,6 +131,8 @@ export class CustomersService {
select: {
id: true,
title: true,
// Prefer the variant's own gallery pick; fall back to product photo.
image: { select: { url: true } },
product: {
select: {
id: true,
@@ -158,7 +160,14 @@ export class CustomersService {
const [result, count] = await Promise.all([
this.prisma.order.findMany({
where,
include,
include: {
...include,
revisions: {
where: { appliedAt: null, discardedAt: null },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
@@ -166,7 +175,41 @@ export class CustomersService {
this.prisma.order.count({ where }),
]);
return { result, count, page, limit };
return {
result: result.map((row) => {
const { revisions, ...order } = row as typeof row & {
revisions?: Array<{
id: string;
items: unknown;
totalGoldWeightGrams: unknown;
wageWeightGrams: unknown;
flatRialCharge: unknown;
note: string | null;
customerDecision: string | null;
createdAt: Date;
}>;
};
const pending = revisions?.[0];
return {
...order,
pendingRevision: pending
? {
id: pending.id,
items: pending.items,
totalGoldWeightGrams: pending.totalGoldWeightGrams,
wageWeightGrams: pending.wageWeightGrams,
flatRialCharge: pending.flatRialCharge,
note: pending.note,
customerDecision: pending.customerDecision,
createdAt: pending.createdAt,
}
: null,
};
}),
count,
page,
limit,
};
}
getLedgerEntries(customerId: string) {
@@ -175,4 +218,53 @@ export class CustomersService {
orderBy: { createdAt: 'desc' },
});
}
/**
* In-app inbox for this customer: rows from NotificationRecipient (broadcast
* or single-customer messages created in the admin panel).
*/
async getNotifications(customerId: string) {
const rows = await this.prisma.notificationRecipient.findMany({
where: { customerId },
orderBy: { notification: { createdAt: 'desc' } },
take: 50,
include: {
notification: { select: { id: true, title: true, body: true, createdAt: true } },
},
});
return rows.map((r) => ({
id: r.id,
notificationId: r.notificationId,
title: r.notification.title,
body: r.notification.body,
createdAt: r.notification.createdAt,
readAt: r.readAt,
isRead: Boolean(r.readAt),
}));
}
unreadNotificationCount(customerId: string) {
return this.prisma.notificationRecipient.count({
where: { customerId, readAt: null },
});
}
async markNotificationRead(customerId: string, recipientId: string) {
const row = await this.prisma.notificationRecipient.findUnique({ where: { id: recipientId } });
if (!row) throw new NotFoundException('پیام یافت نشد');
if (row.customerId !== customerId) throw new ForbiddenException();
if (row.readAt) return row;
return this.prisma.notificationRecipient.update({
where: { id: recipientId },
data: { readAt: new Date() },
});
}
async markAllNotificationsRead(customerId: string) {
const result = await this.prisma.notificationRecipient.updateMany({
where: { customerId, readAt: null },
data: { readAt: new Date() },
});
return { updated: result.count };
}
}
@@ -31,9 +31,9 @@ describe('CustomersService.getOrders — items carry their product', () => {
const variant = include.items.include.productVariant;
expect(variant.select.title).toBe(true);
expect(variant.select.image).toEqual({ select: { url: true } });
expect(variant.select.product.select.title).toBe(true);
// images[0] is the product's main photo, so it must be ordered and limited
// to one — the apps read `images[0].url` and nothing else.
// Product cover is the fallback when the variant has no own image.
expect(variant.select.product.select.images).toMatchObject({
orderBy: { sortOrder: 'asc' },
take: 1,
@@ -83,7 +83,7 @@ describe('CustomersService.getOrders — items carry their product', () => {
count.mockResolvedValue(41);
await expect(service.getOrders('c1', { page: 2, limit: 20 })).resolves.toEqual({
result: [{ id: 'o1' }],
result: [{ id: 'o1', pendingRevision: null }],
count: 41,
page: 2,
limit: 20,
+1 -1
View File
@@ -270,7 +270,7 @@ export class IamService {
/**
* Admin return URL only. Must NOT fall back to the customer/app URL: that
* sent admins to user-app-v2 after password login whenever
* sent admins into the customer app after password login whenever
* `IAM_ADMIN_BROWSER_RETURN_URL` was left blank but the customer return was set.
*/
private adminBrowserReturnUrl(): string | null {
@@ -0,0 +1,356 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { OrderRevisionStatus, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { LedgerService } from '../accounting/ledger.service';
import { SmsService } from '../sms/sms.service';
import { brandName } from '../../common/brand';
/** Setting key: JSON array of OrderStatus values that allow proposeEdit. */
export const ORDER_EDIT_STATUSES_KEY = 'order.edit.editableStatuses';
const DEFAULT_EDITABLE: OrderStatus[] = [
OrderStatus.PLACED,
OrderStatus.ADMIN_APPROVED,
OrderStatus.SOURCING,
OrderStatus.PREPARING,
];
export interface ProposedLineInput {
productVariantId: string;
quantity: number;
/** Final line weight (گرم) — admin may override. */
grams?: number;
rashWeightGrams?: number;
wagePercent?: number;
discountPercent?: number;
flatRialCharge?: number;
}
export interface ProposedLineStored {
productVariantId: string;
quantity: number;
rashWeightGramsSnapshot: string;
wagePercentSnapshot: string;
discountPercentSnapshot: string;
computedWeightGrams: string;
flatRialCharge: string;
}
@Injectable()
export class OrderRevisionService {
private readonly logger = new Logger(OrderRevisionService.name);
constructor(
private readonly prisma: PrismaService,
private readonly ledger: LedgerService,
private readonly sms: SmsService,
) {}
async editableStatuses(): Promise<OrderStatus[]> {
const row = await this.prisma.setting.findUnique({ where: { key: ORDER_EDIT_STATUSES_KEY } });
if (!row?.value) {
await this.prisma.setting.upsert({
where: { key: ORDER_EDIT_STATUSES_KEY },
create: { key: ORDER_EDIT_STATUSES_KEY, value: DEFAULT_EDITABLE },
update: {},
});
return [...DEFAULT_EDITABLE];
}
const raw = row.value;
const list = Array.isArray(raw) ? raw : (raw as { statuses?: unknown })?.statuses;
if (!Array.isArray(list)) return [...DEFAULT_EDITABLE];
const allowed = new Set(Object.values(OrderStatus));
const parsed = list
.map((s) => String(s))
.filter((s): s is OrderStatus => allowed.has(s as OrderStatus));
return parsed.length ? parsed : [...DEFAULT_EDITABLE];
}
async pendingForOrder(orderId: string) {
return this.prisma.orderRevision.findFirst({
where: { orderId, appliedAt: null, discardedAt: null },
orderBy: { createdAt: 'desc' },
});
}
/**
* Admin proposes a full replace of line items. Does not mutate Order items
* until the customer confirms (or admin force-applies).
*/
async propose(
orderId: string,
lines: ProposedLineInput[],
adminId: string,
note?: string,
) {
if (!lines?.length) throw new BadRequestException('حداقل یک آیتم برای سفارش لازم است');
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { customer: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
if (order.status === OrderStatus.CANCELLED) {
throw new BadRequestException('سفارش لغو‌شده قابل ویرایش نیست');
}
const editable = await this.editableStatuses();
if (!editable.includes(order.status)) {
throw new BadRequestException('در این مرحله از سفارش امکان ویرایش وجود ندارد');
}
const existingPending = await this.pendingForOrder(orderId);
if (existingPending) {
throw new BadRequestException(
'یک پیشنهاد ویرایش در انتظار تأیید مشتری است. ابتدا آن را اعمال یا لغو کنید.',
);
}
const computed = await this.computeLines(lines);
const totals = this.sumTotals(computed);
const revision = await this.prisma.$transaction(async (tx) => {
const rev = await tx.orderRevision.create({
data: {
orderId,
items: computed as unknown as Prisma.InputJsonValue,
totalGoldWeightGrams: totals.totalGoldWeightGrams,
wageWeightGrams: totals.wageWeightGrams,
flatRialCharge: totals.flatRialCharge,
proposedByAdminId: adminId,
note: note?.trim() || null,
},
});
await tx.order.update({
where: { id: orderId },
data: { revisionStatus: OrderRevisionStatus.AWAITING_CUSTOMER },
});
return rev;
});
this.notifyCustomerEdit(order.orderNumber, order.customer.mobile);
return this.shapeRevision(revision);
}
/** Customer accepts → apply lines to the live order. */
async confirm(orderId: string, customerId: string) {
const order = await this.requireOwnedPending(orderId, customerId);
const pending = await this.pendingForOrder(orderId);
if (!pending) throw new BadRequestException('پیشنهاد ویرایشی در انتظار نیست');
await this.applyRevision(pending.id, { customerDecision: 'ACCEPTED' });
return this.prisma.order.findUnique({
where: { id: order.id },
include: { items: true, statusEvents: true },
});
}
/**
* Customer rejects → stay AWAITING_CUSTOMER until admin force-applies or discards.
*/
async reject(orderId: string, customerId: string) {
const order = await this.requireOwnedPending(orderId, customerId);
const pending = await this.pendingForOrder(orderId);
if (!pending) throw new BadRequestException('پیشنهاد ویرایشی در انتظار نیست');
await this.prisma.orderRevision.update({
where: { id: pending.id },
data: { customerDecision: 'REJECTED', customerDecidedAt: new Date() },
});
return { orderId: order.id, revisionStatus: OrderRevisionStatus.AWAITING_CUSTOMER, decision: 'REJECTED' };
}
/** Admin applies the pending proposal without waiting for the customer. */
async forceApply(orderId: string, _adminId: string) {
const pending = await this.pendingForOrder(orderId);
if (!pending) throw new BadRequestException('پیشنهاد ویرایشی در انتظار نیست');
await this.applyRevision(pending.id, { customerDecision: pending.customerDecision ?? 'ACCEPTED' });
return this.pendingSummary(orderId);
}
/** Admin drops the proposal; live order unchanged. */
async discard(orderId: string, _adminId: string) {
const pending = await this.pendingForOrder(orderId);
if (!pending) throw new BadRequestException('پیشنهاد ویرایشی در انتظار نیست');
await this.prisma.$transaction(async (tx) => {
await tx.orderRevision.update({
where: { id: pending.id },
data: { discardedAt: new Date() },
});
await tx.order.update({
where: { id: orderId },
data: { revisionStatus: OrderRevisionStatus.NONE },
});
});
return { success: true };
}
shapeRevision(r: {
id: string;
orderId: string;
items: Prisma.JsonValue;
totalGoldWeightGrams: Prisma.Decimal;
wageWeightGrams: Prisma.Decimal;
flatRialCharge: Prisma.Decimal;
note: string | null;
customerDecision: string | null;
customerDecidedAt: Date | null;
appliedAt: Date | null;
discardedAt: Date | null;
createdAt: Date;
}) {
return {
id: r.id,
orderId: r.orderId,
items: r.items,
totalGoldWeightGrams: r.totalGoldWeightGrams.toString(),
wageWeightGrams: r.wageWeightGrams.toString(),
flatRialCharge: r.flatRialCharge.toString(),
note: r.note,
customerDecision: r.customerDecision,
customerDecidedAt: r.customerDecidedAt,
appliedAt: r.appliedAt,
discardedAt: r.discardedAt,
createdAt: r.createdAt,
};
}
private async pendingSummary(orderId: string) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { items: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
return order;
}
private async requireOwnedPending(orderId: string, customerId: string) {
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new NotFoundException('سفارش یافت نشد');
if (order.customerId !== customerId) throw new ForbiddenException();
if (order.revisionStatus !== OrderRevisionStatus.AWAITING_CUSTOMER) {
throw new BadRequestException('این سفارش منتظر تأیید ویرایش نیست');
}
return order;
}
private async computeLines(lines: ProposedLineInput[]): Promise<ProposedLineStored[]> {
const out: ProposedLineStored[] = [];
for (const line of lines) {
const qty = Math.floor(Number(line.quantity));
if (!line.productVariantId || !Number.isFinite(qty) || qty < 1) {
throw new BadRequestException('آیتم سفارش نامعتبر است');
}
const variant = await this.prisma.productVariant.findUnique({
where: { id: line.productVariantId },
});
if (!variant) throw new NotFoundException(`تنوع ${line.productVariantId} یافت نشد`);
const rash = new Prisma.Decimal(
line.rashWeightGrams != null ? line.rashWeightGrams : variant.rashWeightGrams,
);
const wage = new Prisma.Decimal(
line.wagePercent != null ? line.wagePercent : variant.defaultWagePercent,
);
const discount = new Prisma.Decimal(line.discountPercent ?? 0);
const computed =
line.grams != null
? new Prisma.Decimal(line.grams)
: rash.mul(wage.sub(discount).div(100).add(1));
const flat = new Prisma.Decimal(line.flatRialCharge ?? variant.rialWage ?? 0);
out.push({
productVariantId: variant.id,
quantity: qty,
rashWeightGramsSnapshot: rash.toString(),
wagePercentSnapshot: wage.toString(),
discountPercentSnapshot: discount.toString(),
computedWeightGrams: computed.toString(),
flatRialCharge: flat.toString(),
});
}
return out;
}
private sumTotals(lines: ProposedLineStored[]) {
let totalGold = new Prisma.Decimal(0);
let totalRash = new Prisma.Decimal(0);
let flat = new Prisma.Decimal(0);
for (const line of lines) {
totalGold = totalGold.add(new Prisma.Decimal(line.computedWeightGrams).mul(line.quantity));
totalRash = totalRash.add(new Prisma.Decimal(line.rashWeightGramsSnapshot).mul(line.quantity));
flat = flat.add(new Prisma.Decimal(line.flatRialCharge).mul(line.quantity));
}
return {
totalGoldWeightGrams: totalGold,
wageWeightGrams: totalGold.sub(totalRash),
flatRialCharge: flat,
};
}
private async applyRevision(
revisionId: string,
opts: { customerDecision: string },
) {
await this.prisma.$transaction(async (tx) => {
const rev = await tx.orderRevision.findUnique({ where: { id: revisionId } });
if (!rev || rev.appliedAt || rev.discardedAt) {
throw new BadRequestException('پیشنهاد ویرایش قابل اعمال نیست');
}
const items = rev.items as unknown as ProposedLineStored[];
if (!Array.isArray(items) || !items.length) {
throw new BadRequestException('آیتم‌های پیشنهاد نامعتبر است');
}
await tx.orderItem.deleteMany({ where: { orderId: rev.orderId } });
await tx.orderItem.createMany({
data: items.map((it) => ({
orderId: rev.orderId,
productVariantId: it.productVariantId,
quantity: it.quantity,
rashWeightGramsSnapshot: new Prisma.Decimal(it.rashWeightGramsSnapshot),
wagePercentSnapshot: new Prisma.Decimal(it.wagePercentSnapshot),
discountPercentSnapshot: new Prisma.Decimal(it.discountPercentSnapshot),
computedWeightGrams: new Prisma.Decimal(it.computedWeightGrams),
flatRialCharge: new Prisma.Decimal(it.flatRialCharge),
})),
});
await tx.order.update({
where: { id: rev.orderId },
data: {
totalGoldWeightGrams: rev.totalGoldWeightGrams,
wageWeightGrams: rev.wageWeightGrams,
flatRialCharge: rev.flatRialCharge,
revisionStatus: OrderRevisionStatus.NONE,
},
});
await tx.orderRevision.update({
where: { id: rev.id },
data: {
appliedAt: new Date(),
customerDecision: opts.customerDecision,
customerDecidedAt: new Date(),
},
});
await this.ledger.syncOrderDebt(rev.orderId, tx);
});
}
private notifyCustomerEdit(orderNumber: string, mobile: string) {
const body = `سفارش شما به شماره ${orderNumber} ویرایش شده و منتظر تأیید شماست. جزئیات را در اپ ببینید. ${brandName()}`;
void this.sms.send(mobile, body).catch((e: unknown) => {
this.logger.error(`Order-edit SMS to ${mobile} failed: ${String(e)}`);
});
}
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { OrderRevisionService } from './order-revision.service';
import { OrdersController } from './orders.controller';
import { CatalogModule } from '../catalog/catalog.module';
import { AccountingModule } from '../accounting/accounting.module';
@@ -9,7 +10,7 @@ import { SmsModule } from '../sms/sms.module';
@Module({
imports: [CatalogModule, AccountingModule, PricingModule, SmsModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
providers: [OrdersService, OrderRevisionService],
exports: [OrdersService, OrderRevisionService],
})
export class OrdersModule {}
@@ -124,7 +124,14 @@ describe('OrdersService.createOrder — server-side making charge', () => {
customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000) })) },
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const catalog = {
getVariantOrThrow: jest.fn(async () => ({
id: 'v1',
rashWeightGrams: '10',
// Variant اجرت ریالی — charged on the customer path, ignored from the body.
rialWage: '500000',
})),
};
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never);
return { service, created };
}
@@ -135,7 +142,7 @@ describe('OrdersService.createOrder — server-side making charge', () => {
const { service, created } = build();
await service.createOrder(tampered, 'c1', { placedBy: 'customer' });
expect(created[0].totalGoldWeightGrams.toString()).toBe('11');
expect(created[0].flatRialCharge.toString()).toBe('0');
expect(created[0].flatRialCharge.toString()).toBe('500000');
});
it('keeps the numbers an operator entered in the panel', async () => {
@@ -149,10 +156,13 @@ describe('OrdersService.createOrder — server-side making charge', () => {
* MON-08 step 3 (owner ruling): «تست مقایسه‌ای که نشان دهد نتیجه برای کاربر
* عادی یکسان است». An unmodified app sends what the server derives
* (DECISIONS §"Report — MON-08"), so deriving server-side must not move the
* total for an ordinary customer.
* total for an ordinary customer. For flat Rial the honest app mirrors the
* variant's rialWage (same figure the cart shows).
*/
it('gives an honest app exactly the total it sent', async () => {
const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10, discountPercent: 0, flatRialCharge: 0 }] } as never;
const honest = {
items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10, discountPercent: 0, flatRialCharge: 500000 }],
} as never;
const a = build();
await a.service.createOrder(honest, 'c1', { placedBy: 'customer' });
const b = build();
@@ -169,7 +179,7 @@ describe('OrdersService.createOrder — server-side making charge', () => {
const lines = warn.mock.calls.map((c) => String(c[0])).join('\n');
expect(lines).toContain('wagePercent sent=0 used=10');
expect(lines).toContain('discountPercent sent=50 used=0');
expect(lines).toContain('flatRialCharge sent=999 used=0');
expect(lines).toContain('flatRialCharge sent=999 used=500000');
} finally {
warn.mockRestore();
}
@@ -211,7 +221,9 @@ describe('OrdersService.createOrder — server-side making charge', () => {
it('stays quiet when the app sent the right figures', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
const honest = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }] } as never;
const honest = {
items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 10, flatRialCharge: 500000 }],
} as never;
await build().service.createOrder(honest, 'c1', { placedBy: 'customer' });
const pricing = warn.mock.calls.filter((c) => String(c[0]).includes('Order pricing'));
expect(pricing).toHaveLength(0);
+13 -8
View File
@@ -98,7 +98,13 @@ export class OrdersService {
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);
// Customer path: flat Rial comes from the variant (اجرت ریالی), never
// from the request body — same MON-08 posture as wage/discount. Operator
// path keeps the panel-entered figure.
const flatRialCharge = quotes
? new Prisma.Decimal(variant.rialWage ?? 0)
: new Prisma.Decimal(item.flatRialCharge ?? 0);
if (quote) this.warnOnPriceMismatch(variant.id, item, wagePercent, discountPercent, flatRialCharge);
const netPercent = wagePercent.sub(discountPercent);
const computedWeightGrams = rashWeight.mul(netPercent.div(100).add(1));
@@ -109,8 +115,7 @@ export class OrdersService {
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)),
flatRialCharge,
};
}),
);
@@ -216,15 +221,15 @@ export class OrdersService {
* 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.
* Flat Rial is the variant's `rialWage` (اجرت ریالی); the client's number is
* ignored and logged when it differs.
*/
private warnOnPriceMismatch(
variantId: string,
sent: CreateOrderItemInputDto,
wagePercent: Prisma.Decimal,
discountPercent: Prisma.Decimal,
flatRialCharge: Prisma.Decimal,
) {
const differs: string[] = [];
if (!wagePercent.eq(sent.wagePercent)) {
@@ -233,8 +238,8 @@ export class OrdersService {
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 (!flatRialCharge.eq(sent.flatRialCharge ?? 0)) {
differs.push(`flatRialCharge sent=${sent.flatRialCharge ?? 0} used=${flatRialCharge.toString()}`);
}
if (differs.length) {
this.logger.warn(
+2 -1
View File
@@ -13,7 +13,8 @@ import { Prisma } from '@prisma/client';
* its category or its brand (getPromotion; customer-type off)
* final % = percent_pay − discount (setOrder :558)
* weight = rash × (1 + final/100) × qty
* rial charge = 0 (cart.vue: rial_pay: 0)
* rial charge = variant.rialWage × qty (اجرت ریالی; charged in OrdersService,
* not by this percent formula)
*
* The admin «اجرت» screen may change the numbers AND the structure: each term
* has a coefficient (0 switches it off), the promotion can be the largest or