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:
s.esmaeili
2026-10-08 10:40:21 +03:30
co-authored by Claude Opus 5.5
parent 8c5d971d1f
commit 29084904e9
14 changed files with 267 additions and 26 deletions
@@ -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,
};
}