import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; 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. * * Legacy (henza_pwa + henza_server) modelled a sales expert as a `customers` row * with `type = 1`, so it logged into the customer PWA through the very same * two-step flow as everyone else and its extras (`POST /my-child`, the * "فروش گرمی" figure) were just more customer endpoints. The redesigned schema * models a sales expert as an AdminUser with role SALES_EXPERT instead — it has * no Customer row, and therefore no balance, ledger or orders of its own * (`Order.customerId` and `LedgerEntry.customerId` are both NOT NULL and point * at Customer only). * * So the legacy capabilities are rebuilt here against the AdminUser, reading the * agent's book of business through `Customer.referredByAgentId`. * * Eligibility note, carried over from legacy deliberately: legacy's `myChild` * selected purely on `reagent_id = auth()->id()` and never filtered on `type`. * The PWA's `type == 1` gate was cosmetic (and bypassable by navigating straight * to /customerChoice). So "can act on a customer's behalf" means "that customer * is referred by me", which is what every method here enforces. */ @Injectable() export class SalesAgentService { constructor(private readonly prisma: PrismaService) {} /** * Only a SALES_EXPERT may use these routes. Every other admin role — an * accountant, a super admin — gets a clean 403 rather than a half-working * dashboard, because none of the reads below mean anything for them. */ private assertIsSalesExpert(admin: AuthenticatedAdmin) { if (admin.role !== 'SALES_EXPERT') { throw new ForbiddenException('این بخش تنها برای کارشناس فروش است.'); } } /** * Total gold sold, credited to this agent. * * 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; * both arms are kept — see agent-orders.ts. * * Two deliberate fidelity notes: * - 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 * listReagents (the grid figure) and legacy. If those three are ever * reconciled, this is the fourth call site. */ private async gramsSoldFor(agentId: string) { const agg = await this.prisma.order.aggregate({ where: agentSalesWhere(agentId), _sum: { totalGoldWeightGrams: true }, }); return agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0); } /** * The agent's own profile card. Replaces the balance card the customer apps * show for a real customer, which an agent has no data for. */ async me(admin: AuthenticatedAdmin) { this.assertIsSalesExpert(admin); const agent = await this.prisma.adminUser.findUnique({ where: { id: admin.id }, select: { id: true, fullName: true, mobile: true, wagePercent: true, branch: { select: { id: true, name: true } }, _count: { select: { referredCustomers: true } }, }, }); if (!agent) throw new NotFoundException('کارشناس فروش یافت نشد'); return { id: agent.id, fullName: agent.fullName, mobile: agent.mobile, wagePercent: agent.wagePercent, branch: agent.branch, referredCustomerCount: agent._count.referredCustomers, gramsSoldGrams: await this.gramsSoldFor(agent.id), }; } /** * The agent's referred customers — legacy `POST /my-child`. * * Search covers the same columns legacy's myChild did (name, mobile, phone, * store_name, pasazh_name). * * NO isActive filter, deliberately: legacy had `Customer::active()` commented * out on the line directly above the live query * (ApiMobileController.php:187-188), and its picker renders a per-row * activate/deactivate control, which only makes sense when inactive rows are * listed. Archived customers are excluded though — legacy's own login path * (WebApp\AuthController::userAuth) treats is_archive as disqualifying. * * The select is deliberately narrow. This is customer PII reached through an * agent's token, so it carries only what the picker renders — no * nationalCode, no passwordHash. */ async myCustomers(admin: AuthenticatedAdmin, search?: string) { this.assertIsSalesExpert(admin); const term = search?.trim(); const where: Prisma.CustomerWhereInput = { referredByAgentId: admin.id, isArchived: false, ...(term ? { OR: [ { fullName: { contains: term, mode: 'insensitive' as const } }, { mobile: { contains: term } }, { phone: { contains: term } }, { storeName: { contains: term, mode: 'insensitive' as const } }, { pasazhName: { contains: term, mode: 'insensitive' as const } }, ], } : {}), }; return this.prisma.customer.findMany({ where, orderBy: { createdAt: 'desc' }, select: { id: true, fullName: true, mobile: true, phone: true, storeName: true, address: true, isActive: true, goldDebtGrams: true, fiatDebtRial: true, }, }); } /** * Authorises entering "acting as this customer" mode and records it. * * The customer-facing endpoints are @Public() (a pre-existing condition — see * customers.controller.ts), so this is not a hard security boundary: a * determined caller could talk to those endpoints directly. What it does buy: * * 1. The SERVER decides who may be impersonated, for the legitimate flow — * the client cannot widen its own book of business. * 2. An audit row. Without it, staff could place gold-debt-incurring orders * in a customer's name leaving no trace, which is not an acceptable * property for a feature that writes to a ledger. Legacy had no such * record; this is a deliberate improvement on the reference. */ async verifyActingAs(admin: AuthenticatedAdmin, customerId: string) { this.assertIsSalesExpert(admin); const customer = await this.prisma.customer.findUnique({ where: { id: customerId }, select: { id: true, fullName: true, referredByAgentId: true, isArchived: true }, }); if (!customer || customer.isArchived || customer.referredByAgentId !== admin.id) { throw new ForbiddenException('این مشتری در فهرست شما نیست.'); } await this.prisma.customerActivityLog.create({ data: { customerId: customer.id, action: 'agent.acting_as', detail: `کارشناس فروش ${admin.fullName} (${admin.id}) به‌جای این مشتری وارد شد`, }, }); return { id: customer.id, fullName: customer.fullName }; } }