feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminCustomersService, type OmdeScope } from './admin-customers.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Wholesale customer lists. Legacy endpoints, one per near-identical view:
|
||||
* POST customer -> OmdeList
|
||||
* POST banker -> BankerList
|
||||
* POST shopkeeper -> ShopkeeperList
|
||||
* POST major/verify/table -> OmdeNonVerifiedList
|
||||
* POST major/archive/table -> OmdeArchivedList
|
||||
* POST customer/toggle-active, customer/toggle-sts, major/verify
|
||||
* GET major/archive/{id}, major/dashboard/{id}
|
||||
*/
|
||||
@Controller('admin/customers-omde')
|
||||
export class AdminCustomersController {
|
||||
constructor(private readonly customers: AdminCustomersService) {}
|
||||
|
||||
private list(query: TableQueryDto, scope: OmdeScope) {
|
||||
return this.customers.list(query, scope);
|
||||
}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
listAll(@Body() query: TableQueryDto) {
|
||||
return this.list(query, 'all');
|
||||
}
|
||||
|
||||
@Post('banker/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
listBanker(@Body() query: TableQueryDto) {
|
||||
return this.list(query, 'banker');
|
||||
}
|
||||
|
||||
@Post('shopkeeper/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
listShopkeeper(@Body() query: TableQueryDto) {
|
||||
return this.list(query, 'shopkeeper');
|
||||
}
|
||||
|
||||
@Post('non-verified/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
listNonVerified(@Body() query: TableQueryDto) {
|
||||
return this.list(query, 'non_verified');
|
||||
}
|
||||
|
||||
@Post('archived/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde')
|
||||
listArchived(@Body() query: TableQueryDto) {
|
||||
return this.list(query, 'archived');
|
||||
}
|
||||
|
||||
/** Legacy POST single-customer — the retail customer list. */
|
||||
@Post('single/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.single')
|
||||
listSingle(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.customers.listSingle(query, admin.branchId ?? undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET query/reagent` — the sales-expert picker used by the customer
|
||||
* form. Returns a plain array of {id, name}, as legacy did.
|
||||
*/
|
||||
@Get('query/reagent')
|
||||
@RequirePermission('persons.create')
|
||||
queryReagents() {
|
||||
return this.customers.queryReagents();
|
||||
}
|
||||
|
||||
/** Legacy POST wallet/charge. */
|
||||
@Post('wallet/charge')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('wallet')
|
||||
chargeWallet(@Body() body: { customer_id: string; value: string | number }) {
|
||||
return this.customers.chargeWallet(body);
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.customers.create(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.customers.update(id, data);
|
||||
}
|
||||
|
||||
/** Legacy POST reagent — the sales-expert list. */
|
||||
@Post('reagent/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.sellers')
|
||||
listReagents(@Body() query: TableQueryDto) {
|
||||
return this.customers.listReagents(query);
|
||||
}
|
||||
|
||||
/** Legacy `GET single/order/{id}` — that customer's orders. */
|
||||
@Get(':id/orders')
|
||||
@RequirePermission('persons.list.client.omde.view.orders')
|
||||
customerOrders(@Param('id') id: string) {
|
||||
return this.customers.customerOrders(id);
|
||||
}
|
||||
|
||||
/** Legacy `GET single/report/{id}` — that customer's ledger documents. */
|
||||
@Get(':id/documents')
|
||||
@RequirePermission('persons.list.client.omde.view.documents')
|
||||
customerDocuments(@Param('id') id: string) {
|
||||
return this.customers.customerDocuments(id);
|
||||
}
|
||||
|
||||
/** Legacy `GET single/wallet/{id}` — that customer's wallet bonuses. */
|
||||
@Get(':id/wallet')
|
||||
@RequirePermission('wallet')
|
||||
customerWallet(@Param('id') id: string) {
|
||||
return this.customers.customerWallet(id);
|
||||
}
|
||||
|
||||
@Get('dashboard/:id')
|
||||
@RequirePermission('persons.list.client.omde.view')
|
||||
dashboard(@Param('id') id: string) {
|
||||
return this.customers.dashboard(id);
|
||||
}
|
||||
|
||||
@Get('archive/:id')
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
toggleArchive(@Param('id') id: string) {
|
||||
return this.customers.toggleArchive(id);
|
||||
}
|
||||
|
||||
@Post('toggle-active')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
toggleActive(@Body() body: { id: string }) {
|
||||
return this.customers.toggleActive(body.id);
|
||||
}
|
||||
|
||||
@Post('toggle-sts')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
toggleSts(@Body() body: { id: string }) {
|
||||
return this.customers.toggleSts(body.id);
|
||||
}
|
||||
|
||||
@Post('verify')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
verify(
|
||||
@Body()
|
||||
body: { customer_id: string; sts_verify_step_one?: number; sts_verify_step_two?: number },
|
||||
) {
|
||||
return this.customers.verify(body);
|
||||
}
|
||||
|
||||
@Post('credit')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.list.client.omde.edit')
|
||||
updateCredit(@Body() body: { id: string; gram_level?: string; percent?: string }) {
|
||||
return this.customers.updateCredit(body.id, body);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('persons.list.client.omde.view')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.customers.findOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminCustomersController } from './admin-customers.controller';
|
||||
import { AdminCustomersService } from './admin-customers.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminCustomersController],
|
||||
providers: [AdminCustomersService],
|
||||
exports: [AdminCustomersService],
|
||||
})
|
||||
export class AdminCustomersModule {}
|
||||
@@ -0,0 +1,719 @@
|
||||
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<CustomerKind, string> = {
|
||||
SALES_AGENT: ' کارشناس فروش',
|
||||
APP_SHOPKEEPER: 'مغازه دار اپلیکیشن',
|
||||
WALK_IN_SHOPKEEPER: ' مغازه دار حضوری',
|
||||
BANKER: 'بنکدار',
|
||||
WORKSHOP_SUPERVISOR: 'سرپرست کارگاه',
|
||||
};
|
||||
|
||||
const LEGACY_TYPE_ID: 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 => 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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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) },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user