From 29084904e991eae97b45ed5bfeff9bbdca62d5e2 Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Thu, 8 Oct 2026 10:40:21 +0330 Subject: [PATCH] feat(orders): credit each order to the expert who placed it, as legacy did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy fixed `orders.reagent_id` once, when the order was placed, and its «میزان فروش» summed `customer_id = X OR reagent_id = X`. The app credited orders to the customer's CURRENT referrer and left out an expert's own orders, so 9 of 49 imported experts showed a different figure. - Order.salesAgentId (+ referrerCustomerId for 17 legacy orders whose referrer was not an expert); additive migration, recent app orders backfilled from the customer's referrer - createOrder records the expert at placement time - one shared filter (agent-orders.ts) for the experts list, expert detail, dashboard and the expert app; child orders follow salesAgentId - customer page gets its «میزان فروش» back - import carries orders.reagent_id as is; ex-experts kept as admins keep their profile so their own orders stay linked - reconcile-sales: 1,557 of 1,558 match legacy's own formula; balances unchanged Co-Authored-By: Claude Opus 5.5 --- .../src/views/customer/CustomerDetail.vue | 2 + .../migration.sql | 47 ++++++++++++++++ apps/api/prisma/schema.prisma | 23 ++++++++ apps/api/scripts/legacy-import/index.ts | 3 +- .../scripts/legacy-import/steps/commerce.ts | 23 +++++++- .../api/scripts/legacy-import/steps/people.ts | 5 +- .../scripts/legacy-import/steps/reconcile.ts | 56 +++++++++++++++++++ .../admin-customers.service.ts | 27 +++++---- .../admin-dashboard.service.ts | 3 +- .../src/modules/orders/orders.service.spec.ts | 50 +++++++++++++++++ apps/api/src/modules/orders/orders.service.ts | 5 ++ .../src/modules/sales-agent/agent-orders.ts | 29 ++++++++++ .../sales-agent/sales-agent.service.ts | 11 ++-- .../sales-expert-app.service.ts | 9 +-- 14 files changed, 267 insertions(+), 26 deletions(-) create mode 100644 apps/api/prisma/migrations/20261008120000_order_sales_agent/migration.sql create mode 100644 apps/api/src/modules/orders/orders.service.spec.ts create mode 100644 apps/api/src/modules/sales-agent/agent-orders.ts diff --git a/apps/admin-panel/src/views/customer/CustomerDetail.vue b/apps/admin-panel/src/views/customer/CustomerDetail.vue index b9733e7..e0f3b67 100644 --- a/apps/admin-panel/src/views/customer/CustomerDetail.vue +++ b/apps/admin-panel/src/views/customer/CustomerDetail.vue @@ -33,6 +33,8 @@ {{ customer?.rial_remain ?? '-' }} + + {{ dashboard?.grams_sell ?? '-' }}
diff --git a/apps/api/prisma/migrations/20261008120000_order_sales_agent/migration.sql b/apps/api/prisma/migrations/20261008120000_order_sales_agent/migration.sql new file mode 100644 index 0000000..ee34bbc --- /dev/null +++ b/apps/api/prisma/migrations/20261008120000_order_sales_agent/migration.sql @@ -0,0 +1,47 @@ +-- ============================================================================ +-- Order.salesAgentId / Order.referrerCustomerId — additive, idempotent. +-- +-- Owner ruling 2026-10-08: keep legacy's rule exactly. An order is credited to +-- the expert who was the customer's referrer WHEN IT WAS PLACED (legacy +-- `orders.reagent_id`, set once at creation), so a customer moving to another +-- expert later never changes past sales or commission. The app used to credit +-- an order to the customer's CURRENT referrer, which disagreed with legacy for +-- 8 of 49 experts on the imported data. +-- +-- No row is deleted and no existing column changes. +-- ============================================================================ + +ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "salesAgentId" TEXT; +ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "referrerCustomerId" TEXT; + +DO $$ BEGIN + ALTER TABLE "Order" ADD CONSTRAINT "Order_salesAgentId_fkey" + FOREIGN KEY ("salesAgentId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "Order" ADD CONSTRAINT "Order_referrerCustomerId_fkey" + FOREIGN KEY ("referrerCustomerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE INDEX IF NOT EXISTS "Order_salesAgentId_idx" ON "Order"("salesAgentId"); +CREATE INDEX IF NOT EXISTS "Order_referrerCustomerId_idx" ON "Order"("referrerCustomerId"); + +-- Orders placed in the new app before this column existed: the customer's +-- referrer today is the best record there is (these are recent). Orders +-- brought over from legacy (orderNumber 'H-') are NOT guessed here — the +-- import writes their real `orders.reagent_id`. +UPDATE "Order" o + SET "salesAgentId" = c."referredByAgentId" + FROM "Customer" c + WHERE c."id" = o."customerId" + AND o."salesAgentId" IS NULL + AND c."referredByAgentId" IS NOT NULL + AND o."orderNumber" NOT LIKE 'H-%'; + +COMMENT ON COLUMN "Order"."salesAgentId" IS + 'Sales expert credited with this order, fixed when it is placed (legacy orders.reagent_id). Never follows a later change of the customer''s referrer.'; +COMMENT ON COLUMN "Order"."referrerCustomerId" IS + 'History only: legacy orders.reagent_id when it named a customer who is not a sales expert. Never set for new orders.'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3254b59..5bf4387 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -179,6 +179,8 @@ model AdminUser { memberCode String? // legacy mcode referredCustomers Customer[] @relation("ReferralAgent") + /// Orders credited to this sales expert — see Order.salesAgentId. + creditedOrders Order[] @relation("OrderSalesAgent") supervisedWorkshops Workshop[] @relation("WorkshopSupervisor") branchAllocations BranchAllocation[] @relation("AllocationAuthor") postedLedgerEntries LedgerEntry[] @@ -421,6 +423,9 @@ model Customer { /// account (see SalesExpertProfile.tradingCustomerId). Empty for every /// ordinary customer. salesExpertTradingFor SalesExpertProfile? @relation("SalesExpertTradingAccount") + /// Legacy orders that named THIS customer as their referrer — see + /// Order.referrerCustomerId. History only. + referredOrders Order[] @relation("OrderReferrerCustomer") // Credit limit + cached ledger balances (source of truth is LedgerEntry; these // are denormalized for fast reads and reconciled by a scheduled job). @@ -736,6 +741,22 @@ model Order { isSuspendedOverCreditLimit Boolean @default(false) deliveredAt DateTime? // setting this is what triggers debt recognition + /// The sales expert this order is credited to, fixed when it is placed: + /// the customer's referring expert AT THAT MOMENT. Legacy `orders.reagent_id`, + /// set once at creation (ApiMobileController.php:601) and never moved, so a + /// customer changing expert later does not move past sales or commission + /// (owner ruling 2026-10-08). «میزان فروش» counts from here, never from the + /// customer's current referrer. + salesAgentId String? + salesAgent AdminUser? @relation("OrderSalesAgent", fields: [salesAgentId], references: [id], onDelete: SetNull) + + /// History only: a legacy order whose `reagent_id` named a customer who is + /// not a sales expert (one such customer, 17 orders in 2022). Legacy counted + /// those orders in that customer's own «میزان فروش»; this keeps that figure + /// exact. Never set for a new order. + referrerCustomerId String? + referrerCustomer Customer? @relation("OrderReferrerCustomer", fields: [referrerCustomerId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -746,6 +767,8 @@ model Order { @@index([customerId]) @@index([status]) + @@index([salesAgentId]) + @@index([referrerCustomerId]) } model OrderItem { diff --git a/apps/api/scripts/legacy-import/index.ts b/apps/api/scripts/legacy-import/index.ts index 1e89637..cd37215 100644 --- a/apps/api/scripts/legacy-import/index.ts +++ b/apps/api/scripts/legacy-import/index.ts @@ -32,7 +32,7 @@ import { IdMap, makeContext, openSource, type Ctx, type Options } from './lib/co import { admins, branches, cities, customers, experts, roles, workshops } from './steps/people'; import { brands, categories, products, variants } from './steps/catalog'; import { favorites, ledger, orders, preOrders } from './steps/commerce'; -import { reconcile } from './steps/reconcile'; +import { reconcile, reconcileSales } from './steps/reconcile'; /** Order matters: each step reads the ids the earlier ones mapped. */ const STEPS: [string, (ctx: Ctx) => Promise][] = [ @@ -52,6 +52,7 @@ const STEPS: [string, (ctx: Ctx) => Promise][] = [ ['favorites', favorites], ['preorders', preOrders], ['reconcile', reconcile], + ['reconcile-sales', reconcileSales], ]; const ENTITIES = [ diff --git a/apps/api/scripts/legacy-import/steps/commerce.ts b/apps/api/scripts/legacy-import/steps/commerce.ts index 65d65a7..03e6f28 100644 --- a/apps/api/scripts/legacy-import/steps/commerce.ts +++ b/apps/api/scripts/legacy-import/steps/commerce.ts @@ -50,7 +50,26 @@ export async function orders(ctx: Ctx) { continue; } const orderNumber = `H-${String(r.id)}`; + // `orders.reagent_id` is the expert the order was credited to when it was + // placed (never moved afterwards) — carried as is, NOT re-derived from the + // customer's current referrer (owner ruling 2026-10-08). It names a + // Customer row in legacy: an expert maps to their AdminUser; anyone else + // (one customer, 17 orders from 2022) is kept as the referrer customer. + let salesAgentId: string | null = null; + let referrerCustomerId: string | null = null; + if (r.reagent_id !== null) { + salesAgentId = ctx.map.get('expert', r.reagent_id) ?? null; + if (!salesAgentId) referrerCustomerId = ctx.map.get('customer', r.reagent_id) ?? null; + if (!salesAgentId && !referrerCustomerId) { + ctx.report.count(S, 'placing expert not migrated — left empty'); + ctx.report.note(S, `legacy order #${String(r.id)}: its reagent #${String(r.reagent_id)} was not migrated`); + } else { + ctx.report.count(S, salesAgentId ? 'credited to its legacy expert' : 'credited to its legacy referrer customer'); + } + } const data = { + salesAgentId, + referrerCustomerId, customerId, channel: OrderChannel.WHOLESALE, status, @@ -72,9 +91,7 @@ export async function orders(ctx: Ctx) { if (how === 'created') await ctx.db.order.update({ where: { id }, data }); }, }); - ctx.report.count(S, `${res.isNew ? 'created' : 'updated'} — ${status}`); - if (r.reagent_id !== null) ctx.report.count(S, 'placing expert (orders.reagent_id) not carried — no target field'); - } + ctx.report.count(S, `${res.isNew ? 'created' : 'updated'} — ${status}`); } await orderItems(ctx); } diff --git a/apps/api/scripts/legacy-import/steps/people.ts b/apps/api/scripts/legacy-import/steps/people.ts index 0eb8515..ed9fd6d 100644 --- a/apps/api/scripts/legacy-import/steps/people.ts +++ b/apps/api/scripts/legacy-import/steps/people.ts @@ -347,9 +347,12 @@ export async function experts(ctx: Ctx) { include: { salesExpertProfile: true }, }); if (admin.role !== AdminRole.SALES_EXPERT) { + // Owner 2026-10-07: the admin keeps their role. Their expert profile is + // still kept, because it is what links their OWN legacy orders (their + // trading account) to them — without it their «میزان فروش» lost those + // orders. The profile grants nothing: the expert app checks the role. ctx.report.count(S, `mobile already an admin with role ${admin.role} — kept, not demoted`); ctx.report.note(S, `legacy expert #${String(r.id)} shares a mobile with admin role ${admin.role}; the admin was kept and referrals point to it.`); - continue; } if (!admin.salesExpertProfile) { await ctx.db.salesExpertProfile.create({ data: { adminUserId: admin.id, ...profile } }); diff --git a/apps/api/scripts/legacy-import/steps/reconcile.ts b/apps/api/scripts/legacy-import/steps/reconcile.ts index 908abd4..54ddaa1 100644 --- a/apps/api/scripts/legacy-import/steps/reconcile.ts +++ b/apps/api/scripts/legacy-import/steps/reconcile.ts @@ -6,6 +6,7 @@ import { Prisma } from '@prisma/client'; import type { Ctx } from '../lib/context'; import { toNumber } from '../lib/normalize'; +import { agentSalesWhere, customerSalesWhere } from '../../../src/modules/sales-agent/agent-orders'; type Row = Record; const TOLERANCE = new Prisma.Decimal('0.0001'); @@ -89,3 +90,58 @@ export async function reconcile(ctx: Ctx) { ); ctx.report.note(S, `Imported variants with an imageId: ${String(imageIds)} (must be 0 — PROTECTED.md §B).`); } +/** + * «میزان فروش» for every migrated customer and expert, against legacy's own + * formula (CustomerController::updateGramSell, soft-deleted orders excluded): + * SUM(orders.input) WHERE customer_id = X OR reagent_id = X + * computed live from legacy's rows — and, separately, legacy's STORED + * `customers.grams_sell`, which the legacy screens print. The stored value is + * a cache refreshed only on some order events, so where it disagrees with the + * formula the legacy screen itself is out of date; each such row is listed. + * + * Legacy rows the import merged into one target row (duplicate mobiles) are + * compared as one: their legacy figures are summed. + */ +export async function reconcileSales(ctx: Ctx) { + const S = 'reconciliation — sales («میزان فروش»)'; + const rows = await ctx.q( + `SELECT c.id, c.grams_sell AS kept, + (SELECT COALESCE(SUM(o.input), 0) FROM orders o + WHERE o.deleted_at IS NULL AND (o.customer_id = c.id OR o.reagent_id = c.id)) AS live + FROM customers c WHERE c.deleted_at IS NULL`, + ); + const groups = new Map(); + let stale = 0; + for (const r of rows) { + const agentId = ctx.map.get('expert', r.id); + const customerId = ctx.map.get('customer', r.id); + if (!agentId && !customerId) continue; + const key = agentId ? `expert:${agentId}` : `customer:${customerId!}`; + const g = groups.get(key) ?? { + where: agentId ? agentSalesWhere(agentId) : customerSalesWhere(customerId!), + live: 0, + ids: [], + }; + g.live += toNumber(r.live); + g.ids.push(String(r.id)); + groups.set(key, g); + + const live = new Prisma.Decimal(toNumber(r.live).toFixed(4)); + const kept = new Prisma.Decimal(toNumber(r.kept).toFixed(4)); + if (kept.sub(live).abs().gt(TOLERANCE)) { + stale++; + ctx.report.note(S, `legacy's stored figure is out of date for #${String(r.id)}: legacy screen shows ${kept.toString()}, legacy's own formula gives ${live.toString()}`); + } + } + let ok = 0, bad = 0; + for (const [key, g] of groups) { + const agg = await ctx.db.order.aggregate({ where: g.where, _sum: { totalGoldWeightGrams: true } }); + const target = agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0); + const live = new Prisma.Decimal(g.live.toFixed(4)); + if (live.sub(target).abs().lte(TOLERANCE)) ok++; + else { bad++; ctx.report.note(S, `⚠ ${key.split(':')[0]} legacy #${g.ids.join(' + #')}: legacy formula ${live.toString()} / target ${target.toString()}`); } + } + ctx.report.count(S, 'matches legacy formula (±0.0001 g)', ok); + ctx.report.count(S, 'DIFFERS from legacy formula', bad); + ctx.report.count(S, "legacy's stored screen value is stale (legacy disagrees with itself)", stale); +} diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.ts b/apps/api/src/modules/admin-customers/admin-customers.service.ts index c7905d3..3fc293d 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.service.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.service.ts @@ -6,6 +6,7 @@ import { TableService } from '../admin-table/table.service'; import type { TableQueryDto } from '../admin-table/table-query.dto'; import { faToEn } from '../customer-auth/digits'; import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash'; +import { agentSalesWhere, customerSalesWhere } from '../sales-agent/agent-orders'; import { SmsService } from '../sms/sms.service'; import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service'; @@ -318,7 +319,8 @@ export class AdminCustomersService { // legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive active: c.isActive ? 1 : -1, sts: c.canPlaceOrders ? 1 : -1, - sts_str: c.canPlaceOrders ? 'مجاز' : 'غیر مجاز', + // Legacy Customer::$STS labels (Customer.php:94-97). + sts_str: c.canPlaceOrders ? 'فعال' : 'عدم سفارش', type: kindToLegacyType(c.kind), type_str: CUSTOMER_TYPE_STR[c.kind], is_archive: c.isArchived ? 1 : 0, @@ -450,7 +452,7 @@ export class AdminCustomersService { const mapped = await Promise.all( rows.map(async (r) => { const agg = await this.prisma.order.aggregate({ - where: { customer: { referredByAgentId: r.id } }, + where: agentSalesWhere(r.id), _sum: { totalGoldWeightGrams: true }, }); return { @@ -472,7 +474,7 @@ export class AdminCustomersService { child: r._count.referredCustomers, active: r.isActive ? 1 : -1, sts: r.isActive ? 1 : -1, - sts_str: r.isActive ? 'مجاز' : 'غیر مجاز', + sts_str: r.isActive ? 'فعال' : 'عدم سفارش', caj: r.createdAt, uaj: r.createdAt, branch: r.branch ? { id: r.branch.id, name: r.branch.name } : null, @@ -514,14 +516,19 @@ export class AdminCustomersService { }); const orders = await this.prisma.order.findMany({ - where: { customer: { referredByAgentId: id } }, + // The orders credited to this expert (Order.salesAgentId) plus their own — + // the same set «میزان فروش» counts, so the list and the figure agree. + where: agentSalesWhere(id), orderBy: { createdAt: 'desc' }, take: 100, include: { customer: true }, }); const gramsSoldAgg = await this.prisma.order.aggregate({ - where: { customer: { referredByAgentId: id }, ...AdminCustomersService.SOLD }, + // Same figure as the experts list and legacy grams_sell: no status filter + // (CustomerController::updateGramSell). This screen used to count only + // delivered orders, so the same expert showed two different numbers. + where: agentSalesWhere(id), _sum: { totalGoldWeightGrams: true }, }); @@ -555,11 +562,6 @@ export class AdminCustomersService { }; } - /** Orders that reached debt-recognition, reused by reagentDetail(). */ - private static readonly SOLD: Prisma.OrderWhereInput = { - status: { in: ['DELIVERED_TO_AGENT', 'DELIVERED_TO_CUSTOMER'] }, - }; - /** * Legacy `POST single-customer` — the retail ("تک") customer list. * @@ -1032,12 +1034,14 @@ export class AdminCustomersService { const c = await this.prisma.customer.findUnique({ where: { id } }); if (!c) throw new NotFoundException('مشتری یافت نشد'); - const [orderCount, openOrders, ledgerCount] = await Promise.all([ + const [orderCount, openOrders, ledgerCount, sold] = await Promise.all([ this.prisma.order.count({ where: { customerId: id } }), this.prisma.order.count({ where: { customerId: id, deliveredAt: null, status: { not: 'CANCELLED' } }, }), this.prisma.ledgerEntry.count({ where: { customerId: id } }), + // Legacy ViewOmdeCustomer «میزان فروش» (customers.grams_sell). + this.prisma.order.aggregate({ where: customerSalesWhere(id), _sum: { totalGoldWeightGrams: true } }), ]); return { @@ -1047,6 +1051,7 @@ export class AdminCustomersService { gram_level: c.goldCreditLimitGrams, remain: c.goldDebtGrams, rial_remain: c.fiatDebtRial, + grams_sell: sold._sum.totalGoldWeightGrams ?? 0, }; } diff --git a/apps/api/src/modules/admin-dashboard/admin-dashboard.service.ts b/apps/api/src/modules/admin-dashboard/admin-dashboard.service.ts index 6baf2f4..6cc96d7 100644 --- a/apps/api/src/modules/admin-dashboard/admin-dashboard.service.ts +++ b/apps/api/src/modules/admin-dashboard/admin-dashboard.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { OrderStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; +import { agentSalesWhere } from '../sales-agent/agent-orders'; /** * Dashboards — legacy views/DashboardECommerce.vue (`GET get-analytics`) and @@ -217,7 +218,7 @@ export class AdminDashboardService { where: { AND: [ AdminDashboardService.SOLD, - { customer: { referredByAgentId: a.id } }, + agentSalesWhere(a.id), { createdAt: { gte: since30 } }, ], }, diff --git a/apps/api/src/modules/orders/orders.service.spec.ts b/apps/api/src/modules/orders/orders.service.spec.ts new file mode 100644 index 0000000..c69f91a --- /dev/null +++ b/apps/api/src/modules/orders/orders.service.spec.ts @@ -0,0 +1,50 @@ +import { Prisma } from '@prisma/client'; +import { OrdersService } from './orders.service'; + +/** + * Owner ruling 2026-10-08: an order is credited to the customer's expert AT + * THE MOMENT IT IS PLACED (legacy `orders.reagent_id`), and a later change of + * the customer's expert never moves it. + */ +describe('OrdersService.createOrder — sales expert credit', () => { + function build(referredByAgentId: string | null) { + const created: Record[] = []; + const tx = { + order: { + create: jest.fn(async ({ data }: { data: Record }) => { + created.push(data); + return { id: 'o1', ...data }; + }), + }, + }; + const prisma = { + customer: { + findUnique: jest.fn(async () => ({ + id: 'c1', + referredByAgentId, + 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 accounting = { orderStatusReached: jest.fn() }; + const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never); + return { service, created }; + } + + const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never; + + it("records the customer's current expert on the order", async () => { + const { service, created } = build('agent-A'); + await service.createOrder(dto, 'c1'); + expect(created[0].salesAgentId).toBe('agent-A'); + }); + + it('records no expert for a customer without one', async () => { + const { service, created } = build(null); + await service.createOrder(dto, 'c1'); + expect(created[0].salesAgentId).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts index eb6b70b..d37896b 100644 --- a/apps/api/src/modules/orders/orders.service.ts +++ b/apps/api/src/modules/orders/orders.service.ts @@ -105,6 +105,11 @@ export class OrdersService { wageWeightGrams, flatRialCharge, isSuspendedOverCreditLimit, + // Legacy `'reagent_id' => auth()->user()->reagent_id` + // (ApiMobileController.php:601): the customer's expert at THIS + // moment, fixed on the order so a later change of expert never moves + // past sales (owner ruling 2026-10-08). + salesAgentId: customer.referredByAgentId, items: { create: computedItems.map((item) => ({ productVariantId: item.productVariantId, diff --git a/apps/api/src/modules/sales-agent/agent-orders.ts b/apps/api/src/modules/sales-agent/agent-orders.ts new file mode 100644 index 0000000..595696c --- /dev/null +++ b/apps/api/src/modules/sales-agent/agent-orders.ts @@ -0,0 +1,29 @@ +import type { Prisma } from '@prisma/client'; + +/** + * The orders that count as a sales expert's sales («میزان فروش»). + * + * Legacy `CustomerController::updateGramSell`: + * SUM(orders.input) WHERE customer_id = X OR reagent_id = X + * (soft-deleted orders excluded by the model, no status filter). + * + * - `reagent_id = X` is Order.salesAgentId: the expert recorded on the order + * when it was placed — NOT the customer's current referrer, so a customer + * moving to another expert never moves past sales (owner ruling 2026-10-08). + * - `customer_id = X` is the expert's own orders, which live on their trading + * account (SalesExpertProfile.tradingCustomerId, D21). + */ +export function agentSalesWhere(agentId: string): Prisma.OrderWhereInput { + return { + OR: [{ salesAgentId: agentId }, { customer: { salesExpertTradingFor: { adminUserId: agentId } } }], + }; +} + +/** + * The same figure for a CUSTOMER's own page (legacy ViewOmdeCustomer «میزان + * فروش» — the same `grams_sell` column): their own orders plus any legacy + * order that named them as its referrer (Order.referrerCustomerId). + */ +export function customerSalesWhere(customerId: string): Prisma.OrderWhereInput { + return { OR: [{ customerId }, { referrerCustomerId: customerId }] }; +} diff --git a/apps/api/src/modules/sales-agent/sales-agent.service.ts b/apps/api/src/modules/sales-agent/sales-agent.service.ts index c4a95a0..121cfd1 100644 --- a/apps/api/src/modules/sales-agent/sales-agent.service.ts +++ b/apps/api/src/modules/sales-agent/sales-agent.service.ts @@ -2,6 +2,7 @@ import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/commo import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; +import { agentSalesWhere } from './agent-orders'; /** * Backs the sales expert's ("کارشناس فروش") own screens in the customer apps. @@ -45,12 +46,12 @@ export class SalesAgentService { * Legacy kept this denormalised in `customers.grams_sell`, recomputed by * `CustomerController::updateGramSell` as * SUM(orders.input) WHERE customer_id = X OR reagent_id = X - * with NO order-status filter. Computed live here instead of denormalised. + * with NO order-status filter. Computed live here instead of denormalised; + * both arms are kept — see agent-orders.ts. * * Two deliberate fidelity notes: - * - The `OR customer_id = X` arm is structurally empty now: an agent is an - * AdminUser and cannot own an Order, so only the referred-customer arm can - * ever contribute. Kept as a comment rather than silently dropped. + * - The `OR customer_id = X` arm is the expert's trading account (D21): an + * AdminUser cannot own an Order, so their own orders sit on that Customer. * - No status filter, matching legacy. The admin panel disagrees with itself * here — `AdminCustomersService.listReagents` aggregates unfiltered while * `reagentDetail` filters on the SOLD statuses — so this matches @@ -59,7 +60,7 @@ export class SalesAgentService { */ private async gramsSoldFor(agentId: string) { const agg = await this.prisma.order.aggregate({ - where: { customer: { referredByAgentId: agentId } }, + where: agentSalesWhere(agentId), _sum: { totalGoldWeightGrams: true }, }); return agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0); diff --git a/apps/api/src/modules/sales-expert-app/sales-expert-app.service.ts b/apps/api/src/modules/sales-expert-app/sales-expert-app.service.ts index 18e453b..789386d 100644 --- a/apps/api/src/modules/sales-expert-app/sales-expert-app.service.ts +++ b/apps/api/src/modules/sales-expert-app/sales-expert-app.service.ts @@ -50,10 +50,11 @@ export class SalesExpertAppService { const search = (params.search ?? '').trim(); const scope: Prisma.OrderWhereInput = { - // The order belongs to one of THIS expert's referred customers. An - // expert can never see another expert's customers, and no id from the - // request takes part in this. - customer: { referredByAgentId: agentId }, + // Legacy getChildOrder: `where('reagent_id', auth()->id())` + // (ApiMobileController.php:779) — the orders credited to THIS expert when + // they were placed (Order.salesAgentId), not the customer's current + // referrer. No id from the request takes part in this. + salesAgentId: agentId, status: OrderStatus.DELIVERED_TO_AGENT, };