Restores the sales-expert fields that were lost when a sales expert became
an AdminUser instead of a type=1 Customer row.
New SalesExpertProfile holds cityId, gramLevel, phone and nationalCode. A
separate table rather than columns on AdminUser, because these mean
something only for role = SALES_EXPERT. gramLevel is DECIMAL(14,4), matched
to Customer.goldCreditLimitGrams so the same credit limit has identical
precision on both tables. phone and nationalCode copy the reference exactly:
nullable, NOT unique, no format rule — createCustomer validates only mobile
and percent, and both fields are required:false on the reference form.
Inventing a uniqueness constraint would reject data the reference accepts.
reagent_id is deliberately NOT ported (D17). Every use of it in the
reference reads a CUSTOMER's reagent_id, which the target already models as
Customer.referredByAgentId. On a sales expert's own form it was a side
effect of the shared customers form: filling it removed that expert from
the selectable-agent list, because queryReagent filters on
whereNull('reagent_id'). The target's equivalent filters on role and active
only, so no agent can be dropped — strictly better than the reference.
AdminUser gains firstName/lastName with the same additive migration,
backfill and sync trigger as Customer, for all admins rather than only
sales experts. This closes D0 requirement 7, and every remaining
`fname: fullName, lname: ''` was fixed — six further sites across
admin-users, admin-comments, admin-documents, admin-orders and
admin-preorders.
listReagents now returns real city_id, city, gram_level, phone and ncode,
and left-joins so an expert without a profile still lists with empty cells
rather than vanishing. The two grid filters map through the relation. The
service upserts a profile on create and on a role change to SALES_EXPERT,
and the migration backfilled one for every existing expert.
ReagentCustomer.vue gains the four fields plus the city -> commission
autofill, ported verbatim including the reference's falsy test so a city
percent of 0 also falls through to the aux lookup (replicated, not fixed).
Tests: new sales-expert round trip 33/33, covering create with every field,
an edit form with each field populated separately, a byte-identical no-op
save, single-field edits, both grid filters with negative cases, the
left-join case and the referrer dropdown. «محمد علی» + «احمدی» survives
intact. Plus jest 32/32, auth-smoke 53/53, customer trigger 9/9, typecheck
clean on api and admin-panel. Migration dry-run twice in a rolled-back
transaction before applying; drift is zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
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,
|
|
// D0 — real name parts
|
|
fname: p.customer.firstName ?? p.customer.fullName,
|
|
lname: p.customer.lastName ?? '',
|
|
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 }));
|
|
}
|
|
}
|