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
@@ -14,7 +14,11 @@
<DateInput v-model="filters.start_date" label="از تاریخ" />
<DateInput v-model="filters.end_date" label="تا تاریخ" />
<a-form-item label=" ">
<a-button type="primary" @click="load">اعمال</a-button>
<a-space>
<a-button type="primary" @click="load">اعمال</a-button>
<!-- legacy's only output: «دریافت گزارش», the 32-column Excel file -->
<a-button :loading="downloading" @click="downloadExcel">دریافت فایل اکسل</a-button>
</a-space>
</a-form-item>
</div>
</a-form>
@@ -46,6 +50,7 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import DateInput from '../../components/input/DateInput.vue';
import { jdate } from '../../utils/filters';
import api from '../../api/client';
@@ -83,6 +88,27 @@ async function load() {
}
}
const downloading = ref(false);
/** Legacy getReport: the order items + financial documents of the range as Excel. */
async function downloadExcel() {
try {
downloading.value = true;
const { data } = await api.get('admin/report/order-items', { params: filters, responseType: 'blob' });
const url = URL.createObjectURL(data as Blob);
const a = document.createElement('a');
a.href = url;
a.download = `report-${filters.start_date ?? 'all'}-${filters.end_date ?? 'all'}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
console.log(e);
message.error('خطا در دریافت فایل');
} finally {
downloading.value = false;
}
}
onMounted(load);
</script>
+1
View File
@@ -27,6 +27,7 @@
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"exceljs": "^4.4.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
+2
View File
@@ -32,6 +32,7 @@ import { AdminInvoicesModule } from './modules/admin-invoices/admin-invoices.mod
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';
import { AdminReportModule } from './modules/admin-report/admin-report.module';
import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
@Module({
@@ -58,6 +59,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
AdminMiscModule,
AdminUploadsModule,
AdminDashboardModule,
AdminReportModule,
CatalogModule,
CustomersModule,
SmsModule,
@@ -3,6 +3,7 @@ import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@pris
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import { LedgerService } from '../accounting/ledger.service';
import { SmsService } from '../sms/sms.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
@@ -62,6 +63,7 @@ export class AdminDocumentsService {
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly ledger: LedgerService,
private readonly sms: SmsService,
) {}
private static readonly ALLOWED = [
@@ -313,6 +315,7 @@ export class AdminDocumentsService {
if (!e.confirmed) {
await this.ledger.confirmEntry(id, adminUserId);
this.notifyDocumentConfirm(e.customerId, true);
return this.findOne(id);
}
@@ -335,9 +338,24 @@ export class AdminDocumentsService {
});
});
this.notifyDocumentConfirm(e.customerId, false);
return this.findOne(id);
}
// Legacy CashController::toggleConfiremCash sent an SMS on both outcomes.
private notifyDocumentConfirm(customerId: string, confirmed: boolean) {
const text = confirmed
? 'هنزا گلد\nسند مالی شما توسط مدیر سیستم تایید شد.'
: 'هنزا گلد\nسند مالی شما توسط مدیر سیستم رد شد.';
this.prisma.customer
.findUnique({ where: { id: customerId }, select: { mobile: true } })
.then((c) => {
if (c) this.sms.send(c.mobile, text);
})
.catch(() => {});
}
/**
* Legacy `DELETE cash/{id}`.
*
@@ -27,7 +27,7 @@ describe('AdminMiscService — city tree', () => {
update,
},
};
service = new AdminMiscService(prisma as never, {} as never);
service = new AdminMiscService(prisma as never, {} as never, {} as never);
});
it('a city without its own percent takes the nearest ancestor’s (legacy _getP)', async () => {
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { MessageAudience, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import { SmsService } from '../sms/sms.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config';
import { PRICING_SETTING_PREFIX } from '../pricing/wage.service';
@@ -23,6 +24,7 @@ export class AdminMiscService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly sms: SmsService,
) {}
// ── Cities ─────────────────────────────────────────────────────────
@@ -245,12 +247,9 @@ export class AdminMiscService {
}
/**
* 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.
* Legacy `POST sms` — resolves the audience, substitutes `%name` per
* recipient (legacy SmsController::batch + replaceName), delivers via the
* configured gateway (D26 relay), and records the send.
*/
async sendSms(
data: { type?: string | number; mobile?: string; text?: string },
@@ -262,6 +261,18 @@ export class AdminMiscService {
const audience = this.resolveAudience(data.type);
const recipients = await this.resolveRecipients(audience, data.mobile);
const needsNameSub = body.includes('%name');
const customersByMobile = needsNameSub
? await this.customerNamesByMobile(recipients)
: new Map<string, string>();
for (const mobile of recipients) {
const text = needsNameSub
? body.replace(/%name/g, customersByMobile.get(mobile) ?? 'مشتری عزیز')
: body;
this.sms.send(mobile, text, { audience });
}
return this.prisma.smsMessage.create({
data: {
audience,
@@ -273,6 +284,18 @@ export class AdminMiscService {
});
}
private async customerNamesByMobile(mobiles: string[]): Promise<Map<string, string>> {
const rows = await this.prisma.customer.findMany({
where: { mobile: { in: mobiles } },
select: { mobile: true, fullName: true },
});
const map = new Map<string, string>();
for (const r of rows) {
if (r.fullName) map.set(r.mobile, r.fullName + ' عزیز');
}
return map;
}
/**
* Legacy's numeric audience codes, read off views/sms/create.vue where each
* option is gated by its own rid:
@@ -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;
}