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';
|
2026-10-08 10:40:21 +03:30
|
|
|
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
|
2026-10-08 10:40:21 +03:30
|
|
|
* 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:
|
2026-10-08 10:40:21 +03:30
|
|
|
* - 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({
|
2026-10-08 10:40:21 +03:30
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
}
|