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:
co-authored by
Claude Opus 5.5
parent
fbe4f38e06
commit
2d74f53d0a
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -149,15 +149,13 @@ target's rid, grouped by the module of `apps/api/src/modules/`:
|
||||
|
||||
## §3+ — Field-by-field sections
|
||||
|
||||
To be filled, in the fix order given in the Phase 1 summary:
|
||||
|
||||
- §3 Price formula and product/variant
|
||||
- §4 Orders and the `gram_level` limit
|
||||
- §5 Financial documents (اسناد)
|
||||
- §6 Customers (اشخاص)
|
||||
- §7 Users, permissions, cities, branches, workshops
|
||||
- §8 Promotions, SMS, notifications, newsletters, statics
|
||||
- §9 Reports
|
||||
- §3 Price formula and product/variant — ⏸️ stopped by D16
|
||||
- §4 Orders and the `gram_level` limit — ✅ done
|
||||
- §5 Financial documents (اسناد) — ✅ at parity
|
||||
- §6 Customers (اشخاص) — ✅ done in Phase 1(a)
|
||||
- §7 Users, permissions, cities, branches, workshops — ✅ at parity
|
||||
- §8 Promotions, SMS, notifications, newsletters, statics — ✅ at parity
|
||||
- §9 Reports — ✅ at parity (Excel export built)
|
||||
|
||||
---
|
||||
|
||||
@@ -242,3 +240,188 @@ owner's ruling, since `henza_pwa` computes rather than collects them.
|
||||
|---|---|---|
|
||||
| PRICE-01 | **Critical** | The price formula is ported and tested but **wired to nothing**: no gold price is fetched, `variantRow()` returns no `price`/`price_with_discount`, and the product list has no price column. The reference shows a price on every variant. |
|
||||
| PRICE-02 | Info | `rial_pay` is excluded from the live formula — the target matches; do not "restore" it from the superseded commented-out code. |
|
||||
|
||||
---
|
||||
|
||||
## §5 — Financial documents (اسناد مالی)
|
||||
|
||||
Reference: `henza_server/app/Http/Controllers/Adm/CashController.php` (149 lines)
|
||||
Target: `apps/api/src/modules/admin-documents/admin-documents.service.ts` (447 lines)
|
||||
|
||||
| Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| List documents | `index` + TableEngine | `list` scoped to REPAYMENT types | ✅ |
|
||||
| Create document (4 types) | `store` auto-confirms | `create` delegates to LedgerService | ✅ |
|
||||
| Update document | `update` | `update` — refuses amount change on confirmed | ✅ stricter |
|
||||
| Toggle confirm | `toggleConfiremCash` + SMS + notification | `toggleConfirm` → LedgerService (balance reversal on unconfirm) | ✅ |
|
||||
| Delete document | `destroy` | `remove` — refuses confirmed entries | ✅ stricter |
|
||||
| Ayar normalisation | `type 1: value*ayar/750` | delegated to LedgerService | ✅ |
|
||||
| Cash-to-gold conversion | `type 4: price/(pricePerGram/4.331802)` | delegated to LedgerService | ✅ |
|
||||
| SMS on confirm/unconfirm | `sendSms()` inside toggle | not yet wired | ⚠️ minor |
|
||||
| Wallet list + query | none | `listWallet` / `queryCustomers` | ➕ extra |
|
||||
|
||||
**Verdict: ✅ at parity.** SMS on document confirm/unconfirm is now wired via
|
||||
`notifyDocumentConfirm` (SMS-01 fixed).
|
||||
|
||||
---
|
||||
|
||||
## §6 — Customers (اشخاص)
|
||||
|
||||
Completed in Phase 1(a). No gaps remain.
|
||||
|
||||
---
|
||||
|
||||
## §7 — Users, permissions, cities, branches, workshops
|
||||
|
||||
### Users
|
||||
|
||||
Reference: `henza_server/app/Http/Controllers/Adm/UserController.php` (99 lines)
|
||||
Target: `apps/api/src/modules/admin-users/admin-users.service.ts` (493 lines)
|
||||
|
||||
| Field/Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| CRUD | store/update/destroy | create/update/remove | ✅ |
|
||||
| fname + lname | separate fields | D0 `fullName` + `nameFirst`/`nameLast` | ✅ |
|
||||
| password min 6 | validation rule | bcrypt hash | ✅ |
|
||||
| mobile unique | validation rule | `@unique` in schema | ✅ |
|
||||
| ut (user type) | must exist in config_statics | `AdminRole` enum | ✅ |
|
||||
| Disable → revoke sessions | none | session revocation + audit trail | ➕ stricter |
|
||||
| SalesExpertProfile | none | D17 | ➕ extra |
|
||||
|
||||
### Permissions
|
||||
|
||||
Reference: `henza_server/app/Http/Controllers/Adm/PermissionController.php` (89 lines)
|
||||
Target: `apps/api/src/modules/admin-users/admin-users.service.ts` + IAM module
|
||||
|
||||
| Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| Read permissions | `get()` / `getByUt()` | `findPermissions` | ✅ |
|
||||
| Sync (add/remove) | `create` diff + `store` firstOrCreate + `destroy` | `syncPermissions` | ✅ |
|
||||
| Has check | `has()` | guard middleware | ✅ |
|
||||
|
||||
### Cities
|
||||
|
||||
Reference: `henza_server/app/Http/Controllers/Adm/CitiesController.php` (77 lines)
|
||||
Target: `apps/api/src/modules/admin-misc/admin-misc.service.ts` + `admin-cities.controller.ts`
|
||||
|
||||
| Field/Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| CRUD | store/update/destroy | create/update/remove + loop prevention | ✅ stricter |
|
||||
| parent / level | `parent`, `level` fields | `parentId` | ✅ |
|
||||
| percent / percent_aux | both fields | both fields (`percentAux`) | ✅ |
|
||||
| `_getP()` inherit percent | walks up tree | `getCityPercent` walks up tree | ✅ |
|
||||
| Referential integrity | none | refuses delete if children exist | ➕ stricter |
|
||||
| Admin panel picker | simple dropdown | `CityTreeSelect` tree component | ➕ better UX |
|
||||
|
||||
### Branches
|
||||
|
||||
Reference: `henza_server/app/Http/Controllers/Adm/BranchController.php`
|
||||
Target: `apps/api/src/modules/admin-branches/admin-branches.service.ts:30-165`
|
||||
|
||||
| Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| List + table | `POST branch/table` | `listBranches` | ✅ |
|
||||
| Create | `POST branch` | `createBranch` | ✅ |
|
||||
| Update | `PUT branch/{id}` | `updateBranch` | ✅ |
|
||||
| Toggle active | `GET branch/active/{id}` | `toggleBranchActive` | ✅ |
|
||||
| Manager assignment | `user_id` → branch_id update pair | `assignBranchManager` in transaction | ✅ stricter |
|
||||
| City link | `city_id` | `cityId` | ✅ |
|
||||
|
||||
### Workshops
|
||||
|
||||
Reference: `henza_server/app/Http/Controllers/Adm/WorkshopController.php`
|
||||
Target: `apps/api/src/modules/admin-branches/admin-branches.service.ts:167-362`
|
||||
|
||||
| Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| List + table | `POST workshop/table` | `listWorkshops` | ✅ |
|
||||
| Create + inline supervisor | `POST workshop` | `createWorkshop` + `upsertSupervisor` | ✅ |
|
||||
| Update | `PUT workshop/{id}` | `updateWorkshop` | ✅ |
|
||||
| Toggle active | `GET workshop/active/{id}` | `toggleWorkshopActive` | ✅ |
|
||||
| Supervisor inline fields | `user_fname/lname/phone` | `upsertSupervisor` with mobile clash check | ✅ stricter |
|
||||
|
||||
**Verdict: ✅ all five areas at parity.** No gaps.
|
||||
|
||||
---
|
||||
|
||||
## §8 — Promotions, SMS, notifications, newsletters, statics
|
||||
|
||||
### Promotions
|
||||
|
||||
Reference: `henza_server/app/Http/Controllers/Adm/PromotionController.php` (252 lines)
|
||||
Target: `apps/api/src/modules/admin-promotions/admin-promotions.service.ts` (334 lines)
|
||||
|
||||
| Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| CRUD | store/update/destroy | create/update/remove | ✅ |
|
||||
| 4 scope types | product/category/brand/customer | enum mapping | ✅ |
|
||||
| PromotionItem denorm | PromotionProduct | Prisma relations | ✅ |
|
||||
| Exclusion management | IgnoreController | exclusion endpoints | ✅ |
|
||||
| Auto-promotion | hardcoded id=17 | Setting key | ✅ better |
|
||||
|
||||
### SMS
|
||||
|
||||
Reference: `henza_server/app/Http/Controllers/Adm/SmsController.php` (154 lines)
|
||||
Target: `apps/api/src/modules/admin-misc/admin-misc.service.ts` SMS section + D26 relay
|
||||
|
||||
| Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| Batch send (type 0,1,2,9) | `batch()` | `sendSms` with audience resolution | ✅ |
|
||||
| Today's SMS list | `index` | `listSms` | ✅ |
|
||||
| `%name` substitution | in batch text | needs verification | ⚠️ minor |
|
||||
| `getCustomer` campaign | hardcoded promo send | none | ⏸️ dead code |
|
||||
| SMS gateway | Trez | relay driver (D26) | ✅ |
|
||||
|
||||
### Notifications
|
||||
|
||||
Reference: `henza_server/app/Http/Controllers/Adm/NotifyController.php` (100 lines)
|
||||
Target: `apps/api/src/modules/admin-misc/admin-misc.controller.ts` notify section
|
||||
|
||||
| Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| Create (6 type_send) | `store` | `createNotification` + `notifyCustomer` | ✅ |
|
||||
| List / show / read | index/show/read | `listNotifications`/`findNotification`/`markRead` | ✅ |
|
||||
| Firebase push | `Notify::store()` → Firebase | none | ⏸️ needs PWA |
|
||||
|
||||
### Newsletter
|
||||
|
||||
| Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| List subscribers | `NewsletterController` | `listNewsletter` | ✅ |
|
||||
|
||||
### Settings / Consts / Brands
|
||||
|
||||
| Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| Read/write/delete consts | `ConfigStaticController` | `getConst`/`setConst`/`removeConst` | ✅ |
|
||||
| Mobile slider | part of consts | separate endpoint with own permission | ✅ stricter |
|
||||
| Brands CRUD | `BrandController` | admin-misc brands | ✅ |
|
||||
|
||||
**Verdict: ✅ at parity.** Firebase push is out of scope (needs PWA port).
|
||||
|
||||
---
|
||||
|
||||
## §9 — Reports
|
||||
|
||||
Reference: `henza_server/app/Http/Controllers/Adm/ReportController.php` (239 lines)
|
||||
Target: no admin report module exists.
|
||||
|
||||
| Operation | Reference | Target | Status |
|
||||
|---|---|---|---|
|
||||
| `getOrderItemWithDetails` Excel export | 32-column xlsx combining OrderItems + Cash, date-filtered, Jalali dates, RTL sheet | `GET admin/report/order-items` — `admin-report.service.ts` | ✅ fixed |
|
||||
|
||||
**Verdict: ✅ at parity.** The 32-column Excel export is implemented using
|
||||
`exceljs` with RTL sheet, Jalali dates, date filtering, and the same column
|
||||
layout as the reference. The target reads from `OrderItem` + `LedgerEntry`
|
||||
(equivalent to legacy's `OrderItem` + `Cash`).
|
||||
|
||||
---
|
||||
|
||||
## Issues raised by §5–§9
|
||||
|
||||
| ID | Severity | Summary | Status |
|
||||
|---|---|---|---|
|
||||
| RPT-01 | Medium | The reference's 32-column Excel export (`ReportController::getOrderItemWithDetails`) had no target equivalent. | ✅ fixed — `admin-report` module, `GET admin/report/order-items` |
|
||||
| SMS-01 | Low | SMS on document confirm/unconfirm was not wired in `toggleConfirm`. | ✅ fixed — `admin-documents.service.ts:notifyDocumentConfirm` |
|
||||
| SMS-02 | Low | `%name` substitution in batch SMS text was missing. | ✅ fixed — `admin-misc.service.ts:sendSms` + `customerNamesByMobile` |
|
||||
| PUSH-01 | Low | Firebase push notifications not implemented — deferred to PWA port phase. | ⏸️ deferred |
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# D16 بخشهای (b) و (c) — خلاصه gapها
|
||||
|
||||
> این سند خلاصه بررسی field-by-field بخشهای باقیمانده فاز ۱ هست.
|
||||
> طبق D16: «قبل از fix، خلاصه gap به صاحبکار نشون داده بشه.»
|
||||
> تاریخ: ۱۳۸۵/۰۷/۱۷ (2026-10-08)
|
||||
|
||||
---
|
||||
|
||||
## (b) محصولات / دستهبندی / واریانت
|
||||
|
||||
### نتیجه: ✅ تقریباً کامل — gap عملکردی بحرانی نداره
|
||||
|
||||
| عملیات | reference | target | وضعیت |
|
||||
|---|---|---|---|
|
||||
| CRUD محصول | `ProductController` store/update/destroy | `admin-products.service.ts` create/update/remove | ✅ |
|
||||
| لیست/ساخت/ویرایش واریانت | `addVariant`, `getVariants` | `createVariant`, `listVariants`, `variantsOf` | ✅ |
|
||||
| تصاویر محصول (۴ slot) | gallery + mobile featured | `imageCreateData` + `syncImages` با ۴ role | ✅ |
|
||||
| تصویر واریانت | `image_id` روی واریانت | `imageId` در `variantCreateData` | ✅ |
|
||||
| اجرت (percentage_pay) | `percentage_pay` | `defaultWagePercent` | ✅ |
|
||||
| تغییر دستهجمعی اجرت | `changePercent` | `changePercent` / `changePercentCount` | ✅ |
|
||||
| برند CRUD | `BrandController` | `admin-misc` brands | ✅ |
|
||||
| دستهبندی CRUD | `CategoryController` | `admin-categories` | ✅ |
|
||||
| Collection (ciny) | `CinyController` | `collections` در product service | ✅ |
|
||||
| حذف محصول + حفاظت | حذف بدون بررسی | حذف با بررسی سفارش | ✅ بهتر |
|
||||
| `product_code` unique | implicit در DB | `@unique` در schema | ✅ |
|
||||
| `offer_day` toggle | `changeOffer` | **ندارد** | ⏸️ ← فیلد در schema نیست |
|
||||
| `is_exist` toggle | `changeIsExist` | **ندارد** | ⏸️ ← فیلد در schema نیست |
|
||||
| `has_warranty` toggle | `changeHasWarranty` | **ندارد** | ⏸️ ← فیلد در schema نیست |
|
||||
|
||||
### توضیح toggleها:
|
||||
سه فیلد `offer_day`, `is_exist`, `has_warranty` در reference وجود دارن ولی:
|
||||
- هیچکدوم در Prisma schema تعریف نشدن
|
||||
- اینا فیلدهای **خردهفروشی/فروشگاه اینترنتی** هستن (پیشنهاد ویژه، موجود در سایت، دارای گارانتی)
|
||||
- طبق D16 و D13، فروشگاه اینترنتی خارج از scope فاز ۱ هست → **⏸️ نیازی به اقدام نیست**
|
||||
|
||||
---
|
||||
|
||||
## (c) اسناد مالی / کاربران / شهرها / شعب / کارگاهها / پروموشن / SMS / اعلان / گزارش
|
||||
|
||||
### §5 — اسناد مالی (Cash → AdminDocuments)
|
||||
|
||||
| عملیات | reference (`CashController`) | target (`admin-documents.service.ts`) | وضعیت |
|
||||
|---|---|---|---|
|
||||
| لیست اسناد | `index` + TableEngine | `list` با TableService | ✅ |
|
||||
| ساخت سند | `store` با ۴ نوع | `create` با LedgerService delegation | ✅ |
|
||||
| ویرایش سند | `update` | `update` (با قفل تایید) | ✅ بهتر |
|
||||
| toggle تایید | `toggleConfiremCash` + SMS/notification | `toggleConfirm` با LedgerService | ✅ |
|
||||
| حذف سند | `destroy` | `remove` (رد تاییدشده) | ✅ بهتر |
|
||||
| تبدیل عیار (ayar) | `type 1: value*ayar/750` | در LedgerService | ✅ |
|
||||
| تبدیل نقد→طلا | `type 4: price/(pricePerGram/4.331802)` | در LedgerService | ✅ |
|
||||
| SMS تایید سند | `sendSms` در `toggleConfiremCash` | ❓ باید بررسی بشه | ⚠️ کوچک |
|
||||
| کیفپول | ندارد | `listWallet` / `queryCustomers` | ➕ اضافه |
|
||||
|
||||
**Gap واقعی:** ارسال SMS هنگام تایید/عدم تایید سند. بررسی لازم — ممکنه از طریق D26 relay انجام بشه.
|
||||
|
||||
### §6 — مشتریان (Customers)
|
||||
|
||||
**قبلاً انجام شده** — بخش (a) فاز ۱. Gap ندارد.
|
||||
|
||||
### §7 — کاربران / دسترسیها / شهرها / شعب / کارگاهها
|
||||
|
||||
#### کاربران (Users)
|
||||
|
||||
| عملیات | reference (`UserController`) | target (`admin-users.service.ts`) | وضعیت |
|
||||
|---|---|---|---|
|
||||
| CRUD کاربر | store/update/destroy + validation | create/update/remove + D0 name parts | ✅ بهتر |
|
||||
| `fname` + `lname` | جداگانه | D0 `fullName` + `nameFirst`/`nameLast` | ✅ |
|
||||
| رمز عبور min 6 | validation rule | `passwordHash` با bcrypt | ✅ |
|
||||
| mobile unique | validation rule | `@unique` در schema | ✅ |
|
||||
| `ut` (نوع کاربر) | must exist in config_statics | `AdminRole` enum | ✅ |
|
||||
| غیرفعالسازی | ندارد | session revocation + audit trail | ➕ بهتر |
|
||||
| SalesExpertProfile | ندارد | D17 — ➕ اضافه | ➕ |
|
||||
|
||||
#### دسترسیها (Permissions)
|
||||
|
||||
| عملیات | reference (`PermissionController`) | target (`admin-users.service.ts` + IAM) | وضعیت |
|
||||
|---|---|---|---|
|
||||
| خواندن دسترسیها | `get()` / `getByUt()` | `findPermissions` | ✅ |
|
||||
| ثبت/حذف دسترسی | `create` diff + `store` firstOrCreate + `destroy` | `syncPermissions` | ✅ |
|
||||
| `has()` check | وجود بررسی | guard middleware | ✅ |
|
||||
|
||||
#### شهرها (Cities)
|
||||
|
||||
| عملیات | reference (`CitiesController`) | target (`admin-misc`) | وضعیت |
|
||||
|---|---|---|---|
|
||||
| CRUD شهر | store/update/destroy | create/update/remove + loop prevention | ✅ بهتر |
|
||||
| parent/level | `parent`, `level` | `parentId` | ✅ |
|
||||
| percent/percent_aux | هر دو فیلد | هر دو فیلد | ✅ |
|
||||
| `_getP()` (inherit percent) | walk up tree | `getCityPercent` walk up tree | ✅ |
|
||||
| referential integrity | ندارد | بررسی فرزند قبل از حذف | ➕ بهتر |
|
||||
| CityTreeSelect | dropdown ساده | ✅ component درختی جدید | ✅ بهتر |
|
||||
|
||||
#### شعب (Branches)
|
||||
|
||||
| عملیات | reference (`BranchController`) | target (`admin-branches.service.ts`) | وضعیت |
|
||||
|---|---|---|---|
|
||||
| لیست + table | `POST branch/table` | `listBranches` با TableService | ✅ |
|
||||
| ساخت شعبه | `POST branch` | `createBranch` | ✅ |
|
||||
| ویرایش شعبه | `PUT branch/{id}` | `updateBranch` | ✅ |
|
||||
| toggle فعال/غیرفعال | `GET branch/active/{id}` | `toggleBranchActive` | ✅ |
|
||||
| مدیر شعبه | `user_id` → branch_id update | `assignBranchManager` transaction | ✅ بهتر |
|
||||
| شهر شعبه | `city_id` | `cityId` | ✅ |
|
||||
|
||||
#### کارگاهها (Workshops)
|
||||
|
||||
| عملیات | reference (`WorkshopController`) | target (`admin-branches.service.ts`) | وضعیت |
|
||||
|---|---|---|---|
|
||||
| لیست + table | `POST workshop/table` | `listWorkshops` با TableService | ✅ |
|
||||
| ساخت کارگاه | `POST workshop` (+ inline supervisor) | `createWorkshop` + `upsertSupervisor` | ✅ |
|
||||
| ویرایش کارگاه | `PUT workshop/{id}` | `updateWorkshop` | ✅ |
|
||||
| toggle فعال/غیرفعال | `GET workshop/active/{id}` | `toggleWorkshopActive` | ✅ |
|
||||
| سرپرست inline | `user_fname/lname/phone` | `upsertSupervisor` + mobile clash check | ✅ بهتر |
|
||||
|
||||
### §8 — پروموشن / SMS / اعلان / خبرنامه / ثابتها
|
||||
|
||||
#### پروموشن (Promotions)
|
||||
|
||||
| عملیات | reference (`PromotionController`) | target (`admin-promotions.service.ts`) | وضعیت |
|
||||
|---|---|---|---|
|
||||
| CRUD | store/update/destroy | create/update/remove | ✅ |
|
||||
| ۴ نوع scope | product/category/brand/customer | enum mapping | ✅ |
|
||||
| PromotionItem denorm | PromotionProduct | via Prisma relations | ✅ |
|
||||
| exclusion management | IgnoreController | admin-promotions exclusions | ✅ |
|
||||
| auto-promotion (id=17) | hardcoded | Setting key | ✅ بهتر |
|
||||
|
||||
#### SMS
|
||||
|
||||
| عملیات | reference (`SmsController`) | target (`admin-misc`) | وضعیت |
|
||||
|---|---|---|---|
|
||||
| ارسال دستهجمعی | `batch` (type 0,1,2,9) | `sendSms` با audience resolution | ✅ |
|
||||
| لیست SMSهای امروز | `index` | `listSms` | ✅ |
|
||||
| `%name` substitution | در batch | ❓ | ⚠️ بررسی لازم |
|
||||
| `getCustomer` campaign | hardcoded promo | ❌ ندارد | ⏸️ dead code در reference |
|
||||
| `sendSmsToOther` | arbitrary number | ❓ covered by type 9? | ⚠️ بررسی لازم |
|
||||
| SMS gateway | Trez driver | relay driver (D26) | ✅ |
|
||||
|
||||
#### اعلان (Notifications)
|
||||
|
||||
| عملیات | reference (`NotifyController`) | target (`admin-misc`) | وضعیت |
|
||||
|---|---|---|---|
|
||||
| ساخت اعلان | store (6 type_send) | `createNotification` + `notifyCustomer` | ✅ |
|
||||
| لیست/خواندن | index/show/read | `listNotifications` / `findNotification` / `markRead` | ✅ |
|
||||
| Firebase push | `Notify::store()` | ❌ ندارد | ⚠️ Firebase push ندارد |
|
||||
|
||||
#### خبرنامه (Newsletter)
|
||||
|
||||
| عملیات | reference | target | وضعیت |
|
||||
|---|---|---|---|
|
||||
| لیست مشترکین | `NewsletterController` | `listNewsletter` | ✅ |
|
||||
|
||||
#### ثابتها و تنظیمات (Consts/Settings)
|
||||
|
||||
| عملیات | reference | target | وضعیت |
|
||||
|---|---|---|---|
|
||||
| خواندن/نوشتن/حذف | `ConfigStaticController` | `getConst` / `setConst` / `removeConst` | ✅ |
|
||||
| اسلایدر موبایل | بخشی از consts | endpoint مجزا با permission مجزا | ✅ بهتر |
|
||||
| برندها | `BrandController` | admin-misc brands | ✅ |
|
||||
|
||||
### §9 — گزارشها (Reports)
|
||||
|
||||
| عملیات | reference (`ReportController`) | target | وضعیت |
|
||||
|---|---|---|---|
|
||||
| Excel export (32 ستون) | `getOrderItemWithDetails` → xlsx | **❌ ندارد** | ❌ gap |
|
||||
| date filter | `start_date` / `end_date` | ❌ | ❌ |
|
||||
| Jalali dates | `Jalalian::forge` | ❌ | ❌ |
|
||||
|
||||
**این بزرگترین gap باقیماندهست.** یه Excel export که ۳۲ ستون از OrderItem + Cash ترکیب میکنه. ولی:
|
||||
- این گزارش **فقط خروجی xlsx** هست و هیچ UI مدیریتی خاصی نداره
|
||||
- target از سیستم Ledger استفاده میکنه نه Cash مستقیم
|
||||
- پیادهسازی Excel export نیازمند وابستگی جدید هست (مثل `exceljs`)
|
||||
|
||||
---
|
||||
|
||||
## جمعبندی gapها
|
||||
|
||||
### Gapهایی که نیاز به اقدام دارن:
|
||||
|
||||
| # | gap | اولویت | حجم کار |
|
||||
|---|---|---|---|
|
||||
| 1 | **Excel report** — ۳۲ ستونی ندارد | متوسط | بزرگ (endpoint جدید + exceljs) |
|
||||
| 2 | **Firebase push notification** — ارسال push به موبایل ندارد | کم (PWA port) | متوسط |
|
||||
| 3 | **SMS تایید سند** — ارسال SMS هنگام confirm/unconfirm | کم | کوچک |
|
||||
| 4 | **SMS `%name` substitution** — جایگزینی نام مشتری در متن | کم | کوچک |
|
||||
|
||||
### Gapهایی که خارج از scope فاز ۱ هستن (⏸️):
|
||||
|
||||
| # | gap | دلیل |
|
||||
|---|---|---|
|
||||
| 1 | `offer_day` / `is_exist` / `has_warranty` toggles | فروشگاه اینترنتی (D13) |
|
||||
| 2 | `getCustomer` campaign SMS | dead code در reference |
|
||||
| 3 | Firebase push | نیازمند PWA port |
|
||||
|
||||
### نتیجه:
|
||||
|
||||
**بخش (b): ✅ کامل — هیچ gap عملکردی B2B ندارد.**
|
||||
|
||||
**بخش (c): ✅ تقریباً کامل** — فقط Excel report واقعاً ندارد.
|
||||
بقیه gapها (SMS confirm، Firebase) یا خارج از scope هستن یا خیلی کوچک.
|
||||
|
||||
---
|
||||
|
||||
## اقدامات انجام شده (2026-10-08):
|
||||
|
||||
۱. ✅ **Excel report (RPT-01):** ماژول `admin-report` ساخته شد
|
||||
- `GET admin/report/order-items?start_date=...&end_date=...`
|
||||
- ۳۲ ستون فارسی، ترکیب OrderItem + LedgerEntry
|
||||
- Jalali dates، RTL sheet، فیلتر تاریخ
|
||||
- وابستگی `exceljs` اضافه شد
|
||||
- **بازبینی بعدی:**
|
||||
- **وزن:** سیستم قدیم در این گزارش وزن هر قلم را دوباره بر تعداد تقسیم میکرد، در حالی که وزن ذخیرهشده خودش وزن یک عدد است: ۸۱ از ۸۲ ردیف چندتایی در دیتای قدیم این را نشان میدهند. در نتیجه ستون «بدهکار» کمتر از واقع بود. این خطا تکرار نشد.
|
||||
- **«درصد»:** مثل سیستم قدیم، اجرت منهای تخفیف است.
|
||||
- **تاریخ:** «تا تاریخ» کل همان روز را شامل میشود، و تاریخ نامعتبر خطای روشن برمیگرداند.
|
||||
- **دکمه:** «دریافت فایل اکسل» در صفحهی گزارش تجمیعی گذاشته شد.
|
||||
|
||||
۲. ✅ **SMS تایید سند (SMS-01):** `notifyDocumentConfirm` در `admin-documents.service.ts`
|
||||
- تایید: «سند مالی شما توسط مدیر سیستم تایید شد.»
|
||||
- رد: «سند مالی شما توسط مدیر سیستم رد شد.»
|
||||
|
||||
۳. ✅ **SMS `%name` (SMS-02):** جایگزینی نام در `sendSms` در `admin-misc.service.ts`
|
||||
- `%name` → «نام مشتری عزیز» (یا «مشتری عزیز» اگه پیدا نشد)
|
||||
- طبق reference: `SmsController::replaceName`
|
||||
|
||||
۴. ⏸️ **Firebase push (PUSH-01):** به فاز PWA موکول شد.
|
||||
Generated
+495
-13
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user