admin user and ui panel
This commit is contained in:
@@ -65,6 +65,14 @@ export class AdminCustomersController {
|
||||
return this.customers.listSingle(query, admin.branchId ?? undefined);
|
||||
}
|
||||
|
||||
/** Legacy `POST single` — singleCustomer.vue, the retail customer form. */
|
||||
@Post('single')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('persons.create')
|
||||
createSingle(@Body() data: Record<string, unknown>) {
|
||||
return this.customers.createSingle(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET query/reagent` — the sales-expert picker used by the customer
|
||||
* form. Returns a plain array of {id, name}, as legacy did.
|
||||
@@ -104,6 +112,13 @@ export class AdminCustomersController {
|
||||
return this.customers.listReagents(query);
|
||||
}
|
||||
|
||||
/** A sales agent's own detail screen — see reagentDetail()'s header comment. */
|
||||
@Get('reagent/:id')
|
||||
@RequirePermission('persons.list.sellers.view')
|
||||
reagentDetail(@Param('id') id: string) {
|
||||
return this.customers.reagentDetail(id);
|
||||
}
|
||||
|
||||
/** Legacy `GET single/order/{id}` — that customer's orders. */
|
||||
@Get(':id/orders')
|
||||
@RequirePermission('persons.list.client.omde.view.orders')
|
||||
|
||||
@@ -22,14 +22,19 @@ import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
* `type` and Farsi `type_str` the ported grids bind to.
|
||||
*/
|
||||
export const CUSTOMER_TYPE_STR: Record<CustomerKind, string> = {
|
||||
SALES_AGENT: ' کارشناس فروش',
|
||||
SALES_AGENT: ' کارشناس فروش', // legacy value only — see CustomerKind's schema comment
|
||||
APP_SHOPKEEPER: 'مغازه دار اپلیکیشن',
|
||||
WALK_IN_SHOPKEEPER: ' مغازه دار حضوری',
|
||||
BANKER: 'بنکدار',
|
||||
WORKSHOP_SUPERVISOR: 'سرپرست کارگاه',
|
||||
RETAIL: 'مشتری تک',
|
||||
};
|
||||
|
||||
const LEGACY_TYPE_ID: Record<CustomerKind, number> = {
|
||||
// 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,
|
||||
@@ -43,7 +48,7 @@ export const legacyTypeToKind = (t: unknown): CustomerKind | undefined =>
|
||||
(k) => LEGACY_TYPE_ID[k] === Number(t),
|
||||
);
|
||||
|
||||
export const kindToLegacyType = (k: CustomerKind): number => LEGACY_TYPE_ID[k];
|
||||
export const kindToLegacyType = (k: CustomerKind): number | null => LEGACY_TYPE_ID[k] ?? null;
|
||||
|
||||
export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'archived';
|
||||
|
||||
@@ -378,14 +383,85 @@ export class AdminCustomersService {
|
||||
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({
|
||||
where: { customer: { referredByAgentId: id } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
include: { customer: true },
|
||||
});
|
||||
|
||||
const gramsSoldAgg = await this.prisma.order.aggregate({
|
||||
where: { customer: { referredByAgentId: id }, ...AdminCustomersService.SOLD },
|
||||
_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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Orders that reached debt-recognition, reused by reagentDetail(). */
|
||||
private static readonly SOLD: Prisma.OrderWhereInput = {
|
||||
status: { in: ['DELIVERED_TO_AGENT', 'DELIVERED_TO_CUSTOMER'] },
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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);
|
||||
@@ -397,9 +473,13 @@ export class AdminCustomersService {
|
||||
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: where as Prisma.CustomerWhereInput,
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
@@ -409,7 +489,7 @@ export class AdminCustomersService {
|
||||
referredByAgent: { include: { branch: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.customer.count({ where: where as Prisma.CustomerWhereInput }),
|
||||
this.prisma.customer.count({ where: scoped }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
@@ -428,7 +508,42 @@ export class AdminCustomersService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Legacy `POST customer/create` (omdeSave) and `POST/PUT user/profile`. */
|
||||
/**
|
||||
* 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>) {
|
||||
const fullName = String(data.name ?? data.fname ?? '').trim();
|
||||
const mobile = String(data.mobile ?? data.phone ?? '').trim();
|
||||
|
||||
if (!fullName || !mobile) {
|
||||
throw new BadRequestException('نام و موبایل الزامی است');
|
||||
}
|
||||
|
||||
return this.prisma.customer.create({
|
||||
data: {
|
||||
fullName,
|
||||
mobile,
|
||||
nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode
|
||||
passwordHash: '',
|
||||
cityId: (data.city_id as string) || 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>) {
|
||||
const fullName = String(data.name ?? data.fname ?? '').trim();
|
||||
const mobile = String(data.mobile ?? '').trim();
|
||||
@@ -438,6 +553,13 @@ export class AdminCustomersService {
|
||||
throw new BadRequestException('نام و موبایل الزامی است');
|
||||
}
|
||||
|
||||
const kind = legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER;
|
||||
if (kind === CustomerKind.SALES_AGENT) {
|
||||
throw new BadRequestException(
|
||||
'کارشناس فروش از این فرم ثبت نمیشود. از بخش «افزودن مدیر» با نقش «کارشناس فروش» استفاده کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.customer.create({
|
||||
data: {
|
||||
fullName,
|
||||
@@ -454,7 +576,7 @@ export class AdminCustomersService {
|
||||
: 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,
|
||||
kind,
|
||||
phone: (data.phone as string) ?? null,
|
||||
address: (data.address as string) ?? null,
|
||||
storeName: (data.store_name as string) ?? null,
|
||||
@@ -463,6 +585,12 @@ export class AdminCustomersService {
|
||||
memberCode: (data.mcode as string) ?? 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,
|
||||
@@ -487,6 +615,13 @@ export class AdminCustomersService {
|
||||
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);
|
||||
@@ -510,6 +645,17 @@ export class AdminCustomersService {
|
||||
? { 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;
|
||||
}
|
||||
|
||||
return this.prisma.customer.update({ where: { id }, data: patch });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user