feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -5,8 +5,51 @@ import { CustomersModule } from './modules/customers/customers.module';
|
||||
import { OrdersModule } from './modules/orders/orders.module';
|
||||
import { AccountingModule } from './modules/accounting/accounting.module';
|
||||
import { PricingModule } from './modules/pricing/pricing.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { AdminTableModule } from './modules/admin-table/admin-table.module';
|
||||
import { AdminOrdersModule } from './modules/admin-orders/admin-orders.module';
|
||||
import { AdminCitiesModule } from './modules/admin-cities/admin-cities.module';
|
||||
import { AdminPreOrdersModule } from './modules/admin-preorders/admin-preorders.module';
|
||||
import { AdminWorkshopsModule } from './modules/admin-workshops/admin-workshops.module';
|
||||
import { AdminCustomersModule } from './modules/admin-customers/admin-customers.module';
|
||||
import { AdminCommentsModule } from './modules/admin-comments/admin-comments.module';
|
||||
import { AdminProductsModule } from './modules/admin-products/admin-products.module';
|
||||
import { AdminCategoriesModule } from './modules/admin-categories/admin-categories.module';
|
||||
import { AdminDocumentsModule } from './modules/admin-documents/admin-documents.module';
|
||||
import { AdminPromotionsModule } from './modules/admin-promotions/admin-promotions.module';
|
||||
import { AdminUsersModule } from './modules/admin-users/admin-users.module';
|
||||
import { AdminBranchesModule } from './modules/admin-branches/admin-branches.module';
|
||||
import { AdminInvoicesModule } from './modules/admin-invoices/admin-invoices.module';
|
||||
import { AdminMiscModule } from './modules/admin-misc/admin-misc.module';
|
||||
import { AdminUploadsModule } from './modules/admin-uploads/admin-uploads.module';
|
||||
import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, CatalogModule, CustomersModule, OrdersModule, AccountingModule, PricingModule],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
AdminTableModule,
|
||||
AdminOrdersModule,
|
||||
AdminCitiesModule,
|
||||
AdminPreOrdersModule,
|
||||
AdminWorkshopsModule,
|
||||
AdminCustomersModule,
|
||||
AdminCommentsModule,
|
||||
AdminProductsModule,
|
||||
AdminCategoriesModule,
|
||||
AdminDocumentsModule,
|
||||
AdminPromotionsModule,
|
||||
AdminUsersModule,
|
||||
AdminBranchesModule,
|
||||
AdminInvoicesModule,
|
||||
AdminMiscModule,
|
||||
AdminUploadsModule,
|
||||
AdminDashboardModule,
|
||||
CatalogModule,
|
||||
CustomersModule,
|
||||
OrdersModule,
|
||||
AccountingModule,
|
||||
PricingModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
+12
-2
@@ -1,15 +1,25 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'node:path';
|
||||
import { AppModule } from './app.module';
|
||||
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.useGlobalFilters(new PrismaExceptionFilter());
|
||||
app.enableCors();
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
// Uploaded files, mounted OUTSIDE the /api prefix so the URLs stored on
|
||||
// UploadedFile.url ("/uploads/<name>") resolve directly.
|
||||
app.useStaticAssets(process.env.UPLOAD_DIR ?? join(process.cwd(), 'storage', 'uploads'), {
|
||||
prefix: '/uploads/',
|
||||
index: false,
|
||||
dotfiles: 'deny',
|
||||
});
|
||||
// Bind all interfaces explicitly so the container is reachable from nginx.
|
||||
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { LedgerService } from './ledger.service';
|
||||
import { PostGoldRepaymentDto } from './dto/post-gold-repayment.dto';
|
||||
import { PostRialRepaymentDto } from './dto/post-rial-repayment.dto';
|
||||
import { Public } from '../auth/guards';
|
||||
|
||||
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
|
||||
@Controller('accounting')
|
||||
export class AccountingController {
|
||||
constructor(private readonly ledger: LedgerService) {}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminBranchesService } from './admin-branches.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (BranchController + WorkshopController):
|
||||
* POST branch/table
|
||||
* POST branch, workshop -> create
|
||||
* GET/PUT branch/{id}, workshop/{id}
|
||||
* GET branch/active/{id}, workshop/active/{id} -> toggle
|
||||
* GET user/list -> the branch-manager picker
|
||||
*/
|
||||
@Controller('admin/branches')
|
||||
export class AdminBranchesController {
|
||||
constructor(private readonly branches: AdminBranchesService) {}
|
||||
|
||||
// ── branches ──────────────────────────────────────────────────────
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('branch.list')
|
||||
listBranches(@Body() query: TableQueryDto) {
|
||||
return this.branches.listBranches(query);
|
||||
}
|
||||
|
||||
@Get('user-list')
|
||||
@RequirePermission('branch.create')
|
||||
userList() {
|
||||
return this.branches.userList();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('branch.create')
|
||||
createBranch(@Body() data: Record<string, unknown>) {
|
||||
return this.branches.createBranch(data);
|
||||
}
|
||||
|
||||
@Get('active/:id')
|
||||
@RequirePermission('branch.list')
|
||||
toggleBranchActive(@Param('id') id: string) {
|
||||
return this.branches.toggleBranchActive(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('branch.list')
|
||||
findBranch(@Param('id') id: string) {
|
||||
return this.branches.findBranch(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('branch.list')
|
||||
updateBranch(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.branches.updateBranch(id, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminBranchesController } from './admin-branches.controller';
|
||||
import { AdminBranchesService } from './admin-branches.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminBranchesController],
|
||||
providers: [AdminBranchesService],
|
||||
// The workshops module reuses this service — branches and workshops are
|
||||
// near-identical screens and share their logic.
|
||||
exports: [AdminBranchesService],
|
||||
})
|
||||
export class AdminBranchesModule {}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AdminRole, Prisma } 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';
|
||||
|
||||
/**
|
||||
* Branches and workshops — legacy views/branch/* and views/workshop/*, served by
|
||||
* BranchController and WorkshopController:
|
||||
* POST branch/table | workshop/table -> the grids
|
||||
* POST branch | workshop -> create
|
||||
* GET branch/{id} | workshop/{id} -> one row
|
||||
* PUT branch/{id} | workshop/{id} -> update
|
||||
* GET branch/active/{id} | workshop/active/{id} -> toggle active
|
||||
*
|
||||
* The two screens differ in how their manager is handled, and that difference is
|
||||
* preserved: a BRANCH picks an existing admin (`user_id`), while a WORKSHOP
|
||||
* creates its supervisor inline from three fields (user_fname/lname/phone).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminBranchesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
// ── Branches ───────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST branch/table`. */
|
||||
async listBranches(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const aliases: Record<string, string> = { 'user.name': 'adminUsers.some.fullName' };
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['name', 'address', 'phone'],
|
||||
allowed: ['id', 'name', 'address', 'phone', 'isActive', 'cityId', 'city.name'],
|
||||
defaultOrderBy: { name: 'asc' },
|
||||
});
|
||||
void aliases;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.branch.findMany({
|
||||
where: where as Prisma.BranchWhereInput,
|
||||
orderBy: orderBy as Prisma.BranchOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
city: true,
|
||||
adminUsers: { where: { isActive: true }, take: 1 },
|
||||
_count: { select: { adminUsers: true, orders: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.branch.count({ where: where as Prisma.BranchWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
address: b.address,
|
||||
phone: b.phone,
|
||||
active: b.isActive ? 1 : 0,
|
||||
is_active: b.isActive,
|
||||
city_id: b.cityId,
|
||||
city: b.city ? { id: b.city.id, title: b.city.name } : null,
|
||||
// legacy column key is `user.name` — the branch manager
|
||||
user: b.adminUsers[0]
|
||||
? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName }
|
||||
: null,
|
||||
users_count: b._count.adminUsers,
|
||||
orders_count: b._count.orders,
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
async findBranch(id: string) {
|
||||
const b = await this.prisma.branch.findUnique({
|
||||
where: { id },
|
||||
include: { city: true, adminUsers: { where: { isActive: true }, take: 1 } },
|
||||
});
|
||||
if (!b) throw new NotFoundException('شعبه یافت نشد');
|
||||
return {
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
address: b.address,
|
||||
phone: b.phone,
|
||||
is_active: b.isActive,
|
||||
city_id: b.cityId,
|
||||
user_id: b.adminUsers[0]?.id ?? null,
|
||||
user: b.adminUsers[0] ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST branch` — the manager is an EXISTING admin picked by id. */
|
||||
async createBranch(data: Record<string, unknown>) {
|
||||
const name = String(data.name ?? '').trim();
|
||||
if (!name) throw new BadRequestException('نام شعبه الزامی است');
|
||||
|
||||
const branch = await this.prisma.branch.create({
|
||||
data: {
|
||||
name,
|
||||
address: (data.address as string) || null,
|
||||
phone: (data.phone as string) || null,
|
||||
cityId: (data.city_id as string) || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.user_id) {
|
||||
await this.assignBranchManager(branch.id, String(data.user_id));
|
||||
}
|
||||
return this.findBranch(branch.id);
|
||||
}
|
||||
|
||||
/** Legacy `PUT branch/{id}`. */
|
||||
async updateBranch(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.branch.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('شعبه یافت نشد');
|
||||
|
||||
const patch: Prisma.BranchUpdateInput = {};
|
||||
if (data.name !== undefined) patch.name = String(data.name);
|
||||
if (data.address !== undefined) patch.address = (data.address as string) || null;
|
||||
if (data.phone !== undefined) patch.phone = (data.phone as string) || null;
|
||||
if (data.city_id !== undefined) {
|
||||
patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true };
|
||||
}
|
||||
|
||||
await this.prisma.branch.update({ where: { id }, data: patch });
|
||||
|
||||
if (data.user_id !== undefined) {
|
||||
await this.assignBranchManager(id, (data.user_id as string) || null);
|
||||
}
|
||||
return this.findBranch(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the named admin onto this branch and clears whoever was on it before,
|
||||
* so a branch keeps exactly one manager. Legacy did the same through its
|
||||
* `->update(['branch_id' => null])` / `->update(['branch_id' => $id])` pair.
|
||||
*/
|
||||
private async assignBranchManager(branchId: string, userId: string | null) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.adminUser.updateMany({ where: { branchId }, data: { branchId: null } });
|
||||
if (userId) {
|
||||
const user = await tx.adminUser.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('کاربر انتخابشده یافت نشد');
|
||||
await tx.adminUser.update({ where: { id: userId }, data: { branchId } });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `GET branch/active/{id}` — a toggle. */
|
||||
async toggleBranchActive(id: string) {
|
||||
const b = await this.prisma.branch.findUnique({ where: { id } });
|
||||
if (!b) throw new NotFoundException('شعبه یافت نشد');
|
||||
return this.prisma.branch.update({
|
||||
where: { id },
|
||||
data: { isActive: !b.isActive },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Workshops ──────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST workshop/table`. */
|
||||
async listWorkshops(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['name', 'address', 'phone'],
|
||||
allowed: ['id', 'name', 'address', 'phone', 'isActive'],
|
||||
defaultOrderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.workshop.findMany({
|
||||
where: where as Prisma.WorkshopWhereInput,
|
||||
orderBy: orderBy as Prisma.WorkshopOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { supervisor: true, _count: { select: { preOrders: true } } },
|
||||
}),
|
||||
this.prisma.workshop.count({ where: where as Prisma.WorkshopWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((w) => this.workshopRow(w)),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
private workshopRow(
|
||||
w: Prisma.WorkshopGetPayload<{
|
||||
include: { supervisor: true; _count: { select: { preOrders: true } } };
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
address: w.address,
|
||||
phone: w.phone,
|
||||
active: w.isActive ? 1 : 0,
|
||||
is_active: w.isActive,
|
||||
user: w.supervisor ? { id: w.supervisor.id, name: w.supervisor.fullName } : null,
|
||||
user_id: w.supervisorId,
|
||||
preorders_count: w._count.preOrders,
|
||||
};
|
||||
}
|
||||
|
||||
async findWorkshop(id: string) {
|
||||
const w = await this.prisma.workshop.findUnique({
|
||||
where: { id },
|
||||
include: { supervisor: true, _count: { select: { preOrders: true } } },
|
||||
});
|
||||
if (!w) throw new NotFoundException('کارگاه یافت نشد');
|
||||
|
||||
const row = this.workshopRow(w);
|
||||
// The edit form splits the supervisor back into its three fields.
|
||||
const parts = String(w.supervisor?.fullName ?? '').trim().split(' ');
|
||||
return {
|
||||
...row,
|
||||
workshop_name: w.name,
|
||||
workshop_address: w.address,
|
||||
workshop_phone: w.phone,
|
||||
user_fname: parts[0] ?? '',
|
||||
user_lname: parts.slice(1).join(' '),
|
||||
user_phone: w.supervisor?.mobile ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST workshop` — creates the workshop AND its supervisor together,
|
||||
* from user_fname / user_lname / user_phone.
|
||||
*/
|
||||
async createWorkshop(data: Record<string, unknown>) {
|
||||
const name = String(data.workshop_name ?? data.name ?? '').trim();
|
||||
if (!name) throw new BadRequestException('نام کارگاه الزامی است');
|
||||
|
||||
const supervisorId = await this.upsertSupervisor(data, null);
|
||||
|
||||
const w = await this.prisma.workshop.create({
|
||||
data: {
|
||||
name,
|
||||
address: (data.workshop_address as string) || (data.address as string) || null,
|
||||
phone: (data.workshop_phone as string) || (data.phone as string) || null,
|
||||
supervisorId,
|
||||
},
|
||||
});
|
||||
return this.findWorkshop(w.id);
|
||||
}
|
||||
|
||||
/** Legacy `PUT workshop/{id}`. */
|
||||
async updateWorkshop(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.workshop.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('کارگاه یافت نشد');
|
||||
|
||||
const patch: Prisma.WorkshopUpdateInput = {};
|
||||
if (data.workshop_name !== undefined || data.name !== undefined) {
|
||||
patch.name = String(data.workshop_name ?? data.name);
|
||||
}
|
||||
if (data.workshop_address !== undefined || data.address !== undefined) {
|
||||
patch.address = ((data.workshop_address ?? data.address) as string) || null;
|
||||
}
|
||||
if (data.workshop_phone !== undefined || data.phone !== undefined) {
|
||||
patch.phone = ((data.workshop_phone ?? data.phone) as string) || null;
|
||||
}
|
||||
|
||||
const supervisorId = await this.upsertSupervisor(data, existing.supervisorId);
|
||||
if (supervisorId !== undefined) {
|
||||
patch.supervisor = supervisorId
|
||||
? { connect: { id: supervisorId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
|
||||
await this.prisma.workshop.update({ where: { id }, data: patch });
|
||||
return this.findWorkshop(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates the workshop's supervisor from the inline fields.
|
||||
* Returns the id to link, or `undefined` when the form sent no supervisor data
|
||||
* at all (so an update leaves the existing link alone).
|
||||
*/
|
||||
private async upsertSupervisor(
|
||||
data: Record<string, unknown>,
|
||||
currentId: string | null,
|
||||
): Promise<string | null | undefined> {
|
||||
const fname = String(data.user_fname ?? '').trim();
|
||||
const lname = String(data.user_lname ?? '').trim();
|
||||
const phone = String(data.user_phone ?? '').trim();
|
||||
|
||||
if (!fname && !lname && !phone) return undefined;
|
||||
if (!phone) throw new BadRequestException('موبایل سرپرست کارگاه الزامی است');
|
||||
|
||||
const fullName = `${fname} ${lname}`.trim();
|
||||
|
||||
if (currentId) {
|
||||
const current = await this.prisma.adminUser.findUnique({ where: { id: currentId } });
|
||||
if (current) {
|
||||
if (phone !== current.mobile) {
|
||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
|
||||
if (clash && clash.id !== currentId) {
|
||||
throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
}
|
||||
await this.prisma.adminUser.update({
|
||||
where: { id: currentId },
|
||||
data: { fullName, mobile: phone },
|
||||
});
|
||||
return currentId;
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse an existing admin with that mobile rather than colliding on the
|
||||
// unique constraint.
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
|
||||
if (existing) return existing.id;
|
||||
|
||||
const created = await this.prisma.adminUser.create({
|
||||
data: {
|
||||
fullName,
|
||||
mobile: phone,
|
||||
// No password is set: the supervisor cannot sign in until one is issued.
|
||||
passwordHash: '',
|
||||
role: AdminRole.WORKSHOP_SUPERVISOR,
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Legacy `GET workshop/active/{id}` — a toggle. */
|
||||
async toggleWorkshopActive(id: string) {
|
||||
const w = await this.prisma.workshop.findUnique({ where: { id } });
|
||||
if (!w) throw new NotFoundException('کارگاه یافت نشد');
|
||||
return this.prisma.workshop.update({
|
||||
where: { id },
|
||||
data: { isActive: !w.isActive },
|
||||
});
|
||||
}
|
||||
|
||||
/** The admin picker the branch form uses — legacy `GET user/list`. */
|
||||
async userList() {
|
||||
const rows = await this.prisma.adminUser.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
select: { id: true, fullName: true, mobile: true, branchId: true },
|
||||
});
|
||||
return rows.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.fullName,
|
||||
mobile: u.mobile,
|
||||
branch_id: u.branchId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { AdminCategoriesService } from './admin-categories.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (CategoryController + the collection screens):
|
||||
* POST category -> the grid (a read expressed as POST, legacy's shape)
|
||||
* POST category/ -> create
|
||||
* PUT category/{id} -> update
|
||||
* DELETE category/{id} -> delete
|
||||
* GET ciny -> collections ("سینی")
|
||||
*/
|
||||
@Controller('admin/categories')
|
||||
export class AdminCategoriesController {
|
||||
constructor(private readonly categories: AdminCategoriesService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('collection.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.categories.list(query);
|
||||
}
|
||||
|
||||
@Get('tree')
|
||||
@RequirePermission('collection.list')
|
||||
tree(@Query('type') type?: string) {
|
||||
return this.categories.tree(type ? Number(type) : undefined);
|
||||
}
|
||||
|
||||
@Get('type-options')
|
||||
@RequirePermission('collection.list')
|
||||
typeOptions() {
|
||||
return this.categories.typeOptions();
|
||||
}
|
||||
|
||||
// ── collections ("سینی") ─────────────────────────────────────────
|
||||
@Post('collections/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('collection.list')
|
||||
listCollections(@Body() query: TableQueryDto) {
|
||||
return this.categories.listCollections(query);
|
||||
}
|
||||
|
||||
@Post('collections')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('collection.create')
|
||||
createCollection(@Body() body: { title: string; description?: string; is_active?: boolean }) {
|
||||
return this.categories.createCollection(body);
|
||||
}
|
||||
|
||||
@Put('collections/:id')
|
||||
@RequirePermission('collection.list.edit')
|
||||
updateCollection(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { title?: string; description?: string; is_active?: boolean },
|
||||
) {
|
||||
return this.categories.updateCollection(id, body);
|
||||
}
|
||||
|
||||
@Delete('collections/:id')
|
||||
@RequirePermission('collection.list.edit')
|
||||
removeCollection(@Param('id') id: string) {
|
||||
return this.categories.removeCollection(id);
|
||||
}
|
||||
|
||||
// ── categories ────────────────────────────────────────────────────
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('collection.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.categories.create(data);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('collection.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.categories.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('collection.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.categories.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('collection.list.edit')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.categories.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminCategoriesController } from './admin-categories.controller';
|
||||
import { AdminCategoriesService } from './admin-categories.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminCategoriesController],
|
||||
providers: [AdminCategoriesService],
|
||||
})
|
||||
export class AdminCategoriesModule {}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Category + Collection administration — legacy views/apps/category/*
|
||||
* (CategoryController: `POST category`, `POST category/`, `PUT category/{id}`,
|
||||
* `DELETE category/{id}`) and the "سینی" collection screens.
|
||||
*
|
||||
* Legacy Category.type: 1 سایت | 2 اپلیکشن موبایل.
|
||||
*/
|
||||
const TYPE_LABELS: Record<number, string> = {
|
||||
1: 'سایت',
|
||||
2: 'اپلیکشن موبایل',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminCategoriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'title',
|
||||
'type',
|
||||
'parentId',
|
||||
'parent.title',
|
||||
// legacy filter keys
|
||||
'type_str',
|
||||
];
|
||||
|
||||
/** Legacy `POST category` — the category grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) =>
|
||||
f.field === 'type_str' ? { ...f, field: 'type', value: Number(f.value) } : f,
|
||||
),
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['title'],
|
||||
allowed: AdminCategoriesService.ALLOWED,
|
||||
defaultOrderBy: { title: 'asc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.category.findMany({
|
||||
where: where as Prisma.CategoryWhereInput,
|
||||
orderBy: orderBy as Prisma.CategoryOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { parent: true, _count: { select: { products: true, children: true } } },
|
||||
}),
|
||||
this.prisma.category.count({ where: where as Prisma.CategoryWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
private toRow(
|
||||
c: Prisma.CategoryGetPayload<{
|
||||
include: { parent: true; _count: { select: { products: true; children: true } } };
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
type: c.type,
|
||||
type_str: TYPE_LABELS[c.type] ?? '-',
|
||||
image: c.imageUrl,
|
||||
icon: c.iconUrl,
|
||||
meta: c.metaTag,
|
||||
body_html: c.description,
|
||||
parent_id_id: c.parentId,
|
||||
parent: c.parent ? { id: c.parent.id, title: c.parent.title } : null,
|
||||
products_count: c._count.products,
|
||||
children_count: c._count.children,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const c = await this.prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: { parent: true, _count: { select: { products: true, children: true } } },
|
||||
});
|
||||
if (!c) throw new NotFoundException('دسته بندی یافت نشد');
|
||||
return this.toRow(c);
|
||||
}
|
||||
|
||||
/** The parent picker — legacy loaded the full list and built a tree client-side. */
|
||||
async tree(type?: number) {
|
||||
const rows = await this.prisma.category.findMany({
|
||||
where: type ? { type } : {},
|
||||
orderBy: { title: 'asc' },
|
||||
include: { parent: true },
|
||||
});
|
||||
return rows.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
type: c.type,
|
||||
parent_id_id: c.parentId,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Legacy `POST category/`. */
|
||||
async create(data: Record<string, unknown>) {
|
||||
const title = String(data.title ?? '').trim();
|
||||
if (!title) throw new BadRequestException('عنوان دسته الزامی است');
|
||||
|
||||
return this.prisma.category.create({
|
||||
data: {
|
||||
title,
|
||||
type: data.type !== undefined ? Number(data.type) : 1,
|
||||
imageUrl: (data.image as string) || null,
|
||||
iconUrl: (data.icon as string) || null,
|
||||
metaTag: (data.meta as string) || null,
|
||||
description: (data.body_html as string) || null,
|
||||
parentId: (data.parent_id_id as string) || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `PUT category/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.category.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('دسته بندی یافت نشد');
|
||||
|
||||
// A category cannot be its own parent, nor a descendant's child — that would
|
||||
// create a cycle in the tree and hang any recursive render. Legacy had no
|
||||
// such guard.
|
||||
const newParent = data.parent_id_id as string | undefined;
|
||||
if (newParent) {
|
||||
if (newParent === id) {
|
||||
throw new BadRequestException('یک دسته نمیتواند پدر خودش باشد');
|
||||
}
|
||||
if (await this.isDescendant(newParent, id)) {
|
||||
throw new BadRequestException('دستهی انتخابشده زیرمجموعهی همین دسته است');
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Prisma.CategoryUpdateInput = {};
|
||||
if (data.title !== undefined) patch.title = String(data.title);
|
||||
if (data.type !== undefined) patch.type = Number(data.type);
|
||||
if (data.image !== undefined) patch.imageUrl = (data.image as string) || null;
|
||||
if (data.icon !== undefined) patch.iconUrl = (data.icon as string) || null;
|
||||
if (data.meta !== undefined) patch.metaTag = (data.meta as string) || null;
|
||||
if (data.body_html !== undefined) patch.description = (data.body_html as string) || null;
|
||||
if (data.parent_id_id !== undefined) {
|
||||
patch.parent = newParent ? { connect: { id: newParent } } : { disconnect: true };
|
||||
}
|
||||
|
||||
return this.prisma.category.update({ where: { id }, data: patch });
|
||||
}
|
||||
|
||||
/** Walks up from `candidate` to see whether `ancestorId` is above it. */
|
||||
private async isDescendant(candidate: string, ancestorId: string): Promise<boolean> {
|
||||
let cursor: string | null = candidate;
|
||||
const seen = new Set<string>();
|
||||
while (cursor && !seen.has(cursor)) {
|
||||
seen.add(cursor);
|
||||
const row: { parentId: string | null } | null = await this.prisma.category.findUnique({
|
||||
where: { id: cursor },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!row?.parentId) return false;
|
||||
if (row.parentId === ancestorId) return true;
|
||||
cursor = row.parentId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Legacy `DELETE category/{id}`. */
|
||||
async remove(id: string) {
|
||||
const c = await this.prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { products: true, children: true } } },
|
||||
});
|
||||
if (!c) throw new NotFoundException('دسته بندی یافت نشد');
|
||||
|
||||
// Refuses while anything still hangs off it. Legacy deleted regardless,
|
||||
// which orphaned products and left child categories pointing at a missing
|
||||
// parent. Recorded in docs/ADMIN-PORT-PLAN.md.
|
||||
if (c._count.products > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف ممکن نیست: ${c._count.products} محصول در این دسته قرار دارد.`,
|
||||
);
|
||||
}
|
||||
if (c._count.children > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف ممکن نیست: این دسته ${c._count.children} زیردسته دارد.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.category.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
typeOptions() {
|
||||
return Object.entries(TYPE_LABELS).map(([value, title]) => ({ value: Number(value), title }));
|
||||
}
|
||||
|
||||
// ── Collections ("سینی") ───────────────────────────────────────────
|
||||
|
||||
/** Legacy `GET ciny` / the collection grid. */
|
||||
async listCollections(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['title'],
|
||||
allowed: ['id', 'title', 'isActive'],
|
||||
defaultOrderBy: { title: 'asc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.collection.findMany({
|
||||
where: where as Prisma.CollectionWhereInput,
|
||||
orderBy: orderBy as Prisma.CollectionOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { _count: { select: { products: true } } },
|
||||
}),
|
||||
this.prisma.collection.count({ where: where as Prisma.CollectionWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
is_active: c.isActive,
|
||||
products_count: c._count.products,
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
async createCollection(data: { title: string; description?: string; is_active?: boolean }) {
|
||||
if (!data.title?.trim()) throw new BadRequestException('عنوان سینی الزامی است');
|
||||
return this.prisma.collection.create({
|
||||
data: {
|
||||
title: data.title.trim(),
|
||||
description: data.description ?? null,
|
||||
isActive: data.is_active ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateCollection(
|
||||
id: string,
|
||||
data: { title?: string; description?: string; is_active?: boolean },
|
||||
) {
|
||||
const existing = await this.prisma.collection.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('سینی یافت نشد');
|
||||
|
||||
return this.prisma.collection.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.title !== undefined ? { title: data.title } : {}),
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(data.is_active !== undefined ? { isActive: data.is_active } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeCollection(id: string) {
|
||||
const c = await this.prisma.collection.findUnique({ where: { id } });
|
||||
if (!c) throw new NotFoundException('سینی یافت نشد');
|
||||
|
||||
// The join rows are this collection's own membership list, so clearing them
|
||||
// removes no product — unlike the category case above.
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.collectionProduct.deleteMany({ where: { collectionId: id } }),
|
||||
this.prisma.collection.delete({ where: { id } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, HttpCode, Post } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Legacy `POST cities/list` — the city lookup the table filters use.
|
||||
* Returns `{ rows }`, matching what the users store's setCities() reads.
|
||||
*
|
||||
* Deliberately NOT permission-gated: legacy called this from table models at
|
||||
* module load, before any rid check, and it exposes nothing sensitive.
|
||||
*/
|
||||
@Controller('admin/cities')
|
||||
export class AdminCitiesController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
async list() {
|
||||
const cities = await this.prisma.city.findMany({ orderBy: { name: 'asc' } });
|
||||
// legacy shape: { id, title }
|
||||
return { rows: cities.map((c) => ({ id: c.id, title: c.name, province: c.province })) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminCitiesController } from './admin-cities.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminCitiesController],
|
||||
})
|
||||
export class AdminCitiesModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post } from '@nestjs/common';
|
||||
import { AdminCommentsService } from './admin-comments.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (CommentController, routes/admin.php:149-152):
|
||||
* POST comment/table -> the moderation grid
|
||||
* POST comment/sts -> approve / reject
|
||||
* POST comment -> staff reply
|
||||
* DELETE comment/{id}
|
||||
*
|
||||
* All gated on the legacy rid `comment`.
|
||||
*/
|
||||
@Controller('admin/comments')
|
||||
export class AdminCommentsController {
|
||||
constructor(private readonly comments: AdminCommentsService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('comment')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.comments.list(query);
|
||||
}
|
||||
|
||||
@Get('status-options')
|
||||
@RequirePermission('comment')
|
||||
statusOptions() {
|
||||
return this.comments.statusOptions();
|
||||
}
|
||||
|
||||
@Post('sts')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('comment')
|
||||
changeStatus(@Body() body: { id: string; sts: number }) {
|
||||
return this.comments.changeStatus(body.id, body.sts);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('comment')
|
||||
create(
|
||||
@Body() body: { product_id: string; customer_id: string; comment: string; reply_to?: string },
|
||||
) {
|
||||
return this.comments.create(body);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('comment')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.comments.findOne(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('comment')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.comments.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminCommentsController } from './admin-comments.controller';
|
||||
import { AdminCommentsService } from './admin-comments.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminCommentsController],
|
||||
providers: [AdminCommentsService],
|
||||
})
|
||||
export class AdminCommentsModule {}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CommentStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Product comment moderation — legacy views/customer/Comments/Comments.vue,
|
||||
* served by CommentController (`POST comment/table`, `POST comment/sts`,
|
||||
* `POST comment`, `DELETE comment/{id}`).
|
||||
*
|
||||
* Legacy Comment::$STS = [1 => 'در انتظار تایید', 2 => 'تایید شده', 3 => 'رد شده'].
|
||||
* The numeric codes are preserved on the wire (`sts`) so the ported view's
|
||||
* status dropdown posts unchanged, while the DB stores an enum.
|
||||
*/
|
||||
const STATUS_BY_CODE: Record<number, CommentStatus> = {
|
||||
1: CommentStatus.PENDING,
|
||||
2: CommentStatus.APPROVED,
|
||||
3: CommentStatus.REJECTED,
|
||||
};
|
||||
|
||||
const CODE_BY_STATUS: Record<CommentStatus, number> = {
|
||||
PENDING: 1,
|
||||
APPROVED: 2,
|
||||
REJECTED: 3,
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<CommentStatus, string> = {
|
||||
PENDING: 'در انتظار تایید',
|
||||
APPROVED: 'تایید شده',
|
||||
REJECTED: 'رد شده',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminCommentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'body',
|
||||
'customer.fullName',
|
||||
'customer.mobile',
|
||||
'product.title',
|
||||
// legacy filter keys
|
||||
'sts',
|
||||
'comment',
|
||||
'caj',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
sts: 'status',
|
||||
comment: 'body',
|
||||
caj: 'createdAt',
|
||||
uaj: 'updatedAt',
|
||||
'customer.name': 'customer.fullName',
|
||||
'product.name': 'product.title',
|
||||
};
|
||||
|
||||
/** Legacy `POST comment/table`. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) =>
|
||||
f ? (AdminCommentsService.FIELD_ALIASES[f] ?? f) : f;
|
||||
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
// legacy sent the numeric status code
|
||||
if (mapped.field === 'status' && mapped.value !== undefined) {
|
||||
mapped.value = STATUS_BY_CODE[Number(mapped.value)] ?? mapped.value;
|
||||
}
|
||||
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: ['body', 'customer.fullName', 'product.title'],
|
||||
allowed: AdminCommentsService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.comment.findMany({
|
||||
where: where as Prisma.CommentWhereInput,
|
||||
orderBy: orderBy as Prisma.CommentOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true, product: true, replyTo: true, replies: true },
|
||||
}),
|
||||
this.prisma.comment.count({ where: where as Prisma.CommentWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
private toRow(
|
||||
c: Prisma.CommentGetPayload<{
|
||||
include: { customer: true; product: true; replyTo: true; replies?: true };
|
||||
}> & { replies?: { id: string; body: string }[] },
|
||||
) {
|
||||
return {
|
||||
id: c.id,
|
||||
comment: c.body,
|
||||
body: c.body,
|
||||
sts: CODE_BY_STATUS[c.status],
|
||||
sts_str: STATUS_LABELS[c.status],
|
||||
status: c.status,
|
||||
reply_to: c.replyToId,
|
||||
// legacy view reads: text.length > 0 && text[0].comment !== null
|
||||
reply: (c.replies ?? []).map((r) => ({ id: r.id, comment: r.body })),
|
||||
product_id: c.productId,
|
||||
customer_id: c.customerId,
|
||||
product: { id: c.product.id, title: c.product.title, name: c.product.title },
|
||||
customer: {
|
||||
id: c.customer.id,
|
||||
name: c.customer.fullName,
|
||||
fname: c.customer.fullName,
|
||||
lname: '',
|
||||
mobile: c.customer.mobile,
|
||||
},
|
||||
caj: c.createdAt,
|
||||
uaj: c.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const c = await this.prisma.comment.findUnique({
|
||||
where: { id },
|
||||
include: { customer: true, product: true, replyTo: true, replies: true },
|
||||
});
|
||||
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
|
||||
return this.toRow(c);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST comment/sts` — moderate a comment.
|
||||
* Payload is `{ id, sts }` where sts is 1/2/3.
|
||||
*/
|
||||
async changeStatus(id: string, sts: number) {
|
||||
const c = await this.prisma.comment.findUnique({ where: { id } });
|
||||
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
|
||||
|
||||
const status = STATUS_BY_CODE[Number(sts)];
|
||||
if (!status) {
|
||||
throw new NotFoundException('وضعیت نامعتبر است');
|
||||
}
|
||||
return this.prisma.comment.update({ where: { id }, data: { status } });
|
||||
}
|
||||
|
||||
/** Legacy `POST comment` — post a staff reply to a comment. */
|
||||
async create(data: {
|
||||
product_id: string;
|
||||
customer_id: string;
|
||||
comment: string;
|
||||
reply_to?: string;
|
||||
}) {
|
||||
return this.prisma.comment.create({
|
||||
data: {
|
||||
productId: data.product_id,
|
||||
customerId: data.customer_id,
|
||||
body: data.comment,
|
||||
replyToId: data.reply_to ?? null,
|
||||
// A staff reply is visible immediately; legacy did the same.
|
||||
status: CommentStatus.APPROVED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `DELETE comment/{id}`. */
|
||||
async remove(id: string) {
|
||||
const c = await this.prisma.comment.findUnique({
|
||||
where: { id },
|
||||
include: { replies: true },
|
||||
});
|
||||
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
|
||||
|
||||
// Remove replies first so the self-relation does not block the delete.
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.comment.deleteMany({ where: { replyToId: id } }),
|
||||
this.prisma.comment.delete({ where: { id } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Farsi labels for the status dropdown, keyed by legacy's numeric codes. */
|
||||
statusOptions() {
|
||||
return Object.entries(STATUS_LABELS).map(([status, title]) => ({
|
||||
value: CODE_BY_STATUS[status as CommentStatus],
|
||||
title,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints:
|
||||
* GET get-analytics -> views/DashboardECommerce.vue (wholesale)
|
||||
* GET get-analytics-tak -> views/DashboardAnalytics.vue (retail)
|
||||
*/
|
||||
@Controller('admin/dashboard')
|
||||
export class AdminDashboardController {
|
||||
constructor(private readonly dashboard: AdminDashboardService) {}
|
||||
|
||||
@Get('omde')
|
||||
@RequirePermission('dashboard.omde')
|
||||
omde() {
|
||||
return this.dashboard.omdeAnalytics();
|
||||
}
|
||||
|
||||
@Get('tak')
|
||||
@RequirePermission('dashboard.tak')
|
||||
tak() {
|
||||
return this.dashboard.takAnalytics();
|
||||
}
|
||||
|
||||
/** Legacy views/report/order_item.vue. */
|
||||
@Get('report/order-item')
|
||||
@RequirePermission('report.order.item')
|
||||
orderItemReport(@Query('start_date') from?: string, @Query('end_date') to?: string) {
|
||||
return this.dashboard.orderItemReport(from, to);
|
||||
}
|
||||
|
||||
/** Legacy views/report/product.vue. */
|
||||
@Get('report/product')
|
||||
@RequirePermission('report.product')
|
||||
productReport(@Query('start_date') from?: string, @Query('end_date') to?: string) {
|
||||
return this.dashboard.productReport(from, to);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminDashboardController],
|
||||
providers: [AdminDashboardService],
|
||||
})
|
||||
export class AdminDashboardModule {}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Dashboards — legacy views/DashboardECommerce.vue (`GET get-analytics`) and
|
||||
* views/DashboardAnalytics.vue (`GET get-analytics-tak`).
|
||||
*
|
||||
* Legacy's ECommerce page is a Vuexy template with most of its English demo
|
||||
* cards COMMENTED OUT (Congratulations, Support Tracker, Product Orders, Sales
|
||||
* Stats, Activity Timeline — verified line by line). What is actually live:
|
||||
* - three 30-day stat cards: new customers, gold sold, order count
|
||||
* - "عملکرد کارشناسان فروش": agents ranked by grams sold
|
||||
* - "میزان فروش": sales volume
|
||||
* - "سفارشات هفته جاری": this week's orders
|
||||
*
|
||||
* So this returns exactly those figures rather than the template's demo series.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminDashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private daysAgo(n: number) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Orders that have reached the debt-recognition point count as "sold". */
|
||||
private static readonly SOLD: Prisma.OrderWhereInput = {
|
||||
status: { in: [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER] },
|
||||
};
|
||||
|
||||
/** Legacy `GET get-analytics` — the wholesale dashboard. */
|
||||
async omdeAnalytics() {
|
||||
const since30 = this.daysAgo(30);
|
||||
const since7 = this.daysAgo(7);
|
||||
|
||||
const [customerCount, gramsAgg, orderCount, lastOrderRows, agents, monthAgg] =
|
||||
await Promise.all([
|
||||
// تعداد مشتریان ثبت نامی در 30 روز گذشته
|
||||
this.prisma.customer.count({ where: { createdAt: { gte: since30 } } }),
|
||||
|
||||
// طلای فروش رفته در 30 روز گذشته
|
||||
this.prisma.order.aggregate({
|
||||
where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] },
|
||||
_sum: { totalGoldWeightGrams: true },
|
||||
}),
|
||||
|
||||
// تعداد سفارشات 30 روز گذشته
|
||||
this.prisma.order.count({ where: { createdAt: { gte: since30 } } }),
|
||||
|
||||
// سفارشات هفته جاری
|
||||
this.prisma.order.findMany({
|
||||
where: { createdAt: { gte: since7 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
include: { customer: true, branch: true },
|
||||
}),
|
||||
|
||||
// عملکرد کارشناسان فروش
|
||||
this.prisma.adminUser.findMany({
|
||||
where: { role: 'SALES_EXPERT', isActive: true },
|
||||
select: { id: true, fullName: true },
|
||||
}),
|
||||
|
||||
// میزان فروش (same 30-day window, kept separate for clarity)
|
||||
this.prisma.order.aggregate({
|
||||
where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] },
|
||||
_sum: { totalGoldWeightGrams: true },
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Grams sold per agent, through the customers they referred. The view sorts
|
||||
// this itself by grams_sell, so the shape matters more than the order.
|
||||
const reagent = await Promise.all(
|
||||
agents.map(async (a) => {
|
||||
const agg = await this.prisma.order.aggregate({
|
||||
where: {
|
||||
AND: [
|
||||
AdminDashboardService.SOLD,
|
||||
{ customer: { referredByAgentId: a.id } },
|
||||
{ createdAt: { gte: since30 } },
|
||||
],
|
||||
},
|
||||
_sum: { totalGoldWeightGrams: true },
|
||||
_count: true,
|
||||
});
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.fullName,
|
||||
grams_sell: agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0),
|
||||
order_count: agg._count,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
customerCount,
|
||||
allgram: gramsAgg._sum.totalGoldWeightGrams ?? 0,
|
||||
ordercount: orderCount,
|
||||
reagent,
|
||||
lastOrder: lastOrderRows.map((o) => this.orderRow(o)),
|
||||
sumLastOrder: monthAgg._count,
|
||||
sumLastOrderMonth: monthAgg._sum.totalGoldWeightGrams ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `GET get-analytics-tak` — the retail dashboard. */
|
||||
async takAnalytics() {
|
||||
const since7 = this.daysAgo(7);
|
||||
|
||||
const rows = await this.prisma.order.findMany({
|
||||
where: { createdAt: { gte: since7 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
include: { customer: true, branch: true },
|
||||
});
|
||||
|
||||
// The retail view reads only `model.lastOrder`; the rest of its cards are
|
||||
// the Vuexy demo series and are not rendered from server data.
|
||||
return { lastOrder: rows.map((o) => this.orderRow(o)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `report/order_item` — "گزارش تجمیعی سفارشات و اسناد مالی": order
|
||||
* lines aggregated over a date range.
|
||||
*/
|
||||
async orderItemReport(from?: string, to?: string) {
|
||||
const where = this.dateWhere(from, to);
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where,
|
||||
include: {
|
||||
customer: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const rows = orders.flatMap((o) =>
|
||||
o.items.map((it) => ({
|
||||
order_id: o.id,
|
||||
order_number: o.orderNumber,
|
||||
customer: o.customer.fullName,
|
||||
product: it.productVariant.product.title,
|
||||
variant: it.productVariant.title,
|
||||
qty: it.quantity,
|
||||
w: it.computedWeightGrams,
|
||||
total_w: it.computedWeightGrams.mul(it.quantity),
|
||||
caj: o.createdAt,
|
||||
})),
|
||||
);
|
||||
|
||||
const totalWeight = rows.reduce(
|
||||
(s, r) => s.add(r.total_w),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
summary: {
|
||||
order_count: orders.length,
|
||||
item_count: rows.length,
|
||||
total_weight: totalWeight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `report/product` — per-product sales over a date range. */
|
||||
async productReport(from?: string, to?: string) {
|
||||
const where = this.dateWhere(from, to);
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where,
|
||||
include: { items: { include: { productVariant: { include: { product: true } } } } },
|
||||
});
|
||||
|
||||
const byProduct = new Map<
|
||||
string,
|
||||
{ id: string; title: string; qty: number; weight: Prisma.Decimal; orders: Set<string> }
|
||||
>();
|
||||
|
||||
for (const o of orders) {
|
||||
for (const it of o.items) {
|
||||
const p = it.productVariant.product;
|
||||
const row = byProduct.get(p.id) ?? {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
qty: 0,
|
||||
weight: new Prisma.Decimal(0),
|
||||
orders: new Set<string>(),
|
||||
};
|
||||
row.qty += it.quantity;
|
||||
row.weight = row.weight.add(it.computedWeightGrams.mul(it.quantity));
|
||||
row.orders.add(o.id);
|
||||
byProduct.set(p.id, row);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...byProduct.values()]
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
total: r.qty,
|
||||
w: r.weight,
|
||||
order_count: r.orders.size,
|
||||
}))
|
||||
.sort((a, b) => Number(b.w) - Number(a.w));
|
||||
|
||||
return { rows, summary: { product_count: rows.length } };
|
||||
}
|
||||
|
||||
/** Shared date filter for both reports; an open end stays open. */
|
||||
private dateWhere(from?: string, to?: string): Prisma.OrderWhereInput {
|
||||
if (!from && !to) return {};
|
||||
const createdAt: Prisma.DateTimeFilter = {};
|
||||
if (from) createdAt.gte = new Date(from);
|
||||
if (to) {
|
||||
// Include the whole end day, not just its midnight.
|
||||
const end = new Date(to);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
createdAt.lte = end;
|
||||
}
|
||||
return { createdAt };
|
||||
}
|
||||
|
||||
/** The row shape both dashboards' tables read. */
|
||||
private orderRow(
|
||||
o: Prisma.OrderGetPayload<{ include: { customer: true; branch: true } }>,
|
||||
) {
|
||||
const labels: Record<OrderStatus, string> = {
|
||||
SUSPENDED: 'معلق (سقف اعتبار)',
|
||||
PLACED: 'ثبت شده',
|
||||
ADMIN_APPROVED: 'تایید مدیر',
|
||||
SOURCING: 'در حال تامین',
|
||||
PREPARING: 'در حال آمادهسازی',
|
||||
DELIVERED_TO_AGENT: 'تحویل به نماینده',
|
||||
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
// legacy reads `statusColor` straight off the row
|
||||
const colors: Record<OrderStatus, string> = {
|
||||
SUSPENDED: 'warning',
|
||||
PLACED: 'primary',
|
||||
ADMIN_APPROVED: 'primary',
|
||||
SOURCING: 'warning',
|
||||
PREPARING: 'warning',
|
||||
DELIVERED_TO_AGENT: 'success',
|
||||
DELIVERED_TO_CUSTOMER: 'success',
|
||||
CANCELLED: 'danger',
|
||||
};
|
||||
|
||||
return {
|
||||
id: o.id,
|
||||
name: o.orderNumber,
|
||||
customer: o.customer.fullName,
|
||||
w: o.totalGoldWeightGrams,
|
||||
sts_str: labels[o.status],
|
||||
statusColor: colors[o.status],
|
||||
caj: o.createdAt,
|
||||
// legacy rendered a parent/children tree for split orders; the redesigned
|
||||
// schema has no such relation, so both are empty rather than faked.
|
||||
parent: null,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminDocumentsService } from './admin-documents.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (CashController + the wallet screens):
|
||||
* POST cash/table -> the documents grid
|
||||
* POST cash -> file a repayment document
|
||||
* GET cash/{id} -> one document
|
||||
* PUT cash/{id} -> edit
|
||||
* DELETE cash/{id} -> delete
|
||||
* GET query/customer -> the customer picker
|
||||
* POST wallet -> granted wallet bonuses
|
||||
*/
|
||||
@Controller('admin/documents')
|
||||
export class AdminDocumentsController {
|
||||
constructor(private readonly documents: AdminDocumentsService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('document.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.documents.list(query);
|
||||
}
|
||||
|
||||
@Get('type-options')
|
||||
@RequirePermission('document.list')
|
||||
typeOptions() {
|
||||
return this.documents.typeOptions();
|
||||
}
|
||||
|
||||
@Get('query/customer')
|
||||
@RequirePermission('document.create')
|
||||
queryCustomers() {
|
||||
return this.documents.queryCustomers();
|
||||
}
|
||||
|
||||
@Post('wallet/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('wallet')
|
||||
listWallet(@Body() query: TableQueryDto) {
|
||||
return this.documents.listWallet(query);
|
||||
}
|
||||
|
||||
// ── wallet settings (views/manage/wallet.vue) ────────────────────
|
||||
@Get('wallet/config')
|
||||
@RequirePermission('config.wallet')
|
||||
getWalletConfig() {
|
||||
return this.documents.getSetting('wallet.config');
|
||||
}
|
||||
|
||||
@Post('wallet/config')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('config.wallet')
|
||||
setWalletConfig(@Body() body: Record<string, unknown>) {
|
||||
return this.documents.setSetting('wallet.config', body);
|
||||
}
|
||||
|
||||
@Get('wallet/birthday/config')
|
||||
@RequirePermission('config.wallet')
|
||||
getBirthdayConfig() {
|
||||
return this.documents.getSetting('wallet.birthday');
|
||||
}
|
||||
|
||||
@Post('wallet/birthday/config')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('config.wallet')
|
||||
setBirthdayConfig(@Body() body: Record<string, unknown>) {
|
||||
return this.documents.setSetting('wallet.birthday', body);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('document.create')
|
||||
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.documents.create(data, admin.id);
|
||||
}
|
||||
|
||||
/** Legacy POST cash/confirem/{id} — a toggle, not a one-way confirm. */
|
||||
@Post(':id/confirm')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('document.list.edit')
|
||||
toggleConfirm(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.documents.toggleConfirm(id, admin.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('document.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.documents.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('document.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.documents.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('document.list.edit')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.documents.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
import { AdminDocumentsController } from './admin-documents.controller';
|
||||
import { AdminDocumentsService } from './admin-documents.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AccountingModule],
|
||||
controllers: [AdminDocumentsController],
|
||||
providers: [AdminDocumentsService],
|
||||
})
|
||||
export class AdminDocumentsModule {}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Financial documents ("اسناد مالی") — legacy views/finical/*, served by
|
||||
* CashController (`POST cash/table`, `POST cash`, `PUT cash/{id}`,
|
||||
* `GET cash/{id}`, `DELETE cash/{id}`).
|
||||
*
|
||||
* A legacy "cash" row IS a ledger entry: a customer repayment recorded as melted
|
||||
* gold, a transfer, a cheque or cash. The four legacy `type` codes map onto the
|
||||
* existing LedgerSourceType enum:
|
||||
*
|
||||
* 1 آب شده -> REPAYMENT_MELTED_GOLD (karat-normalized: value * ayar / 750)
|
||||
* 2 حواله -> REPAYMENT_TRANSFER
|
||||
* 3 چک -> REPAYMENT_CHEQUE
|
||||
* 4 پولی -> REPAYMENT_CASH (normalized via the gold price per gram)
|
||||
*
|
||||
* Posting delegates to LedgerService, which owns the karat and price-per-gram
|
||||
* conversions and the confirm/balance rules. Re-implementing them here would let
|
||||
* the admin path drift from the customer-facing one.
|
||||
*/
|
||||
const TYPE_BY_CODE: Record<number, LedgerSourceType> = {
|
||||
1: LedgerSourceType.REPAYMENT_MELTED_GOLD,
|
||||
2: LedgerSourceType.REPAYMENT_TRANSFER,
|
||||
3: LedgerSourceType.REPAYMENT_CHEQUE,
|
||||
4: LedgerSourceType.REPAYMENT_CASH,
|
||||
};
|
||||
|
||||
/** The instrument name LedgerService.postGoldRepayment expects. */
|
||||
const INSTRUMENT_BY_TYPE: Record<string, 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH'> = {
|
||||
REPAYMENT_MELTED_GOLD: 'MELTED_GOLD',
|
||||
REPAYMENT_TRANSFER: 'TRANSFER',
|
||||
REPAYMENT_CHEQUE: 'CHEQUE',
|
||||
REPAYMENT_CASH: 'CASH',
|
||||
};
|
||||
|
||||
const CODE_BY_TYPE: Partial<Record<LedgerSourceType, number>> = {
|
||||
REPAYMENT_MELTED_GOLD: 1,
|
||||
REPAYMENT_TRANSFER: 2,
|
||||
REPAYMENT_CHEQUE: 3,
|
||||
REPAYMENT_CASH: 4,
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<LedgerSourceType, string> = {
|
||||
ORDER_STOCK: 'سفارش',
|
||||
ORDER_PRE_ORDER: 'سفارش ساختنی',
|
||||
REPAYMENT_MELTED_GOLD: 'آب شده',
|
||||
REPAYMENT_TRANSFER: 'حواله',
|
||||
REPAYMENT_CHEQUE: 'چک',
|
||||
REPAYMENT_CASH: 'پولی',
|
||||
WALLET_BONUS: 'هدیه کیف پول',
|
||||
ADJUSTMENT: 'اصلاح',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminDocumentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly ledger: LedgerService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'sourceType',
|
||||
'currency',
|
||||
'direction',
|
||||
'amount',
|
||||
'confirmed',
|
||||
'createdAt',
|
||||
'karatPerMille',
|
||||
'labName',
|
||||
'customer.fullName',
|
||||
// legacy filter keys
|
||||
'type_str',
|
||||
'ayar',
|
||||
'lab',
|
||||
'caj',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
type_str: 'sourceType',
|
||||
ayar: 'karatPerMille',
|
||||
lab: 'labName',
|
||||
caj: 'createdAt',
|
||||
uaj: 'createdAt',
|
||||
'customer.name': 'customer.fullName',
|
||||
value: 'amount',
|
||||
price: 'priceRial',
|
||||
original_value: 'originalValue',
|
||||
account_name: 'accountName',
|
||||
bank_name: 'bankName',
|
||||
to: 'transferTo',
|
||||
recive_date: 'receivedAt',
|
||||
price_per_gram: 'pricePerGramRialAtEntry',
|
||||
ang: 'hallmark',
|
||||
};
|
||||
|
||||
/** Legacy `POST cash/table` — the documents grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) => (f ? (AdminDocumentsService.FIELD_ALIASES[f] ?? f) : f);
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
if (mapped.field === 'sourceType' && mapped.value !== undefined) {
|
||||
mapped.value = TYPE_BY_CODE[Number(mapped.value)] ?? mapped.value;
|
||||
}
|
||||
if (mapped.field === 'confirmed' && 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: ['note', 'customer.fullName', 'labName'],
|
||||
allowed: AdminDocumentsService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// The grid is the REPAYMENT document list; order-generated debt entries are
|
||||
// not documents an operator files, so they are excluded as legacy's separate
|
||||
// `cash` table naturally did.
|
||||
const scoped: Prisma.LedgerEntryWhereInput = {
|
||||
AND: [
|
||||
where as Prisma.LedgerEntryWhereInput,
|
||||
{ sourceType: { in: Object.values(TYPE_BY_CODE) } },
|
||||
],
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.ledgerEntry.findMany({
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true, postedByAdminUser: true },
|
||||
}),
|
||||
this.prisma.ledgerEntry.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 model binds unchanged. */
|
||||
private toRow(
|
||||
e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdminUser: true } }>,
|
||||
) {
|
||||
return {
|
||||
id: e.id,
|
||||
type: CODE_BY_TYPE[e.sourceType] ?? null,
|
||||
type_str: TYPE_LABELS[e.sourceType],
|
||||
sourceType: e.sourceType,
|
||||
currency: e.currency,
|
||||
direction: e.direction,
|
||||
// legacy `value` is the normalized final weight, `original_value` the raw one
|
||||
value: e.amount,
|
||||
original_value: e.originalValue,
|
||||
price: e.priceRial,
|
||||
price_per_gram: e.pricePerGramRialAtEntry,
|
||||
ayar: e.karatPerMille,
|
||||
lab: e.labName,
|
||||
ang: e.hallmark,
|
||||
account_name: e.accountName,
|
||||
bank_name: e.bankName,
|
||||
to: e.transferTo,
|
||||
recive_date: e.receivedAt,
|
||||
note: e.note,
|
||||
// legacy grid renders `confirmed` through a slot using 0/1
|
||||
confirmed: e.confirmed ? 1 : 0,
|
||||
confirmedAt: e.confirmedAt,
|
||||
customer: {
|
||||
id: e.customer.id,
|
||||
name: e.customer.fullName,
|
||||
fname: e.customer.fullName,
|
||||
lname: '',
|
||||
mobile: e.customer.mobile,
|
||||
},
|
||||
posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null,
|
||||
caj: e.createdAt,
|
||||
uaj: e.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `GET cash/{id}`. */
|
||||
async findOne(id: string) {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({
|
||||
where: { id },
|
||||
include: { customer: true, postedByAdminUser: true },
|
||||
});
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
return this.toRow(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST cash` — file a repayment document.
|
||||
*
|
||||
* The gram/karat/price conversions are LedgerService's job; this method only
|
||||
* translates the legacy form shape and attaches the instrument metadata.
|
||||
*/
|
||||
async create(data: Record<string, unknown>, actorAdminUserId: string) {
|
||||
const typeCode = Number(data.type ?? 2);
|
||||
const sourceType = TYPE_BY_CODE[typeCode];
|
||||
if (!sourceType) throw new BadRequestException('نوع سند نامعتبر است');
|
||||
|
||||
const customerId = String(data.customer_id ?? '');
|
||||
if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است');
|
||||
|
||||
// LedgerService takes the legacy instrument name and the RAW figure, then
|
||||
// applies the karat / price-per-gram conversion itself.
|
||||
const instrument = INSTRUMENT_BY_TYPE[sourceType];
|
||||
|
||||
// Which field carries the raw figure depends on the instrument, exactly as
|
||||
// the legacy form did: cash posts a Rial price, melted gold posts the
|
||||
// pre-normalization weight, transfer/cheque post grams directly.
|
||||
const rawValue =
|
||||
instrument === 'CASH'
|
||||
? String(data.price ?? 0)
|
||||
: instrument === 'MELTED_GOLD'
|
||||
? String(data.original_value ?? data.value ?? 0)
|
||||
: String(data.value ?? 0);
|
||||
|
||||
const entry = await this.ledger.postGoldRepayment({
|
||||
customerId,
|
||||
instrument,
|
||||
rawValue,
|
||||
karatPerMille: data.ayar ? Number(data.ayar) : undefined,
|
||||
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined,
|
||||
note: (data.note as string) ?? undefined,
|
||||
postedByAdminUserId: actorAdminUserId,
|
||||
});
|
||||
|
||||
// Attach the instrument metadata the ledger service does not model.
|
||||
await this.prisma.ledgerEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: this.metadataPatch(data),
|
||||
});
|
||||
|
||||
return this.findOne(entry.id);
|
||||
}
|
||||
|
||||
private metadataPatch(data: Record<string, unknown>): Prisma.LedgerEntryUpdateInput {
|
||||
const patch: Prisma.LedgerEntryUpdateInput = {};
|
||||
if (data.lab !== undefined) patch.labName = (data.lab as string) || null;
|
||||
if (data.ang !== undefined) patch.hallmark = (data.ang as string) || null;
|
||||
if (data.account_name !== undefined) patch.accountName = (data.account_name as string) || null;
|
||||
if (data.bank_name !== undefined) patch.bankName = (data.bank_name as string) || null;
|
||||
if (data.to !== undefined) patch.transferTo = (data.to as string) || null;
|
||||
if (data.original_value !== undefined && data.original_value !== null) {
|
||||
patch.originalValue = new Prisma.Decimal(String(data.original_value));
|
||||
}
|
||||
if (data.price !== undefined && data.price !== null) {
|
||||
patch.priceRial = new Prisma.Decimal(String(data.price));
|
||||
}
|
||||
if (data.recive_date) patch.receivedAt = new Date(String(data.recive_date));
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `PUT cash/{id}` — edit a document.
|
||||
*
|
||||
* Only metadata and the note are editable. Changing the AMOUNT of a confirmed
|
||||
* entry would silently rewrite a customer's debt, so that is refused: the
|
||||
* correct accounting action is a reversing adjustment, not an edit. Legacy
|
||||
* allowed it. Recorded in docs/ADMIN-PORT-PLAN.md.
|
||||
*/
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('سند یافت نشد');
|
||||
|
||||
const amountChanged =
|
||||
data.value !== undefined && !new Prisma.Decimal(String(data.value)).equals(existing.amount);
|
||||
|
||||
if (existing.confirmed && amountChanged) {
|
||||
throw new BadRequestException(
|
||||
'مبلغ سند تاییدشده قابل ویرایش نیست، چون بدهی مشتری بر اساس آن محاسبه شده است. برای اصلاح، سند جدید ثبت کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const patch = this.metadataPatch(data);
|
||||
if (data.note !== undefined) patch.note = (data.note as string) || null;
|
||||
if (!existing.confirmed && amountChanged) {
|
||||
patch.amount = new Prisma.Decimal(String(data.value));
|
||||
}
|
||||
|
||||
await this.prisma.ledgerEntry.update({ where: { id }, data: patch });
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST cash/confirem/{id}` (legacy's own spelling) — a TOGGLE: the grid
|
||||
* shows a tick on an unconfirmed row and a cross on a confirmed one, both
|
||||
* calling this same endpoint.
|
||||
*
|
||||
* Confirming delegates to LedgerService so the customer balance moves through
|
||||
* the one code path that owns it. Un-confirming reverses that same amount
|
||||
* rather than editing the balance directly, so the two stay consistent.
|
||||
*/
|
||||
async toggleConfirm(id: string, adminUserId: string) {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
|
||||
if (!e.confirmed) {
|
||||
await this.ledger.confirmEntry(id, adminUserId);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
// Un-confirm: undo this entry's effect on the cached balance, then clear the
|
||||
// flag. A repayment is a CREDIT against gold debt, so reversing it adds the
|
||||
// amount back.
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delta =
|
||||
e.direction === LedgerDirection.CREDIT ? e.amount : e.amount.negated();
|
||||
const field =
|
||||
e.currency === LedgerCurrency.GOLD_GRAM ? 'goldDebtGrams' : 'fiatDebtRial';
|
||||
|
||||
await tx.customer.update({
|
||||
where: { id: e.customerId },
|
||||
data: { [field]: { increment: delta } },
|
||||
});
|
||||
await tx.ledgerEntry.update({
|
||||
where: { id },
|
||||
data: { confirmed: false, confirmedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE cash/{id}`.
|
||||
*
|
||||
* A confirmed entry has already moved the customer's balance; deleting it
|
||||
* would leave that balance overstated with no trace. Refused — legacy allowed
|
||||
* it. Unconfirmed entries never touched a balance, so they delete cleanly.
|
||||
*/
|
||||
async remove(id: string) {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
if (e.confirmed) {
|
||||
throw new BadRequestException(
|
||||
'حذف سند تاییدشده ممکن نیست، چون در تراز مشتری اعمال شده است.',
|
||||
);
|
||||
}
|
||||
await this.prisma.ledgerEntry.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
typeOptions() {
|
||||
return Object.entries(TYPE_BY_CODE).map(([code, sourceType]) => ({
|
||||
value: Number(code),
|
||||
title: TYPE_LABELS[sourceType],
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Wallet ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST wallet` grid — the list of granted wallet bonuses. */
|
||||
async listWallet(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['customer.fullName'],
|
||||
allowed: ['id', 'goldGrams', 'grantedAt', 'expiresAt', 'isActive', 'customer.fullName'],
|
||||
defaultOrderBy: { grantedAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.walletBonus.findMany({
|
||||
where: where as Prisma.WalletBonusWhereInput,
|
||||
orderBy: orderBy as Prisma.WalletBonusOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true },
|
||||
}),
|
||||
this.prisma.walletBonus.count({ where: where as Prisma.WalletBonusWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((w) => ({
|
||||
id: w.id,
|
||||
value: w.goldGrams,
|
||||
grams: w.goldGrams,
|
||||
is_active: w.isActive,
|
||||
consumed: Boolean(w.consumedAt),
|
||||
caj: w.grantedAt,
|
||||
expires_at: w.expiresAt,
|
||||
customer: {
|
||||
id: w.customer.id,
|
||||
name: w.customer.fullName,
|
||||
mobile: w.customer.mobile,
|
||||
},
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET /wallet/config` and `/wallet/birthday/config`, plus their POST
|
||||
* counterparts — the wallet settings screen (views/manage/wallet.vue).
|
||||
* Stored in the Setting key/value table that replaces legacy `config_static`.
|
||||
*/
|
||||
async getSetting(key: string) {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key } });
|
||||
return row?.value ?? {};
|
||||
}
|
||||
|
||||
async setSetting(key: string, value: Record<string, unknown>) {
|
||||
const row = await this.prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: value as Prisma.InputJsonValue },
|
||||
create: { key, value: value as Prisma.InputJsonValue },
|
||||
});
|
||||
return row.value;
|
||||
}
|
||||
|
||||
/** The customer picker used by the document form — legacy `GET query/customer`. */
|
||||
async queryCustomers() {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
select: { id: true, fullName: true, mobile: true },
|
||||
});
|
||||
return rows.map((c) => ({ id: c.id, name: c.fullName, fname: c.fullName, lname: '', mobile: c.mobile }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminInvoicesService } from './admin-invoices.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (BranchController's `branch/product` family):
|
||||
* POST branch/product/table
|
||||
* POST branch/product
|
||||
* GET branch/product/{id}
|
||||
* DELETE branch/product/{id}
|
||||
* GET branch/list -> the branch picker
|
||||
*/
|
||||
@Controller('admin/invoices')
|
||||
export class AdminInvoicesController {
|
||||
constructor(private readonly invoices: AdminInvoicesService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('invoice.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.invoices.list(query);
|
||||
}
|
||||
|
||||
@Get('branch-list')
|
||||
@RequirePermission('invoice.create')
|
||||
branchList() {
|
||||
return this.invoices.branchList();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('invoice.create')
|
||||
create(
|
||||
@Body() data: { branch_id?: string; products?: string[]; note?: string },
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.invoices.create(data, admin.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('invoice.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.invoices.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('invoice.list')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() data: { branch_id?: string; products?: string[]; note?: string },
|
||||
) {
|
||||
return this.invoices.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('invoice.list')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.invoices.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminInvoicesController } from './admin-invoices.controller';
|
||||
import { AdminInvoicesService } from './admin-invoices.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminInvoicesController],
|
||||
providers: [AdminInvoicesService],
|
||||
})
|
||||
export class AdminInvoicesModule {}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Branch stock allocations — legacy views/invoice/* ("فاکتور انتقال"), served by
|
||||
* BranchController as `branch/product`:
|
||||
* POST branch/product/table -> the grid
|
||||
* POST branch/product -> create { branch_id, products: [id...] }
|
||||
* GET branch/product/{id} -> one allocation
|
||||
* DELETE branch/product/{id} -> delete
|
||||
*
|
||||
* Despite the screen being labelled an invoice, it is not a sales document: it
|
||||
* records which products head office allocated to a branch, and who did it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminInvoicesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'note',
|
||||
'createdAt',
|
||||
'branchId',
|
||||
'branch.name',
|
||||
'allocatorAdminUser.fullName',
|
||||
// legacy filter keys
|
||||
'name',
|
||||
'caj',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
name: 'branch.name',
|
||||
caj: 'createdAt',
|
||||
'allocator_user.name': 'allocatorAdminUser.fullName',
|
||||
};
|
||||
|
||||
/** Legacy `POST branch/product/table`. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) => (f ? (AdminInvoicesService.FIELD_ALIASES[f] ?? f) : f);
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => ({ ...f, field: mapField(f.field) })),
|
||||
order: query.order
|
||||
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['note', 'branch.name'],
|
||||
allowed: AdminInvoicesService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.branchAllocation.findMany({
|
||||
where: where as Prisma.BranchAllocationWhereInput,
|
||||
orderBy: orderBy as Prisma.BranchAllocationOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
branch: true,
|
||||
allocatorAdminUser: true,
|
||||
items: { include: { product: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.branchAllocation.count({ where: where as Prisma.BranchAllocationWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
private toRow(
|
||||
a: Prisma.BranchAllocationGetPayload<{
|
||||
include: {
|
||||
branch: true;
|
||||
allocatorAdminUser: true;
|
||||
items: { include: { product: true } };
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const sumWeight = a.items.reduce(
|
||||
(sum, it) => sum.add(it.weightGrams ?? new Prisma.Decimal(0)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
// legacy column key is `name` — the receiving branch
|
||||
name: a.branch.name,
|
||||
branch_id: a.branchId,
|
||||
branch: { id: a.branch.id, name: a.branch.name },
|
||||
sum_weight: sumWeight,
|
||||
count_item: a.items.length,
|
||||
note: a.note,
|
||||
allocator_user: a.allocatorAdminUser
|
||||
? { id: a.allocatorAdminUser.id, name: a.allocatorAdminUser.fullName }
|
||||
: null,
|
||||
caj: a.createdAt,
|
||||
products: a.items.map((it) => ({
|
||||
id: it.productId,
|
||||
pid: it.productId,
|
||||
title: it.product.title,
|
||||
product_code: it.product.productCode,
|
||||
grams: it.weightGrams,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `GET branch/product/{id}`. */
|
||||
async findOne(id: string) {
|
||||
const a = await this.prisma.branchAllocation.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
branch: true,
|
||||
allocatorAdminUser: true,
|
||||
items: { include: { product: true } },
|
||||
},
|
||||
});
|
||||
if (!a) throw new NotFoundException('فاکتور یافت نشد');
|
||||
return this.toRow(a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST branch/product` with `{ branch_id, products: [id...] }`.
|
||||
*
|
||||
* Each product's current lightest variant weight is snapshotted, so a later
|
||||
* catalog edit does not silently rewrite what this document says was moved.
|
||||
*/
|
||||
async create(data: { branch_id?: string; products?: string[]; note?: string }, actorId: string) {
|
||||
const branchId = String(data.branch_id ?? '');
|
||||
if (!branchId) throw new BadRequestException('شعبه انتخاب نشده است');
|
||||
|
||||
const branch = await this.prisma.branch.findUnique({ where: { id: branchId } });
|
||||
if (!branch) throw new NotFoundException('شعبه یافت نشد');
|
||||
|
||||
const productIds = [...new Set((data.products ?? []).map((p) => String(p)).filter(Boolean))];
|
||||
if (productIds.length === 0) throw new BadRequestException('هیچ محصولی انتخاب نشده است');
|
||||
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { id: { in: productIds } },
|
||||
include: { variants: { where: { isActive: true } } },
|
||||
});
|
||||
if (products.length !== productIds.length) {
|
||||
throw new BadRequestException('برخی از محصولات انتخابشده یافت نشدند');
|
||||
}
|
||||
|
||||
const allocation = await this.prisma.branchAllocation.create({
|
||||
data: {
|
||||
branchId,
|
||||
allocatorAdminUserId: actorId,
|
||||
note: data.note ?? null,
|
||||
items: {
|
||||
create: products.map((p) => ({
|
||||
productId: p.id,
|
||||
weightGrams: p.variants.length
|
||||
? p.variants.reduce(
|
||||
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
|
||||
p.variants[0].rashWeightGrams,
|
||||
)
|
||||
: null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.findOne(allocation.id);
|
||||
}
|
||||
|
||||
/** Legacy `PUT branch/product/{id}` — the edit screen replaces the product set. */
|
||||
async update(id: string, data: { branch_id?: string; products?: string[]; note?: string }) {
|
||||
const existing = await this.prisma.branchAllocation.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('فاکتور یافت نشد');
|
||||
|
||||
if (data.branch_id) {
|
||||
const branch = await this.prisma.branch.findUnique({
|
||||
where: { id: String(data.branch_id) },
|
||||
});
|
||||
if (!branch) throw new NotFoundException('شعبه یافت نشد');
|
||||
}
|
||||
|
||||
if (Array.isArray(data.products)) {
|
||||
const productIds = [...new Set(data.products.map((p) => String(p)).filter(Boolean))];
|
||||
if (productIds.length === 0) {
|
||||
throw new BadRequestException('هیچ محصولی انتخاب نشده است');
|
||||
}
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { id: { in: productIds } },
|
||||
include: { variants: { where: { isActive: true } } },
|
||||
});
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.branchAllocationItem.deleteMany({ where: { allocationId: id } }),
|
||||
this.prisma.branchAllocationItem.createMany({
|
||||
data: products.map((p) => ({
|
||||
allocationId: id,
|
||||
productId: p.id,
|
||||
weightGrams: p.variants.length
|
||||
? p.variants.reduce(
|
||||
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
|
||||
p.variants[0].rashWeightGrams,
|
||||
)
|
||||
: null,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
await this.prisma.branchAllocation.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.branch_id ? { branchId: String(data.branch_id) } : {}),
|
||||
...(data.note !== undefined ? { note: data.note } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/** Legacy `DELETE branch/product/{id}`. Items cascade. */
|
||||
async remove(id: string) {
|
||||
const a = await this.prisma.branchAllocation.findUnique({ where: { id } });
|
||||
if (!a) throw new NotFoundException('فاکتور یافت نشد');
|
||||
await this.prisma.branchAllocation.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Legacy `GET branch/list` — the branch picker for the invoice form. */
|
||||
async branchList() {
|
||||
const rows = await this.prisma.branch.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminMiscService } from './admin-misc.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Cities, SMS, notifications, newsletter, activity log and settings — the
|
||||
* remaining admin screens. See AdminMiscService for the legacy endpoint map.
|
||||
*/
|
||||
@Controller('admin/misc')
|
||||
export class AdminMiscController {
|
||||
constructor(private readonly misc: AdminMiscService) {}
|
||||
|
||||
// ── cities ────────────────────────────────────────────────────────
|
||||
@Post('cities/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('cities.list')
|
||||
listCities(@Body() query: TableQueryDto) {
|
||||
return this.misc.listCities(query);
|
||||
}
|
||||
|
||||
@Post('cities')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('cities.create')
|
||||
createCity(@Body() data: { title?: string; name?: string; province?: string }) {
|
||||
return this.misc.createCity(data);
|
||||
}
|
||||
|
||||
@Get('cities/percent/:id')
|
||||
@RequirePermission('cities.list')
|
||||
getCityPercent(@Param('id') id: string) {
|
||||
return this.misc.getCityPercent(id, false);
|
||||
}
|
||||
|
||||
@Get('cities/percent-aux/:id')
|
||||
@RequirePermission('cities.list')
|
||||
getCityPercentAux(@Param('id') id: string) {
|
||||
return this.misc.getCityPercent(id, true);
|
||||
}
|
||||
|
||||
@Put('cities/:id')
|
||||
@RequirePermission('cities.list.view.edit')
|
||||
updateCity(@Param('id') id: string, @Body() data: Record<string, never>) {
|
||||
return this.misc.updateCity(id, data);
|
||||
}
|
||||
|
||||
@Delete('cities/:id')
|
||||
@RequirePermission('cities.list.remove')
|
||||
removeCity(@Param('id') id: string) {
|
||||
return this.misc.removeCity(id);
|
||||
}
|
||||
|
||||
// ── sms ───────────────────────────────────────────────────────────
|
||||
@Post('sms/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('sms.list')
|
||||
listSms(@Body() query: TableQueryDto) {
|
||||
return this.misc.listSms(query);
|
||||
}
|
||||
|
||||
@Post('sms')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('sms.create')
|
||||
sendSms(
|
||||
@Body() data: { type?: string | number; mobile?: string; text?: string },
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.misc.sendSms(data, admin.id);
|
||||
}
|
||||
|
||||
// ── notifications ─────────────────────────────────────────────────
|
||||
@Post('notify/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('notify.me')
|
||||
listNotifications(@Body() query: TableQueryDto) {
|
||||
return this.misc.listNotifications(query);
|
||||
}
|
||||
|
||||
@Post('notify')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('notify.create')
|
||||
createNotification(
|
||||
@Body() data: { title?: string; text?: string; body?: string; type?: string | number },
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.misc.createNotification(data, admin.id);
|
||||
}
|
||||
|
||||
@Post('notify/customer')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('notify.create')
|
||||
notifyCustomer(
|
||||
@Body() data: { customer_id?: string; title?: string; text?: string; body?: string },
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.misc.notifyCustomer(data, admin.id);
|
||||
}
|
||||
|
||||
@Get('notify/read/:id')
|
||||
@RequirePermission('notify.me')
|
||||
markRead(@Param('id') id: string) {
|
||||
return this.misc.markNotificationRead(id);
|
||||
}
|
||||
|
||||
@Get('notify/:id')
|
||||
@RequirePermission('notify.me')
|
||||
findNotification(@Param('id') id: string) {
|
||||
return this.misc.findNotification(id);
|
||||
}
|
||||
|
||||
// ── newsletter ────────────────────────────────────────────────────
|
||||
@Post('newsletter/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('newsletters')
|
||||
listNewsletter(@Body() query: TableQueryDto) {
|
||||
return this.misc.listNewsletter(query);
|
||||
}
|
||||
|
||||
// ── activity log ──────────────────────────────────────────────────
|
||||
@Get('log/customers')
|
||||
@RequirePermission('statics')
|
||||
logCustomers() {
|
||||
return this.misc.logCustomers();
|
||||
}
|
||||
|
||||
@Get('log/customer/:id')
|
||||
@RequirePermission('statics')
|
||||
customerLog(@Param('id') id: string) {
|
||||
return this.misc.customerLog(id);
|
||||
}
|
||||
|
||||
// ── settings + brands ("مدیریت ثابت ها") ─────────────────────────
|
||||
@Get('dashboard-links')
|
||||
@RequirePermission('config.report')
|
||||
dashboardLinks() {
|
||||
return this.misc.dashboardLinks();
|
||||
}
|
||||
|
||||
@Get('const')
|
||||
@RequirePermission('config.statics')
|
||||
listConsts() {
|
||||
return this.misc.listConsts();
|
||||
}
|
||||
|
||||
@Post('brands')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('config.statics.brand.create')
|
||||
createBrand(@Body() body: { title: string }) {
|
||||
return this.misc.createBrand(body.title);
|
||||
}
|
||||
|
||||
@Put('brands/:id')
|
||||
@RequirePermission('config.statics.brand.edit')
|
||||
updateBrand(@Param('id') id: string, @Body() body: { title: string }) {
|
||||
return this.misc.updateBrand(id, body.title);
|
||||
}
|
||||
|
||||
@Delete('brands/:id')
|
||||
@RequirePermission('config.statics.brand.remove')
|
||||
removeBrand(@Param('id') id: string) {
|
||||
return this.misc.removeBrand(id);
|
||||
}
|
||||
|
||||
@Get('const/:key')
|
||||
@RequirePermission('config.statics')
|
||||
getConst(@Param('key') key: string) {
|
||||
return this.misc.getConst(key);
|
||||
}
|
||||
|
||||
@Put('const/:key')
|
||||
@RequirePermission('config.statics')
|
||||
setConst(@Param('key') key: string, @Body() body: Record<string, unknown>) {
|
||||
// Legacy posts either a bare value under `val` or a whole object.
|
||||
return this.misc.setConst(key, body.val !== undefined ? body.val : body);
|
||||
}
|
||||
|
||||
@Delete('const/:key')
|
||||
@RequirePermission('config.statics')
|
||||
removeConst(@Param('key') key: string) {
|
||||
return this.misc.removeConst(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminMiscController } from './admin-misc.controller';
|
||||
import { AdminMiscService } from './admin-misc.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminMiscController],
|
||||
providers: [AdminMiscService],
|
||||
})
|
||||
export class AdminMiscModule {}
|
||||
@@ -0,0 +1,524 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { MessageAudience, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* The remaining admin screens — legacy views/city, views/sms, views/notify,
|
||||
* views/newsletters, views/statics and views/manage/*:
|
||||
*
|
||||
* cities, cities/{id}, cities/get-percent/{id}, cities/get-percent-aux/{id}
|
||||
* sms/table, sms
|
||||
* notify, notify/customer, notify/{id}, notify/read/{id}
|
||||
* ddf -> the newsletter list (legacy's own opaque name)
|
||||
* log/list/customer, log/ago/{id}
|
||||
* const, const/{key}, const/by-id/{id}
|
||||
* dashboard/link
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminMiscService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
// ── Cities ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST cities/list` grid. */
|
||||
async listCities(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['name', 'province'],
|
||||
allowed: ['id', 'name', 'province', 'percent', 'percentAux'],
|
||||
defaultOrderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.city.findMany({
|
||||
where: where as Prisma.CityWhereInput,
|
||||
orderBy: orderBy as Prisma.CityOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { _count: { select: { customers: true, branches: true } } },
|
||||
}),
|
||||
this.prisma.city.count({ where: where as Prisma.CityWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.name,
|
||||
name: c.name,
|
||||
province: c.province,
|
||||
percent: c.percent,
|
||||
percent_aux: c.percentAux,
|
||||
customers_count: c._count.customers,
|
||||
branches_count: c._count.branches,
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/** Legacy `POST cities`. */
|
||||
async createCity(data: { title?: string; name?: string; province?: string }) {
|
||||
const name = String(data.title ?? data.name ?? '').trim();
|
||||
if (!name) throw new BadRequestException('نام شهر الزامی است');
|
||||
return this.prisma.city.create({ data: { name, province: data.province ?? null } });
|
||||
}
|
||||
|
||||
/** Legacy `PUT cities/{id}` — the grid's inline edit also sets the percents. */
|
||||
async updateCity(
|
||||
id: string,
|
||||
data: { title?: string; name?: string; province?: string; percent?: unknown; percent_aux?: unknown },
|
||||
) {
|
||||
const existing = await this.prisma.city.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('شهر یافت نشد');
|
||||
|
||||
const patch: Prisma.CityUpdateInput = {};
|
||||
if (data.title !== undefined || data.name !== undefined) {
|
||||
patch.name = String(data.title ?? data.name);
|
||||
}
|
||||
if (data.province !== undefined) patch.province = data.province ?? null;
|
||||
if (data.percent !== undefined) {
|
||||
patch.percent = data.percent === null ? null : new Prisma.Decimal(String(data.percent));
|
||||
}
|
||||
if (data.percent_aux !== undefined) {
|
||||
patch.percentAux =
|
||||
data.percent_aux === null ? null : new Prisma.Decimal(String(data.percent_aux));
|
||||
}
|
||||
|
||||
return this.prisma.city.update({ where: { id }, data: patch });
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE cities/{id}`.
|
||||
*
|
||||
* Refuses while customers or branches still point at the city — legacy deleted
|
||||
* regardless, orphaning those rows.
|
||||
*/
|
||||
async removeCity(id: string) {
|
||||
const c = await this.prisma.city.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { customers: true, branches: true, addresses: true } } },
|
||||
});
|
||||
if (!c) throw new NotFoundException('شهر یافت نشد');
|
||||
|
||||
const refs = c._count.customers + c._count.branches + c._count.addresses;
|
||||
if (refs > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف این شهر ممکن نیست، چون ${refs} مشتری، شعبه یا آدرس به آن ارجاع دارد.`,
|
||||
);
|
||||
}
|
||||
await this.prisma.city.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Legacy `GET cities/get-percent/{id}` — returns the bare number. */
|
||||
async getCityPercent(id: string, aux: boolean) {
|
||||
const c = await this.prisma.city.findUnique({ where: { id } });
|
||||
if (!c) throw new NotFoundException('شهر یافت نشد');
|
||||
return aux ? c.percentAux : c.percent;
|
||||
}
|
||||
|
||||
// ── SMS ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST sms/table` — the sent-message log. */
|
||||
async listSms(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['body', 'recipients'],
|
||||
allowed: ['id', 'audience', 'body', 'createdAt', 'sentCount'],
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.smsMessage.findMany({
|
||||
where: where as Prisma.SmsMessageWhereInput,
|
||||
orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { sentByAdminUser: true },
|
||||
}),
|
||||
this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((s) => ({
|
||||
id: s.id,
|
||||
type: s.audience,
|
||||
text: s.body,
|
||||
mobile: s.recipients,
|
||||
sent_count: s.sentCount,
|
||||
sender: s.sentByAdminUser?.fullName ?? null,
|
||||
caj: s.createdAt,
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST sms` — records the send.
|
||||
*
|
||||
* NOTE no SMS gateway is wired up: legacy handed the message to an external
|
||||
* provider, and this port has no credentials for one. The message is stored
|
||||
* with the audience it resolved to, so the log is accurate, but nothing is
|
||||
* actually transmitted. Flagged in docs/ADMIN-PORT-PLAN.md.
|
||||
*/
|
||||
async sendSms(
|
||||
data: { type?: string | number; mobile?: string; text?: string },
|
||||
actorId: string,
|
||||
) {
|
||||
const body = String(data.text ?? '').trim();
|
||||
if (!body) throw new BadRequestException('متن پیامک الزامی است');
|
||||
|
||||
const audience = this.resolveAudience(data.type);
|
||||
const recipients = await this.resolveRecipients(audience, data.mobile);
|
||||
|
||||
return this.prisma.smsMessage.create({
|
||||
data: {
|
||||
audience,
|
||||
recipients: recipients.join(','),
|
||||
body,
|
||||
sentCount: recipients.length,
|
||||
sentByAdminUserId: actorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy's numeric audience codes, read off views/sms/create.vue where each
|
||||
* option is gated by its own rid:
|
||||
* -1 sms.create.single | 0 sms.create.all | 1 sms.create.reagent
|
||||
* 2 sms.create.omde | 9 sms.create.multiple
|
||||
* (there is no option for sms.create.single-customer "tak" in the legacy
|
||||
* markup even though the rid exists — preserved as unreachable.)
|
||||
*/
|
||||
private resolveAudience(type?: string | number): MessageAudience {
|
||||
const byCode: Record<string, MessageAudience> = {
|
||||
'-1': MessageAudience.SINGLE_NUMBER,
|
||||
'0': MessageAudience.ALL_CUSTOMERS,
|
||||
'1': MessageAudience.SALES_AGENTS,
|
||||
'2': MessageAudience.WHOLESALE_CUSTOMERS,
|
||||
'9': MessageAudience.MULTIPLE_NUMBERS,
|
||||
};
|
||||
const byName: Record<string, MessageAudience> = {
|
||||
single: MessageAudience.SINGLE_NUMBER,
|
||||
multiple: MessageAudience.MULTIPLE_NUMBERS,
|
||||
all: MessageAudience.ALL_CUSTOMERS,
|
||||
omde: MessageAudience.WHOLESALE_CUSTOMERS,
|
||||
tak: MessageAudience.RETAIL_CUSTOMERS,
|
||||
reagent: MessageAudience.SALES_AGENTS,
|
||||
};
|
||||
const key = String(type ?? '');
|
||||
return byCode[key] ?? byName[key] ?? MessageAudience.SINGLE_NUMBER;
|
||||
}
|
||||
|
||||
/** Expands an audience into the actual phone numbers it covers. */
|
||||
private async resolveRecipients(
|
||||
audience: MessageAudience,
|
||||
explicit?: string,
|
||||
): Promise<string[]> {
|
||||
if (
|
||||
audience === MessageAudience.SINGLE_NUMBER ||
|
||||
audience === MessageAudience.MULTIPLE_NUMBERS
|
||||
) {
|
||||
const list = String(explicit ?? '')
|
||||
.split(/[,\s\n]+/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
if (list.length === 0) throw new BadRequestException('شماره موبایل وارد نشده است');
|
||||
return list;
|
||||
}
|
||||
|
||||
if (audience === MessageAudience.SALES_AGENTS) {
|
||||
const rows = await this.prisma.adminUser.findMany({
|
||||
where: { role: 'SALES_EXPERT', isActive: true },
|
||||
select: { mobile: true },
|
||||
});
|
||||
return rows.map((r) => r.mobile);
|
||||
}
|
||||
|
||||
// The customer audiences. WHOLESALE/RETAIL had a Customer.type column in
|
||||
// legacy that the schema redesign removed, so both currently resolve to all
|
||||
// active customers — the same gap recorded for the omde/tak lists.
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { isActive: true },
|
||||
select: { mobile: true },
|
||||
});
|
||||
return rows.map((r) => r.mobile);
|
||||
}
|
||||
|
||||
// ── Notifications ──────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST notify` — broadcast to an audience. */
|
||||
async createNotification(
|
||||
data: { title?: string; text?: string; body?: string; type?: string | number },
|
||||
actorId: string,
|
||||
) {
|
||||
const title = String(data.title ?? '').trim();
|
||||
const body = String(data.body ?? data.text ?? '').trim();
|
||||
if (!title) throw new BadRequestException('عنوان نوتیفیکیشن الزامی است');
|
||||
if (!body) throw new BadRequestException('متن نوتیفیکیشن الزامی است');
|
||||
|
||||
const audience = this.resolveAudience(data.type);
|
||||
const customers = await this.prisma.customer.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return this.prisma.notification.create({
|
||||
data: {
|
||||
title,
|
||||
body,
|
||||
audience,
|
||||
sentByAdminUserId: actorId,
|
||||
recipients: { create: customers.map((c) => ({ customerId: c.id })) },
|
||||
},
|
||||
include: { _count: { select: { recipients: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `POST notify/customer` — send to one named customer. */
|
||||
async notifyCustomer(
|
||||
data: { customer_id?: string; title?: string; text?: string; body?: string },
|
||||
actorId: string,
|
||||
) {
|
||||
const customerId = String(data.customer_id ?? '');
|
||||
if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است');
|
||||
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
|
||||
const title = String(data.title ?? '').trim();
|
||||
const body = String(data.body ?? data.text ?? '').trim();
|
||||
if (!title || !body) throw new BadRequestException('عنوان و متن الزامی است');
|
||||
|
||||
return this.prisma.notification.create({
|
||||
data: {
|
||||
title,
|
||||
body,
|
||||
audience: MessageAudience.SINGLE_NUMBER,
|
||||
sentByAdminUserId: actorId,
|
||||
recipients: { create: [{ customerId }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `GET notify/{id}` — one notification with its delivery state. */
|
||||
async findNotification(id: string) {
|
||||
const n = await this.prisma.notification.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
sentByAdminUser: true,
|
||||
recipients: { include: { customer: true } },
|
||||
},
|
||||
});
|
||||
if (!n) throw new NotFoundException('نوتیفیکیشن یافت نشد');
|
||||
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
text: n.body,
|
||||
audience: n.audience,
|
||||
sender: n.sentByAdminUser?.fullName ?? null,
|
||||
caj: n.createdAt,
|
||||
recipients: n.recipients.map((r) => ({
|
||||
id: r.id,
|
||||
customer: { id: r.customer.id, name: r.customer.fullName, mobile: r.customer.mobile },
|
||||
read_at: r.readAt,
|
||||
is_read: Boolean(r.readAt),
|
||||
})),
|
||||
read_count: n.recipients.filter((r) => r.readAt).length,
|
||||
total_count: n.recipients.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST notify/list` — the notification log. */
|
||||
async listNotifications(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['title', 'body'],
|
||||
allowed: ['id', 'title', 'body', 'audience', 'createdAt'],
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.notification.findMany({
|
||||
where: where as Prisma.NotificationWhereInput,
|
||||
orderBy: orderBy as Prisma.NotificationOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
sentByAdminUser: true,
|
||||
_count: { select: { recipients: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.notification.count({ where: where as Prisma.NotificationWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
text: n.body,
|
||||
audience: n.audience,
|
||||
sender: n.sentByAdminUser?.fullName ?? null,
|
||||
total_count: n._count.recipients,
|
||||
caj: n.createdAt,
|
||||
})),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/** Legacy `GET notify/read/{id}` — mark one recipient row read. */
|
||||
async markNotificationRead(recipientId: string) {
|
||||
const r = await this.prisma.notificationRecipient.findUnique({ where: { id: recipientId } });
|
||||
if (!r) throw new NotFoundException('یافت نشد');
|
||||
if (r.readAt) return r;
|
||||
return this.prisma.notificationRecipient.update({
|
||||
where: { id: recipientId },
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Newsletter ─────────────────────────────────────────────────────
|
||||
|
||||
/** Legacy `POST ddf` — the newsletter signup list (legacy's own endpoint name). */
|
||||
async listNewsletter(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['email', 'mobile'],
|
||||
allowed: ['id', 'email', 'mobile', 'createdAt'],
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.newsletterSubscriber.findMany({
|
||||
where: where as Prisma.NewsletterSubscriberWhereInput,
|
||||
orderBy: orderBy as Prisma.NewsletterSubscriberOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
this.prisma.newsletterSubscriber.count({
|
||||
where: where as Prisma.NewsletterSubscriberWhereInput,
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((n) => ({ id: n.id, email: n.email, mobile: n.mobile, caj: n.createdAt })),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Activity log ("آمار") ─────────────────────────────────────────
|
||||
|
||||
/** Legacy `GET log/list/customer` — customers that have any activity. */
|
||||
async logCustomers() {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { activityLogs: { some: {} } },
|
||||
orderBy: { fullName: 'asc' },
|
||||
select: { id: true, fullName: true, mobile: true },
|
||||
});
|
||||
return rows.map((c) => ({ id: c.id, name: c.fullName, mobile: c.mobile }));
|
||||
}
|
||||
|
||||
/** Legacy `GET log/ago/{customerId}` — that customer's recent activity. */
|
||||
async customerLog(customerId: string) {
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
|
||||
const rows = await this.prisma.customerActivityLog.findMany({
|
||||
where: { customerId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return rows.map((l) => ({
|
||||
id: l.id,
|
||||
action: l.action,
|
||||
detail: l.detail,
|
||||
caj: l.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Settings ("مدیریت ثابت ها") ───────────────────────────────────
|
||||
|
||||
/** Legacy `GET const/{key}`. */
|
||||
async getConst(key: string) {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key } });
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/** Legacy `POST const` / `PUT const/{key}`. */
|
||||
async setConst(key: string, value: unknown) {
|
||||
const row = await this.prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: value as Prisma.InputJsonValue },
|
||||
create: { key, value: value as Prisma.InputJsonValue },
|
||||
});
|
||||
return row.value;
|
||||
}
|
||||
|
||||
async listConsts() {
|
||||
const rows = await this.prisma.setting.findMany({ orderBy: { key: 'asc' } });
|
||||
return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt }));
|
||||
}
|
||||
|
||||
async removeConst(key: string) {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key } });
|
||||
if (!row) throw new NotFoundException('تنظیم یافت نشد');
|
||||
await this.prisma.setting.delete({ where: { key } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Brands (managed from the same "ثابت ها" screen) ───────────────
|
||||
|
||||
async createBrand(title: string) {
|
||||
if (!title?.trim()) throw new BadRequestException('عنوان برند الزامی است');
|
||||
return this.prisma.brand.create({ data: { title: title.trim() } });
|
||||
}
|
||||
|
||||
async updateBrand(id: string, title: string) {
|
||||
const b = await this.prisma.brand.findUnique({ where: { id } });
|
||||
if (!b) throw new NotFoundException('برند یافت نشد');
|
||||
return this.prisma.brand.update({ where: { id }, data: { title } });
|
||||
}
|
||||
|
||||
async removeBrand(id: string) {
|
||||
const b = await this.prisma.brand.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { products: true } } },
|
||||
});
|
||||
if (!b) throw new NotFoundException('برند یافت نشد');
|
||||
if (b._count.products > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف این برند ممکن نیست، چون ${b._count.products} محصول به آن ارجاع دارد.`,
|
||||
);
|
||||
}
|
||||
await this.prisma.brand.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Legacy `GET dashboard/link` — the report shortcuts on views/manage/report. */
|
||||
async dashboardLinks() {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: 'dashboard.links' } });
|
||||
return row?.value ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { UpdateOrderStatusDto } from './update-status.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (henza_server routes/admin.php):
|
||||
* POST single-order -> the single-sale order grid
|
||||
* GET order/{id} -> order detail + status timeline
|
||||
*
|
||||
* Each route is gated by the same rid the legacy router used in `meta.role`,
|
||||
* so the API enforces what the menu already hides.
|
||||
*/
|
||||
@Controller('admin/orders')
|
||||
export class AdminOrdersController {
|
||||
constructor(private readonly orders: AdminOrdersService) {}
|
||||
|
||||
/** Legacy POST single-order. POST, not GET, because XTable posts its filters. */
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.list')
|
||||
list(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
/**
|
||||
* Branch scoping, ported from legacy AdminController::getSingleOrders:
|
||||
*
|
||||
* if (!is_null(auth()->user()->branch_id)) {
|
||||
* $records->where('branch_id', auth()->user()->branch_id);
|
||||
* }
|
||||
*
|
||||
* i.e. ANY user carrying a branch_id sees only that branch. Faithful to legacy.
|
||||
*
|
||||
* Consequence worth knowing: the seed gives head-office admins a home branch,
|
||||
* so under this rule they see only that branch's orders. That is legacy's real
|
||||
* behaviour, not a defect introduced here — head office was expected to have a
|
||||
* null branch_id. Recorded in docs/ADMIN-PORT-PLAN.md.
|
||||
*/
|
||||
return this.orders.list(query, admin.branchId ?? undefined);
|
||||
}
|
||||
|
||||
/** Legacy POST major/order/table — the wholesale grid. */
|
||||
@Post('wholesale/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.omde')
|
||||
listWholesale(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.orders.listWholesale(query, admin.branchId ?? undefined);
|
||||
}
|
||||
|
||||
/** Legacy POST order-tamin — orders that reached the sourcing stage. */
|
||||
@Post('tamin/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.tamin')
|
||||
listTamin(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.orders.listTamin(query, admin.branchId ?? undefined);
|
||||
}
|
||||
|
||||
/** Legacy POST delete-order. */
|
||||
@Post('delete')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.omde')
|
||||
remove(@Body() body: { id: string }) {
|
||||
return this.orders.remove(body.id);
|
||||
}
|
||||
|
||||
/** Legacy POST /order from the admin order builder (views/order/create.vue). */
|
||||
@Post('create')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.create')
|
||||
createFromAdmin(
|
||||
@Body()
|
||||
body: {
|
||||
customer_id: string;
|
||||
order_items: { vid: string; qty: number; percent?: number; discount?: number }[];
|
||||
sts?: number | string;
|
||||
note?: string;
|
||||
},
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.orders.createFromAdmin(body, admin.id);
|
||||
}
|
||||
|
||||
/** Legacy GET order/type. */
|
||||
@Get('type-options')
|
||||
@RequirePermission('order.create')
|
||||
typeOptions() {
|
||||
return this.orders.typeOptions();
|
||||
}
|
||||
|
||||
@Get('status-options')
|
||||
@RequirePermission('order.list')
|
||||
statusOptions() {
|
||||
return this.orders.statusOptions();
|
||||
}
|
||||
|
||||
/** Legacy POST single/order-sts/edit. */
|
||||
@Post('status')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.list')
|
||||
setStatus(@Body() dto: UpdateOrderStatusDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.orders.setStatus(dto, admin.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('order.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.orders.findOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { OrdersModule } from '../orders/orders.module';
|
||||
import { AdminOrdersController } from './admin-orders.controller';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, OrdersModule],
|
||||
controllers: [AdminOrdersController],
|
||||
providers: [AdminOrdersService],
|
||||
})
|
||||
export class AdminOrdersModule {}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { OrdersService } from '../orders/orders.service';
|
||||
import type { UpdateOrderStatusDto } from './update-status.dto';
|
||||
|
||||
/**
|
||||
* Farsi status labels, matching legacy's `sts_str` column.
|
||||
*
|
||||
* Legacy stored status as an integer `sts` and the server derived the string via
|
||||
* Order::$SELECT_STS_STR. The new schema uses an enum, so the mapping is explicit.
|
||||
* Debt is recognised at sts>4 (DELIVERED_TO_AGENT) — see the schema notes.
|
||||
*/
|
||||
const STATUS_LABELS: Record<OrderStatus, string> = {
|
||||
SUSPENDED: 'معلق (سقف اعتبار)',
|
||||
PLACED: 'ثبت شده',
|
||||
ADMIN_APPROVED: 'تایید مدیر',
|
||||
SOURCING: 'در حال تامین',
|
||||
PREPARING: 'در حال آمادهسازی',
|
||||
DELIVERED_TO_AGENT: 'تحویل به نماینده',
|
||||
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
|
||||
/** Order of the status timeline shown in the legacy "وضعیت سفارش" modal. */
|
||||
const STATUS_FLOW: OrderStatus[] = [
|
||||
OrderStatus.PLACED,
|
||||
OrderStatus.ADMIN_APPROVED,
|
||||
OrderStatus.SOURCING,
|
||||
OrderStatus.PREPARING,
|
||||
OrderStatus.DELIVERED_TO_AGENT,
|
||||
OrderStatus.DELIVERED_TO_CUSTOMER,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AdminOrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
private readonly orders: OrdersService,
|
||||
) {}
|
||||
|
||||
/** Fields the XTable filter bar and sorter may reference. */
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'orderNumber',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'totalGoldWeightGrams',
|
||||
'flatRialCharge',
|
||||
'isSuspendedOverCreditLimit',
|
||||
'customer.fullName',
|
||||
'customer.mobile',
|
||||
'branch.name',
|
||||
];
|
||||
|
||||
/**
|
||||
* Legacy `POST single-order` — the order list grid.
|
||||
*
|
||||
* `branchId` narrows the result to one branch, which is how the branch-mode
|
||||
* build of the panel scopes its list.
|
||||
*/
|
||||
async list(query: TableQueryDto, branchId?: string, channel: OrderChannel = OrderChannel.RETAIL) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
|
||||
allowed: AdminOrdersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const scoped: Prisma.OrderWhereInput = {
|
||||
AND: [
|
||||
where as Prisma.OrderWhereInput,
|
||||
{ channel },
|
||||
...(branchId ? [{ branchId }] : []),
|
||||
],
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
customer: true,
|
||||
branch: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where: scoped }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shapes a row the way the ported list view reads it.
|
||||
*
|
||||
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`)
|
||||
* so the ported template bindings keep working unchanged.
|
||||
*/
|
||||
private toRow(o: Prisma.OrderGetPayload<{
|
||||
include: {
|
||||
customer: true;
|
||||
branch: true;
|
||||
items: { include: { productVariant: { include: { product: true } } } };
|
||||
};
|
||||
}>) {
|
||||
return {
|
||||
id: o.id,
|
||||
orderNumber: o.orderNumber,
|
||||
status: o.status,
|
||||
sts_str: STATUS_LABELS[o.status],
|
||||
caj: o.createdAt,
|
||||
uaj: o.updatedAt,
|
||||
total_price: o.flatRialCharge,
|
||||
totalGoldWeightGrams: o.totalGoldWeightGrams,
|
||||
wageWeightGrams: o.wageWeightGrams,
|
||||
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
|
||||
deliveredAt: o.deliveredAt,
|
||||
branch_id: o.branchId,
|
||||
branch: o.branch ? { id: o.branch.id, name: o.branch.name } : null,
|
||||
customer: {
|
||||
id: o.customer.id,
|
||||
// Legacy split names into fname/lname; the new schema has one fullName.
|
||||
// Both are exposed so ported templates binding either keep working.
|
||||
fname: o.customer.fullName,
|
||||
lname: '',
|
||||
fullName: o.customer.fullName,
|
||||
phone: o.customer.mobile,
|
||||
},
|
||||
items: o.items.map((it) => ({
|
||||
id: it.id,
|
||||
title: it.productVariant.product.title,
|
||||
variant_title: it.productVariant.title,
|
||||
qty: it.quantity,
|
||||
grams: it.computedWeightGrams,
|
||||
rash: it.rashWeightGramsSnapshot,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST major/order/table` — the wholesale (عمده) order grid.
|
||||
*
|
||||
* Legacy kept the two sales channels in SEPARATE tables: `orders_orders`
|
||||
* (SingleCustomerOrders, served by AdminController::getSingleOrders) and
|
||||
* `orders` (Order, served by MajorOrderController::index). Neither controller
|
||||
* applied a type filter because the table itself was the discriminator, so the
|
||||
* two lists never overlapped.
|
||||
*
|
||||
* The redesigned schema has one Order model, so `Order.channel` carries that
|
||||
* distinction instead and each list filters on it.
|
||||
*/
|
||||
async listWholesale(query: TableQueryDto, branchId?: string) {
|
||||
return this.list(query, branchId, OrderChannel.WHOLESALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST order-tamin` — MajorOrderController::getTaminOrders.
|
||||
*
|
||||
* Legacy narrowed to orders whose activity contained Order::$STS[3], i.e. those
|
||||
* that have reached the sourcing stage. The equivalent here is a status event
|
||||
* recording a transition into SOURCING.
|
||||
*/
|
||||
async listTamin(query: TableQueryDto, branchId?: string) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
|
||||
allowed: AdminOrdersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const conditions: Prisma.OrderWhereInput[] = [
|
||||
where as Prisma.OrderWhereInput,
|
||||
{ statusEvents: { some: { toStatus: OrderStatus.SOURCING } } },
|
||||
];
|
||||
if (branchId) conditions.push({ branchId });
|
||||
|
||||
const scoped: Prisma.OrderWhereInput = { AND: conditions };
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
customer: true,
|
||||
branch: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where: scoped }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy `POST delete-order` — remove a wholesale order. */
|
||||
async remove(id: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: { ledgerEntries: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
|
||||
// Refuses once debt has been posted: deleting the order would orphan the
|
||||
// ledger entries and leave the customer permanently in debt. Legacy had no
|
||||
// such guard. Recorded in docs/ADMIN-PORT-PLAN.md.
|
||||
if (order.ledgerEntries.length > 0) {
|
||||
throw new BadRequestException(
|
||||
'حذف این سفارش ممکن نیست، چون برای آن سند حسابداری ثبت شده است.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.orderStatusEvent.deleteMany({ where: { orderId: id } }),
|
||||
this.prisma.orderItem.deleteMany({ where: { orderId: id } }),
|
||||
this.prisma.order.delete({ where: { id } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Legacy `GET order/:id` detail, including the status timeline. */
|
||||
async findOne(id: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customer: true,
|
||||
branch: true,
|
||||
items: { include: { productVariant: { include: { product: true } } } },
|
||||
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
|
||||
const reached = new Set(order.statusEvents.map((e) => e.toStatus));
|
||||
|
||||
return {
|
||||
...this.toRow(order),
|
||||
// legacy `activity`: one entry per step, `proceed` marking those reached
|
||||
activity: STATUS_FLOW.map((s) => {
|
||||
const event = order.statusEvents.find((e) => e.toStatus === s);
|
||||
return {
|
||||
status: s,
|
||||
title: STATUS_LABELS[s],
|
||||
proceed: reached.has(s),
|
||||
uaj: event?.createdAt ?? null,
|
||||
note: event?.note ?? '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Farsi labels for the status dropdowns. */
|
||||
statusOptions() {
|
||||
return Object.entries(STATUS_LABELS).map(([value, title]) => ({ value, title }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET order/type` — the "نوع فروش" dropdown.
|
||||
* Legacy Order::$TYPES = [1 => 'ثبت سفارش توسط مسئول فروش', 2 => 'ثبت مشتری'].
|
||||
*/
|
||||
typeOptions() {
|
||||
return { 1: 'ثبت سفارش توسط مسئول فروش', 2: 'ثبت مشتری' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST /order` from views/order/create.vue — the admin order builder.
|
||||
*
|
||||
* Delegates to OrdersService.createOrder, which owns the weight formula
|
||||
* w = rash * (1 + (wagePercent - discountPercent)/100)
|
||||
* and the credit-limit gate that suspends rather than rejects an over-limit
|
||||
* order. Reimplementing either here would let the two paths drift.
|
||||
*
|
||||
* Legacy also posted total_price / total_weight / gold_price_original computed
|
||||
* in the BROWSER. Those are ignored: the server recomputes them from the
|
||||
* variant snapshots, so a tampered or stale client total cannot alter a debt.
|
||||
*/
|
||||
async createFromAdmin(
|
||||
payload: {
|
||||
customer_id: string;
|
||||
order_items: {
|
||||
vid: string;
|
||||
qty: number;
|
||||
percent?: number;
|
||||
discount?: number;
|
||||
rial_input?: number;
|
||||
}[];
|
||||
sts?: number | string;
|
||||
note?: string;
|
||||
},
|
||||
actorAdminUserId: string,
|
||||
) {
|
||||
if (!payload.customer_id) throw new BadRequestException('مشتری انتخاب نشده است');
|
||||
if (!Array.isArray(payload.order_items) || payload.order_items.length === 0) {
|
||||
throw new BadRequestException('سبد سفارش خالی است');
|
||||
}
|
||||
|
||||
const items = await Promise.all(
|
||||
payload.order_items.map(async (it) => {
|
||||
const variant = await this.prisma.productVariant.findUnique({ where: { id: it.vid } });
|
||||
if (!variant) throw new NotFoundException(`تنوع محصول یافت نشد: ${it.vid}`);
|
||||
return {
|
||||
productVariantId: it.vid,
|
||||
quantity: Number(it.qty) || 1,
|
||||
// Fall back to the variant's own default wage when the builder did not
|
||||
// negotiate a different one.
|
||||
wagePercent: Number(it.percent ?? variant.defaultWagePercent),
|
||||
discountPercent: Number(it.discount ?? 0),
|
||||
flatRialCharge: Number(it.rial_input ?? 0),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const order = await this.orders.createOrder({
|
||||
customerId: payload.customer_id,
|
||||
items,
|
||||
// The admin builder serves the omde (major/*) flow, so orders it creates
|
||||
// belong to the wholesale channel — legacy wrote them to the `orders`
|
||||
// table, which MajorOrderController::index listed.
|
||||
channel: OrderChannel.WHOLESALE,
|
||||
});
|
||||
|
||||
// legacy `sts`: advance the new order to the chosen delivery status.
|
||||
const target = this.statusFromLegacyCode(payload.sts);
|
||||
if (target && target !== OrderStatus.PLACED) {
|
||||
await this.orders.updateStatus(order.id, target, actorAdminUserId, payload.note);
|
||||
}
|
||||
|
||||
return this.findOne(order.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy delivery-status codes from create.vue's `sts` map:
|
||||
* 1 ثبت سفارش | 2 تایید مدیر سیستم | 3 در حال تامین
|
||||
* 4 در حال آماده سازی | 5 ارسال | 6 تحویل مشتری
|
||||
*/
|
||||
private statusFromLegacyCode(code?: number | string): OrderStatus | undefined {
|
||||
const map: Record<number, OrderStatus> = {
|
||||
1: OrderStatus.PLACED,
|
||||
2: OrderStatus.ADMIN_APPROVED,
|
||||
3: OrderStatus.SOURCING,
|
||||
4: OrderStatus.PREPARING,
|
||||
5: OrderStatus.DELIVERED_TO_AGENT,
|
||||
6: OrderStatus.DELIVERED_TO_CUSTOMER,
|
||||
};
|
||||
return code === undefined ? undefined : map[Number(code)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST single/order-sts/edit` — advance or remove a status.
|
||||
*
|
||||
* Setting a status delegates to OrdersService.updateStatus rather than writing
|
||||
* the row here, because that method also posts the gold debt to the ledger at
|
||||
* DELIVERED_TO_AGENT. Duplicating the update would silently skip that.
|
||||
*/
|
||||
async setStatus(dto: UpdateOrderStatusDto, actorAdminUserId: string) {
|
||||
if (dto.remove) {
|
||||
return this.removeStatusEvent(dto.orderId, dto.status);
|
||||
}
|
||||
return this.orders.updateStatus(dto.orderId, dto.status, actorAdminUserId, dto.note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy's `remove: true` branch: drop a recorded status event.
|
||||
*
|
||||
* Deliberately refuses to remove DELIVERED_TO_AGENT. In legacy, deleting that
|
||||
* row left the posted debt ledger entries behind, so the customer stayed in
|
||||
* debt for an order no longer marked delivered. Rather than reproduce a
|
||||
* data-corrupting path, this rejects and explains — recorded in
|
||||
* docs/ADMIN-PORT-PLAN.md.
|
||||
*/
|
||||
private async removeStatusEvent(orderId: string, status: OrderStatus) {
|
||||
if (status === OrderStatus.DELIVERED_TO_AGENT) {
|
||||
throw new BadRequestException(
|
||||
'حذف وضعیت «تحویل به نماینده» ممکن نیست، چون بدهی طلا در همین مرحله ثبت شده است.',
|
||||
);
|
||||
}
|
||||
|
||||
const event = await this.prisma.orderStatusEvent.findFirst({
|
||||
where: { orderId, toStatus: status },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!event) throw new NotFoundException('وضعیت مورد نظر یافت نشد');
|
||||
|
||||
await this.prisma.orderStatusEvent.delete({ where: { id: event.id } });
|
||||
|
||||
// Roll the order back to whatever status is now the latest recorded one.
|
||||
const latest = await this.prisma.orderStatusEvent.findFirst({
|
||||
where: { orderId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: latest?.toStatus ?? OrderStatus.PLACED },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Body of legacy `POST single/order-sts/edit`.
|
||||
*
|
||||
* Legacy posted the whole timeline row back, so it carried `status`, an optional
|
||||
* `note`, and `remove: true` when the operator was deleting a status instead of
|
||||
* setting one. The order id travelled on the row as `order_id`/`orderId`.
|
||||
*/
|
||||
export class UpdateOrderStatusDto {
|
||||
@IsString()
|
||||
orderId!: string;
|
||||
|
||||
@IsEnum(OrderStatus)
|
||||
status!: OrderStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
/** legacy: row.remove = true -> delete this status event rather than add one */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
remove?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminPreOrdersService } from './admin-preorders.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (CustomOrderController):
|
||||
* POST get-custom-orders -> the "سفارش های ساختنی" grid
|
||||
* GET get-custom-order/{id}
|
||||
* PUT custom-order/{id}
|
||||
* DELETE custom-order/{id}
|
||||
*/
|
||||
@Controller('admin/pre-orders')
|
||||
export class AdminPreOrdersController {
|
||||
constructor(private readonly preOrders: AdminPreOrdersService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.makeOrder')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.preOrders.list(query);
|
||||
}
|
||||
|
||||
@Get('status-options')
|
||||
@RequirePermission('order.makeOrder')
|
||||
statusOptions() {
|
||||
return this.preOrders.statusOptions();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('order.makeOrder')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.preOrders.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('order.makeOrder')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.preOrders.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('order.makeOrder')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.preOrders.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminPreOrdersController } from './admin-preorders.controller';
|
||||
import { AdminPreOrdersService } from './admin-preorders.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminPreOrdersController],
|
||||
providers: [AdminPreOrdersService],
|
||||
})
|
||||
export class AdminPreOrdersModule {}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PreOrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Custom / made-to-order requests — legacy "سفارش های ساختنی".
|
||||
*
|
||||
* Legacy served these from CustomOrderController (`POST get-custom-orders`,
|
||||
* `PUT custom-order/{id}`, `DELETE custom-order/{id}`). The redesigned schema
|
||||
* models them as PreOrder: a design request that an admin prices and later
|
||||
* converts into a real, debt-bearing Order.
|
||||
*/
|
||||
const PREORDER_STATUS_LABELS: Record<PreOrderStatus, string> = {
|
||||
PLACED: 'ثبت شده',
|
||||
ADMIN_APPROVED: 'تایید مدیر',
|
||||
WORKSHOP_APPROVED: 'تایید کارگاه',
|
||||
READY_TO_SHIP: 'آماده ارسال',
|
||||
DELIVERED: 'تحویل شده',
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminPreOrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'colorName',
|
||||
'sizeSpec',
|
||||
'stoneColor',
|
||||
'minaColor',
|
||||
'estimatedWeightGrams',
|
||||
'estimatedPriceRial',
|
||||
'customer.fullName',
|
||||
'customer.mobile',
|
||||
];
|
||||
|
||||
/** Legacy `POST get-custom-orders`. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['customer.fullName', 'customer.mobile'],
|
||||
allowed: AdminPreOrdersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.preOrder.findMany({
|
||||
where: where as Prisma.PreOrderWhereInput,
|
||||
orderBy: orderBy as Prisma.PreOrderOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { customer: true, city: true, workshop: true },
|
||||
}),
|
||||
this.prisma.preOrder.count({ where: where as Prisma.PreOrderWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy field names (color, size, color_mina, color_stone, ...) preserved. */
|
||||
private toRow(
|
||||
p: Prisma.PreOrderGetPayload<{ include: { customer: true; city: true; workshop: true } }>,
|
||||
) {
|
||||
return {
|
||||
id: p.id,
|
||||
status: p.status,
|
||||
sts_str_fa: PREORDER_STATUS_LABELS[p.status],
|
||||
color: p.colorName,
|
||||
size: p.sizeSpec,
|
||||
color_mina: p.minaColor,
|
||||
color_stone: p.stoneColor,
|
||||
// legacy 'sum' (quantity); a PreOrder models a single design request
|
||||
sum: 1,
|
||||
description: p.description,
|
||||
designImageUrl: p.designImageUrl,
|
||||
estimatedWeightGrams: p.estimatedWeightGrams,
|
||||
estimatedPriceRial: p.estimatedPriceRial,
|
||||
time_creat_fa: p.createdAt,
|
||||
time_update_fa: p.updatedAt,
|
||||
convertedOrderId: p.convertedOrderId,
|
||||
city: p.city ? { id: p.city.id, title: p.city.name } : null,
|
||||
workshop: p.workshop ? { id: p.workshop.id, name: p.workshop.name } : null,
|
||||
customer: {
|
||||
id: p.customer.id,
|
||||
fname: p.customer.fullName,
|
||||
lname: '',
|
||||
name: p.customer.fullName,
|
||||
fullName: p.customer.fullName,
|
||||
mobile: p.customer.mobile,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const p = await this.prisma.preOrder.findUnique({
|
||||
where: { id },
|
||||
include: { customer: true, city: true, workshop: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException('سفارش ساختنی یافت نشد');
|
||||
return this.toRow(p);
|
||||
}
|
||||
|
||||
/** Legacy `PUT custom-order/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.preOrder.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('سفارش ساختنی یافت نشد');
|
||||
|
||||
const patch: Prisma.PreOrderUpdateInput = {};
|
||||
if (data.status !== undefined) patch.status = data.status as PreOrderStatus;
|
||||
if (data.color !== undefined) patch.colorName = data.color as string;
|
||||
if (data.size !== undefined) patch.sizeSpec = data.size as string;
|
||||
if (data.color_mina !== undefined) patch.minaColor = data.color_mina as string;
|
||||
if (data.color_stone !== undefined) patch.stoneColor = data.color_stone as string;
|
||||
if (data.description !== undefined) patch.description = data.description as string;
|
||||
if (data.estimatedWeightGrams !== undefined) {
|
||||
patch.estimatedWeightGrams = new Prisma.Decimal(data.estimatedWeightGrams as string);
|
||||
}
|
||||
if (data.estimatedPriceRial !== undefined) {
|
||||
patch.estimatedPriceRial = new Prisma.Decimal(data.estimatedPriceRial as string);
|
||||
}
|
||||
|
||||
return this.prisma.preOrder.update({ where: { id }, data: patch });
|
||||
}
|
||||
|
||||
/** Legacy `DELETE custom-order/{id}`. */
|
||||
async remove(id: string) {
|
||||
const p = await this.prisma.preOrder.findUnique({ where: { id } });
|
||||
if (!p) throw new NotFoundException('سفارش ساختنی یافت نشد');
|
||||
|
||||
// Once converted into a real Order the design request is part of an
|
||||
// accounting trail; deleting it would orphan that link. Legacy had no guard.
|
||||
if (p.convertedOrderId) {
|
||||
throw new BadRequestException(
|
||||
'حذف ممکن نیست: این سفارش ساختنی قبلاً به سفارش قطعی تبدیل شده است.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.preOrderStatusEvent.deleteMany({ where: { preOrderId: id } }),
|
||||
this.prisma.preOrder.delete({ where: { id } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
statusOptions() {
|
||||
return Object.entries(PREORDER_STATUS_LABELS).map(([value, title]) => ({ value, title }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminProductsService } from './admin-products.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (ProductController):
|
||||
* POST products -> the product grid
|
||||
* GET product/show/{id}
|
||||
* POST product/create
|
||||
* PUT product/update/{id}
|
||||
* POST delete/product/{id} (a delete expressed as POST — legacy's shape)
|
||||
* POST get/variants (a read expressed as POST — legacy's shape)
|
||||
* GET product/{id}/variant
|
||||
* POST change/percent/count, POST change/all
|
||||
* GET brand, POST category
|
||||
*/
|
||||
@Controller('admin/products')
|
||||
export class AdminProductsController {
|
||||
constructor(private readonly products: AdminProductsService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.products.list(query);
|
||||
}
|
||||
|
||||
@Post('variants/list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list')
|
||||
listVariants(@Body() query: TableQueryDto & { product_id?: string }) {
|
||||
return this.products.listVariants(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST product/variant` — the cart's rehydrate call. Given a list of
|
||||
* product ids it returns each product with `variants_all`, so the basket can
|
||||
* refresh prices/stock and drop anything that went out of stock.
|
||||
*/
|
||||
@Post('by-ids')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list')
|
||||
byIds(@Body() body: { products_id: string[] }) {
|
||||
return this.products.byIds(body.products_id ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST add/variant` — creates a bespoke weight variant from the order
|
||||
* builder's inline weight editor.
|
||||
*/
|
||||
@Post('variants/create')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.create')
|
||||
createVariant(@Body() body: { product_id: string; title: string; grams: number }) {
|
||||
return this.products.createVariant(body);
|
||||
}
|
||||
|
||||
@Get('show-type-options')
|
||||
@RequirePermission('product.list')
|
||||
showTypeOptions() {
|
||||
return this.products.showTypeOptions();
|
||||
}
|
||||
|
||||
@Get('brands')
|
||||
@RequirePermission('product.list')
|
||||
brands() {
|
||||
return this.products.brands();
|
||||
}
|
||||
|
||||
/** Brands as an XTable grid — the exclusion picker needs a selectable table. */
|
||||
@Post('brands-table')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list')
|
||||
brandsTable(@Body() query: TableQueryDto) {
|
||||
return this.products.brandsTable(query);
|
||||
}
|
||||
|
||||
|
||||
@Get('collections')
|
||||
@RequirePermission('product.list')
|
||||
collections() {
|
||||
return this.products.collections();
|
||||
}
|
||||
|
||||
@Post('change-percent/count')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.percent')
|
||||
changePercentCount(@Body() body: { brand_id?: string; category_id?: string }) {
|
||||
return this.products.changePercentCount(body);
|
||||
}
|
||||
|
||||
@Post('change-percent/apply')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.percent')
|
||||
changePercent(@Body() body: { percent: number; brand_id?: string; category_id?: string }) {
|
||||
return this.products.changePercent(body);
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.products.create(data);
|
||||
}
|
||||
|
||||
@Post('delete/:id')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('product.list.edit')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.products.remove(id);
|
||||
}
|
||||
|
||||
@Get(':id/variants')
|
||||
@RequirePermission('product.list')
|
||||
variantsOf(@Param('id') id: string) {
|
||||
return this.products.variantsOf(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('product.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.products.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('product.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.products.update(id, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminProductsController } from './admin-products.controller';
|
||||
import { AdminProductsService } from './admin-products.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminProductsController],
|
||||
providers: [AdminProductsService],
|
||||
})
|
||||
export class AdminProductsModule {}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Product administration — legacy views/apps/product/* served by
|
||||
* ProductController (`POST products`, `POST product/create`,
|
||||
* `PUT product/update/{id}`, `GET product/show/{id}`,
|
||||
* `POST delete/product/{id}`, `POST get/variants`, `POST change/percent/count`).
|
||||
*
|
||||
* Legacy `show_type` values, preserved as integers:
|
||||
* 1 سایت | 2 گوشی | 3 سایت و گوشی
|
||||
*/
|
||||
const SHOW_TYPE_LABELS: Record<number, string> = {
|
||||
1: 'سایت',
|
||||
2: 'گوشی',
|
||||
3: 'سایت و گوشی',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminProductsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'title',
|
||||
'productCode',
|
||||
'showType',
|
||||
'isActive',
|
||||
'createdAt',
|
||||
'brand.title',
|
||||
'category.title',
|
||||
// legacy filter keys
|
||||
'product_code',
|
||||
'show_type',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
product_code: 'productCode',
|
||||
show_type: 'showType',
|
||||
'category.collection.title': 'category.title',
|
||||
caj: 'createdAt',
|
||||
};
|
||||
|
||||
private applyAliases(query: TableQueryDto): TableQueryDto {
|
||||
const mapField = (f?: string) => (f ? (AdminProductsService.FIELD_ALIASES[f] ?? f) : f);
|
||||
return {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
if (mapped.field === 'showType' && mapped.value !== undefined) {
|
||||
mapped.value = Number(mapped.value);
|
||||
}
|
||||
return mapped;
|
||||
}),
|
||||
order: query.order
|
||||
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST products` — the product grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
const aliased = this.applyAliases(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['title', 'productCode'],
|
||||
allowed: AdminProductsService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
where: where as Prisma.ProductWhereInput,
|
||||
orderBy: orderBy as Prisma.ProductOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
brand: true,
|
||||
category: true,
|
||||
images: { orderBy: { sortOrder: 'asc' } },
|
||||
variants: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.product.count({ where: where as Prisma.ProductWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy field names preserved so ported table models bind unchanged. */
|
||||
private toRow(
|
||||
p: Prisma.ProductGetPayload<{
|
||||
include: { brand: true; category: true; images: true; variants: true };
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
product_code: p.productCode,
|
||||
show_type: p.showType,
|
||||
show_type_str: SHOW_TYPE_LABELS[p.showType] ?? '-',
|
||||
is_active: p.isActive,
|
||||
description: p.description,
|
||||
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
|
||||
// Legacy nested this as category.collection.title in the grid model.
|
||||
category: p.category
|
||||
? { id: p.category.id, title: p.category.title, collection: { title: p.category.title } }
|
||||
: null,
|
||||
// legacy grid slot reads `ProductImage`
|
||||
ProductImage: p.images.map((i) => ({ id: i.id, image: i.url, full: i.url, url: i.url })),
|
||||
images: p.images.map((i) => ({ id: i.id, url: i.url, sortOrder: i.sortOrder })),
|
||||
// The order builder's grid reads `thumb.full` and `variants_all` directly
|
||||
// off each row, so both travel with the list as they did in legacy.
|
||||
thumb: p.images[0] ? { full: p.images[0].url } : null,
|
||||
variants_all: p.variants.map((v) => this.variantRow(v)),
|
||||
variants_count: p.variants.length,
|
||||
// legacy min_gram / max_gram: the product's lightest and heaviest variant,
|
||||
// shown by the storefront and by the branch-allocation picker.
|
||||
min_gram: p.variants.length
|
||||
? p.variants.reduce(
|
||||
(min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min),
|
||||
p.variants[0].rashWeightGrams,
|
||||
)
|
||||
: null,
|
||||
max_gram: p.variants.length
|
||||
? p.variants.reduce(
|
||||
(max, v) => (v.rashWeightGrams.gt(max) ? v.rashWeightGrams : max),
|
||||
p.variants[0].rashWeightGrams,
|
||||
)
|
||||
: null,
|
||||
caj: p.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `GET product/show/{id}`. */
|
||||
async findOne(id: string) {
|
||||
const p = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
brand: true,
|
||||
category: true,
|
||||
images: { orderBy: { sortOrder: 'asc' } },
|
||||
variants: true,
|
||||
collections: { include: { collection: true } },
|
||||
},
|
||||
});
|
||||
if (!p) throw new NotFoundException('محصول یافت نشد');
|
||||
|
||||
return {
|
||||
...this.toRow(p),
|
||||
body_html: p.description,
|
||||
brand_id: p.brandId,
|
||||
cats: p.categoryId ? [p.categoryId] : [],
|
||||
ciny_ids: p.collections.map((c) => c.collectionId),
|
||||
variants_all: p.variants.map((v) => this.variantRow(v)),
|
||||
};
|
||||
}
|
||||
|
||||
private variantRow(v: Prisma.ProductVariantGetPayload<object>) {
|
||||
return {
|
||||
id: v.id,
|
||||
product_id_id: v.productId,
|
||||
title: v.title,
|
||||
sku: v.sku,
|
||||
grams: v.rashWeightGrams,
|
||||
percentage_pay: v.defaultWagePercent,
|
||||
qty: v.qty,
|
||||
is_pre_order: v.isPreOrder,
|
||||
is_active: v.isActive,
|
||||
// Legacy had a separate rial making-charge column; the schema models wage
|
||||
// as a percentage only. Returned as null rather than faked.
|
||||
rial_pay: null,
|
||||
color_str: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST get/variants` — a POST that reads, per legacy's own routing. */
|
||||
async listVariants(query: TableQueryDto & { product_id?: string }) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['title', 'sku'],
|
||||
allowed: ['id', 'title', 'sku', 'qty', 'rashWeightGrams', 'isActive', 'product.title'],
|
||||
defaultOrderBy: { title: 'asc' },
|
||||
});
|
||||
|
||||
const scoped: Prisma.ProductVariantWhereInput = query.product_id
|
||||
? { AND: [where as Prisma.ProductVariantWhereInput, { productId: query.product_id }] }
|
||||
: (where as Prisma.ProductVariantWhereInput);
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.productVariant.findMany({
|
||||
where: scoped,
|
||||
orderBy: orderBy as Prisma.ProductVariantOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { product: true },
|
||||
}),
|
||||
this.prisma.productVariant.count({ where: scoped }),
|
||||
]);
|
||||
|
||||
return this.table.respond(
|
||||
rows.map((v) => ({ ...this.variantRow(v), product: { id: v.product.id, title: v.product.title } })),
|
||||
total,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST product/variant` — used by the cart to refresh the products it
|
||||
* holds. Returns each product with `variants_all` attached, the shape
|
||||
* stores/cart.ts reads.
|
||||
*/
|
||||
async byIds(ids: string[]) {
|
||||
if (!ids.length) return [];
|
||||
const rows = await this.prisma.product.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: {
|
||||
brand: true,
|
||||
category: true,
|
||||
images: { orderBy: { sortOrder: 'asc' } },
|
||||
variants: true,
|
||||
},
|
||||
});
|
||||
return rows.map((p) => ({
|
||||
...this.toRow(p),
|
||||
variants_all: p.variants.map((v) => this.variantRow(v)),
|
||||
// The cart reads `promotion.id`; there is no Promotion model yet (Batch 9).
|
||||
promotion: null,
|
||||
thumb: p.images[0] ? { full: p.images[0].url } : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST add/variant` — the order builder's inline weight editor creates
|
||||
* a bespoke variant rather than mutating the shared catalog one, so other
|
||||
* orders keep the weight they were placed with.
|
||||
*/
|
||||
async createVariant(body: { product_id: string; title: string; grams: number }) {
|
||||
if (!body.product_id) throw new BadRequestException('محصول مشخص نشده است');
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: body.product_id },
|
||||
include: { variants: { take: 1 } },
|
||||
});
|
||||
if (!product) throw new NotFoundException('محصول یافت نشد');
|
||||
|
||||
return this.prisma.productVariant.create({
|
||||
data: {
|
||||
productId: body.product_id,
|
||||
title: body.title,
|
||||
rashWeightGrams: new Prisma.Decimal(String(body.grams ?? 0)),
|
||||
// Inherit the product's existing wage rate so the bespoke weight prices
|
||||
// the same way as its siblings.
|
||||
defaultWagePercent:
|
||||
product.variants[0]?.defaultWagePercent ?? new Prisma.Decimal(0),
|
||||
qty: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `GET product/{id}/variant`. */
|
||||
async variantsOf(productId: string) {
|
||||
const rows = await this.prisma.productVariant.findMany({ where: { productId } });
|
||||
return rows.map((v) => this.variantRow(v));
|
||||
}
|
||||
|
||||
/** Shape of one inline variant row posted by the product form. */
|
||||
private variantCreateData(v: Record<string, unknown>) {
|
||||
return {
|
||||
title: String(v.title ?? ''),
|
||||
rashWeightGrams: new Prisma.Decimal(String(v.grams ?? 0)),
|
||||
defaultWagePercent: new Prisma.Decimal(String(v.percentage_pay ?? 0)),
|
||||
qty: Number(v.qty ?? 0),
|
||||
isPreOrder: Boolean(v.is_pre_order),
|
||||
isActive: v.is_active !== undefined ? Boolean(v.is_active) : true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST product/create`. */
|
||||
async create(data: Record<string, unknown>) {
|
||||
const title = String(data.title ?? '').trim();
|
||||
if (!title) throw new BadRequestException('عنوان محصول الزامی است');
|
||||
|
||||
const cats = Array.isArray(data.cats) ? (data.cats as string[]) : [];
|
||||
const cinyIds = Array.isArray(data.ciny_ids) ? (data.ciny_ids as string[]) : [];
|
||||
const variants = Array.isArray(data.variants)
|
||||
? (data.variants as Record<string, unknown>[])
|
||||
: [];
|
||||
|
||||
return this.prisma.product.create({
|
||||
data: {
|
||||
title,
|
||||
description: (data.body_html as string) ?? (data.description as string) ?? null,
|
||||
productCode: (data.product_code as string) || null,
|
||||
showType: data.show_type !== undefined ? Number(data.show_type) : 3,
|
||||
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||
brandId: (data.brand_id as string) || null,
|
||||
categoryId: cats[0] ?? null,
|
||||
collections: cinyIds.length
|
||||
? { create: cinyIds.map((collectionId) => ({ collectionId })) }
|
||||
: undefined,
|
||||
variants: variants.length
|
||||
? { create: variants.map((v) => this.variantCreateData(v)) }
|
||||
: undefined,
|
||||
},
|
||||
include: { variants: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `PUT product/update/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('محصول یافت نشد');
|
||||
|
||||
const patch: Prisma.ProductUpdateInput = {};
|
||||
if (data.title !== undefined) patch.title = String(data.title);
|
||||
if (data.body_html !== undefined) patch.description = String(data.body_html);
|
||||
if (data.product_code !== undefined) patch.productCode = (data.product_code as string) || null;
|
||||
if (data.show_type !== undefined) patch.showType = Number(data.show_type);
|
||||
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
|
||||
if (data.brand_id !== undefined) {
|
||||
patch.brand = data.brand_id ? { connect: { id: String(data.brand_id) } } : { disconnect: true };
|
||||
}
|
||||
if (Array.isArray(data.cats)) {
|
||||
const catId = (data.cats as string[])[0];
|
||||
patch.category = catId ? { connect: { id: catId } } : { disconnect: true };
|
||||
}
|
||||
|
||||
await this.prisma.product.update({ where: { id }, data: patch });
|
||||
|
||||
// Variants are edited inline in the product form, so the posted array is the
|
||||
// desired end state: update the ones that still carry an id, create the new
|
||||
// ones, and deactivate (never delete) the ones that disappeared — deleting
|
||||
// would break the OrderItem snapshots that reference them.
|
||||
if (Array.isArray(data.variants)) {
|
||||
const posted = data.variants as Record<string, unknown>[];
|
||||
// Collect ids as we go: a NEWLY created variant must count as kept, or the
|
||||
// sweep below would deactivate the row we just added.
|
||||
const keptIds: string[] = [];
|
||||
|
||||
for (const v of posted) {
|
||||
const payload = this.variantCreateData(v);
|
||||
if (v.id) {
|
||||
await this.prisma.productVariant.update({
|
||||
where: { id: String(v.id) },
|
||||
data: payload,
|
||||
});
|
||||
keptIds.push(String(v.id));
|
||||
} else {
|
||||
const created = await this.prisma.productVariant.create({
|
||||
data: { ...payload, productId: id },
|
||||
});
|
||||
keptIds.push(created.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Anything no longer posted is deactivated, never deleted — OrderItem
|
||||
// snapshots reference these rows.
|
||||
await this.prisma.productVariant.updateMany({
|
||||
where: { productId: id, id: { notIn: keptIds.length ? keptIds : ['__none__'] } },
|
||||
data: { isActive: false },
|
||||
});
|
||||
}
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST delete/product/{id}` — a DELETE expressed as POST.
|
||||
*
|
||||
* Refuses when the product has ever been ordered: its variants are referenced
|
||||
* by OrderItem snapshots, and removing them would break order history.
|
||||
* Legacy had no such guard. Deactivation is offered instead.
|
||||
*/
|
||||
async remove(id: string) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: { variants: { include: { orderItems: { take: 1 } } } },
|
||||
});
|
||||
if (!product) throw new NotFoundException('محصول یافت نشد');
|
||||
|
||||
const hasOrders = product.variants.some((v) => v.orderItems.length > 0);
|
||||
if (hasOrders) {
|
||||
throw new BadRequestException(
|
||||
'حذف این محصول ممکن نیست، چون در سفارشهای ثبتشده استفاده شده است. میتوانید آن را غیرفعال کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.collectionProduct.deleteMany({ where: { productId: id } }),
|
||||
this.prisma.comment.deleteMany({ where: { productId: id } }),
|
||||
this.prisma.productImage.deleteMany({ where: { productId: id } }),
|
||||
this.prisma.productVariant.deleteMany({ where: { productId: id } }),
|
||||
this.prisma.product.delete({ where: { id } }),
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST change/percent/count` — how many variants a bulk wage-percent
|
||||
* change would affect, used by ChangePercent.vue before applying.
|
||||
*/
|
||||
async changePercentCount(filter: { brand_id?: string; category_id?: string }) {
|
||||
const where: Prisma.ProductVariantWhereInput = {
|
||||
product: {
|
||||
...(filter.brand_id ? { brandId: filter.brand_id } : {}),
|
||||
...(filter.category_id ? { categoryId: filter.category_id } : {}),
|
||||
},
|
||||
};
|
||||
return { count: await this.prisma.productVariant.count({ where }) };
|
||||
}
|
||||
|
||||
/** Legacy `POST change/all` — apply a bulk wage-percent change. */
|
||||
async changePercent(data: { percent: number; brand_id?: string; category_id?: string }) {
|
||||
if (data.percent === undefined || Number.isNaN(Number(data.percent))) {
|
||||
throw new BadRequestException('درصد نامعتبر است');
|
||||
}
|
||||
const where: Prisma.ProductVariantWhereInput = {
|
||||
product: {
|
||||
...(data.brand_id ? { brandId: data.brand_id } : {}),
|
||||
...(data.category_id ? { categoryId: data.category_id } : {}),
|
||||
},
|
||||
};
|
||||
const result = await this.prisma.productVariant.updateMany({
|
||||
where,
|
||||
data: { defaultWagePercent: new Prisma.Decimal(String(data.percent)) },
|
||||
});
|
||||
return { updated: result.count };
|
||||
}
|
||||
|
||||
/** Legacy `GET brand` and `POST category` — pickers for the product form. */
|
||||
async brands() {
|
||||
return this.prisma.brand.findMany({ orderBy: { title: 'asc' } });
|
||||
}
|
||||
|
||||
/** Brands in the XTable envelope, for the promotion-exclusion picker. */
|
||||
async brandsTable(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
searchable: ['title'],
|
||||
allowed: ['id', 'title'],
|
||||
defaultOrderBy: { title: 'asc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.brand.findMany({
|
||||
where: where as Prisma.BrandWhereInput,
|
||||
orderBy: orderBy as Prisma.BrandOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
this.prisma.brand.count({ where: where as Prisma.BrandWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows, total, query, page, limit);
|
||||
}
|
||||
|
||||
|
||||
async collections() {
|
||||
return this.prisma.collection.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { title: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
showTypeOptions() {
|
||||
return Object.entries(SHOW_TYPE_LABELS).map(([value, title]) => ({
|
||||
value: Number(value),
|
||||
title,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminPromotionsService } from './admin-promotions.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints:
|
||||
* GET/POST/PUT/DELETE product/variant/promotion -> PromotionController
|
||||
* GET /autopromotion -> the auto-discount screen
|
||||
* GET/POST/DELETE /ignore -> views/offersIgnore
|
||||
*/
|
||||
@Controller('admin/promotions')
|
||||
export class AdminPromotionsController {
|
||||
constructor(private readonly promotions: AdminPromotionsService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offers.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.promotions.list(query);
|
||||
}
|
||||
|
||||
@Get('scope-options')
|
||||
@RequirePermission('offers.list')
|
||||
scopeOptions() {
|
||||
return this.promotions.scopeOptions();
|
||||
}
|
||||
|
||||
@Get('usage-options')
|
||||
@RequirePermission('offers.list')
|
||||
usageOptions() {
|
||||
return this.promotions.usageOptions();
|
||||
}
|
||||
|
||||
@Get('auto')
|
||||
@RequirePermission('offers.autopromotion')
|
||||
getAutoPromotion() {
|
||||
return this.promotions.getAutoPromotion();
|
||||
}
|
||||
|
||||
@Post('auto')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offers.autopromotion')
|
||||
setAutoPromotion(@Body() body: { state?: boolean; discount_percent?: number | string }) {
|
||||
return this.promotions.setAutoPromotion(body);
|
||||
}
|
||||
|
||||
// ── exclusions ────────────────────────────────────────────────────
|
||||
@Get('exclusions')
|
||||
@RequirePermission('offersIgnore')
|
||||
listExclusions() {
|
||||
return this.promotions.listExclusions();
|
||||
}
|
||||
|
||||
@Post('exclusions')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offersIgnore')
|
||||
createExclusion(@Body() body: { type: number; item_id?: string[] }) {
|
||||
return this.promotions.createExclusion(body);
|
||||
}
|
||||
|
||||
@Delete('exclusions/:id')
|
||||
@RequirePermission('offersIgnore')
|
||||
removeExclusion(@Param('id') id: string) {
|
||||
return this.promotions.removeExclusion(id);
|
||||
}
|
||||
|
||||
// ── promotions ────────────────────────────────────────────────────
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offers.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.promotions.create(data);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('offers.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.promotions.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('offers.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.promotions.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('offers.list.edit')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.promotions.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminPromotionsController } from './admin-promotions.controller';
|
||||
import { AdminPromotionsService } from './admin-promotions.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminPromotionsController],
|
||||
providers: [AdminPromotionsService],
|
||||
})
|
||||
export class AdminPromotionsModule {}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, PromotionScope, PromotionUsage } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Promotions — legacy views/promotion/*, served by PromotionController
|
||||
* (`GET/POST/PUT/DELETE product/variant/promotion`) plus the exclusion list
|
||||
* (`/ignore`) behind views/offersIgnore.
|
||||
*
|
||||
* Legacy `type` (scope): 1 محصولات | 2 دسته بندی | 3 مشتری | 4 برند
|
||||
* Legacy `use_only` (usage): 1 فقط یکبار | 2 تا تاریخ پایان
|
||||
*/
|
||||
const SCOPE_BY_CODE: Record<number, PromotionScope> = {
|
||||
1: PromotionScope.PRODUCT,
|
||||
2: PromotionScope.CATEGORY,
|
||||
3: PromotionScope.CUSTOMER,
|
||||
4: PromotionScope.BRAND,
|
||||
};
|
||||
|
||||
const CODE_BY_SCOPE: Record<PromotionScope, number> = {
|
||||
PRODUCT: 1,
|
||||
CATEGORY: 2,
|
||||
CUSTOMER: 3,
|
||||
BRAND: 4,
|
||||
};
|
||||
|
||||
const SCOPE_LABELS: Record<PromotionScope, string> = {
|
||||
PRODUCT: 'محصولات',
|
||||
CATEGORY: 'دسته بندی',
|
||||
CUSTOMER: 'مشتری',
|
||||
BRAND: 'برند',
|
||||
};
|
||||
|
||||
const USAGE_BY_CODE: Record<number, PromotionUsage> = {
|
||||
1: PromotionUsage.ONCE,
|
||||
2: PromotionUsage.UNTIL_END_DATE,
|
||||
};
|
||||
|
||||
const CODE_BY_USAGE: Record<PromotionUsage, number> = {
|
||||
ONCE: 1,
|
||||
UNTIL_END_DATE: 2,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminPromotionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'title',
|
||||
'scope',
|
||||
'usage',
|
||||
'discountPercent',
|
||||
'isActive',
|
||||
'platform',
|
||||
'startsAt',
|
||||
'endsAt',
|
||||
'createdAt',
|
||||
// legacy filter keys
|
||||
'promotion_type_str',
|
||||
'status',
|
||||
'start',
|
||||
'end',
|
||||
'discount_percent',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
promotion_type_str: 'scope',
|
||||
type: 'scope',
|
||||
use_only: 'usage',
|
||||
status: 'isActive',
|
||||
start: 'startsAt',
|
||||
end: 'endsAt',
|
||||
discount_percent: 'discountPercent',
|
||||
caj: 'createdAt',
|
||||
};
|
||||
|
||||
/** Legacy `GET product/variant/promotion` — the promotions grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) => (f ? (AdminPromotionsService.FIELD_ALIASES[f] ?? f) : f);
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
if (mapped.field === 'scope' && mapped.value !== undefined) {
|
||||
mapped.value = SCOPE_BY_CODE[Number(mapped.value)] ?? mapped.value;
|
||||
}
|
||||
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: ['title', 'description'],
|
||||
allowed: AdminPromotionsService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.promotion.findMany({
|
||||
where: where as Prisma.PromotionWhereInput,
|
||||
orderBy: orderBy as Prisma.PromotionOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { product: true, category: true, customer: true, brand: true },
|
||||
}),
|
||||
this.prisma.promotion.count({ where: where as Prisma.PromotionWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
private toRow(
|
||||
p: Prisma.PromotionGetPayload<{
|
||||
include: { product: true; category: true; customer: true; brand: true };
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
type: CODE_BY_SCOPE[p.scope],
|
||||
promotion_type_str: SCOPE_LABELS[p.scope],
|
||||
use_only: CODE_BY_USAGE[p.usage],
|
||||
discount_percent: p.discountPercent,
|
||||
// legacy grid renders `status` through a slot
|
||||
status: p.isActive ? 1 : 0,
|
||||
is_active: p.isActive,
|
||||
/**
|
||||
* Legacy `type_customer`: 1 = تک (retail), 2 = عمده (wholesale). The grid
|
||||
* uses it BOTH to pick which permission gates the row's edit/delete icons
|
||||
* AND to choose which edit route opens. A customer-scoped promotion is the
|
||||
* retail ("tak") kind; everything else is the wholesale kind.
|
||||
*/
|
||||
type_customer: p.scope === PromotionScope.CUSTOMER ? 1 : 2,
|
||||
platform: p.platform,
|
||||
start: p.startsAt,
|
||||
end: p.endsAt,
|
||||
product_id: p.productId,
|
||||
category_id: p.categoryId,
|
||||
customer_id: p.customerId,
|
||||
brand_id: p.brandId,
|
||||
product: p.product ? { id: p.product.id, title: p.product.title } : null,
|
||||
category: p.category ? { id: p.category.id, title: p.category.title } : null,
|
||||
customer: p.customer ? { id: p.customer.id, name: p.customer.fullName } : null,
|
||||
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
|
||||
caj: p.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const p = await this.prisma.promotion.findUnique({
|
||||
where: { id },
|
||||
include: { product: true, category: true, customer: true, brand: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException('تخفیف یافت نشد');
|
||||
return this.toRow(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the legacy form payload onto the model, attaching the ONE target that
|
||||
* matches the chosen scope and clearing the others — so a promotion can never
|
||||
* carry, say, both a product and a customer.
|
||||
*/
|
||||
private buildData(data: Record<string, unknown>) {
|
||||
const scope = SCOPE_BY_CODE[Number(data.type ?? 1)];
|
||||
if (!scope) throw new BadRequestException('نوع تخفیف نامعتبر است');
|
||||
|
||||
const usage = USAGE_BY_CODE[Number(data.use_only ?? 1)] ?? PromotionUsage.ONCE;
|
||||
const percent = new Prisma.Decimal(String(data.discount_percent ?? 0));
|
||||
if (percent.lt(0) || percent.gt(100)) {
|
||||
throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد');
|
||||
}
|
||||
|
||||
const startsAt = data.start ? new Date(String(data.start)) : null;
|
||||
const endsAt = data.end ? new Date(String(data.end)) : null;
|
||||
if (startsAt && endsAt && endsAt < startsAt) {
|
||||
throw new BadRequestException('تاریخ پایان نمیتواند قبل از تاریخ شروع باشد');
|
||||
}
|
||||
|
||||
return {
|
||||
title: String(data.title ?? '').trim(),
|
||||
description: (data.description as string) || null,
|
||||
scope,
|
||||
usage,
|
||||
discountPercent: percent,
|
||||
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||
platform: data.platform !== undefined ? Number(data.platform) : 3,
|
||||
startsAt,
|
||||
endsAt,
|
||||
productId: scope === PromotionScope.PRODUCT ? ((data.product_id as string) ?? null) : null,
|
||||
categoryId: scope === PromotionScope.CATEGORY ? ((data.category_id as string) ?? null) : null,
|
||||
customerId: scope === PromotionScope.CUSTOMER ? ((data.customer_id as string) ?? null) : null,
|
||||
brandId: scope === PromotionScope.BRAND ? ((data.brand_id as string) ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST product/variant/promotion`. */
|
||||
async create(data: Record<string, unknown>) {
|
||||
const payload = this.buildData(data);
|
||||
if (!payload.title) throw new BadRequestException('عنوان تخفیف الزامی است');
|
||||
return this.prisma.promotion.create({ data: payload });
|
||||
}
|
||||
|
||||
/** Legacy `PUT product/variant/promotion/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.promotion.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('تخفیف یافت نشد');
|
||||
// legacy disabled the type selector on edit (`:disabled="this.id"`), so the
|
||||
// scope is not re-read here either.
|
||||
const payload = this.buildData({ ...data, type: CODE_BY_SCOPE[existing.scope] });
|
||||
return this.prisma.promotion.update({ where: { id }, data: payload });
|
||||
}
|
||||
|
||||
/** Legacy `DELETE product/variant/promotion/{id}`. */
|
||||
async remove(id: string) {
|
||||
const p = await this.prisma.promotion.findUnique({ where: { id } });
|
||||
if (!p) throw new NotFoundException('تخفیف یافت نشد');
|
||||
await this.prisma.promotion.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
scopeOptions() {
|
||||
return Object.entries(SCOPE_BY_CODE).map(([code, scope]) => ({
|
||||
value: Number(code),
|
||||
title: SCOPE_LABELS[scope],
|
||||
}));
|
||||
}
|
||||
|
||||
usageOptions() {
|
||||
return [
|
||||
{ value: 1, title: ' فقط یکبار' },
|
||||
{ value: 2, title: 'تا تاریخ پایان' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET /autopromotion` and its POST counterpart — the settings screen
|
||||
* for "تخفیف اتوماتیک یکبار مصرف ویژه مشتریان جدید": a one-shot welcome
|
||||
* discount for new customers, configured as an on/off state plus a percentage.
|
||||
*
|
||||
* Stored in the Setting key/value table, like the other config screens.
|
||||
*/
|
||||
async getAutoPromotion() {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: 'promotion.auto' } });
|
||||
return row?.value ?? { state: false, discount_percent: null };
|
||||
}
|
||||
|
||||
async setAutoPromotion(data: { state?: boolean; discount_percent?: number | string }) {
|
||||
const percent = Number(data.discount_percent ?? 0);
|
||||
if (Number.isNaN(percent) || percent < 0 || percent > 100) {
|
||||
throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد');
|
||||
}
|
||||
const value = { state: Boolean(data.state), discount_percent: percent };
|
||||
const row = await this.prisma.setting.upsert({
|
||||
where: { key: 'promotion.auto' },
|
||||
update: { value },
|
||||
create: { key: 'promotion.auto', value },
|
||||
});
|
||||
return row.value;
|
||||
}
|
||||
|
||||
// ── Exclusions (views/offersIgnore) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Legacy `GET /ignore` — the full exclusion list.
|
||||
*
|
||||
* The view reads it as a flat array and filters client-side:
|
||||
* items_id(type) { return this.ignoreList.filter(i => i.type == type).map(x => x.item_id) }
|
||||
* so the flat shape with `item_id` is preserved.
|
||||
*/
|
||||
async listExclusions() {
|
||||
const rows = await this.prisma.promotionExclusion.findMany({
|
||||
orderBy: [{ type: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map((e) => ({ id: e.id, type: e.type, item_id: e.itemId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST /ignore` with `{ type, item_id: [...] }`.
|
||||
*
|
||||
* The posted array is the COMPLETE selection for that type — the view binds it
|
||||
* to a checkbox table — so this replaces that type's rows rather than adding
|
||||
* to them. Anything unchecked is therefore removed.
|
||||
*/
|
||||
async createExclusion(data: { type: number; item_id?: string[] }) {
|
||||
const type = Number(data.type);
|
||||
if (!type) throw new BadRequestException('نوع استثناء الزامی است');
|
||||
|
||||
const itemIds = Array.isArray(data.item_id)
|
||||
? data.item_id.map((x) => String(x)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.promotionExclusion.deleteMany({ where: { type } }),
|
||||
this.prisma.promotionExclusion.createMany({
|
||||
data: itemIds.map((itemId) => ({ type, itemId })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.listExclusions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE /ignore/{id}` — the view's "حذف نوع استثناء" card removes a
|
||||
* whole TYPE, so a numeric id is treated as a type and a cuid as one row.
|
||||
*/
|
||||
async removeExclusion(id: string) {
|
||||
if (/^\d+$/.test(id)) {
|
||||
const type = Number(id);
|
||||
const { count } = await this.prisma.promotionExclusion.deleteMany({ where: { type } });
|
||||
if (count === 0) throw new NotFoundException('استثنایی برای این نوع یافت نشد');
|
||||
return { success: true, removed: count };
|
||||
}
|
||||
|
||||
const e = await this.prisma.promotionExclusion.findUnique({ where: { id } });
|
||||
if (!e) throw new NotFoundException('استثناء یافت نشد');
|
||||
await this.prisma.promotionExclusion.delete({ where: { id } });
|
||||
return { success: true, removed: 1 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TableService } from './table.service';
|
||||
|
||||
/**
|
||||
* Global so every admin grid module can inject TableService without re-importing.
|
||||
* It is stateless — pure request-shape translation.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [TableService],
|
||||
exports: [TableService],
|
||||
})
|
||||
export class AdminTableModule {}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* One row of the XTable filter bar.
|
||||
*
|
||||
* Mirrors the object legacy's XFilterRow pushed into `query.filters`:
|
||||
* `{ id, field, op, value, value2? }`. `id` is a client-side react key
|
||||
* (a random float) and is accepted but ignored.
|
||||
*/
|
||||
export class TableFilterDto {
|
||||
@IsOptional()
|
||||
id?: number | string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
field?: string;
|
||||
|
||||
/**
|
||||
* Legacy operators, kept verbatim. `between` uses value + value2 and is what
|
||||
* the date-range filter emits.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(['=', '!=', '>', '>=', '<', '<=', 'like', 'in', 'between', 'null', 'notnull'])
|
||||
op?: string;
|
||||
|
||||
@IsOptional()
|
||||
value?: unknown;
|
||||
|
||||
@IsOptional()
|
||||
value2?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request body XTable POSTs, ported from its requestData():
|
||||
*
|
||||
* { ...query, select, titles, report_title, op? }
|
||||
*
|
||||
* where query = { search, filters, total, limit, page }.
|
||||
*/
|
||||
export class TableQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TableFilterDto)
|
||||
filters?: TableFilterDto[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
limit?: number;
|
||||
|
||||
/** Sort spec: '+field' ascending, '-field' descending (legacy `order`). */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
order?: string;
|
||||
|
||||
/** Columns currently visible; drives the export payload. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
select?: string[];
|
||||
|
||||
/** key -> Farsi column heading, used for export headers. */
|
||||
@IsOptional()
|
||||
titles?: Record<string, string>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
report_title?: string;
|
||||
|
||||
/**
|
||||
* Export mode. Legacy expected `{ url }` back and did `window.open(data.url)`.
|
||||
* Exports are not implemented yet; the controller rejects these explicitly
|
||||
* rather than returning a broken url.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsIn(['excel', 'json', 'print'])
|
||||
op?: 'excel' | 'json' | 'print';
|
||||
|
||||
/** Echoed back inside `query`; the client owns this value. */
|
||||
@IsOptional()
|
||||
total?: number;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import type { TableFilterDto, TableQueryDto } from './table-query.dto';
|
||||
|
||||
/**
|
||||
* Translates the legacy XTable request contract into Prisma query arguments.
|
||||
*
|
||||
* The response shape is dictated by legacy's getDataUsage() (x-table mixin):
|
||||
*
|
||||
* laravel mode -> { rows, query } <- what the admin panel used
|
||||
* other mode -> { rows: [[...]], count, page }
|
||||
*
|
||||
* The panel's XTbl models all ran in 'laravel' mode, so that is the shape
|
||||
* produced here: `{ rows, query }` with query carrying total/page/limit.
|
||||
*/
|
||||
|
||||
export interface TableResult<T> {
|
||||
rows: T[];
|
||||
query: {
|
||||
search?: string;
|
||||
filters: TableFilterDto[];
|
||||
total: number;
|
||||
limit: number;
|
||||
page: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildOptions {
|
||||
/** Columns the free-text `search` box scans (legacy initSearch([...])). */
|
||||
searchable?: string[];
|
||||
/** Whitelist of filterable/sortable fields; anything else is rejected. */
|
||||
allowed?: string[];
|
||||
/** Sort applied when the request specifies none. */
|
||||
defaultOrderBy?: Record<string, 'asc' | 'desc'>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TableService {
|
||||
/**
|
||||
* Turns a dotted path into a nested Prisma object:
|
||||
* 'customer.fullName', 'x' -> { customer: { fullName: 'x' } }
|
||||
*/
|
||||
private nest(path: string, leaf: unknown): Record<string, unknown> {
|
||||
const parts = path.split('.');
|
||||
return parts.reduceRight<unknown>((acc, key, i) => {
|
||||
return i === parts.length - 1 ? { [key]: leaf } : { [key]: acc };
|
||||
}, leaf) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private assertAllowed(field: string, allowed?: string[]) {
|
||||
if (allowed && !allowed.includes(field)) {
|
||||
throw new BadRequestException(`Field not filterable: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps one legacy filter row to a Prisma condition. */
|
||||
private toCondition(f: TableFilterDto, allowed?: string[]): Record<string, unknown> | null {
|
||||
if (!f.field || !f.op) return null;
|
||||
this.assertAllowed(f.field, allowed);
|
||||
|
||||
const v = f.value;
|
||||
switch (f.op) {
|
||||
case '=':
|
||||
return this.nest(f.field, { equals: v });
|
||||
case '!=':
|
||||
return this.nest(f.field, { not: v });
|
||||
case '>':
|
||||
return this.nest(f.field, { gt: v });
|
||||
case '>=':
|
||||
return this.nest(f.field, { gte: v });
|
||||
case '<':
|
||||
return this.nest(f.field, { lt: v });
|
||||
case '<=':
|
||||
return this.nest(f.field, { lte: v });
|
||||
case 'like':
|
||||
// Legacy sent '%value%' to SQL LIKE; `contains` is the Prisma equivalent.
|
||||
return this.nest(f.field, {
|
||||
contains: String(v ?? '').replace(/^%|%$/g, ''),
|
||||
mode: 'insensitive',
|
||||
});
|
||||
case 'in':
|
||||
return this.nest(f.field, { in: Array.isArray(v) ? v : [v] });
|
||||
case 'between':
|
||||
return this.nest(f.field, { gte: v, lte: f.value2 });
|
||||
case 'null':
|
||||
return this.nest(f.field, { equals: null });
|
||||
case 'notnull':
|
||||
return this.nest(f.field, { not: null });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds `where`, `orderBy`, `skip` and `take` from an XTable request. */
|
||||
build(query: TableQueryDto, opts: BuildOptions = {}) {
|
||||
const page = query.page && query.page > 0 ? query.page : 1;
|
||||
const limit = query.limit && query.limit > 0 ? query.limit : 10;
|
||||
|
||||
const and: Record<string, unknown>[] = [];
|
||||
|
||||
for (const f of query.filters ?? []) {
|
||||
const cond = this.toCondition(f, opts.allowed);
|
||||
if (cond) and.push(cond);
|
||||
}
|
||||
|
||||
// Free-text search across the declared columns (legacy: OR over initSearch).
|
||||
const search = query.search?.trim();
|
||||
if (search && opts.searchable?.length) {
|
||||
and.push({
|
||||
OR: opts.searchable.map((field) =>
|
||||
this.nest(field, { contains: search, mode: 'insensitive' }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// legacy `order`: '+field' asc, '-field' desc
|
||||
let orderBy = opts.defaultOrderBy ?? { createdAt: 'desc' as const };
|
||||
if (query.order) {
|
||||
const dir = query.order.startsWith('-') ? 'desc' : 'asc';
|
||||
const field = query.order.replace(/^[+-]/, '');
|
||||
if (field) {
|
||||
this.assertAllowed(field, opts.allowed);
|
||||
orderBy = this.nest(field, dir) as Record<string, 'asc' | 'desc'>;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
where: and.length ? { AND: and } : {},
|
||||
orderBy,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wraps rows + count into the exact envelope XTable expects. */
|
||||
respond<T>(rows: T[], total: number, query: TableQueryDto, page: number, limit: number): TableResult<T> {
|
||||
return {
|
||||
rows,
|
||||
query: {
|
||||
search: query.search,
|
||||
filters: query.filters ?? [],
|
||||
total,
|
||||
limit,
|
||||
page,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Exports are not implemented; fail loudly instead of returning a dead url. */
|
||||
assertNotExport(query: TableQueryDto) {
|
||||
if (query.op) {
|
||||
throw new BadRequestException(
|
||||
`Export ('${query.op}') is not implemented yet in the new backend.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { AdminUploadsService } from './admin-uploads.service';
|
||||
import { CurrentAdmin } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
import { Body } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* The subset of multer's file object this controller uses. Declared locally
|
||||
* rather than pulling in @types/multer for one interface — multer itself ships
|
||||
* with @nestjs/platform-express, but its types do not.
|
||||
*/
|
||||
interface UploadedMulterFile {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy endpoints:
|
||||
* POST file/create -> storeWithoutData
|
||||
* PUT file/update/{id} -> updateBody (metadata only; not ported — nothing
|
||||
* in henza_admin calls it)
|
||||
*
|
||||
* The multipart field name `file` matches what every legacy uploader posts
|
||||
* (MyUpload.vue, SingleUploadInput.vue, UploadInput.vue, PodcastUploadInput.vue).
|
||||
*
|
||||
* PERMISSIONS. Legacy gated this on nothing at all: `file/create` sat outside
|
||||
* any middleware, and henza_server's `auth:api` group is itself commented out
|
||||
* (routes/admin.php:12), so the endpoint accepted anonymous uploads. That is a
|
||||
* security bug, not a feature, so it is NOT reproduced — these routes require a
|
||||
* signed-in admin. No specific rid is demanded, since legacy's permission
|
||||
* vocabulary has no media/file entry to port and inventing one would let it
|
||||
* drift from the 144 real rids.
|
||||
*/
|
||||
@Controller('admin/files')
|
||||
export class AdminUploadsController {
|
||||
constructor(private readonly uploads: AdminUploadsService) {}
|
||||
|
||||
@Post('create')
|
||||
@HttpCode(200)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
create(
|
||||
@UploadedFile() file: UploadedMulterFile | undefined,
|
||||
@Body('purpose') purpose: string | undefined,
|
||||
@CurrentAdmin() admin: AuthenticatedAdmin,
|
||||
) {
|
||||
return this.uploads.store(file, purpose, admin.id ?? null);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.uploads.findOne(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminUploadsController } from './admin-uploads.controller';
|
||||
import { AdminUploadsService } from './admin-uploads.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminUploadsController],
|
||||
providers: [AdminUploadsService],
|
||||
exports: [AdminUploadsService],
|
||||
})
|
||||
export class AdminUploadsModule {}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* File uploads — ports legacy `POST file/create`
|
||||
* (henza_server Adm/FileController::storeWithoutData).
|
||||
*
|
||||
* Legacy behaviour, reproduced here:
|
||||
* - the multipart field is named `file`
|
||||
* - the stored name is md5(time + original name) + the original extension
|
||||
* - files land in one flat public directory (legacy: public/storage)
|
||||
* - the JSON response carries an `id`, which the ant-design Upload wrappers
|
||||
* (SingleUploadInput.vue:87, MyUpload.vue) require before emitting `ok`
|
||||
*
|
||||
* Deliberately NOT ported: legacy's commented-out webp re-encoding, watermarking
|
||||
* and thumbnailing. That code is dead in legacy too — every branch of it is
|
||||
* inside a `/* ... */` block, leaving only the plain `$file->move(...)` live.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminUploadsService {
|
||||
/** Matches legacy's flat public/storage directory. */
|
||||
private readonly dir = process.env.UPLOAD_DIR ?? join(process.cwd(), 'storage', 'uploads');
|
||||
|
||||
/** Mirrors legacy's `type` field: 1 = image, 2 = any other document. */
|
||||
private static readonly IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/;
|
||||
private static readonly ALLOWED = /^(image\/(jpeg|png|gif|webp|svg\+xml)|application\/pdf)$/;
|
||||
|
||||
/** 8 MB. Legacy relied on PHP's upload_max_filesize; this makes it explicit. */
|
||||
private static readonly MAX_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async store(
|
||||
file: { originalname: string; mimetype: string; size: number; buffer: Buffer } | undefined,
|
||||
purpose: string | undefined,
|
||||
actorAdminUserId: string | null,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('فایلی ارسال نشده است');
|
||||
if (file.size > AdminUploadsService.MAX_BYTES) {
|
||||
throw new BadRequestException('حجم فایل بیش از حد مجاز است (۸ مگابایت)');
|
||||
}
|
||||
if (!AdminUploadsService.ALLOWED.test(file.mimetype)) {
|
||||
throw new BadRequestException('نوع فایل مجاز نیست');
|
||||
}
|
||||
|
||||
// legacy: md5(time() . original_name) . '.' . ext
|
||||
const ext = extname(file.originalname).toLowerCase();
|
||||
const stored =
|
||||
createHash('md5').update(`${Date.now()}${file.originalname}${randomUUID()}`).digest('hex') +
|
||||
ext;
|
||||
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
await writeFile(join(this.dir, stored), file.buffer);
|
||||
|
||||
return this.prisma.uploadedFile.create({
|
||||
data: {
|
||||
url: `/uploads/${stored}`,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
sizeBytes: file.size,
|
||||
purpose: purpose ?? null,
|
||||
uploadedByAdminUserId: actorAdminUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const f = await this.prisma.uploadedFile.findUnique({ where: { id } });
|
||||
if (!f) throw new NotFoundException('فایل یافت نشد');
|
||||
return f;
|
||||
}
|
||||
|
||||
/** True when the stored file is an image, for callers that care. */
|
||||
static isImage(mime: string) {
|
||||
return AdminUploadsService.IMAGE_MIME.test(mime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (UserController + RoleController):
|
||||
* GET/POST/PUT/DELETE users
|
||||
* GET const/__list__ut -> access-level picker
|
||||
* POST const -> create an access level
|
||||
* GET roles/{ut} -> rids of one level
|
||||
* POST roles/create -> set rids
|
||||
* DELETE roles/{ut}/{rid} -> revoke one rid
|
||||
*/
|
||||
@Controller('admin/users')
|
||||
export class AdminUsersController {
|
||||
constructor(private readonly users: AdminUsersService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('user.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.users.list(query);
|
||||
}
|
||||
|
||||
@Get('role-options')
|
||||
@RequirePermission('user.list')
|
||||
roleOptions() {
|
||||
return this.users.roleOptions();
|
||||
}
|
||||
|
||||
/** Legacy GET const/__list__ut. */
|
||||
@Get('types')
|
||||
@RequirePermission('user.list')
|
||||
listUserTypes() {
|
||||
return this.users.listUserTypes();
|
||||
}
|
||||
|
||||
/** Legacy POST const {key:"__list__ut", val:"<title>"}. */
|
||||
@Post('types')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('user.permission.create')
|
||||
createUserType(@Body() body: { val?: string; title?: string }) {
|
||||
return this.users.createUserType(body.val ?? body.title ?? '');
|
||||
}
|
||||
|
||||
/** Legacy GET roles/{ut}. */
|
||||
@Get('types/:ut/roles')
|
||||
@RequirePermission('user.permission')
|
||||
getRoles(@Param('ut') ut: string) {
|
||||
return this.users.getRoles(Number(ut));
|
||||
}
|
||||
|
||||
/** Legacy POST roles/create {ut, roles}. */
|
||||
@Post('roles')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('user.permission.edit')
|
||||
setRoles(@Body() body: { ut: number | string; roles: string[] }) {
|
||||
return this.users.setRoles(Number(body.ut), body.roles ?? []);
|
||||
}
|
||||
|
||||
/** Legacy DELETE roles/{ut}/{rid}. */
|
||||
@Delete('types/:ut/roles/:rid')
|
||||
@RequirePermission('user.permission.edit')
|
||||
revokeRole(@Param('ut') ut: string, @Param('rid') rid: string) {
|
||||
return this.users.revokeRole(Number(ut), rid);
|
||||
}
|
||||
|
||||
/** The "اطلاعات من" screen. */
|
||||
@Get('me')
|
||||
myData(@CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.users.myData(admin.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('user.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.users.create(data);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('user.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.users.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('user.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.users.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('user.list.remove')
|
||||
remove(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.users.remove(id, admin.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminUsersController],
|
||||
providers: [AdminUsersService],
|
||||
})
|
||||
export class AdminUsersModule {}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AdminRole, Prisma } 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';
|
||||
|
||||
/**
|
||||
* Admin users and the permission matrix — legacy views/user/*, served by
|
||||
* UserController and RoleController:
|
||||
* POST/GET/PUT/DELETE users -> the admin user list and form
|
||||
* GET const/__list__ut -> the user-type ("سطح دسترسی") picker
|
||||
* POST const -> create a new user type
|
||||
* GET roles/{ut} -> the rids granted to a user type
|
||||
* POST roles/create -> grant rids
|
||||
* DELETE roles/{ut}/{rid} -> revoke one rid
|
||||
*
|
||||
* The permission matrix writes the `permission(rid, userTypeId)` rows that
|
||||
* Phase 1 modelled, so this screen is what actually administers the ACL.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminUsersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'fullName',
|
||||
'mobile',
|
||||
'role',
|
||||
'isActive',
|
||||
'createdAt',
|
||||
'userTypeId',
|
||||
'userType.title',
|
||||
'branch.name',
|
||||
// legacy filter keys
|
||||
'name',
|
||||
'ut',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
name: 'fullName',
|
||||
ut: 'userTypeId',
|
||||
caj: 'createdAt',
|
||||
active: 'isActive',
|
||||
};
|
||||
|
||||
/** Legacy `GET users` — the admin user grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) => (f ? (AdminUsersService.FIELD_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';
|
||||
}
|
||||
if (mapped.field === 'userTypeId' && mapped.value !== undefined) {
|
||||
mapped.value = Number(mapped.value);
|
||||
}
|
||||
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: AdminUsersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.adminUser.findMany({
|
||||
where: where as Prisma.AdminUserWhereInput,
|
||||
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { userType: true, branch: true },
|
||||
}),
|
||||
this.prisma.adminUser.count({ where: where as Prisma.AdminUserWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
/** Legacy field names preserved; the password hash is never exposed. */
|
||||
private toRow(u: Prisma.AdminUserGetPayload<{ include: { userType: true; branch: true } }>) {
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.fullName,
|
||||
fname: u.fullName,
|
||||
lname: '',
|
||||
mobile: u.mobile,
|
||||
role: u.role,
|
||||
ut: u.userTypeId,
|
||||
user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null,
|
||||
branch_id: u.branchId,
|
||||
branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null,
|
||||
percent: u.wagePercent,
|
||||
active: u.isActive ? 1 : -1,
|
||||
is_active: u.isActive,
|
||||
caj: u.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const u = await this.prisma.adminUser.findUnique({
|
||||
where: { id },
|
||||
include: { userType: true, branch: true },
|
||||
});
|
||||
if (!u) throw new NotFoundException('کاربر یافت نشد');
|
||||
return this.toRow(u);
|
||||
}
|
||||
|
||||
/** Legacy `POST users`. */
|
||||
async create(data: Record<string, unknown>) {
|
||||
const fullName = String(data.name ?? data.fname ?? '').trim();
|
||||
const mobile = String(data.mobile ?? '').trim();
|
||||
const password = String(data.password ?? '');
|
||||
|
||||
if (!fullName) throw new BadRequestException('نام الزامی است');
|
||||
if (!mobile) throw new BadRequestException('موبایل الزامی است');
|
||||
if (!password) throw new BadRequestException('رمز عبور الزامی است');
|
||||
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
|
||||
return this.prisma.adminUser.create({
|
||||
data: {
|
||||
fullName,
|
||||
mobile,
|
||||
passwordHash: await bcrypt.hash(password, 10),
|
||||
role: (data.role as AdminRole) ?? AdminRole.SALES_EXPERT,
|
||||
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
|
||||
branchId: (data.branch_id as string) || null,
|
||||
wagePercent:
|
||||
data.percent !== undefined && data.percent !== null
|
||||
? new Prisma.Decimal(String(data.percent))
|
||||
: null,
|
||||
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||
},
|
||||
include: { userType: true, branch: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `PUT users/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('کاربر یافت نشد');
|
||||
|
||||
const patch: Prisma.AdminUserUpdateInput = {};
|
||||
if (data.name !== undefined) patch.fullName = String(data.name);
|
||||
if (data.mobile !== undefined) {
|
||||
const mobile = String(data.mobile);
|
||||
if (mobile !== existing.mobile) {
|
||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
patch.mobile = mobile;
|
||||
}
|
||||
// A blank password field means "leave it unchanged" — legacy did the same.
|
||||
if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10);
|
||||
if (data.role !== undefined) patch.role = data.role as AdminRole;
|
||||
if (data.ut !== undefined) {
|
||||
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
|
||||
}
|
||||
if (data.branch_id !== undefined) {
|
||||
patch.branch = data.branch_id
|
||||
? { connect: { id: String(data.branch_id) } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (data.percent !== undefined) {
|
||||
patch.wagePercent =
|
||||
data.percent === null ? null : new Prisma.Decimal(String(data.percent));
|
||||
}
|
||||
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
|
||||
|
||||
return this.prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: patch,
|
||||
include: { userType: true, branch: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE users/{id}`.
|
||||
*
|
||||
* Refuses to delete an admin who has posted ledger entries or changed order
|
||||
* statuses — those rows reference them, and losing the actor would erase the
|
||||
* audit trail. Deactivation is offered instead. Legacy deleted regardless.
|
||||
*/
|
||||
async remove(id: string, actorId: string) {
|
||||
const u = await this.prisma.adminUser.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
|
||||
},
|
||||
});
|
||||
if (!u) throw new NotFoundException('کاربر یافت نشد');
|
||||
|
||||
if (id === actorId) {
|
||||
throw new BadRequestException('نمیتوانید حساب کاربری خودتان را حذف کنید');
|
||||
}
|
||||
|
||||
const refs = u._count.postedLedgerEntries + u._count.orderStatusEvents;
|
||||
if (refs > 0) {
|
||||
throw new BadRequestException(
|
||||
`حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. میتوانید غیرفعالش کنید.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.adminUser.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── User types + the permission matrix ─────────────────────────────
|
||||
|
||||
/** Legacy `GET const/__list__ut` — the access-level picker. */
|
||||
async listUserTypes() {
|
||||
const rows = await this.prisma.userType.findMany({
|
||||
orderBy: { id: 'asc' },
|
||||
include: { _count: { select: { adminUsers: true, permissions: true } } },
|
||||
});
|
||||
// legacy returned {id, title} pairs; the counts are additive, not breaking.
|
||||
return rows.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
users_count: t._count.adminUsers,
|
||||
permissions_count: t._count.permissions,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST const` with `{key: "__list__ut", val: "<title>"}` — creates a
|
||||
* new access level. Legacy's `config_static` used an auto-increment id; here
|
||||
* the next free integer is taken explicitly since UserType.id is not generated.
|
||||
*/
|
||||
async createUserType(title: string) {
|
||||
if (!title?.trim()) throw new BadRequestException('نام سطح دسترسی الزامی است');
|
||||
|
||||
const last = await this.prisma.userType.findFirst({ orderBy: { id: 'desc' } });
|
||||
const nextId = (last?.id ?? 84) + 1; // 85 is legacy's first, per Permission.vue
|
||||
|
||||
return this.prisma.userType.create({
|
||||
data: { id: nextId, title: title.trim() },
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy `GET roles/{ut}` — the rids granted to one access level. */
|
||||
async getRoles(userTypeId: number) {
|
||||
const type = await this.prisma.userType.findUnique({
|
||||
where: { id: userTypeId },
|
||||
include: { permissions: true },
|
||||
});
|
||||
if (!type) throw new NotFoundException('سطح دسترسی یافت نشد');
|
||||
// legacy's tree binds to a flat array of rid strings
|
||||
return type.permissions.map((p) => p.rid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST roles/create` with `{ut, roles: [...]}`.
|
||||
*
|
||||
* The posted array is the complete tick-state of the permission tree, so it
|
||||
* REPLACES that type's rows — which is what legacy's live code does too: it
|
||||
* posts the whole `this.rid` array and nothing else.
|
||||
*
|
||||
* (Legacy also carries a COMMENTED-OUT block that diffed the old and new sets
|
||||
* and fired one DELETE per removed rid on a 500ms setTimeout. That path is
|
||||
* dead in legacy and is not revived here; the transaction below achieves the
|
||||
* same end atomically.)
|
||||
*/
|
||||
async setRoles(userTypeId: number, rids: string[]) {
|
||||
const type = await this.prisma.userType.findUnique({ where: { id: userTypeId } });
|
||||
if (!type) throw new NotFoundException('سطح دسترسی یافت نشد');
|
||||
|
||||
const clean = [...new Set((rids ?? []).map((r) => String(r)).filter(Boolean))];
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.permission.deleteMany({ where: { userTypeId } }),
|
||||
this.prisma.permission.createMany({
|
||||
data: clean.map((rid) => ({ rid, userTypeId })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.getRoles(userTypeId);
|
||||
}
|
||||
|
||||
/** Legacy `DELETE roles/{ut}/{rid}` — revoke a single rid. */
|
||||
async revokeRole(userTypeId: number, rid: string) {
|
||||
const existing = await this.prisma.permission.findUnique({
|
||||
where: { rid_userTypeId: { rid, userTypeId } },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('این دسترسی وجود ندارد');
|
||||
|
||||
await this.prisma.permission.delete({
|
||||
where: { rid_userTypeId: { rid, userTypeId } },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */
|
||||
async myData(adminId: string) {
|
||||
return this.findOne(adminId);
|
||||
}
|
||||
|
||||
/** The AdminRole picker for the user form. */
|
||||
roleOptions() {
|
||||
return [
|
||||
{ value: AdminRole.SUPER_ADMIN, title: 'مدیر ارشد' },
|
||||
{ value: AdminRole.SYSTEM_APPROVER, title: 'تایید مدیر سیستم' },
|
||||
{ value: AdminRole.SALES_EXPERT, title: 'کارشناس فروش' },
|
||||
{ value: AdminRole.WORKSHOP_SUPERVISOR, title: 'سرپرست کارگاه' },
|
||||
{ value: AdminRole.ACCOUNTANT, title: 'حسابدار' },
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminBranchesService } from '../admin-branches/admin-branches.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Workshops — legacy views/workshop/*, served by WorkshopController:
|
||||
* POST workshop/table -> the grid (also the picker the PreOrder detail uses)
|
||||
* POST workshop -> create (workshop + its supervisor, together)
|
||||
* GET/PUT workshop/{id}
|
||||
* GET workshop/active/{id} -> toggle
|
||||
*
|
||||
* The list route predates the rest (Batch 3 added it for the made-to-order
|
||||
* screen's workshop picker); the CRUD arrived with Batch 11. Both live here so
|
||||
* there is one owner of /admin/workshops, and the logic sits in
|
||||
* AdminBranchesService alongside the branch code it mirrors.
|
||||
*/
|
||||
@Controller('admin/workshops')
|
||||
export class AdminWorkshopsController {
|
||||
constructor(private readonly branches: AdminBranchesService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.branches.listWorkshops(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('workshop.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.branches.createWorkshop(data);
|
||||
}
|
||||
|
||||
@Get('active/:id')
|
||||
@RequirePermission('workshop.list')
|
||||
toggleActive(@Param('id') id: string) {
|
||||
return this.branches.toggleWorkshopActive(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('workshop.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.branches.findWorkshop(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('workshop.list')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.branches.updateWorkshop(id, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminBranchesModule } from '../admin-branches/admin-branches.module';
|
||||
import { AdminWorkshopsController } from './admin-workshops.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AdminBranchesModule],
|
||||
controllers: [AdminWorkshopsController],
|
||||
})
|
||||
export class AdminWorkshopsModule {}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Every permission id (`rid`) the admin panel actually enforces.
|
||||
*
|
||||
* Regenerated mechanically from legacy (see scratchpad/convert_tree.js) as the
|
||||
* union of two enforcement points:
|
||||
* - 58 gated by a route's `meta.role` (router/admin.js, router/branch.js)
|
||||
* - 100 gated inside views via `$hasPermission('...')`
|
||||
* = 144 distinct rids (the two sets overlap)
|
||||
*
|
||||
* An earlier revision of this file listed only the 58 route-gated ones, which
|
||||
* left the seeded super-admin unable to use the in-view actions. Corrected.
|
||||
*
|
||||
* These are LEAVES as written. Ancestors ("order" from "order.list", "persons"
|
||||
* from "persons.list.client.omde", ...) are NOT stored — they are derived at
|
||||
* runtime by expandWithParents(), exactly as legacy's checkParent() did.
|
||||
*/
|
||||
export const ALL_RIDS = [
|
||||
'branch.create',
|
||||
'branch.list',
|
||||
'buy.order.list',
|
||||
'cities.create',
|
||||
'cities.list',
|
||||
'cities.list.remove',
|
||||
'cities.list.view',
|
||||
'cities.list.view.edit',
|
||||
'collection.create',
|
||||
'collection.list',
|
||||
'collection.list.edit',
|
||||
'collection.list.remove',
|
||||
'comment',
|
||||
'comment.remove',
|
||||
'comment.state',
|
||||
'comment.view',
|
||||
'comment.view.edit',
|
||||
'config.report',
|
||||
'config.statics',
|
||||
'config.statics.brand',
|
||||
'config.statics.brand.create',
|
||||
'config.statics.brand.edit',
|
||||
'config.statics.brand.remove',
|
||||
'config.statics.tags',
|
||||
'config.statics.tags.create',
|
||||
'config.statics.tags.edit',
|
||||
'config.statics.tags.remove',
|
||||
'config.wallet',
|
||||
'custom.config.statics.slider',
|
||||
'custom.config.statics.tags.banner.ids',
|
||||
'dashboard.omde',
|
||||
'dashboard.tak',
|
||||
'document.create',
|
||||
'document.list',
|
||||
'document.list.confirm',
|
||||
'document.list.deny',
|
||||
'document.list.edit',
|
||||
'document.list.get',
|
||||
'document.list.remove',
|
||||
'invoice.create',
|
||||
'invoice.list',
|
||||
'newsletters',
|
||||
'notify.create',
|
||||
'notify.me',
|
||||
'offers.autopromotion',
|
||||
'offers.create',
|
||||
'offers.create.tak',
|
||||
'offers.list',
|
||||
'offers.list.edit',
|
||||
'offers.list.remove',
|
||||
'offers.list.tak.edit',
|
||||
'offers.list.tak.remove',
|
||||
'offersIgnore',
|
||||
'order.create',
|
||||
'order.list',
|
||||
'order.list.pdf',
|
||||
'order.list.sts',
|
||||
'order.list.sts.remove',
|
||||
'order.list.sts.submit',
|
||||
'order.list.view',
|
||||
'order.list.view.edit',
|
||||
'order.makeOrder',
|
||||
'order.makeOrder.get',
|
||||
'order.makeOrder.remove',
|
||||
'order.makeOrder.state',
|
||||
'order.makeOrder.state.remove',
|
||||
'order.makeOrder.state.submit',
|
||||
'order.makeOrder.view',
|
||||
'order.makeOrder.view.edit',
|
||||
'order.omde',
|
||||
'order.omde.get',
|
||||
'order.omde.remove',
|
||||
'order.omde.state',
|
||||
'order.omde.state.remove',
|
||||
'order.omde.state.submit',
|
||||
'order.omde.view',
|
||||
'order.omde.view.edit',
|
||||
'order.omde.view.remove',
|
||||
'order.tamin',
|
||||
'order.tamin.state',
|
||||
'order.tamin.state.remove',
|
||||
'order.tamin.state.submit',
|
||||
'order.tamin.view',
|
||||
'order.tamin.view.edit',
|
||||
'persons.create',
|
||||
'persons.create.client',
|
||||
'persons.create.customer.tak',
|
||||
'persons.create.seller',
|
||||
'persons.list.client.omde',
|
||||
'persons.list.client.omde.able',
|
||||
'persons.list.client.omde.able.order',
|
||||
'persons.list.client.omde.edit',
|
||||
'persons.list.client.omde.unable',
|
||||
'persons.list.client.omde.unable.order',
|
||||
'persons.list.client.omde.view',
|
||||
'persons.list.client.omde.view.documents',
|
||||
'persons.list.client.omde.view.orders',
|
||||
'persons.list.client.single',
|
||||
'persons.list.client.single.edit',
|
||||
'persons.list.client.single.view',
|
||||
'persons.list.sellers',
|
||||
'persons.list.sellers.able',
|
||||
'persons.list.sellers.able.order',
|
||||
'persons.list.sellers.edit',
|
||||
'persons.list.sellers.unable',
|
||||
'persons.list.sellers.unable.order',
|
||||
'persons.list.sellers.view',
|
||||
'persons.list.sellers.view.documents',
|
||||
'persons.list.sellers.view.orders',
|
||||
'product.create',
|
||||
'product.list',
|
||||
'product.list.edit',
|
||||
'product.list.group.edit',
|
||||
'product.list.remove',
|
||||
'product.list.variants',
|
||||
'product.list.variants.add',
|
||||
'product.list.variants.edit',
|
||||
'product.list.variants.remove',
|
||||
'product.list.view',
|
||||
'product.percent',
|
||||
'report.order.item',
|
||||
'report.product',
|
||||
'sms.create',
|
||||
'sms.create.all',
|
||||
'sms.create.multiple',
|
||||
'sms.create.omde',
|
||||
'sms.create.reagent',
|
||||
'sms.create.single',
|
||||
'sms.list',
|
||||
'statics',
|
||||
'user.create',
|
||||
'user.list',
|
||||
'user.list.create',
|
||||
'user.list.edit',
|
||||
'user.list.remove',
|
||||
'user.permission',
|
||||
'user.permission.create',
|
||||
'user.permission.edit',
|
||||
'wallet',
|
||||
'wallet.charge',
|
||||
'wallet.list',
|
||||
'wallet.percent',
|
||||
'workshop.create',
|
||||
'workshop.list',
|
||||
] as const;
|
||||
|
||||
export type Rid = (typeof ALL_RIDS)[number];
|
||||
|
||||
/**
|
||||
* Port of henza_admin/src/store/users/index.js getParents() — returns every
|
||||
* ancestor of a dotted rid, longest first.
|
||||
*
|
||||
* getParents(['a','b','c']) -> ['a.b', 'a']
|
||||
*/
|
||||
function getParents(keys: string[]): string[] {
|
||||
const work = [...keys];
|
||||
const parents: string[] = [];
|
||||
while (work.length > 1) {
|
||||
work.pop();
|
||||
parents.push(work.join('.'));
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of henza_admin/src/store/users/index.js checkParent().
|
||||
*
|
||||
* Granting "persons.list.client.omde" implicitly grants "persons.list.client",
|
||||
* "persons.list" and "persons", so parent menu nodes stay visible. The legacy
|
||||
* version mutated a module-scoped `rid` variable; this one is pure, which changes
|
||||
* nothing observable but makes it safe to call concurrently on the server.
|
||||
*
|
||||
* Note this only walks UP. A rid never implies its children — which is why the
|
||||
* legacy nav item guarded by "wallet.list" stays hidden for a user granted only
|
||||
* "wallet". That mismatch is a legacy bug, recorded in docs/ADMIN-PORT-PLAN.md,
|
||||
* and is preserved here rather than silently repaired.
|
||||
*/
|
||||
export function expandWithParents(values: string[]): string[] {
|
||||
let rid: string[] = [...values];
|
||||
for (const x of values) {
|
||||
const keys = x.split('.');
|
||||
if (keys.length > 1) {
|
||||
rid = [...new Set([...rid, ...getParents(keys)])];
|
||||
}
|
||||
}
|
||||
return rid;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { CurrentAdmin, Public } from './guards';
|
||||
import type { AuthenticatedAdmin } from './jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (henza_server routes/admin.php):
|
||||
* POST admin-login -> AdminController::login
|
||||
* GET users/show -> UserController::showData
|
||||
*
|
||||
* Exposed here under the clean /api/admin/auth prefix; the admin panel's mapping
|
||||
* layer translates the legacy paths onto these.
|
||||
*/
|
||||
@Controller('admin/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
/** Legacy `users/show` — the router guard's permission refresh. */
|
||||
@Get('me')
|
||||
me(@CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.authService.showData(admin.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { JwtAuthGuard, PermissionsGuard } from './guards';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET ?? 'dev-only-insecure-secret',
|
||||
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
||||
// `string` from process.env does not satisfy; the cast is the documented escape.
|
||||
signOptions: {
|
||||
expiresIn: (process.env.JWT_EXPIRES_IN ?? '12h') as `${number}h`,
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
// Global: authenticate first, then check the rid. Routes opt out with @Public().
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Port of legacy AdminController::login (henza_server).
|
||||
*
|
||||
* Legacy issued a random 60-char string as `api_token` and persisted it on the
|
||||
* user row; this issues a signed JWT instead. The response keeps the legacy
|
||||
* shape the admin panel's auth store reads:
|
||||
* - `api_token` -> stored in localStorage under token/branch_token
|
||||
* - `roles` -> [{ rid }], mapped to rids by the frontend then expanded
|
||||
* through checkParent()
|
||||
*
|
||||
* Deliberate deviation from legacy: a bad phone and a bad password both return
|
||||
* 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for
|
||||
* a wrong password, which let anyone enumerate valid admin phone numbers.
|
||||
*/
|
||||
async login(dto: LoginDto) {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { mobile: dto.phone },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
|
||||
}
|
||||
|
||||
const passwordOk = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!passwordOk) {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
|
||||
}
|
||||
|
||||
const apiToken = await this.jwt.signAsync({ sub: user.id, mobile: user.mobile });
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
ut: user.userTypeId,
|
||||
branch_id: user.branchId,
|
||||
api_token: apiToken,
|
||||
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of legacy UserController::showData, which the router guard calls on every
|
||||
* cold load to refresh the permission list:
|
||||
*
|
||||
* $user['permission'] = Permission::where('ut',$user->ut)->select('rid')->get()->pluck('rid');
|
||||
*
|
||||
* Returns raw (unexpanded) rids — the frontend runs checkParent() on them, as in
|
||||
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
|
||||
*/
|
||||
async showData(adminId: string) {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: adminId },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
ut: user.userTypeId,
|
||||
branch_id: user.branchId,
|
||||
user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null,
|
||||
permission: (user.userType?.permissions ?? []).map((p) => p.rid),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Mirrors the legacy contract in AdminController::login:
|
||||
* $this->validate($request, ['phone' => 'required', 'password' => 'required']);
|
||||
*
|
||||
* The field is named `phone` (not `mobile`) even though it is matched against
|
||||
* `users.mobile` — kept as-is so the ported login form posts an unchanged body.
|
||||
*/
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { AuthenticatedAdmin } from './jwt.strategy';
|
||||
|
||||
/** Marks a route as reachable without a token (login, health checks). */
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
/** Requires the caller to hold this rid (ancestors count — see expandWithParents). */
|
||||
export const REQUIRED_RID_KEY = 'requiredRid';
|
||||
export const RequirePermission = (rid: string) => SetMetadata(REQUIRED_RID_KEY, rid);
|
||||
|
||||
/** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */
|
||||
export const CurrentAdmin = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthenticatedAdmin =>
|
||||
ctx.switchToHttp().getRequest().user,
|
||||
);
|
||||
|
||||
/**
|
||||
* JWT guard that honours @Public().
|
||||
*
|
||||
* Registered globally in AuthModule, so every route is authenticated by default
|
||||
* and opting out is explicit. The legacy app had the inverse default — its router
|
||||
* guard skipped a hardcoded `noneAuthUrl` list — which meant any route added
|
||||
* without a `meta.role` was silently unprotected.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Enforces @RequirePermission(rid) against the expanded permission list. */
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRid = this.reflector.getAllAndOverride<string>(REQUIRED_RID_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRid) return true;
|
||||
|
||||
const user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user;
|
||||
if (!user) throw new ForbiddenException();
|
||||
|
||||
if (!user.permissions.includes(requiredRid)) {
|
||||
throw new ForbiddenException(`Missing permission: ${requiredRid}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { expandWithParents } from './acl.constants';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
mobile: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The authenticated principal attached to `req.user`.
|
||||
*
|
||||
* `permissions` is already expanded with ancestors, so guards can test a rid with
|
||||
* a plain `includes()` — the same shape the legacy frontend held in its Vuex store
|
||||
* after checkParent().
|
||||
*/
|
||||
export interface AuthenticatedAdmin {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
role: string;
|
||||
userTypeId: number | null;
|
||||
branchId: string | null;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET ?? 'dev-only-insecure-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<AuthenticatedAdmin> {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Permissions are re-read on every request rather than baked into the token,
|
||||
// so a permission change takes effect immediately instead of at next login.
|
||||
const rids = user.userType?.permissions.map((p) => p.rid) ?? [];
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
userTypeId: user.userTypeId,
|
||||
branchId: user.branchId,
|
||||
permissions: expandWithParents(rids),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { Public } from '../auth/guards';
|
||||
|
||||
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
|
||||
@Controller('catalog/products')
|
||||
export class CatalogController {
|
||||
constructor(private readonly catalog: CatalogService) {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { CustomersService } from './customers.service';
|
||||
import { Public } from '../auth/guards';
|
||||
|
||||
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
|
||||
@Controller('customers')
|
||||
export class CustomersController {
|
||||
constructor(private readonly customers: CustomersService) {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsArray, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator';
|
||||
import { OrderChannel } from '@prisma/client';
|
||||
import { ArrayMinSize, IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
export class CreateOrderItemInputDto {
|
||||
@IsString()
|
||||
@@ -31,6 +32,15 @@ export class CreateOrderDto {
|
||||
@IsString()
|
||||
branchId?: string;
|
||||
|
||||
/**
|
||||
* Which sales channel placed the order. Legacy had no such field because the
|
||||
* two channels lived in separate tables (orders vs orders_orders); the one
|
||||
* Order model carries the distinction here. Defaults to RETAIL.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsEnum(OrderChannel)
|
||||
channel?: OrderChannel;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||
import { Public } from '../auth/guards';
|
||||
|
||||
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
|
||||
@Controller('orders')
|
||||
export class OrdersController {
|
||||
constructor(private readonly orders: OrdersService) {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { OrderStatus, Prisma } from '@prisma/client';
|
||||
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
@@ -80,6 +80,7 @@ export class OrdersService {
|
||||
orderNumber: this.generateOrderNumber(),
|
||||
customerId: dto.customerId,
|
||||
branchId: dto.branchId,
|
||||
channel: dto.channel ?? OrderChannel.RETAIL,
|
||||
status: initialStatus,
|
||||
totalGoldWeightGrams,
|
||||
wageWeightGrams,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { PricingService } from './pricing.service';
|
||||
import { Public } from '../auth/guards';
|
||||
|
||||
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
|
||||
@Controller('pricing')
|
||||
export class PricingController {
|
||||
constructor(private readonly pricing: PricingService) {}
|
||||
|
||||
Reference in New Issue
Block a user