Rebuild order flow: settle after pricing, notifications, and invoice expiry.

Hide prices until admin sends the invoice, enforce status transitions, add customer/admin SMS+in-app notifications, schedule cash invoice reminders, and ship updated dist/prisma-client/deploy-bundle for git-pull deploys.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-09 12:00:13 +03:30
co-authored by Cursor
parent 06e5f48d07
commit d4aaa8e997
102 changed files with 2989 additions and 509 deletions
+132 -72
View File
@@ -17,6 +17,10 @@ const order_number_1 = require("../common/utils/order-number");
const check_rass_1 = require("../common/utils/check-rass");
const cash_expiry_1 = require("../common/utils/cash-expiry");
const order_status_1 = require("../common/utils/order-status");
const order_transitions_1 = require("../common/utils/order-transitions");
const meterage_1 = require("../common/utils/meterage");
const notifications_service_1 = require("../notifications/notifications.service");
const notification_events_1 = require("../notifications/notification-events");
const name_1 = require("../common/utils/name");
const phone_1 = require("../common/utils/phone");
const orderInclude = {
@@ -32,19 +36,21 @@ function roundMoney(value) {
return Math.round(value * 100) / 100;
}
const CHECK_EDITABLE_STATUSES = [
client_1.OrderStatus.awaiting_review,
client_1.OrderStatus.pricing,
client_1.OrderStatus.needs_correction,
client_1.OrderStatus.awaiting_settlement_fix,
client_1.OrderStatus.check_in_progress,
client_1.OrderStatus.awaiting_check_review,
client_1.OrderStatus.check_needs_fix,
client_1.OrderStatus.check_rejected,
];
const PRICE_HIDDEN_STATUSES = [
client_1.OrderStatus.awaiting_review,
client_1.OrderStatus.pricing,
];
let OrdersService = class OrdersService {
prisma;
constructor(prisma) {
notifications;
constructor(prisma, notifications) {
this.prisma = prisma;
this.notifications = notifications;
}
async findMyOrders(userId) {
const orders = await this.prisma.order.findMany({
@@ -95,12 +101,17 @@ let OrdersService = class OrdersService {
return this.mapOrder(order, true);
}
async submitOrder(userId, dto) {
const company = await this.prisma.company.findUnique({
where: { id: dto.companyId },
});
const [company, settings] = await Promise.all([
this.prisma.company.findUnique({ where: { id: dto.companyId } }),
this.prisma.systemSettings.findUnique({
where: { id: 'default' },
select: { maxMeteragePerInvoice: true },
}),
]);
if (!company || !company.isActive) {
throw new common_1.NotFoundException('شرکت یافت نشد یا غیرفعال است');
}
const maxMeterage = (0, meterage_1.resolveMaxMeterage)(company.maxMeteragePerInvoice, settings?.maxMeteragePerInvoice);
const productIds = dto.items.map((i) => i.productId);
const products = await this.prisma.product.findMany({
where: { id: { in: productIds }, companyId: company.id, isActive: true },
@@ -128,31 +139,12 @@ let OrdersService = class OrdersService {
productCode: product.code,
color: product.color,
requestedMeterage: item.meterage,
unitPrice: product.basePrice,
notes: item.notes?.trim() || null,
};
});
if (totalMeterage > company.maxMeteragePerInvoice) {
throw new common_1.BadRequestException(`حداکثر متراژ مجاز هر فاکتور ${company.maxMeteragePerInvoice} متر است`);
if (totalMeterage > maxMeterage) {
throw new common_1.BadRequestException(`حداکثر متراژ مجاز هر فاکتور ${maxMeterage} متر است`);
}
const settlementType = dto.settlementType;
if (settlementType === client_1.SettlementType.check) {
if (!dto.checks?.length) {
throw new common_1.BadRequestException('برای تسویه چکی حداقل یک چک لازم است');
}
}
const checkMode = settlementType === client_1.SettlementType.check
? (dto.checkMode ?? client_1.CheckRegistrationMode.full)
: null;
const rass = settlementType === client_1.SettlementType.check && dto.checks?.length
? (0, check_rass_1.calculateCheckRass)(dto.checks.map((c) => ({
amount: c.amount,
dueDate: c.dueDate,
})))
: null;
const cashInvoiceExpiresAt = settlementType === client_1.SettlementType.cash
? (0, cash_expiry_1.computeCashInvoiceExpiry)(company)
: null;
const order = await this.createWithUniqueOrderNumber((orderNumber) => this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
@@ -160,33 +152,19 @@ let OrdersService = class OrdersService {
userId,
companyId: company.id,
companyName: company.name,
settlementType,
checkMode,
settlementType: null,
checkMode: null,
status: client_1.OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
checkRassDays: rass?.rassDays ?? null,
checkRassDate: rass?.rassDate ? new Date(rass.rassDate) : null,
cashInvoiceExpiresAt,
items: { create: itemsData },
checks: settlementType === client_1.SettlementType.check && dto.checks
? {
create: dto.checks.map((c, index) => this.mapCheckCreate(c, index + 1, checkMode)),
}
: undefined,
statusHistory: {
create: [
{
fromStatus: null,
toStatus: client_1.OrderStatus.submitted,
changedBy: 'customer',
note: 'سفارش ثبت شد',
},
{
fromStatus: client_1.OrderStatus.submitted,
toStatus: client_1.OrderStatus.awaiting_review,
changedBy: 'customer',
note: 'ارسال برای بررسی مدیریت',
note: 'سفارش ثبت شد و برای بررسی مدیریت ارسال شد',
},
],
},
@@ -201,13 +179,57 @@ let OrdersService = class OrdersService {
}
return created;
}));
this.notifications.dispatchOrderEvent(order.id, 'order_submitted');
this.notifications.dispatchAdmin({
title: 'سفارش جدید ثبت شد',
body: `سفارش ${order.orderNumber} با ${order.items.length} قلم و ${order.totalMeterage} متر در انتظار قیمت‌گذاری است.`,
link: `/orders/${order.id}`,
});
return this.mapOrder(order);
}
async adminCreate(adminId, dto) {
const settlementType = dto.settlementType ?? dto.settlementMethod;
if (!settlementType) {
throw new common_1.BadRequestException('نوع تسویه الزامی است');
async chooseSettlement(userId, id, dto) {
const order = await this.getOrder(id);
if (order.userId !== userId) {
throw new common_1.ForbiddenException('دسترسی به این سفارش مجاز نیست');
}
if (order.status !== client_1.OrderStatus.awaiting_settlement) {
throw new common_1.BadRequestException('انتخاب روش تسویه فقط پس از صدور فاکتور توسط مدیریت امکان‌پذیر است');
}
const isCash = dto.settlementType === client_1.SettlementType.cash;
const nextStatus = isCash
? client_1.OrderStatus.awaiting_cash_payment
: client_1.OrderStatus.check_in_progress;
(0, order_transitions_1.assertTransition)(order.status, nextStatus);
await this.prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id },
data: {
settlementType: dto.settlementType,
checkMode: isCash
? null
: (dto.checkMode ?? client_1.CheckRegistrationMode.full),
cashInvoiceExpiresAt: isCash
? (0, cash_expiry_1.computeCashInvoiceExpiry)(order.company)
: null,
cashReminderSentAt: null,
status: nextStatus,
},
});
await tx.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: order.status,
toStatus: nextStatus,
changedBy: 'customer',
note: isCash
? 'مشتری تسویه نقدی را انتخاب کرد'
: 'مشتری تسویه چکی را انتخاب کرد',
},
});
});
return this.findMyOrder(userId, id);
}
async adminCreate(adminId, dto) {
const company = await this.prisma.company.findUnique({
where: { id: dto.companyId },
});
@@ -245,7 +267,7 @@ let OrdersService = class OrdersService {
userId: ownerId,
companyId: company.id,
companyName: company.name,
settlementType,
settlementType: null,
status: client_1.OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
@@ -338,6 +360,18 @@ let OrdersService = class OrdersService {
if (order.status === dto.status) {
return this.mapOrder(order, true);
}
if (dto.force) {
if (!dto.reason?.trim()) {
throw new common_1.BadRequestException('برای تغییر دستی وضعیت، ذکر دلیل الزامی است');
}
}
else {
(0, order_transitions_1.assertTransition)(order.status, dto.status);
}
if (dto.status === client_1.OrderStatus.awaiting_settlement &&
(order.finalAmount ?? 0) <= 0) {
throw new common_1.BadRequestException('ابتدا سفارش را قیمت‌گذاری کنید تا فاکتور قابل ارسال باشد');
}
await this.prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id },
@@ -349,7 +383,9 @@ let OrdersService = class OrdersService {
fromStatus: order.status,
toStatus: dto.status,
changedBy: `admin:${adminId}`,
note: dto.note || null,
note: dto.force
? `تغییر دستی وضعیت${dto.note ? ` — ${dto.note}` : ''}`
: dto.note || null,
reason: dto.reason || null,
},
});
@@ -365,6 +401,12 @@ let OrdersService = class OrdersService {
}
}
});
const event = (0, notification_events_1.eventForStatus)(dto.status);
if (event) {
this.notifications.dispatchOrderEvent(id, event, {
note: dto.reason ?? dto.note ?? null,
});
}
return this.findAdminOrder(id);
}
async updateChecks(userId, id, dto) {
@@ -378,8 +420,18 @@ let OrdersService = class OrdersService {
if (!CHECK_EDITABLE_STATUSES.includes(order.status)) {
throw new common_1.BadRequestException('در وضعیت فعلی سفارش امکان ویرایش چک‌ها وجود ندارد');
}
if (!dto.checks.length) {
throw new common_1.BadRequestException('حداقل یک چک لازم است');
}
const checkMode = dto.checkMode ?? order.checkMode ?? client_1.CheckRegistrationMode.full;
const rass = (0, check_rass_1.calculateCheckRass)(dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })), order.createdAt);
const goesToReview = order.status === client_1.OrderStatus.check_in_progress ||
order.status === client_1.OrderStatus.check_needs_fix ||
order.status === client_1.OrderStatus.check_rejected;
const nextStatus = goesToReview
? client_1.OrderStatus.awaiting_check_review
: order.status;
const isFix = order.status !== client_1.OrderStatus.check_in_progress;
await this.prisma.$transaction(async (tx) => {
await tx.check.deleteMany({ where: { orderId: id } });
await tx.check.createMany({
@@ -394,23 +446,30 @@ let OrdersService = class OrdersService {
checkMode,
checkRassDays: rass.rassDays,
checkRassDate: rass.rassDate ? new Date(rass.rassDate) : null,
status: order.status === client_1.OrderStatus.check_needs_fix
? client_1.OrderStatus.awaiting_check_review
: order.status,
status: nextStatus,
},
});
if (order.status === client_1.OrderStatus.check_needs_fix) {
if (nextStatus !== order.status) {
await tx.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: order.status,
toStatus: client_1.OrderStatus.awaiting_check_review,
toStatus: nextStatus,
changedBy: 'customer',
note: 'چک‌ها اصلاح و مجدداً ارسال شد',
note: isFix
? 'چک‌ها اصلاح و مجدداً ارسال شد'
: 'چک‌ها برای بررسی ارسال شد',
},
});
}
});
if (nextStatus !== order.status) {
this.notifications.dispatchAdmin({
title: isFix ? 'چک‌های اصلاح‌شده رسید' : 'چک‌های سفارش ثبت شد',
body: `مشتری ${dto.checks.length} چک برای سفارش ${order.orderNumber} ثبت کرد و منتظر بررسی است.`,
link: `/orders/${id}`,
});
}
return this.findMyOrder(userId, id);
}
mapCheckCreate(c, row, defaultMode) {
@@ -467,6 +526,7 @@ let OrdersService = class OrdersService {
}
mapOrder(order, adminView = false) {
const customerName = (0, name_1.joinFullName)(order.user.firstName, order.user.lastName);
const hidePrices = !adminView && PRICE_HIDDEN_STATUSES.includes(order.status);
const base = {
id: order.id,
orderNumber: order.orderNumber,
@@ -478,12 +538,14 @@ let OrdersService = class OrdersService {
checkMode: order.checkMode ?? undefined,
status: order.status,
adminStatus: (0, order_status_1.toAdminStatus)(order.status),
allowedTransitions: adminView ? (0, order_transitions_1.allowedTransitions)(order.status) : [],
totalMeterage: order.totalMeterage,
meterage: order.totalMeterage,
totalAmount: order.totalAmount,
shippingCost: order.shippingCost,
totalDiscount: order.totalDiscount,
finalAmount: order.finalAmount,
totalAmount: hidePrices ? null : order.totalAmount,
shippingCost: hidePrices ? null : order.shippingCost,
totalDiscount: hidePrices ? null : order.totalDiscount,
finalAmount: hidePrices ? null : order.finalAmount,
pricesVisible: !hidePrices,
notes: order.notes ?? undefined,
checkRassDays: order.checkRassDays ?? undefined,
checkRassDate: order.checkRassDate?.toISOString(),
@@ -503,12 +565,12 @@ let OrdersService = class OrdersService {
color: item.color,
requestedMeterage: item.requestedMeterage,
confirmedMeterage: item.confirmedMeterage,
unitPrice: item.unitPrice,
supplyPrice: item.unitPrice,
unitDiscount: item.unitDiscount ?? 0,
discount: item.unitDiscount ?? 0,
lineTotal: item.lineTotal,
totalAmount: item.lineTotal,
unitPrice: hidePrices ? null : item.unitPrice,
supplyPrice: hidePrices ? null : item.unitPrice,
unitDiscount: hidePrices ? null : (item.unitDiscount ?? 0),
discount: hidePrices ? null : (item.unitDiscount ?? 0),
lineTotal: hidePrices ? null : item.lineTotal,
totalAmount: hidePrices ? null : item.lineTotal,
notes: item.notes ?? undefined,
})),
checks: order.checks.map((c) => ({
@@ -573,15 +635,13 @@ let OrdersService = class OrdersService {
changedAt: h.changedAt.toISOString(),
})),
};
if (adminView) {
return base;
}
return base;
}
};
exports.OrdersService = OrdersService;
exports.OrdersService = OrdersService = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
notifications_service_1.NotificationsService])
], OrdersService);
//# sourceMappingURL=orders.service.js.map