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>
1103 lines
45 KiB
TypeScript
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 });
|
|
}
|
|
}
|