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