feat(admin): D16 (b)(c) — Excel order-item report, document-confirm SMS, %name in bulk SMS

Finished from a parallel session that expired (owner: «بساز»).

- RPT-01: GET admin/report/order-items — legacy report/order_item's
  32-column Excel (order items, one row per unit, plus financial
  documents), RTL, Jalali dates. The «گزارش تجمیعی» screen gets
  «دریافت فایل اکسل». Review fixes:
  - weight: computedWeightGrams is already one unit's weight; legacy
    divided it by qty again, under-reporting «بدهکار» on multi-unit
    lines. Not repeated.
  - «درصد»: the final percent (wage − discount), as legacy.
  - dates: the end date covers its whole day; a bad date is a 400.
- SMS-01: confirming or rejecting a financial document texts the customer,
  as legacy CashController::toggleConfiremCash.
- SMS-02: bulk SMS substitutes %name per recipient and is actually sent
  through the configured gateway (D26 relay), no longer only logged.
- Docs: 01-ADMIN-MATRIX and D16-BC-GAP-SUMMARY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-08 16:34:45 +03:30
co-authored by Claude Opus 5.5
parent fbe4f38e06
commit 2d74f53d0a
12 changed files with 1241 additions and 30 deletions
@@ -0,0 +1,21 @@
import { Controller, Get, Query } from '@nestjs/common';
import { AdminReportService } from './admin-report.service';
import { RequirePermission } from '../auth/guards';
/**
* Legacy `GET report/get-order-item-with-details` — a combined order-item +
* financial-document Excel export with 32 Persian-header columns, date-filtered.
*/
@Controller('admin/report')
export class AdminReportController {
constructor(private readonly report: AdminReportService) {}
@Get('order-items')
@RequirePermission('report.order.item')
getOrderItemReport(
@Query('start_date') startDate?: string,
@Query('end_date') endDate?: string,
) {
return this.report.getOrderItemReport(startDate, endDate);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminReportController } from './admin-report.controller';
import { AdminReportService } from './admin-report.service';
@Module({
imports: [PrismaModule],
controllers: [AdminReportController],
providers: [AdminReportService],
})
export class AdminReportModule {}
@@ -0,0 +1,221 @@
import { BadRequestException, Injectable, StreamableFile } from '@nestjs/common';
import { LedgerSourceType, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { toJalali, formatJalali } from '../accounting-sync/jalali';
import * as ExcelJS from 'exceljs';
const TYPE_LABELS: Partial<Record<LedgerSourceType, string>> = {
REPAYMENT_MELTED_GOLD: 'آب شده',
REPAYMENT_TRANSFER: 'حواله',
REPAYMENT_CHEQUE: 'چک',
REPAYMENT_CASH: 'پولی',
};
const REPAYMENT_TYPES: LedgerSourceType[] = [
LedgerSourceType.REPAYMENT_MELTED_GOLD,
LedgerSourceType.REPAYMENT_TRANSFER,
LedgerSourceType.REPAYMENT_CHEQUE,
LedgerSourceType.REPAYMENT_CASH,
];
const HEADERS = [
'کد آیتم',
'نام محصول',
'کد محصول',
'عنوان تنوع',
'وزن خام تحویل مشتری',
'وزن نهایی با درصد',
'تعداد',
'درصد',
'اجرت ریالی',
'توضیحات',
'شماره سفارش',
'تاریخ ثبت',
'تاریخ',
'سال',
'ماه',
'روز',
'ساعت',
'شماره سند',
'نوع سند',
'میزان پول',
'میزان گرم',
'وضعیت سند',
'نام مشتری',
'نام خانوادگی مشتری',
'نام و نام خانوادگی مشتری',
'کارشناس فروش',
'شماره موبایل مشتری',
'آدرس',
'تلفن مغازه',
'بدهکار',
'بستانکار',
'بدهی ریالی',
];
@Injectable()
export class AdminReportService {
constructor(private readonly prisma: PrismaService) {}
async getOrderItemReport(startDate?: string, endDate?: string): Promise<StreamableFile> {
const dateFilter: Prisma.DateTimeFilter = {};
if (startDate) dateFilter.gte = parseDate(startDate, 'از تاریخ');
if (endDate) {
// The whole end day, as the on-screen report (admin-dashboard dateWhere).
const end = parseDate(endDate, 'تا تاریخ');
end.setHours(23, 59, 59, 999);
dateFilter.lte = end;
}
const hasDateFilter = startDate || endDate;
const orderItems = await this.prisma.orderItem.findMany({
where: hasDateFilter ? { order: { createdAt: dateFilter } } : undefined,
orderBy: { id: 'desc' },
include: {
order: {
include: {
customer: true,
salesAgent: true,
},
},
productVariant: { include: { product: true } },
},
});
const ledgerEntries = await this.prisma.ledgerEntry.findMany({
where: {
sourceType: { in: REPAYMENT_TYPES },
...(hasDateFilter ? { createdAt: dateFilter } : {}),
},
include: { customer: true },
});
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('گزارش', {
views: [{ rightToLeft: true }],
});
ws.columns = HEADERS.map((h) => ({ header: h, width: 18 }));
for (const item of orderItems) {
const order = item.order;
const customer = order.customer;
const jalali = toJalali(order.createdAt);
const jalaliStr = formatJalali(jalali, '/');
const hour = new Date(order.createdAt.getTime() + 3.5 * 60 * 60 * 1000)
.toISOString()
.slice(11, 16);
// computedWeightGrams is ONE unit's weight (OrdersService.createOrder
// multiplies it by quantity for the order total), as legacy's order_items.w
// is. Legacy's export divided it by qty again, so every multi-unit line
// under-reported its weight; not repeated here.
const perUnitWeight = Number(item.computedWeightGrams);
// Legacy's «درصد» is the final percent: percent_pay − discount.
const finalPercent = Number(item.wagePercentSnapshot.sub(item.discountPercentSnapshot));
for (let i = 0; i < item.quantity; i++) {
ws.addRow([
item.id,
item.productVariant?.product?.title ?? '',
item.productVariant?.product?.productCode ?? '',
item.productVariant?.title ?? '',
Number(item.rashWeightGramsSnapshot),
round4(perUnitWeight),
1,
finalPercent,
Number(item.flatRialCharge),
'',
order.orderNumber,
jalaliStr,
jalaliStr,
jalali.jy,
jalali.jm,
jalali.jd,
hour,
null,
null,
null,
null,
null,
customer.firstName ?? '',
customer.lastName ?? '',
customer.fullName,
order.salesAgent?.fullName ?? '',
customer.mobile,
customer.address ?? '',
customer.phone ?? '',
round4(perUnitWeight),
0,
Number(item.flatRialCharge),
]);
}
}
for (const entry of ledgerEntries) {
const customer = entry.customer;
const jalali = toJalali(entry.createdAt);
const jalaliStr = formatJalali(jalali, '/');
const hour = new Date(entry.createdAt.getTime() + 3.5 * 60 * 60 * 1000)
.toISOString()
.slice(11, 16);
const isCashOrCheque =
entry.sourceType === LedgerSourceType.REPAYMENT_CASH ||
entry.sourceType === LedgerSourceType.REPAYMENT_CHEQUE;
const rialAmount = isCashOrCheque ? Number(entry.priceRial ?? 0) : 0;
const gramAmount = !isCashOrCheque ? Number(entry.amount) : 0;
ws.addRow([
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
entry.orderId ?? '',
jalaliStr,
jalaliStr,
jalali.jy,
jalali.jm,
jalali.jd,
hour,
entry.id,
TYPE_LABELS[entry.sourceType] ?? '',
rialAmount,
gramAmount,
entry.confirmed ? 'تایید شده' : 'تایید نشده',
customer.firstName ?? '',
customer.lastName ?? '',
customer.fullName,
null,
customer.mobile,
customer.address ?? '',
customer.phone ?? '',
0,
gramAmount,
0,
]);
}
const buffer = await wb.xlsx.writeBuffer();
return new StreamableFile(Buffer.from(buffer), {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: 'attachment; filename="report.xlsx"',
});
}
}
function parseDate(v: string, field: string): Date {
const d = new Date(v);
if (Number.isNaN(d.getTime())) throw new BadRequestException(`${field}: تاریخ معتبر نیست`);
return d;
}
function round4(n: number): number {
return Math.round(n * 10000) / 10000;
}