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