Files
tehran_tala/apps/api/src/modules/sales-agent/sales-agent.service.ts
T

191 lines
7.5 KiB
TypeScript
Raw Normal View History

2026-09-21 17:37:26 +03:30
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';
2026-09-21 17:37:26 +03:30
/**
* 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.
2026-09-21 17:37:26 +03:30
*
* 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.
2026-09-21 17:37:26 +03:30
* - 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),
2026-09-21 17:37:26 +03:30
_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 };
}
}