Files
hedena_gold/apps/api/src/modules/admin-customers/admin-customers.service.ts
T
mahmoodandClaude Sonnet 5.5 5f3b00497e feat(auth): one mobile, one person across sales experts and customers
Experts (AdminUser) and customers (Customer) sign in to the same apps by mobile
but live in different tables, so the database cannot make the number unique
across them. A number could be given to an expert and to a customer, and the
login then silently merged the two (the customer row was adopted as the expert's
trading account), which left the expert on a customer session and the panel's
name edits unreachable.

The reference rejected a duplicate at entry (`unique:customers`, the agent being
a customers row too); this does the same where a number is typed in:

- customer create/update (wholesale, banker, walk-in): refused when an ACTIVE
  expert holds the number;
- expert create/update: refused when a customer holds it (the expert's own
  trading account excepted);
- the app's self sign-up: refused when an active expert holds it.

Only a number being created or CHANGED is checked, so existing duplicates keep
signing in and stay editable; the login's "expert wins" adoption is untouched.
Plain equality on the two unique columns, normalised with faToEn, so both
lookups are index hits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:24:37 +03:30

1238 lines
50 KiB
TypeScript

import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { CustomerKind, Prisma, VerificationStep } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
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';
import { AccountingConfigService } from '../accounting-sync/accounting-config.service';
import { brandName, brandSiteUrl } from '../../common/brand';
import { activeExpertHoldsMobile, EXPERT_HOLDS_MOBILE } from '../../common/mobile-owner';
/**
* Wholesale ("عمده") customer lists — legacy views/customer/omde/*.
*
* Legacy shipped FIVE near-identical 615-line views (OmdeList, BankerList,
* ShopkeeperList, OmdeNonVerifiedList, OmdeArchivedList) whose table models
* differed by exactly ONE line: the endpoint url. Verified by diff:
* ShopkeeperList vs OmdeList = 4 differing lines (title + model import only).
*
* They are served here by one method with a `scope`, matching the five legacy
* endpoints: customer, banker, shopkeeper, major/verify/table, major/archive/table.
*/
/**
* Legacy Customer::$TYPES, verbatim from henza_server app/Models/Customer.php:85
* (including its leading-space quirks). Kept so rows still carry the numeric
* `type` and Farsi `type_str` the ported grids bind to.
*/
export const CUSTOMER_TYPE_STR: Record<CustomerKind, string> = {
SALES_AGENT: ' کارشناس فروش', // legacy value only — see CustomerKind's schema comment
APP_SHOPKEEPER: 'مغازه دار اپلیکیشن',
WALK_IN_SHOPKEEPER: ' مغازه دار حضوری',
BANKER: 'بنکدار',
WORKSHOP_SUPERVISOR: 'سرپرست کارگاه',
RETAIL: 'مشتری تک',
};
// legacy had no numeric type for RETAIL (it lived in a separate table/model
// entirely — see listSingle()'s header comment), so it is deliberately absent
// here; legacyTypeToKind() returns undefined for it and kindToLegacyType()
// is never called with it.
const LEGACY_TYPE_ID: Partial<Record<CustomerKind, number>> = {
SALES_AGENT: 1,
APP_SHOPKEEPER: 2,
WALK_IN_SHOPKEEPER: 3,
BANKER: 4,
WORKSHOP_SUPERVISOR: 5,
};
/** Inverse of kindToLegacyType — legacy forms post the numeric `type`. */
export const legacyTypeToKind = (t: unknown): CustomerKind | undefined =>
(Object.keys(LEGACY_TYPE_ID) as CustomerKind[]).find(
(k) => LEGACY_TYPE_ID[k] === Number(t),
);
export const kindToLegacyType = (k: CustomerKind): number | null => LEGACY_TYPE_ID[k] ?? null;
export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'archived';
/**
* Legacy exposed verification as two numeric steps. Confirmed from the view's
* own switch statements and its accept/reject payloads (OmdeList.vue:445-487):
*
* 1 = در انتظار (pending) -> HelpCircleIcon
* 2 = accepted -> posted by acceptStepOne/acceptStepTwo
* 3 = rejected -> posted by rejectStepOne/rejectStepTwo
*
* NOTE the template gates the second step and the order/account switches on
* `sts_verify_step_one !== 2`, i.e. only an ACCEPTED first step unlocks them.
* The new schema models one ordered enum, so these numbers are derived from it.
*/
/**
* PERS-03: the birth date both customer forms collect (legacy bulkCustomer
* `birth_date`, singleCustomer `birth_day`). DateInput sends the Gregorian
* 'YYYY-MM-DD' of the Jalali date picked; stored at UTC midnight, as the
* import stores legacy's. undefined = not sent (leave as is), '' = cleared.
*/
function birthDateOrNull(raw: unknown): Date | null | undefined {
if (raw === undefined) return undefined;
if (raw === null || String(raw).trim() === '') return null;
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(raw).trim());
const d = m ? new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])) : null;
if (!d || d.getUTCMonth() !== +m![2] - 1 || d.getUTCFullYear() < 1900 || d > new Date()) {
throw new BadRequestException('تاریخ تولد معتبر نیست');
}
return d;
}
/**
* A national code as the operator typed it, digits normalised; empty -> null.
* The column is optional and unique (2026-10-07): an absent code is NULL,
* never the mobile or '' — two customers without one must not collide, and
* the column must only ever hold a national code.
*/
function nationalCodeOrNull(raw: unknown): string | null {
return faToEn(String(raw ?? '').trim()) || null;
}
function verifyStepOne(step: VerificationStep): number {
switch (step) {
case VerificationStep.UNVERIFIED:
return 3; // rejected / not yet submitted
case VerificationStep.STEP_ONE_SUBMITTED:
return 1; // pending review
case VerificationStep.REJECTED:
// Must be explicit: falling into `default` would report 2 (accepted),
// and the view unlocks the order/account switches on step one === 2.
// Rejected customers keep them locked — legacy's step-one reject also
// set active = -1 (MajorCustomerController.php:116-117).
return 3;
case VerificationStep.STEP_TWO_SUBMITTED:
case VerificationStep.VERIFIED:
// both imply step one was accepted
return 2;
}
}
function verifyStepTwo(step: VerificationStep): number {
switch (step) {
case VerificationStep.STEP_TWO_SUBMITTED:
return 1; // pending review
case VerificationStep.VERIFIED:
return 2; // accepted
case VerificationStep.UNVERIFIED:
case VerificationStep.STEP_ONE_SUBMITTED:
case VerificationStep.REJECTED:
return 3; // rejected / not reached
}
}
@Injectable()
export class AdminCustomersService {
private readonly logger = new Logger(AdminCustomersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly sms: SmsService,
private readonly accounting: AccountingHooksService,
private readonly accountingConfig: AccountingConfigService,
) {}
/**
* The welcome SMS the reference sends after every new customer is created
* (AdminController.php:378-387), with the brand and the PWA link changed to
* BRAND_NAME / BRAND_SITE_URL (common/brand.ts); same line order. The commented-out Android/iOS lines are not sent
* there and are not sent here.
*
* D20 — awaiting it would let a slow or unconfigured gateway hold up the
* HTTP response, and `send()` already swallows every error, so the result is
* only logged. The reference behaves the same way: `Sms::Send` is fired
* inline and its return value discarded, so a failed message never blocked
* the creation.
*/
private sendWelcomeSms(mobile: string, firstName: string, lastName: string) {
const body =
`${brandName()}\n` +
`همکار گرامی ${firstName} ${lastName}\n` +
`نام کاربری : ${mobile}\n` +
'لینک pwa \n' +
brandSiteUrl();
void this.sms.send(mobile, body).catch((e: unknown) => {
// send() is documented never to throw; this is belt-and-braces so an
// unexpected rejection can never become an unhandled promise rejection.
this.logger.error(`Welcome SMS to ${mobile} failed: ${String(e)}`);
});
}
private static readonly ALLOWED = [
'id',
'fullName',
'firstName',
'lastName',
'makingChargeOffsetPercent',
'mobile',
'nationalCode',
'cityId',
'isActive',
'createdAt',
'updatedAt',
'verificationStep',
'goldCreditLimitGrams',
'goldDebtGrams',
'fiatDebtRial',
'accountingGoldBalance',
'accountingRialBalance',
'referredByAgent.fullName',
// legacy filter keys, accepted so the ported table models work unchanged
'name',
'city_id',
'active',
'gram_level',
'remain',
'rial_remain',
];
/** Translates legacy filter field names onto real schema paths. */
private static readonly FIELD_ALIASES: Record<string, string> = {
name: 'fullName',
// D0 — these now have real columns to point at. Sorting or filtering on
// the last name used to silently act on the whole name.
fname: 'firstName',
lname: 'lastName',
percent: 'makingChargeOffsetPercent',
city_id: 'cityId',
active: 'isActive',
gram_level: 'goldCreditLimitGrams',
remain: 'goldDebtGrams',
rial_remain: 'fiatDebtRial',
acc_remain: 'accountingGoldBalance',
acc_rial_remain: 'accountingRialBalance',
caj: 'createdAt',
uaj: 'updatedAt',
'reagent.name': 'referredByAgent.fullName',
};
private applyAliases(query: TableQueryDto): TableQueryDto {
const mapField = (f?: string) =>
f ? (AdminCustomersService.FIELD_ALIASES[f] ?? f) : f;
return {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
// legacy sent active as '1' / '-1'; the schema stores a boolean
if (mapped.field === 'isActive' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
}
/** The per-scope WHERE clause, one per legacy endpoint. */
/**
* Verified against the legacy server (henza_server), not inferred from the
* views — AdminController::getBanker / getShopkeeper / getCustomer and
* MajorCustomerController::indexArchive / indexVerify:
*
* customer -> type=2 AND is_archive=0 (مغازه دار اپلیکیشن)
* banker -> type=4 AND is_archive=0 (بنکدار)
* shopkeeper -> type=3 AND is_archive=0 (مغازه دار حضوری)
* major/verify/table -> awaiting first approval (inactive + step pending)
* major/archive/table -> is_archive=1 (no type filter)
*
* NOTE every non-archive list also excludes archived rows, so archiving a
* customer removes them from ALL type lists at once.
*
* «در انتظار» and «عمده» MAY show the same customer, exactly as in the
* reference: getCustomer has no verification filter, so a customer awaiting
* approval is in both lists.
*/
private scopeWhere(scope: OmdeScope): Prisma.CustomerWhereInput {
const pendingVerification: Prisma.CustomerWhereInput = {
isActive: false,
verificationStep: {
in: [VerificationStep.STEP_ONE_SUBMITTED, VerificationStep.STEP_TWO_SUBMITTED],
},
};
// A sales expert is an AdminUser; their trading Customer row exists only so
// the app can hold orders/ledger for them (D21). It must not appear next to
// real wholesale customers in «عمده» / «در انتظار».
const notExpertTradingAccount: Prisma.CustomerWhereInput = {
salesExpertTradingFor: null,
};
switch (scope) {
case 'banker':
return { kind: CustomerKind.BANKER, isArchived: false };
case 'shopkeeper':
// type 3 only — "لیست مغازه داران حضوری". App shopkeepers (type 2) are
// the main omde list, not this one.
return { kind: CustomerKind.WALK_IN_SHOPKEEPER, isArchived: false };
case 'non_verified':
return { isArchived: false, ...pendingVerification, ...notExpertTradingAccount };
case 'archived':
return { isArchived: true, ...notExpertTradingAccount };
case 'all':
default:
// type=2 + not archived (AdminController::getCustomer:166-168), minus an
// expert's trading row. NO verification/active filter: the reference
// lists every non-archived type-2 customer, pending ones included, and
// only counts the inactive ones separately. Filtering them out here
// emptied the list for customers migrated with a pending step.
return {
kind: CustomerKind.APP_SHOPKEEPER,
isArchived: false,
...notExpertTradingAccount,
};
}
}
/**
* Opt-in (X-Debug header) troubleshooting block for an empty list: counts only,
* no names/mobiles. Shows the exact WHERE the request ran, how many customers
* the scope matches before the grid's own filters, and how many customers
* exist per kind / are an expert's trading account. Off with DIAG_LOG=false.
*/
private async listDebug(
scope: OmdeScope,
scoped: Prisma.CustomerWhereInput,
total: number,
returned: number,
) {
const [byKind, inScope, expertTradingAccounts] = await Promise.all([
this.prisma.customer.groupBy({
by: ['kind', 'isActive', 'isArchived', 'verificationStep'],
_count: { _all: true },
}),
this.prisma.customer.count({ where: this.scopeWhere(scope) }),
this.prisma.customer.count({ where: { salesExpertTradingFor: { isNot: null } } }),
]);
return {
scope,
where: scoped,
matched_with_grid_filters: total,
returned_rows: returned,
matched_by_scope_only: inScope,
customers_total: byKind.reduce((n, g) => n + g._count._all, 0),
expert_trading_accounts: expertTradingAccounts,
by_kind: byKind.map(({ _count, ...g }) => ({ ...g, n: _count._all })),
};
}
async list(query: TableQueryDto, scope: OmdeScope = 'all', debug = false) {
this.table.assertNotExport(query);
const aliased = this.applyAliases(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
// D0 — `fullName` is kept in sync by the trigger so it still matches, but
// the parts are searched too: a surname-only query is the common case and
// it must not depend on how the whole name happens to be spaced.
searchable: ['fullName', 'firstName', 'lastName', 'mobile', 'nationalCode'],
allowed: AdminCustomersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const scoped: Prisma.CustomerWhereInput = {
AND: [where as Prisma.CustomerWhereInput, this.scopeWhere(scope)],
};
const [rows, total] = await Promise.all([
this.prisma.customer.findMany({
where: scoped,
orderBy: AdminCustomersService.nullsLast(orderBy) as Prisma.CustomerOrderByWithRelationInput,
skip,
take,
include: { city: true, referredByAgent: true, avatarFile: true },
}),
this.prisma.customer.count({ where: scoped }),
]);
const linked = await this.accountingLinked(rows.map((r) => r.id));
const result = this.table.respond(
rows.map((r) => ({ ...this.toRow(r), ...AdminCustomersService.accountingCells(r, linked) })),
total,
query,
page,
limit,
);
if (!debug || process.env.DIAG_LOG === 'false') return result;
return { ...result, debug: await this.listDebug(scope, scoped, total, rows.length) };
}
/**
* The accounting balance is null for a customer not linked to the software,
* and Postgres puts nulls FIRST on a descending sort — the biggest debtors
* would sit under every unlinked customer.
*/
private static nullsLast(orderBy: Record<string, unknown>) {
const [field, dir] = Object.entries(orderBy)[0] ?? [];
return field === 'accountingGoldBalance' || field === 'accountingRialBalance'
? { [field]: { sort: dir, nulls: 'last' } }
: orderBy;
}
/**
* D27 — which of these customers are linked to the CONNECTED accounting
* software; null when none is connected (the list then hides its columns).
* The provider is checked because a balance read from a previous provider
* stays on the customer row.
*/
private async accountingLinked(ids: string[]): Promise<Set<string> | null> {
const config = await this.accountingConfig.get();
if (config.provider === 'none' || this.accountingConfig.inactiveReason(config)) return null;
const links = await this.prisma.accountingLink.findMany({
where: { provider: config.provider, entityType: 'CUSTOMER', entityId: { in: ids } },
select: { entityId: true },
});
return new Set(links.map((l) => l.entityId));
}
/** The balance in the accounting software (app convention: positive = owes). */
private static accountingCells(
c: {
id: string;
accountingGoldBalance: Prisma.Decimal | null;
accountingRialBalance: Prisma.Decimal | null;
accountingBalanceAt: Date | null;
},
linked: Set<string> | null,
) {
if (!linked) return {};
const on = linked.has(c.id);
return {
acc_remain: on ? c.accountingGoldBalance : null,
acc_rial_remain: on ? c.accountingRialBalance : null,
acc_at: on ? c.accountingBalanceAt : null,
};
}
/** Legacy field names preserved so the ported table models bind unchanged. */
private toRow(
c: Prisma.CustomerGetPayload<{
include: { city: true; referredByAgent: true; avatarFile: true };
}>,
) {
return {
id: c.id,
// D0 — the schema now has the real parts, as the reference always did
// (2020_10_01_120430_customers_table.php:18-19). Before this, the edit
// form showed the WHOLE name in the "first name" box and saved an empty
// last name. `firstName`/`lastName` are backfilled and kept in sync with
// `fullName` by the customer_sync_full_name trigger, so the fallbacks
// below only matter for a row written before the migration ran.
name: c.fullName,
fname: c.firstName ?? c.fullName,
lname: c.lastName ?? '',
mobile: c.mobile,
national_code: c.nationalCode,
city_id: c.cityId,
city: c.city ? { id: c.city.id, title: c.city.name } : null,
reagent: c.referredByAgent
? { id: c.referredByAgent.id, name: c.referredByAgent.fullName }
: null,
gram_level: c.goldCreditLimitGrams,
remain: c.goldDebtGrams,
rial_remain: c.fiatDebtRial,
// D9 — legacy `percent` is a column ON THE CUSTOMER
// (customer_add.php:32), the per-customer making-charge offset. The port
// read the referring AGENT's commission rate instead, which is a
// different number entirely (CUST-02). The legacy key is kept so the
// ported table models and forms bind unchanged.
percent: c.makingChargeOffsetPercent,
// legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive
active: c.isActive ? 1 : -1,
sts: c.canPlaceOrders ? 1 : -1,
// 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,
sts_verify_step_one: verifyStepOne(c.verificationStep),
sts_verify_step_two: verifyStepTwo(c.verificationStep),
verificationStep: c.verificationStep,
verification_rejection_reason: c.verificationRejectionReason,
verification_rejected_at: c.verificationRejectedAt,
national_id_image: c.nationalIdImageUrl,
business_license: c.businessLicenseUrl,
shop_image: c.shopImageUrl,
caj: c.createdAt,
uaj: c.updatedAt,
// Legacy returned the avatar as a nested object and the form read
// `data.image.image` for the preview URL — kept in that exact shape.
image_id: c.avatarFileId,
image: c.avatarFile ? { id: c.avatarFile.id, image: c.avatarFile.url } : null,
phone: c.phone,
address: c.address,
store_name: c.storeName,
zone: c.zone,
pasazh_name: c.pasazhName,
mcode: c.memberCode,
// Both legacy names: bulkCustomer reads birth_date, singleCustomer birth_day.
birth_date: c.birthDate ? c.birthDate.toISOString().slice(0, 10) : null,
birth_day: c.birthDate ? c.birthDate.toISOString().slice(0, 10) : null,
};
}
async findOne(id: string) {
const c = await this.prisma.customer.findUnique({
where: { id },
include: {
city: true,
referredByAgent: true,
avatarFile: true,
addresses: { include: { city: true } },
},
});
if (!c) throw new NotFoundException('مشتری یافت نشد');
return {
...this.toRow(c),
addresses: c.addresses.map((a) => ({
id: a.id,
city: a.city ? { id: a.city.id, title: a.city.name } : null,
})),
};
}
/**
* Legacy `POST reagent` — the sales-expert ("کارشناس فروش") list.
*
* In legacy these lived in the same `customer` table under `type=1`, which is
* why the view reuses the customer column set. The redesigned schema models a
* sales expert as an AdminUser with role SALES_EXPERT, so this reads from there
* and maps onto the same legacy field names the ported table expects.
*/
async listReagents(query: TableQueryDto) {
this.table.assertNotExport(query);
const aliases: Record<string, string> = {
name: 'fullName',
// D0/D17 — real columns on AdminUser now, as on Customer.
fname: 'firstName',
lname: 'lastName',
caj: 'createdAt',
active: 'isActive',
percent: 'wagePercent',
// D17 — these live on the related SalesExpertProfile, so the grid's two
// filters have to reach through the relation.
city_id: 'salesExpertProfile.cityId',
gram_level: 'salesExpertProfile.gramLevel',
};
const mapField = (f?: string) => (f ? (aliases[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'isActive' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'firstName', 'lastName', 'mobile'],
allowed: [
'id',
'fullName',
'firstName',
'lastName',
'mobile',
'isActive',
'createdAt',
'wagePercent',
'role',
// D17 — the grid's «شهر» and «سطح اعتبار» filters.
'salesExpertProfile.cityId',
'salesExpertProfile.gramLevel',
],
defaultOrderBy: { createdAt: 'desc' },
});
const scoped: Prisma.AdminUserWhereInput = {
AND: [where as Prisma.AdminUserWhereInput, { role: 'SALES_EXPERT' }],
};
const [rows, total] = await Promise.all([
this.prisma.adminUser.findMany({
where: scoped,
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
skip,
take,
// D17 — a LEFT JOIN by nature in Prisma: an expert without a profile
// still comes back, with `salesExpertProfile` null, so a missing row
// degrades to empty cells rather than dropping the agent from the grid.
include: {
branch: true,
salesExpertProfile: { include: { city: true } },
_count: { select: { referredCustomers: true } },
},
}),
this.prisma.adminUser.count({ where: scoped }),
]);
// Total gold sold per agent, from the orders of the customers they referred.
const mapped = await Promise.all(
rows.map(async (r) => {
const agg = await this.prisma.order.aggregate({
where: agentSalesWhere(r.id),
_sum: { totalGoldWeightGrams: true },
});
return {
id: r.id,
name: r.fullName,
// D0 requirement 7 — the real parts, as on Customer. This used to be
// `fname: r.fullName, lname: ''`, the same defect the customer forms had.
fname: r.firstName ?? r.fullName,
lname: r.lastName ?? '',
mobile: r.mobile,
// D17 — real values now, so the grid's city column and its filter work.
// Null-safe: an expert with no profile row simply shows empty.
city_id: r.salesExpertProfile?.cityId ?? null,
city: r.salesExpertProfile?.city
? { id: r.salesExpertProfile.city.id, title: r.salesExpertProfile.city.name }
: null,
percent: r.wagePercent,
grams_sell: agg._sum.totalGoldWeightGrams ?? 0,
child: r._count.referredCustomers,
active: r.isActive ? 1 : -1,
sts: r.isActive ? 1 : -1,
sts_str: r.isActive ? 'فعال' : 'عدم سفارش',
caj: r.createdAt,
uaj: r.createdAt,
branch: r.branch ? { id: r.branch.id, name: r.branch.name } : null,
// D17 — from SalesExpertProfile. Null-safe for an expert with no row.
gram_level: r.salesExpertProfile?.gramLevel ?? null,
phone: r.salesExpertProfile?.phone ?? null,
ncode: r.salesExpertProfile?.nationalCode ?? null,
// Still genuinely absent: a sales expert is an AdminUser and has no
// ledger of its own, so it carries no gram or rial debt (D10).
remain: null,
rial_remain: null,
address: r.address ?? null,
};
}),
);
return this.table.respond(mapped, total, query, page, limit);
}
/**
* The sales agent's own detail screen ("مشاهده اطلاعات" on ReagentList.vue).
*
* A sales agent is an AdminUser now, not a Customer, so it has no debt/
* ledger of its own — "سند های مالی" doesn't apply the way it does for a
* wholesale customer. This shows the agent's profile plus the customers
* they referred and the orders those customers placed instead.
*/
async reagentDetail(id: string) {
const agent = await this.prisma.adminUser.findUnique({
where: { id, role: 'SALES_EXPERT' },
include: { branch: true, avatarFile: true },
});
if (!agent) throw new NotFoundException('کارشناس فروش یافت نشد');
const referred = await this.prisma.customer.findMany({
where: { referredByAgentId: id },
orderBy: { createdAt: 'desc' },
include: { city: true },
});
const orders = await this.prisma.order.findMany({
// 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({
// 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 },
});
return {
id: agent.id,
name: agent.fullName,
mobile: agent.mobile,
percent: agent.wagePercent,
active: agent.isActive ? 1 : -1,
branch: agent.branch ? { id: agent.branch.id, name: agent.branch.name } : null,
image: agent.avatarFile ? { id: agent.avatarFile.id, image: agent.avatarFile.url } : null,
caj: agent.createdAt,
grams_sell: gramsSoldAgg._sum.totalGoldWeightGrams ?? 0,
referred_customers: referred.map((c) => ({
id: c.id,
name: c.fullName,
mobile: c.mobile,
city: c.city ? { id: c.city.id, title: c.city.name } : null,
type_str: CUSTOMER_TYPE_STR[c.kind],
active: c.isActive ? 1 : -1,
caj: c.createdAt,
})),
orders: orders.map((o) => ({
id: o.id,
name: o.orderNumber,
customer: o.customer.fullName,
w: o.totalGoldWeightGrams,
status: o.status,
caj: o.createdAt,
})),
};
}
/**
* Legacy `POST single-customer` — the retail ("تک") customer list.
*
* Legacy read this from a COMPLETELY SEPARATE table (SingleCustomer / `login`)
* that could never contain a wholesale/agent/shopkeeper/banker row — the
* separation was structural, not a WHERE-clause discipline. The redesigned
* schema folded every kind into one Customer model, so this list MUST filter
* on `kind: RETAIL` to reproduce that same separation; without it, every
* Customer row (including a mis-created sales agent — see admin-users
* module's header comment) leaked in here. Filtered explicitly rather than
* inferred, so a future creation-path bug can't quietly widen this list again.
*/
async listSingle(query: TableQueryDto, branchId?: string) {
this.table.assertNotExport(query);
const aliased = this.applyAliases(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'mobile'],
allowed: [...AdminCustomersService.ALLOWED, 'branch.name'],
defaultOrderBy: { createdAt: 'desc' },
});
const scoped: Prisma.CustomerWhereInput = {
AND: [where as Prisma.CustomerWhereInput, { kind: CustomerKind.RETAIL }],
};
const [rows, total] = await Promise.all([
this.prisma.customer.findMany({
where: scoped,
orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput,
skip,
take,
include: {
city: true,
avatarFile: true,
referredByAgent: { include: { branch: true } },
},
}),
this.prisma.customer.count({ where: scoped }),
]);
return this.table.respond(
rows.map((r) => ({
...this.toRow(r),
// legacy single-customer columns: fname/lname/phone/email/branch.name
email: null,
branch: r.referredByAgent?.branch
? { id: r.referredByAgent.branch.id, name: r.referredByAgent.branch.name }
: null,
})),
total,
query,
page,
limit,
);
}
/**
* Legacy `POST single` — singleCustomer.vue, the retail ("تک") customer
* form. This endpoint never existed before (singleCustomer.vue posted to
* a path nothing served — see this method's git history / the bug report
* that led here); its edit path happened to work only because `update()`
* is generic enough to patch any Customer row, agent or otherwise.
*/
async createSingle(data: Record<string, unknown>) {
// D0 — store the parts when the caller sends them. This is the retail
// (storefront) form, which D13 puts out of scope for parity work; the name
// handling is corrected only so the shared Customer columns stay
// consistent, and no storefront behaviour changes.
const firstName = String(data.fname ?? data.name ?? '').trim();
const lastName = String(data.lname ?? '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
const mobile = String(data.mobile ?? data.phone ?? '').trim();
if (!fullName || !mobile) {
throw new BadRequestException('نام و موبایل الزامی است');
}
return this.prisma.customer.create({
data: {
fullName,
firstName: firstName || null,
lastName: lastName || null,
mobile,
// singleCustomer.vue collects no national code.
nationalCode: null,
passwordHash: OTP_ONLY_PASSWORD_HASH,
cityId: (data.city_id as string) || null,
birthDate: birthDateOrNull(data.birth_day ?? data.birth_date) ?? null,
kind: CustomerKind.RETAIL,
},
});
}
/**
* Legacy `POST customer/create` (omdeSave) and `POST/PUT user/profile`.
*
* Sales agents (`type`/`kind` SALES_AGENT) are REJECTED here — a sales
* agent is an AdminUser (role SALES_EXPERT) now, created via
* `POST admin/users`, not a Customer row. bulkCustomer.vue no longer offers
* that option; this guard is defense-in-depth against any other caller
* (e.g. reg.vue, or a stale client) still sending type=1.
*/
async create(data: Record<string, unknown>) {
// D0 — the form posts fname and lname separately; store them separately.
// `name` is still accepted for the callers that only send a whole name.
const firstName = String(data.fname ?? data.name ?? '').trim();
const lastName = String(data.lname ?? '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
// D19 gap 2 — the reference normalises the submitted mobile with
// Utility::faTOen before storing it (AdminController.php:333,364). Without
// this, an operator typing ۰۹۱۲… stores Persian digits and that customer
// can never log in: the app's own faToEn turns the typed number into ASCII
// and the lookup misses.
const mobile = faToEn(String(data.mobile ?? '').trim());
const nationalCode = nationalCodeOrNull(data.national_code ?? data.nationalCode);
if (!fullName || !mobile) {
throw new BadRequestException('نام و موبایل الزامی است');
}
// One mobile, one person: an active sales expert signs in with this number
// too. (A number another CUSTOMER holds is refused by the unique index.)
if (await activeExpertHoldsMobile(this.prisma, mobile)) {
throw new BadRequestException(EXPERT_HOLDS_MOBILE);
}
// D9 — the reference makes `percent` required on create
// (AdminController.php:354) with the stock Laravel message, and its
// attribute has no Persian translation, so the text is verbatim what the
// reference produces.
if (data.percent === undefined || data.percent === null || data.percent === '') {
throw new BadRequestException('وارد کردن percent الزامی است');
}
const kind = legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER;
if (kind === CustomerKind.SALES_AGENT) {
throw new BadRequestException(
'کارشناس فروش از این فرم ثبت نمی‌شود. از بخش «افزودن مدیر» با نقش «کارشناس فروش» استفاده کنید.',
);
}
const created = await this.prisma.customer.create({
data: {
fullName,
firstName: firstName || null,
lastName: lastName || null,
// D9 — per-customer making-charge offset. Required above, so this is
// always a real value; the reference's own form default is 3
// (AdminController.php:338) and is applied client-side.
makingChargeOffsetPercent: new Prisma.Decimal(String(data.percent)),
mobile,
nationalCode,
// The form collects a password (bulkCustomer.vue / ReagentCustomer.vue).
// It used to be discarded and '' stored, so the operator typed a password
// that silently did nothing. Hashed here when supplied; otherwise the
// account signs in by OTP only.
passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : OTP_ONLY_PASSWORD_HASH,
cityId: (data.city_id as string) ?? null,
goldCreditLimitGrams: data.gram_level
? new Prisma.Decimal(String(data.gram_level))
: new Prisma.Decimal(0),
// legacy `type` — defaults to 2 (مغازه دار اپلیکیشن), matching the
// main omde list, when the form omits it.
kind,
phone: (data.phone as string) ?? null,
address: (data.address as string) ?? null,
storeName: (data.store_name as string) ?? null,
pasazhName: (data.pasazh_name as string) ?? null,
zone: (data.zone as string) ?? null,
memberCode: (data.mcode as string) ?? null,
birthDate: birthDateOrNull(data.birth_date ?? data.birth_day) ?? null,
// legacy `image_id` from MyUpload's `ok` payload
avatarFileId: (data.image_id as string) || null,
// The three document photos the wholesale form collects. These were
// already read back in toRow() but never persisted, so every upload was
// silently discarded on save.
nationalIdImageUrl: (data.national_id_image as string) || null,
businessLicenseUrl: (data.business_license as string) || null,
shopImageUrl: (data.shop_image as string) || null,
// The sales agent who introduced this customer. The form's dropdown was
// populated from a working endpoint and the selection then thrown away.
referredByAgentId: (data.reagent_id as string) || null,
},
});
// D19 gap 1 — the reference sends this only on CREATE, guarded by
// `if (!isset($input['id']))` (AdminController.php:378). Fire-and-forget,
// so the operator's save never waits on the gateway.
this.sendWelcomeSms(mobile, firstName, lastName);
return created;
}
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.customer.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('مشتری یافت نشد');
const patch: Prisma.CustomerUpdateInput = {};
// D0 — write the parts, not a collapsed whole name. The
// customer_sync_full_name trigger rebuilds `fullName` from them, and when
// a caller sends both the parts win (see the migration's header).
if (data.fname !== undefined || data.lname !== undefined) {
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
const lastName = String(data.lname ?? existing.lastName ?? '').trim();
patch.firstName = firstName || null;
patch.lastName = lastName || null;
} else if (data.name !== undefined) {
// Whole-name-only callers: the trigger derives the parts.
patch.fullName = String(data.name);
}
// D9 — this was simply not handled, so editing it silently did nothing.
if (data.percent !== undefined && data.percent !== null && data.percent !== '') {
patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent));
}
// D19 gap 2 — same normalisation as create(). The reference discards the
// submitted mobile entirely on edit (AdminController.php:348); D19 ruled
// that lock NOT ported, so the field stays editable here and is normalised
// instead. Logged in ISSUES as PERS-01.
if (data.mobile !== undefined) {
const mobile = faToEn(String(data.mobile));
patch.mobile = mobile;
// Asked only when the number CHANGES: a customer who already shares a
// number with an expert (older data) stays editable and keeps signing in.
// A trading account may carry its own expert's number.
if (mobile !== existing.mobile && (await activeExpertHoldsMobile(this.prisma, mobile, id))) {
throw new BadRequestException(EXPERT_HOLDS_MOBILE);
}
}
if (data.national_code !== undefined) patch.nationalCode = nationalCodeOrNull(data.national_code);
if (data.city_id !== undefined) {
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
}
if (data.gram_level !== undefined) {
patch.goldCreditLimitGrams = new Prisma.Decimal(String(data.gram_level));
}
if (data.type !== undefined) {
const kind = legacyTypeToKind(data.type);
if (!kind) throw new BadRequestException('نوع مشتری نامعتبر است');
// See create()'s header comment — a Customer row must never become a
// sales agent; that's an AdminUser (role SALES_EXPERT) now.
if (kind === CustomerKind.SALES_AGENT) {
throw new BadRequestException(
'کارشناس فروش از این فرم ویرایش نمی‌شود. از بخش «افزودن مدیر» با نقش «کارشناس فروش» استفاده کنید.',
);
}
patch.kind = kind;
}
if (data.phone !== undefined) patch.phone = String(data.phone);
if (data.address !== undefined) patch.address = String(data.address);
if (data.store_name !== undefined) patch.storeName = String(data.store_name);
if (data.pasazh_name !== undefined) patch.pasazhName = String(data.pasazh_name);
if (data.zone !== undefined) patch.zone = String(data.zone);
if (data.mcode !== undefined) patch.memberCode = String(data.mcode);
const birthDate = birthDateOrNull(data.birth_date !== undefined ? data.birth_date : data.birth_day);
if (birthDate !== undefined) patch.birthDate = birthDate;
if (data.reagent_id !== undefined) {
patch.referredByAgent = data.reagent_id
? { connect: { id: String(data.reagent_id) } }
: { disconnect: true };
}
// Only when non-empty: an untouched password field must not clear the hash.
if (data.password) {
patch.passwordHash = await bcrypt.hash(String(data.password), 10);
}
if (data.image_id !== undefined) {
// legacy sent '' to mean "avatar removed"
patch.avatarFile = data.image_id
? { connect: { id: String(data.image_id) } }
: { disconnect: true };
}
// Same three document photos as in create(). '' means "removed", so these
// normalise to null rather than storing an empty string.
if (data.national_id_image !== undefined) {
patch.nationalIdImageUrl = (data.national_id_image as string) || null;
}
if (data.business_license !== undefined) {
patch.businessLicenseUrl = (data.business_license as string) || null;
}
if (data.shop_image !== undefined) {
patch.shopImageUrl = (data.shop_image as string) || null;
}
// D27 — the fields the accounting account carries. A change to one of them
// is pushed to a linked account.
const contactChanged = ['fname', 'lname', 'name', 'mobile', 'national_code', 'phone', 'address'].some(
(k) => data[k] !== undefined,
);
return this.prisma.$transaction(async (tx) => {
const updated = await tx.customer.update({ where: { id }, data: patch });
if (contactChanged) await this.accounting.customerEdited(tx, id);
return updated;
});
}
/**
* The three per-customer panels the "مشاهده اطلاعات" screens show, ported from
* legacy `single/order/{id}`, `single/report/{id}` and `single/wallet/{id}`.
*/
async customerOrders(customerId: string) {
const rows = await this.prisma.order.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
include: { items: true, branch: true },
});
return rows.map((o) => ({
id: o.id,
name: o.orderNumber,
w: o.totalGoldWeightGrams,
status: o.status,
items_count: o.items.length,
branch: o.branch?.name ?? null,
caj: o.createdAt,
}));
}
async customerDocuments(customerId: string) {
const rows = await this.prisma.ledgerEntry.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
});
return rows.map((e) => ({
id: e.id,
type: e.sourceType,
currency: e.currency,
direction: e.direction,
value: e.amount,
confirmed: e.confirmed ? 1 : 0,
note: e.note,
caj: e.createdAt,
}));
}
async customerWallet(customerId: string) {
const rows = await this.prisma.walletBonus.findMany({
where: { customerId },
orderBy: { grantedAt: 'desc' },
});
return rows.map((w) => ({
id: w.id,
value: w.goldGrams,
is_active: w.isActive,
consumed: Boolean(w.consumedAt),
caj: w.grantedAt,
expires_at: w.expiresAt,
}));
}
/** Legacy `GET query/reagent` — sales experts, for the customer form's picker. */
async queryReagents() {
const rows = await this.prisma.adminUser.findMany({
where: { role: 'SALES_EXPERT', isActive: true },
orderBy: { fullName: 'asc' },
select: { id: true, fullName: true },
});
return rows.map((r) => ({ id: r.id, name: r.fullName }));
}
/**
* Legacy `POST wallet/charge` — grant a wallet bonus to a customer.
*
* Legacy posted `{ customer_id, value }`. The redesigned schema models a
* wallet grant as a WalletBonus in GOLD GRAMS with an expiry, so `value` is
* read as grams. Legacy had no expiry column; a one-year window is applied
* here because `expiresAt` is required — flagged in docs/ADMIN-PORT-PLAN.md.
*/
async chargeWallet(data: { customer_id: string; value: string | number }) {
const customer = await this.prisma.customer.findUnique({ where: { id: data.customer_id } });
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const grams = new Prisma.Decimal(String(data.value ?? 0));
if (grams.lte(0)) {
throw new BadRequestException('میزان شارژ باید بزرگتر از صفر باشد');
}
const expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
return this.prisma.walletBonus.create({
data: { customerId: data.customer_id, goldGrams: grams, expiresAt },
});
}
/**
* Legacy `POST customer/toggle-active` — the ACCOUNT switch
* (AdminController::customerToggleActive).
*
* Legacy also nulled `api_token` (force-logout) and, when reactivating, sent
* the customer an SMS. The SMS is deliberately NOT fired inline here — it
* belongs to the notification module — and is noted in docs/ADMIN-PORT-PLAN.md.
*/
async toggleActive(id: string) {
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
return this.prisma.customer.update({
where: { id },
data: { isActive: !c.isActive },
});
}
/**
* Legacy `POST customer/toggle-sts` — the ORDERING switch
* ("معلق کردن سفارش" / "فعال کردن سفارش"), AdminController::customerToggleSts.
* Independent of the account switch above.
*/
async toggleSts(id: string) {
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
return this.prisma.customer.update({
where: { id },
data: { canPlaceOrders: !c.canPlaceOrders },
});
}
/**
* Legacy `POST major/verify`.
*
* Legacy's payload is `{ customer_id, sts_verify_step_one }` or
* `{ customer_id, sts_verify_step_two }`, where the value is 2 (accept) or
* 3 (reject) — see OmdeList.vue acceptStepOne/rejectStepOne. Accepted here in
* exactly that shape so the ported view posts unchanged, plus an optional
* `reason` on a reject.
*
* A reject at either step records REJECTED with its reason and time (owner
* ruling 2026-10-07). It used to send the customer back to UNVERIFIED /
* STEP_ONE_SUBMITTED, so a rejection left no trace and looked like a
* customer who had simply not finished. Same rule as the legacy import:
* a rejected first OR second step is REJECTED (DATA-MIGRATION-MAP §5.5).
* Accepting again clears the reason.
*/
async verify(payload: {
customer_id: string;
sts_verify_step_one?: number;
sts_verify_step_two?: number;
reason?: string | null;
}) {
const id = payload.customer_id;
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
const step = payload.sts_verify_step_one ?? payload.sts_verify_step_two;
if (step === undefined) throw new BadRequestException('مرحله‌ی تایید مشخص نشده است');
let data: Prisma.CustomerUpdateInput;
if (step === 2) {
data = {
verificationStep:
payload.sts_verify_step_one !== undefined
? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending
: VerificationStep.VERIFIED,
verificationRejectionReason: null,
verificationRejectedAt: null,
// Reference MajorCustomerController.php:108-110 — accepting step one
// activates the account. An app self-registration is created inactive
// and can sign in only from here on.
...(payload.sts_verify_step_one !== undefined ? { isActive: true } : {}),
};
} else {
data = {
verificationStep: VerificationStep.REJECTED,
verificationRejectionReason: String(payload.reason ?? '').trim() || null,
verificationRejectedAt: new Date(),
};
}
const next = data.verificationStep;
return this.prisma.$transaction(async (tx) => {
const updated = await tx.customer.update({ where: { id }, data });
// D27 — a verified customer gets an account in the accounting software
// (queued; searched by mobile first, so an existing account is linked).
if (next === VerificationStep.VERIFIED && c.verificationStep !== VerificationStep.VERIFIED) {
await this.accounting.customerVerified(tx, id);
}
return updated;
});
}
/** Legacy `GET major/archive/{id}` — MajorCustomerController::archive. */
async toggleArchive(id: string) {
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
return this.prisma.customer.update({
where: { id },
data: { isArchived: !c.isArchived },
});
}
/** Legacy `GET major/dashboard/{id}` — the per-customer summary panel. */
async dashboard(id: string) {
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
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 {
total_order: orderCount,
open_order: openOrders,
ledger_entries: ledgerCount,
gram_level: c.goldCreditLimitGrams,
remain: c.goldDebtGrams,
rial_remain: c.fiatDebtRial,
grams_sell: sold._sum.totalGoldWeightGrams ?? 0,
};
}
/** Legacy `PATCH` of the credit level / commission from the list row menu. */
async updateCredit(id: string, data: { gram_level?: string; percent?: string }) {
const c = await this.prisma.customer.findUnique({ where: { id } });
if (!c) throw new NotFoundException('مشتری یافت نشد');
if (data.gram_level === undefined) {
throw new BadRequestException('مقدار سطح اعتبار ارسال نشده است');
}
// D9 — `percent` was in this method's signature but was never written, so
// editing it from the row menu silently did nothing.
const patch: Prisma.CustomerUpdateInput = {
goldCreditLimitGrams: new Prisma.Decimal(data.gram_level),
};
if (data.percent !== undefined && data.percent !== null && String(data.percent) !== '') {
patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent));
}
return this.prisma.customer.update({ where: { id }, data: patch });
}
}