Files
tehran_tala/apps/api/src/modules/admin-customers/admin-customers.service.ts
T
s.esmaeiliandCursor 4d635cb89a fix(sms): every SMS says منتزر instead of هنزا گلد
The SMS texts were ported verbatim from henza and still carried its brand:
the login code, the welcome message (with the app.henzagold.com link), the
two complete-profile messages and the financial-document confirm/reject.
All now say منتزر, and the welcome link is https://bnk.montzar.ir.

The login code keeps its first line ("<#> code is <CODE>") and the trailing
SMS Retriever hash unchanged, so Android autofill still works. «اپلیکشن» is
corrected to «اپلیکیشن» in the same line.

Tests: sms.service.spec OTP template. All 263 API tests pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 17:10:45 +03:30

1103 lines
45 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';
/**
* 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,
) {}
/**
* 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
* منتزر; 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 =
'منتزر\n' +
`همکار گرامی ${firstName} ${lastName}\n` +
`نام کاربری : ${mobile}\n` +
'لینک pwa \n' +
'https://bnk.montzar.ir';
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',
'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',
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 -> is_archive=0 AND (step_one=1 OR step_two=1)
* 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.
*/
private scopeWhere(scope: OmdeScope): Prisma.CustomerWhereInput {
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,
verificationStep: {
in: [VerificationStep.STEP_ONE_SUBMITTED, VerificationStep.STEP_TWO_SUBMITTED],
},
};
case 'archived':
return { isArchived: true };
case 'all':
default:
// legacy getCustomer is type=2 + is_archive=0 — not an unfiltered list.
return { kind: CustomerKind.APP_SHOPKEEPER, isArchived: false };
}
}
async list(query: TableQueryDto, scope: OmdeScope = 'all') {
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: orderBy as Prisma.CustomerOrderByWithRelationInput,
skip,
take,
include: { city: true, referredByAgent: true, avatarFile: true },
}),
this.prisma.customer.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** 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('نام و موبایل الزامی است');
}
// 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) patch.mobile = faToEn(String(data.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,
};
} 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 });
}
}