import { BadRequestException, Injectable, 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'; /** * 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: ' کارشناس فروش', APP_SHOPKEEPER: 'مغازه دار اپلیکیشن', WALK_IN_SHOPKEEPER: ' مغازه دار حضوری', BANKER: 'بنکدار', WORKSHOP_SUPERVISOR: 'سرپرست کارگاه', }; const LEGACY_TYPE_ID: Record = { 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 => LEGACY_TYPE_ID[k]; 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. */ 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 default: // STEP_TWO_SUBMITTED and 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 default: return 3; // rejected / not reached } } @Injectable() export class AdminCustomersService { constructor( private readonly prisma: PrismaService, private readonly table: TableService, ) {} private static readonly ALLOWED = [ 'id', 'fullName', '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', fname: 'fullName', lname: 'fullName', 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, { searchable: ['fullName', '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, // Legacy stored fname/lname separately; the new schema has one fullName. name: c.fullName, fname: c.fullName, lname: '', 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, // legacy `percent` came from the referring agent's commission rate percent: c.referredByAgent?.wagePercent ?? null, // legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive active: c.isActive ? 1 : -1, sts: c.canPlaceOrders ? 1 : -1, 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, 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, }; } 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', lname: 'fullName', caj: 'createdAt', active: 'isActive', percent: 'wagePercent', }; 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', 'mobile'], allowed: ['id', 'fullName', 'mobile', 'isActive', 'createdAt', 'wagePercent', 'role'], 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, include: { branch: 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: { customer: { referredByAgentId: r.id } }, _sum: { totalGoldWeightGrams: true }, }); return { id: r.id, name: r.fullName, fname: r.fullName, lname: '', mobile: r.mobile, city_id: null, city: 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, // Legacy columns an AdminUser has no counterpart for. gram_level: null, remain: null, rial_remain: null, phone: null, address: null, }; }), ); return this.table.respond(mapped, total, query, page, limit); } /** * Legacy `POST single-customer` — the retail ("تک") customer list. * * Legacy read this from a different table than the wholesale list * (`orders_orders`-era SingleCustomerOrders lived alongside a separate customer * record). The redesigned schema has one Customer model, so both lists read the * same rows; only the column set and actions differ, exactly as the two legacy * table models did. Same gap noted for orders in docs/ADMIN-PORT-PLAN.md. */ 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 [rows, total] = await Promise.all([ this.prisma.customer.findMany({ where: where as Prisma.CustomerWhereInput, orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput, skip, take, include: { city: true, avatarFile: true, referredByAgent: { include: { branch: true } }, }, }), this.prisma.customer.count({ where: where as Prisma.CustomerWhereInput }), ]); 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 customer/create` (omdeSave) and `POST/PUT user/profile`. */ async create(data: Record) { const fullName = String(data.name ?? data.fname ?? '').trim(); const mobile = String(data.mobile ?? '').trim(); const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim(); if (!fullName || !mobile) { throw new BadRequestException('نام و موبایل الزامی است'); } return this.prisma.customer.create({ data: { fullName, mobile, nationalCode: nationalCode || mobile, // schema requires it and it is unique // 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; '' still means // "no password yet", which no bcrypt hash can ever match. passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : '', 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: legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER, 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, // legacy `image_id` from MyUpload's `ok` payload avatarFileId: (data.image_id 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, }, }); } async update(id: string, data: Record) { const existing = await this.prisma.customer.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('مشتری یافت نشد'); const patch: Prisma.CustomerUpdateInput = {}; if (data.name !== undefined) patch.fullName = String(data.name); if (data.mobile !== undefined) patch.mobile = String(data.mobile); if (data.national_code !== undefined) patch.nationalCode = String(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('نوع مشتری نامعتبر است'); 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); 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 }; } return this.prisma.customer.update({ where: { id }, data: patch }); } /** * 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. */ async verify(payload: { customer_id: string; sts_verify_step_one?: number; sts_verify_step_two?: number; }) { const id = payload.customer_id; const c = await this.prisma.customer.findUnique({ where: { id } }); if (!c) throw new NotFoundException('مشتری یافت نشد'); let next: VerificationStep; if (payload.sts_verify_step_one !== undefined) { next = payload.sts_verify_step_one === 2 ? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending : VerificationStep.UNVERIFIED; // rejected } else if (payload.sts_verify_step_two !== undefined) { next = payload.sts_verify_step_two === 2 ? VerificationStep.VERIFIED : VerificationStep.STEP_ONE_SUBMITTED; // rejected -> back to step one accepted } else { throw new BadRequestException('مرحله‌ی تایید مشخص نشده است'); } return this.prisma.customer.update({ where: { id }, data: { verificationStep: next } }); } /** 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] = 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 } }), ]); return { total_order: orderCount, open_order: openOrders, ledger_entries: ledgerCount, gram_level: c.goldCreditLimitGrams, remain: c.goldDebtGrams, rial_remain: c.fiatDebtRial, }; } /** 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('مقدار سطح اعتبار ارسال نشده است'); } return this.prisma.customer.update({ where: { id }, data: { goldCreditLimitGrams: new Prisma.Decimal(data.gram_level) }, }); } }