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 = { 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> = { 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 = { 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 = { 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) { // 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) { // 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) { 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 }); } }