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
@@ -0,0 +1,2 @@
-- Product labels from Setting `__list__tags`, assigned per product in admin.
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,29 @@
-- Admin order edit + customer confirm (Order.revisionStatus / OrderRevision).
CREATE TYPE "OrderRevisionStatus" AS ENUM ('NONE', 'AWAITING_CUSTOMER');
ALTER TABLE "Order" ADD COLUMN "revisionStatus" "OrderRevisionStatus" NOT NULL DEFAULT 'NONE';
CREATE TABLE "OrderRevision" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"items" JSONB NOT NULL,
"totalGoldWeightGrams" DECIMAL(14,4) NOT NULL,
"wageWeightGrams" DECIMAL(14,4) NOT NULL,
"flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0,
"proposedByAdminUserId" TEXT,
"note" TEXT,
"customerDecision" TEXT,
"customerDecidedAt" TIMESTAMP(3),
"appliedAt" TIMESTAMP(3),
"discardedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderRevision_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Order_revisionStatus_idx" ON "Order"("revisionStatus");
CREATE INDEX "OrderRevision_orderId_idx" ON "OrderRevision"("orderId");
CREATE INDEX "OrderRevision_createdAt_idx" ON "OrderRevision"("createdAt");
ALTER TABLE "OrderRevision" ADD CONSTRAINT "OrderRevision_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+46
View File
@@ -693,6 +693,9 @@ model Product {
/// Legacy Product.show_type: 1 site, 2 mobile, 3 both.
showType Int @default(3)
isActive Boolean @default(true)
/// Labels from Setting `__list__tags`, assigned on the product form. The
/// storefront shows them on list cards only (not the product detail page).
tags String[] @default([])
createdAt DateTime @default(now())
/// Stock of the linked کارساخته as last read from the accounting software
@@ -800,6 +803,12 @@ model ProductVariant {
// Orders — stock orders (debt-bearing)
// ─────────────────────────────────────────────────────────────────────────
/// Customer-confirm state for an admin-proposed order edit (not a pipeline status).
enum OrderRevisionStatus {
NONE
AWAITING_CUSTOMER
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
@@ -811,6 +820,9 @@ model Order {
channel OrderChannel @default(RETAIL)
status OrderStatus @default(PLACED)
/// Pending admin edit awaiting customer confirm (or admin force/discard).
revisionStatus OrderRevisionStatus @default(NONE)
/// Total gold weight incl. wage across all items ("allGram" / order.w).
totalGoldWeightGrams Decimal @db.Decimal(14, 4)
/// Pure making-charge weight component (totalGoldWeightGrams minus raw weight sum).
@@ -843,14 +855,48 @@ model Order {
items OrderItem[]
statusEvents OrderStatusEvent[]
ledgerEntries LedgerEntry[]
revisions OrderRevision[]
convertedFromPreOrder PreOrder?
@@index([customerId])
@@index([status])
@@index([revisionStatus])
@@index([salesAgentId])
@@index([referrerCustomerId])
}
/// Proposed line-item edit for an Order. Live until applied or discarded.
/// While pending, Order.revisionStatus = AWAITING_CUSTOMER and charged figures
/// stay on the current Order until the customer confirms (or admin force-applies).
model OrderRevision {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
/// Proposed lines: [{ productVariantId, quantity, rashWeightGramsSnapshot,
/// wagePercentSnapshot, discountPercentSnapshot, computedWeightGrams, flatRialCharge }]
items Json
totalGoldWeightGrams Decimal @db.Decimal(14, 4)
wageWeightGrams Decimal @db.Decimal(14, 4)
flatRialCharge Decimal @default(0) @db.Decimal(20, 2)
proposedByAdminId String? @map("proposedByAdminUserId")
note String?
/// null | ACCEPTED | REJECTED — reject leaves AWAITING_CUSTOMER for admin decision.
customerDecision String?
customerDecidedAt DateTime?
appliedAt DateTime?
discardedAt DateTime?
createdAt DateTime @default(now())
@@index([orderId])
@@index([createdAt])
}
model OrderItem {
id String @id @default(cuid())
orderId String
+7 -25
View File
@@ -38,9 +38,6 @@ const chk = (cond, msg) => {
};
const step = (t) => console.log('\n' + t);
/** Differences found between the two apps, reported at the end. */
const divergences = [];
const createdOrders = [];
let token = null;
@@ -92,19 +89,16 @@ function loadCartRules(app) {
}
(async () => {
// ── 0. BOTH APPS' CART RULES ────────────────────────────────────────────
step('0 — CART STOCK RULES, in each app\'s own source');
// ── 0. USER-APP CART RULES ──────────────────────────────────────────────
step('0 — CART STOCK RULES, in user-app source');
const v1 = loadCartRules('user-app');
const v2 = loadCartRules('user-app-v2');
for (const [name, m] of [['user-app', v1], ['user-app-v2', v2]]) {
chk(m.hasCapQty, `${name}: capQty() exists`);
chk(m.hasMaxQty, `${name}: maxQty() exists`);
chk(m.hasAddGuard, `${name}: addToCart refuses an un-orderable variant`);
chk(m.hasQtyClamp, `${name}: updateFeild clamps a qty change`);
}
chk(v1.hasCapQty, 'user-app: capQty() exists');
chk(v1.hasMaxQty, 'user-app: maxQty() exists');
chk(v1.hasAddGuard, 'user-app: addToCart refuses an un-orderable variant');
chk(v1.hasQtyClamp, 'user-app: updateFeild clamps a qty change');
if (v1.capQty && v2.capQty) {
if (v1.capQty) {
const cases = [
{ want: 10, variant: { qty: 3 }, expect: 3, label: 'want 10 of 3 in stock -> 3' },
{ want: 2, variant: { qty: 5 }, expect: 2, label: 'want 2 of 5 in stock -> 2' },
@@ -114,17 +108,10 @@ function loadCartRules(app) {
];
for (const c of cases) {
const a = v1.capQty(c.want, c.variant);
const b = v2.capQty(c.want, c.variant);
chk(a === c.expect, `user-app: ${c.label} (got ${a})`);
chk(b === c.expect, `user-app-v2: ${c.label} (got ${b})`);
if (a !== b) divergences.push(`capQty(${c.want}, ${JSON.stringify(c.variant)}): v1=${a} v2=${b}`);
}
}
const sameStore = v1.src === v2.src;
chk(sameStore, 'the two cart stores are byte-identical');
if (!sameStore) divergences.push('cartStore.ts differs between the apps');
// ── 1. LOGIN WITH THE MASTER CODE ───────────────────────────────────────
step('1 — LOGIN with the delivery master code');
const customer = await prisma.customer.findFirst({
@@ -281,11 +268,6 @@ function loadCartRules(app) {
console.log('\nFailures:');
failures.forEach((f) => console.log(' - ' + f));
}
console.log(
divergences.length
? `\nDIVERGENCES between the apps:\n${divergences.map((d) => ' - ' + d).join('\n')}`
: '\nNo behavioural divergence between user-app and user-app-v2.',
);
console.log('='.repeat(60));
await prisma.$disconnect();
@@ -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