feat(orders): credit each order to the expert who placed it, as legacy did
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8c5d971d1f
commit
29084904e9
@@ -33,6 +33,8 @@
|
|||||||
<a-descriptions-item label="بدهی ریالی">
|
<a-descriptions-item label="بدهی ریالی">
|
||||||
{{ customer?.rial_remain ?? '-' }}
|
{{ customer?.rial_remain ?? '-' }}
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
|
<!-- legacy ViewOmdeCustomer.vue:28-30 -->
|
||||||
|
<a-descriptions-item label="میزان فروش">{{ dashboard?.grams_sell ?? '-' }}</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
|
|
||||||
<div v-if="dashboard" class="stats mt-4">
|
<div v-if="dashboard" class="stats mt-4">
|
||||||
|
|||||||
@@ -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-<id>') 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.';
|
||||||
@@ -179,6 +179,8 @@ model AdminUser {
|
|||||||
memberCode String? // legacy mcode
|
memberCode String? // legacy mcode
|
||||||
|
|
||||||
referredCustomers Customer[] @relation("ReferralAgent")
|
referredCustomers Customer[] @relation("ReferralAgent")
|
||||||
|
/// Orders credited to this sales expert — see Order.salesAgentId.
|
||||||
|
creditedOrders Order[] @relation("OrderSalesAgent")
|
||||||
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
||||||
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
||||||
postedLedgerEntries LedgerEntry[]
|
postedLedgerEntries LedgerEntry[]
|
||||||
@@ -421,6 +423,9 @@ model Customer {
|
|||||||
/// account (see SalesExpertProfile.tradingCustomerId). Empty for every
|
/// account (see SalesExpertProfile.tradingCustomerId). Empty for every
|
||||||
/// ordinary customer.
|
/// ordinary customer.
|
||||||
salesExpertTradingFor SalesExpertProfile? @relation("SalesExpertTradingAccount")
|
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
|
// Credit limit + cached ledger balances (source of truth is LedgerEntry; these
|
||||||
// are denormalized for fast reads and reconciled by a scheduled job).
|
// are denormalized for fast reads and reconciled by a scheduled job).
|
||||||
@@ -736,6 +741,22 @@ model Order {
|
|||||||
isSuspendedOverCreditLimit Boolean @default(false)
|
isSuspendedOverCreditLimit Boolean @default(false)
|
||||||
deliveredAt DateTime? // setting this is what triggers debt recognition
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -746,6 +767,8 @@ model Order {
|
|||||||
|
|
||||||
@@index([customerId])
|
@@index([customerId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([salesAgentId])
|
||||||
|
@@index([referrerCustomerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model OrderItem {
|
model OrderItem {
|
||||||
|
|||||||
@@ -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 { admins, branches, cities, customers, experts, roles, workshops } from './steps/people';
|
||||||
import { brands, categories, products, variants } from './steps/catalog';
|
import { brands, categories, products, variants } from './steps/catalog';
|
||||||
import { favorites, ledger, orders, preOrders } from './steps/commerce';
|
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. */
|
/** Order matters: each step reads the ids the earlier ones mapped. */
|
||||||
const STEPS: [string, (ctx: Ctx) => Promise<void>][] = [
|
const STEPS: [string, (ctx: Ctx) => Promise<void>][] = [
|
||||||
@@ -52,6 +52,7 @@ const STEPS: [string, (ctx: Ctx) => Promise<void>][] = [
|
|||||||
['favorites', favorites],
|
['favorites', favorites],
|
||||||
['preorders', preOrders],
|
['preorders', preOrders],
|
||||||
['reconcile', reconcile],
|
['reconcile', reconcile],
|
||||||
|
['reconcile-sales', reconcileSales],
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENTITIES = [
|
const ENTITIES = [
|
||||||
|
|||||||
@@ -50,7 +50,26 @@ export async function orders(ctx: Ctx) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const orderNumber = `H-${String(r.id)}`;
|
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 = {
|
const data = {
|
||||||
|
salesAgentId,
|
||||||
|
referrerCustomerId,
|
||||||
customerId,
|
customerId,
|
||||||
channel: OrderChannel.WHOLESALE,
|
channel: OrderChannel.WHOLESALE,
|
||||||
status,
|
status,
|
||||||
@@ -72,9 +91,7 @@ export async function orders(ctx: Ctx) {
|
|||||||
if (how === 'created') await ctx.db.order.update({ where: { id }, data });
|
if (how === 'created') await ctx.db.order.update({ where: { id }, data });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.report.count(S, `${res.isNew ? 'created' : 'updated'} — ${status}`);
|
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');
|
|
||||||
}
|
|
||||||
await orderItems(ctx);
|
await orderItems(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -347,9 +347,12 @@ export async function experts(ctx: Ctx) {
|
|||||||
include: { salesExpertProfile: true },
|
include: { salesExpertProfile: true },
|
||||||
});
|
});
|
||||||
if (admin.role !== AdminRole.SALES_EXPERT) {
|
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.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.`);
|
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) {
|
if (!admin.salesExpertProfile) {
|
||||||
await ctx.db.salesExpertProfile.create({ data: { adminUserId: admin.id, ...profile } });
|
await ctx.db.salesExpertProfile.create({ data: { adminUserId: admin.id, ...profile } });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import type { Ctx } from '../lib/context';
|
import type { Ctx } from '../lib/context';
|
||||||
import { toNumber } from '../lib/normalize';
|
import { toNumber } from '../lib/normalize';
|
||||||
|
import { agentSalesWhere, customerSalesWhere } from '../../../src/modules/sales-agent/agent-orders';
|
||||||
|
|
||||||
type Row = Record<string, unknown>;
|
type Row = Record<string, unknown>;
|
||||||
const TOLERANCE = new Prisma.Decimal('0.0001');
|
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).`);
|
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<Row>(
|
||||||
|
`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<string, { where: Prisma.OrderWhereInput; live: number; ids: string[] }>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TableService } from '../admin-table/table.service';
|
|||||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||||
import { faToEn } from '../customer-auth/digits';
|
import { faToEn } from '../customer-auth/digits';
|
||||||
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||||
|
import { agentSalesWhere, customerSalesWhere } from '../sales-agent/agent-orders';
|
||||||
import { SmsService } from '../sms/sms.service';
|
import { SmsService } from '../sms/sms.service';
|
||||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.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
|
// legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive
|
||||||
active: c.isActive ? 1 : -1,
|
active: c.isActive ? 1 : -1,
|
||||||
sts: c.canPlaceOrders ? 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: kindToLegacyType(c.kind),
|
||||||
type_str: CUSTOMER_TYPE_STR[c.kind],
|
type_str: CUSTOMER_TYPE_STR[c.kind],
|
||||||
is_archive: c.isArchived ? 1 : 0,
|
is_archive: c.isArchived ? 1 : 0,
|
||||||
@@ -450,7 +452,7 @@ export class AdminCustomersService {
|
|||||||
const mapped = await Promise.all(
|
const mapped = await Promise.all(
|
||||||
rows.map(async (r) => {
|
rows.map(async (r) => {
|
||||||
const agg = await this.prisma.order.aggregate({
|
const agg = await this.prisma.order.aggregate({
|
||||||
where: { customer: { referredByAgentId: r.id } },
|
where: agentSalesWhere(r.id),
|
||||||
_sum: { totalGoldWeightGrams: true },
|
_sum: { totalGoldWeightGrams: true },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -472,7 +474,7 @@ export class AdminCustomersService {
|
|||||||
child: r._count.referredCustomers,
|
child: r._count.referredCustomers,
|
||||||
active: r.isActive ? 1 : -1,
|
active: r.isActive ? 1 : -1,
|
||||||
sts: r.isActive ? 1 : -1,
|
sts: r.isActive ? 1 : -1,
|
||||||
sts_str: r.isActive ? 'مجاز' : 'غیر مجاز',
|
sts_str: r.isActive ? 'فعال' : 'عدم سفارش',
|
||||||
caj: r.createdAt,
|
caj: r.createdAt,
|
||||||
uaj: r.createdAt,
|
uaj: r.createdAt,
|
||||||
branch: r.branch ? { id: r.branch.id, name: r.branch.name } : null,
|
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({
|
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' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100,
|
take: 100,
|
||||||
include: { customer: true },
|
include: { customer: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const gramsSoldAgg = await this.prisma.order.aggregate({
|
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 },
|
_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.
|
* Legacy `POST single-customer` — the retail ("تک") customer list.
|
||||||
*
|
*
|
||||||
@@ -1032,12 +1034,14 @@ export class AdminCustomersService {
|
|||||||
const c = await this.prisma.customer.findUnique({ where: { id } });
|
const c = await this.prisma.customer.findUnique({ where: { id } });
|
||||||
if (!c) throw new NotFoundException('مشتری یافت نشد');
|
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 } }),
|
||||||
this.prisma.order.count({
|
this.prisma.order.count({
|
||||||
where: { customerId: id, deliveredAt: null, status: { not: 'CANCELLED' } },
|
where: { customerId: id, deliveredAt: null, status: { not: 'CANCELLED' } },
|
||||||
}),
|
}),
|
||||||
this.prisma.ledgerEntry.count({ where: { customerId: id } }),
|
this.prisma.ledgerEntry.count({ where: { customerId: id } }),
|
||||||
|
// Legacy ViewOmdeCustomer «میزان فروش» (customers.grams_sell).
|
||||||
|
this.prisma.order.aggregate({ where: customerSalesWhere(id), _sum: { totalGoldWeightGrams: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1047,6 +1051,7 @@ export class AdminCustomersService {
|
|||||||
gram_level: c.goldCreditLimitGrams,
|
gram_level: c.goldCreditLimitGrams,
|
||||||
remain: c.goldDebtGrams,
|
remain: c.goldDebtGrams,
|
||||||
rial_remain: c.fiatDebtRial,
|
rial_remain: c.fiatDebtRial,
|
||||||
|
grams_sell: sold._sum.totalGoldWeightGrams ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OrderStatus, Prisma } from '@prisma/client';
|
import { OrderStatus, Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { agentSalesWhere } from '../sales-agent/agent-orders';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboards — legacy views/DashboardECommerce.vue (`GET get-analytics`) and
|
* Dashboards — legacy views/DashboardECommerce.vue (`GET get-analytics`) and
|
||||||
@@ -217,7 +218,7 @@ export class AdminDashboardService {
|
|||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
AdminDashboardService.SOLD,
|
AdminDashboardService.SOLD,
|
||||||
{ customer: { referredByAgentId: a.id } },
|
agentSalesWhere(a.id),
|
||||||
{ createdAt: { gte: since30 } },
|
{ createdAt: { gte: since30 } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<string, unknown>[] = [];
|
||||||
|
const tx = {
|
||||||
|
order: {
|
||||||
|
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -105,6 +105,11 @@ export class OrdersService {
|
|||||||
wageWeightGrams,
|
wageWeightGrams,
|
||||||
flatRialCharge,
|
flatRialCharge,
|
||||||
isSuspendedOverCreditLimit,
|
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: {
|
items: {
|
||||||
create: computedItems.map((item) => ({
|
create: computedItems.map((item) => ({
|
||||||
productVariantId: item.productVariantId,
|
productVariantId: item.productVariantId,
|
||||||
|
|||||||
@@ -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 }] };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/commo
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||||
|
import { agentSalesWhere } from './agent-orders';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backs the sales expert's ("کارشناس فروش") own screens in the customer apps.
|
* 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
|
* Legacy kept this denormalised in `customers.grams_sell`, recomputed by
|
||||||
* `CustomerController::updateGramSell` as
|
* `CustomerController::updateGramSell` as
|
||||||
* SUM(orders.input) WHERE customer_id = X OR reagent_id = X
|
* 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:
|
* Two deliberate fidelity notes:
|
||||||
* - The `OR customer_id = X` arm is structurally empty now: an agent is an
|
* - The `OR customer_id = X` arm is the expert's trading account (D21): an
|
||||||
* AdminUser and cannot own an Order, so only the referred-customer arm can
|
* AdminUser cannot own an Order, so their own orders sit on that Customer.
|
||||||
* ever contribute. Kept as a comment rather than silently dropped.
|
|
||||||
* - No status filter, matching legacy. The admin panel disagrees with itself
|
* - No status filter, matching legacy. The admin panel disagrees with itself
|
||||||
* here — `AdminCustomersService.listReagents` aggregates unfiltered while
|
* here — `AdminCustomersService.listReagents` aggregates unfiltered while
|
||||||
* `reagentDetail` filters on the SOLD statuses — so this matches
|
* `reagentDetail` filters on the SOLD statuses — so this matches
|
||||||
@@ -59,7 +60,7 @@ export class SalesAgentService {
|
|||||||
*/
|
*/
|
||||||
private async gramsSoldFor(agentId: string) {
|
private async gramsSoldFor(agentId: string) {
|
||||||
const agg = await this.prisma.order.aggregate({
|
const agg = await this.prisma.order.aggregate({
|
||||||
where: { customer: { referredByAgentId: agentId } },
|
where: agentSalesWhere(agentId),
|
||||||
_sum: { totalGoldWeightGrams: true },
|
_sum: { totalGoldWeightGrams: true },
|
||||||
});
|
});
|
||||||
return agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0);
|
return agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0);
|
||||||
|
|||||||
@@ -50,10 +50,11 @@ export class SalesExpertAppService {
|
|||||||
const search = (params.search ?? '').trim();
|
const search = (params.search ?? '').trim();
|
||||||
|
|
||||||
const scope: Prisma.OrderWhereInput = {
|
const scope: Prisma.OrderWhereInput = {
|
||||||
// The order belongs to one of THIS expert's referred customers. An
|
// Legacy getChildOrder: `where('reagent_id', auth()->id())`
|
||||||
// expert can never see another expert's customers, and no id from the
|
// (ApiMobileController.php:779) — the orders credited to THIS expert when
|
||||||
// request takes part in this.
|
// they were placed (Order.salesAgentId), not the customer's current
|
||||||
customer: { referredByAgentId: agentId },
|
// referrer. No id from the request takes part in this.
|
||||||
|
salesAgentId: agentId,
|
||||||
status: OrderStatus.DELIVERED_TO_AGENT,
|
status: OrderStatus.DELIVERED_TO_AGENT,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user