feat(tahesab): polish asnad invoice PDF and prior balance

Compute حساب قبل from asnad before this doc date/number, add in-app preview layout, and leave a blank mesghal line for handwriting after print.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-24 21:47:46 +03:30
co-authored by Cursor
parent 1ba352f127
commit 2d690b4c04
6 changed files with 1364 additions and 143 deletions
+529 -99
View File
@@ -1,5 +1,6 @@
import type { TahesabInvoiceSettingsDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import { formatTahesabBedBes } from '@/lib/tahesab';
export type TahesabInvoiceGroup = {
no: string;
@@ -15,15 +16,15 @@ function escapeHtml(value: string) {
.replace(/"/g, '&quot;');
}
function dash(v: unknown, fallback = '—'): string {
export function dashInvoice(v: unknown, fallback = '—'): string {
if (v == null) return fallback;
const s = String(v).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return fallback;
return s;
}
function fmtNum(v: unknown): string {
const s = dash(v, '');
export function fmtInvoiceNum(v: unknown): string {
const s = dashInvoice(v, '');
if (!s) return '—';
const n = Number(String(s).replace(/,/g, ''));
if (!Number.isFinite(n)) return toFaDigits(s);
@@ -32,58 +33,285 @@ function fmtNum(v: unknown): string {
);
}
function metalLabel(v: unknown): string {
const s = dash(v, '');
if (s === '0') return 'طلا';
if (s === '1') return 'پلاتین';
if (s === '2') return 'نقره';
return s || '—';
function invoicePenCount(r: Record<string, unknown>): number {
const s = dashInvoice(r.Count, '');
if (!s) return 1;
const n = Number(String(s).replace(/,/g, ''));
if (!Number.isFinite(n) || n <= 0) return 1;
return Math.round(n);
}
function rowDesc(r: Record<string, unknown>): string {
const parts = [dash(r.NO, ''), dash(r.No_Kar, ''), dash(r.Sharh1, '')].filter(
(p) => p && p !== '—',
);
return parts.join(' · ') || '—';
export function rowInvoiceDesc(r: Record<string, unknown>): string {
const parts = [
dashInvoice(r.NO, ''),
dashInvoice(r.No_Kar, ''),
dashInvoice(r.Sharh1, ''),
].filter((p) => p && p !== '—');
const base = parts.join(' · ') || '—';
const pens = invoicePenCount(r);
return `${base} (${toFaDigits(String(pens))} قلم)`;
}
function sumMali(rows: Record<string, unknown>[]): number {
/** اولین مقدار غیرخالی از کلیدها (رشتهٔ "null" ته‌حساب نادیده گرفته می‌شود؛ صفر معتبر است) */
export function pickInvoiceField(
r: Record<string, unknown>,
...keys: string[]
): unknown {
for (const k of keys) {
const raw = r[k];
if (raw == null) continue;
const s = String(raw).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') continue;
return raw;
}
return null;
}
/** درصد اجرت/سود ردیف سند */
export function pickInvoiceDarsad(r: Record<string, unknown>): unknown {
return pickInvoiceField(r, 'DarsadSood', 'DarsadVazni');
}
export function pickInvoiceMazaneh(r: Record<string, unknown>): unknown {
return pickInvoiceField(r, 'Mazaneh', 'Fi');
}
export function sumInvoiceField(
rows: Record<string, unknown>[],
key: string,
): number {
let t = 0;
for (const r of rows) {
const n = Number(String(r.Mali ?? '').replace(/,/g, ''));
const raw = pickInvoiceField(r, key);
if (raw == null) continue;
const n = Number(String(raw).replace(/,/g, ''));
if (Number.isFinite(n)) t += n;
}
return t;
}
export function sumInvoiceMali(rows: Record<string, unknown>[]): number {
return sumInvoiceField(rows, 'Mali');
}
/** 0=طلا، 2=نقره، null=نامشخص */
export function invoiceRowMetal(
r: Record<string, unknown>,
): 'gold' | 'silver' | null {
const jRaw = r.Jens_Felez;
const j =
jRaw == null || String(jRaw).trim() === '' || String(jRaw) === 'null'
? NaN
: Number(jRaw);
if (j === 0) return 'gold';
if (j === 2) return 'silver';
const no = dashInvoice(r.NO, '');
if (no.includes('نقره')) return 'silver';
if (no.includes('طلا')) return 'gold';
const ayar = Number(dashInvoice(r.Ayar, ''));
if (Number.isFinite(ayar)) {
if (ayar >= 900) return 'silver';
if (ayar >= 700 && ayar < 900) return 'gold';
}
return null;
}
export function invoiceMetalCounts(rows: Record<string, unknown>[]): {
gold: number;
silver: number;
} {
let gold = 0;
let silver = 0;
for (const r of rows) {
const m = invoiceRowMetal(r);
if (m === 'gold') gold += 1;
else if (m === 'silver') silver += 1;
}
return { gold, silver };
}
/**
* عنوان ستون وزن تبدیلی — در ته‌حساب معمولاً نقره به ۹۲۵ و طلا به ۷۵۰ تبدیل می‌شود.
* سند مخلوط → «وزن تبدیلی».
*/
export function tabdilVaznColumnLabel(
rows: Record<string, unknown>[],
): string {
const { gold, silver } = invoiceMetalCounts(rows);
if (gold > 0 && silver > 0) return 'وزن تبدیلی';
if (silver > 0) return 'وزن ۹۲۵';
if (gold > 0) return 'وزن ۷۵۰';
return 'وزن تبدیلی';
}
/** جمع یک فیلد وزنی به تفکیک فلز — طلا و نقره با هم جمع نمی‌شوند */
export function sumWeightByMetal(
rows: Record<string, unknown>[],
key: 'Vazn' | 'TabdilVazn',
): { gold: number; silver: number; mixed: boolean } {
let gold = 0;
let silver = 0;
for (const r of rows) {
const m = invoiceRowMetal(r);
const n = numOrZero(r[key]);
if (m === 'gold') gold += n;
else if (m === 'silver') silver += n;
}
const hasGold = gold !== 0;
const hasSilver = silver !== 0;
return { gold, silver, mixed: hasGold && hasSilver };
}
/** نمایش جمع وزنی؛ در سند مخلوط طلا و نقره جدا (با بد/بس برای وزن تبدیلی) */
export function formatInvoiceWeightBalance(
rows: Record<string, unknown>[],
key: 'Vazn' | 'TabdilVazn' = 'TabdilVazn',
signed = true,
): string {
const { gold, silver, mixed } = sumWeightByMetal(rows, key);
const fmt = (n: number) =>
signed ? formatTahesabBedBes(n) : fmtInvoiceNum(n);
if (mixed) {
const parts: string[] = [];
if (gold !== 0) parts.push(`طلا ${fmt(gold)}`);
if (silver !== 0) parts.push(`نقره ${fmt(silver)}`);
return parts.join(' · ') || '—';
}
if (gold !== 0) return fmt(gold);
if (silver !== 0) return fmt(silver);
return fmt(sumInvoiceField(rows, key));
}
function numOrZero(v: unknown): number {
const s = dashInvoice(v, '');
if (!s) return 0;
const n = Number(String(s).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
}
/**
* فقط برای خروجی فاکتور PDF/پیش‌نمایش: ردیف‌های هم‌نام (همان شرح کار)
* با عیار و مظنه/فی و درصد یکسان را در یک ردیف جمع می‌زند.
* کارت اسناد دست‌نخورده می‌ماند.
*/
export function aggregateSameNameInvoiceRows(
rows: Record<string, unknown>[],
): Record<string, unknown>[] {
const map = new Map<string, Record<string, unknown>>();
const order: string[] = [];
for (const r of rows) {
const name = dashInvoice(r.No_Kar, '') || dashInvoice(r.NO, '') || '—';
const key = [
dashInvoice(r.NO, ''),
name,
dashInvoice(r.Ayar, ''),
dashInvoice(pickInvoiceMazaneh(r), ''),
dashInvoice(pickInvoiceDarsad(r), ''),
].join('|');
const existing = map.get(key);
if (!existing) {
map.set(key, {
...r,
No_Kar: name === '—' ? r.No_Kar : name,
Count: numOrZero(r.Count) || 1,
Vazn: numOrZero(r.Vazn),
TabdilVazn: numOrZero(r.TabdilVazn),
Mali: numOrZero(r.Mali),
});
order.push(key);
continue;
}
existing.Count = numOrZero(existing.Count) + (numOrZero(r.Count) || 1);
existing.Vazn = numOrZero(existing.Vazn) + numOrZero(r.Vazn);
existing.TabdilVazn =
numOrZero(existing.TabdilVazn) + numOrZero(r.TabdilVazn);
existing.Mali = numOrZero(existing.Mali) + numOrZero(r.Mali);
}
return order.map((k, i) => {
const row = map.get(k)!;
return { ...row, Radif: i + 1 };
});
}
const dash = dashInvoice;
const fmtNum = fmtInvoiceNum;
const rowDesc = rowInvoiceDesc;
const sumMali = sumInvoiceMali;
/** خروجی فاکتور سند ته‌حساب — چاپ / ذخیره PDF از دیالوگ مرورگر */
export function printTahesabInvoicePdf(opts: {
group: TahesabInvoiceGroup;
settings: TahesabInvoiceSettingsDto | null;
customerName: string;
customerPhone?: string | null;
moshtariCode?: number | null;
priorWeight?: number | null;
priorRial?: number | null;
finalWeight?: number | null;
finalRial?: number | null;
/** پنجره‌ای که هم‌زمان با کلیک کاربر باز شده (برای دور زدن بلاک پاپ‌آپ) */
targetWindow?: Window | null;
}): boolean {
const { group, settings, customerName, moshtariCode, targetWindow } = opts;
const {
group,
settings,
customerName,
customerPhone,
moshtariCode,
priorWeight,
priorRial,
finalWeight,
finalRial,
targetWindow,
} = opts;
const first = group.rows[0] ?? {};
const time = dash(first.Tarikh_Time ?? first.ZamanSabt, '');
const user = dash(first.User, '');
const temporary = first.isSanadMovaghat === true;
const total = sumMali(group.rows);
const invoiceRows = aggregateSameNameInvoiceRows(group.rows);
const total = sumMali(invoiceRows);
const totalVaznLabel = formatInvoiceWeightBalance(group.rows, 'Vazn', false);
const totalTabdilLabel = formatInvoiceWeightBalance(
group.rows,
'TabdilVazn',
true,
);
const tabdilLabel = tabdilVaznColumnLabel(group.rows);
const generatedAt = formatJalaliDateTime(new Date());
const phoneFa = customerPhone?.trim()
? toFaDigits(customerPhone.trim())
: '—';
const codeFa =
moshtariCode != null ? toFaDigits(String(moshtariCode)) : '—';
// ته‌حساب قیمت مثقال نقرهٔ خامِ لحظهٔ ثبت را در سند برنمی‌گرداند؛ خط خالی برای نوشتن دستی
const mesghalHtml = `<div class="mesghal-box"><span class="ib-k">قیمت یک مثقال نقره خام:</span><span class="mesghal-blank"></span></div>`;
const accountHtml = `<div class="account-box">
<div class="acc-line">
<span class="acc-title">حساب قبل:</span>
<span class="acc-pair"><span class="ib-k">وزنی</span><strong dir="ltr">${formatTahesabBedBes(priorWeight)}</strong></span>
<span class="acc-pair"><span class="ib-k">ریالی</span><strong dir="ltr">${formatTahesabBedBes(priorRial)}</strong></span>
</div>
<div class="acc-line">
<span class="acc-title">مانده نهایی:</span>
<span class="acc-pair"><span class="ib-k">وزنی</span><strong dir="ltr">${formatTahesabBedBes(finalWeight)}</strong></span>
<span class="acc-pair"><span class="ib-k">ریالی</span><strong dir="ltr">${formatTahesabBedBes(finalRial)}</strong></span>
</div>
</div>`;
const rowsHtml = group.rows
const rowsHtml = invoiceRows
.map((r, i) => {
const idx = dash(r.Radif, String(i + 1));
return `<tr>
<td>${toFaDigits(idx)}</td>
<td>${escapeHtml(rowDesc(r))}</td>
<td>${escapeHtml(metalLabel(r.Jens_Felez))}</td>
<td class="ltr">${fmtNum(r.Count)}</td>
<td class="col-radif">${toFaDigits(idx)}</td>
<td class="col-desc">${escapeHtml(rowDesc(r))}</td>
<td class="ltr col-darsad">${fmtNum(pickInvoiceDarsad(r))}</td>
<td class="ltr">${fmtNum(r.Ayar)}</td>
<td class="ltr">${fmtNum(r.Vazn)}</td>
<td class="ltr">${fmtNum(r.Mazaneh ?? r.Fi)}</td>
<td class="ltr">${fmtNum(r.Mali)}</td>
<td>${escapeHtml(dash(r.Arz, 'IRR'))}</td>
<td class="ltr col-tabdil">${formatTahesabBedBes(r.TabdilVazn)}</td>
<td class="ltr">${fmtNum(pickInvoiceMazaneh(r))}</td>
<td class="ltr col-mali">${formatTahesabBedBes(r.Mali)}</td>
</tr>`;
})
.join('');
@@ -93,20 +321,33 @@ export function printTahesabInvoicePdf(opts: {
: '';
const company = escapeHtml(settings?.companyName?.trim() || 'فاکتور ته‌حساب');
const subtitle = settings?.companySubtitle?.trim()
? `<div class="sub">${escapeHtml(settings.companySubtitle.trim())}</div>`
: '';
const address = settings?.address?.trim()
? `<div class="muted">${escapeHtml(settings.address.trim())}</div>`
? `<div class="sub" dir="ltr">${escapeHtml(settings.companySubtitle.trim())}</div>`
: '';
const phone = settings?.phone?.trim()
? `<div class="muted">${escapeHtml(settings.phone.trim())}</div>`
? `<div class="dim">تلفن: <bdi dir="ltr">${escapeHtml(settings.phone.trim())}</bdi></div>`
: '';
const headerText = settings?.headerText?.trim()
? `<p class="note">${escapeHtml(settings.headerText.trim())}</p>`
? `<p class="header-text">${escapeHtml(settings.headerText.trim())}</p>`
: '';
const footerText = settings?.footerText?.trim()
? `<p class="note foot-note">${escapeHtml(settings.footerText.trim())}</p>`
? `<p class="note">${escapeHtml(settings.footerText.trim())}</p>`
: '';
const signAddr = escapeHtml(settings?.address?.trim() || '—');
const issuerName = escapeHtml(dash(first.User, '') || '—');
const receiverName = escapeHtml(customerName || '—');
const signRow = `<div class="sign-row">
<div class="sign-col">
<span class="ib-k">صادر کننده سند:</span>
<strong class="sign-name">${issuerName}</strong>
<div class="sign-space"></div>
</div>
<div class="sign-addr">${signAddr}</div>
<div class="sign-col">
<span class="ib-k">گیرنده سند:</span>
<strong class="sign-name">${receiverName}</strong>
<div class="sign-space"></div>
</div>
</div>`;
const html = `<!DOCTYPE html>
<html lang="fa" dir="rtl">
@@ -125,21 +366,42 @@ export function printTahesabInvoicePdf(opts: {
print-color-adjust: exact;
}
.top {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 14px;
border-bottom: 2px solid #111827;
padding-bottom: 12px;
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px 16px;
align-items: center;
margin-bottom: 6px;
border: 1px solid #111827;
border-radius: 10px;
padding: 12px 14px;
}
.brand { display: flex; gap: 12px; align-items: center; }
.logo { max-height: 64px; max-width: 140px; object-fit: contain; }
.org { font-size: 18px; font-weight: 800; margin: 0; }
.sub { color: #374151; margin-top: 2px; }
.muted { color: #6b7280; margin-top: 2px; }
.doc { text-align: left; }
.doc-type { font-weight: 800; font-size: 15px; }
.top-start { text-align: right; min-width: 0; }
.top-end { text-align: left; min-width: 0; }
.top-center {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.header-text {
margin: 0 0 8px;
white-space: pre-wrap;
font-size: 16px;
font-weight: 800;
line-height: 1.5;
}
.logo { max-height: 110px; max-width: 220px; object-fit: contain; }
.org { font-size: 16px; font-weight: 800; margin: 0; }
.sub {
color: #374151;
margin-top: 8px;
direction: ltr;
unicode-bidi: isolate;
text-align: left;
font-size: 13px;
}
.dim { color: #6b7280; margin-top: 2px; }
.doc-date { font-weight: 800; font-size: 15px; margin-bottom: 4px; }
.badge {
display: inline-block;
margin-top: 4px;
@@ -149,44 +411,195 @@ export function printTahesabInvoicePdf(opts: {
border-radius: 999px;
font-size: 11px;
}
.meta {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px 16px;
.party {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 20px;
margin-bottom: 14px;
border: 1px solid #111827;
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
}
.lbl { color: #6b7280; font-size: 11px; display: block; margin-bottom: 2px; }
.val { font-weight: 600; }
.party-item { white-space: nowrap; }
.party-phone { margin-inline-start: auto; }
.page-foot { margin-top: 16px; display: flex; flex-direction: column; gap: 10px; }
.note {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 10px;
margin: 0 0 12px;
margin: 0;
white-space: pre-wrap;
}
table { width: 100%; border-collapse: collapse; }
.sign-row {
display: grid;
grid-template-columns: 1fr 1.2fr 1fr;
gap: 12px;
align-items: start;
margin-top: 4px;
}
.sign-col {
display: flex;
flex-direction: column;
gap: 4px;
text-align: center;
}
.sign-name { font-size: 13px; font-weight: 800; }
.sign-space {
min-height: 56px;
margin-top: 4px;
border-bottom: 1px dashed #d1d5db;
}
.sign-addr {
color: #6b7280;
font-size: 12px;
text-align: center;
line-height: 1.55;
max-width: 100%;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
padding-top: 2px;
}
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td {
padding: 7px 8px;
border: 1px solid #e5e7eb;
text-align: right;
vertical-align: top;
}
th { background: #f3f4f6; font-size: 11px; }
th {
background: #f3f4f6;
font-size: 11px;
text-align: center;
vertical-align: middle;
}
.col-radif {
width: 5%;
white-space: nowrap;
padding-inline: 4px;
text-align: center;
vertical-align: middle;
}
.col-desc { width: 32%; word-break: break-word; }
.col-darsad { width: 6%; white-space: nowrap; padding-inline: 4px; }
.col-tabdil { width: 14%; white-space: nowrap; }
.col-mali { width: 13%; white-space: nowrap; }
.ltr { direction: ltr; unicode-bidi: isolate; text-align: left; }
.totals {
margin-top: 12px;
tfoot td {
font-weight: 800;
background: #f9fafb;
vertical-align: middle;
}
.totals-label { text-align: right; }
.weight-sum { font-size: 11px; white-space: nowrap; line-height: 1.35; }
.invoice-balance {
display: grid;
grid-template-columns: 1.2fr 1fr 1fr 1fr;
margin-top: 10px;
border: 1px solid #111827;
border-radius: 10px;
overflow: hidden;
font-size: 13px;
background: #f3f4f6;
}
.invoice-balance > div { padding: 8px 12px; }
.invoice-balance > .ib-cell { border-inline-start: 1px solid #e5e7eb; }
.ib-when {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 6px;
font-weight: 600;
white-space: nowrap;
}
.totals-box {
min-width: 220px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
.ib-label {
font-weight: 800;
display: flex;
align-items: center;
justify-content: left;
text-align: left;
}
.t-row { display: flex; justify-content: space-between; gap: 24px; font-weight: 800; }
.foot-note { margin-top: 16px; }
.ib-cell {
position: relative;
display: flex;
align-items: center;
width: 100%;
min-height: 1.4em;
padding-left: 7.5rem;
box-sizing: border-box;
white-space: nowrap;
}
.ib-k { color: #6b7280; font-size: 12px; }
.ib-val {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
text-align: left;
unicode-bidi: isolate;
font-weight: 800;
}
.account-row {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 10px;
margin-top: 10px;
}
.mesghal-box {
border: 1px solid #111827;
border-radius: 10px;
background: transparent;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 2.5rem;
}
.mesghal-blank {
flex: 1;
min-width: 4rem;
border-bottom: 1px dotted #9ca3af;
align-self: flex-end;
margin-bottom: 0.35rem;
margin-inline-start: 0.5rem;
}
.account-box {
border: 1px solid #111827;
border-radius: 10px;
background: transparent;
overflow: hidden;
display: flex;
flex-direction: column;
}
.acc-line {
display: grid;
grid-template-columns: auto 1fr 1fr;
align-items: stretch;
gap: 0;
border-top: 1px solid #e5e7eb;
}
.acc-line:first-child { border-top: 0; }
.acc-title {
font-weight: 800;
white-space: nowrap;
padding: 6px 12px;
display: flex;
align-items: center;
}
.acc-pair {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
padding: 6px 12px;
border-inline-start: 1px solid #e5e7eb;
}
.acc-pair strong { font-weight: 800; unicode-bidi: isolate; }
@media print {
body { font-size: 11px; }
}
@@ -194,52 +607,69 @@ export function printTahesabInvoicePdf(opts: {
</head>
<body>
<div class="top">
<div class="brand">
${logo}
<div>
<p class="org">${company}</p>
${subtitle}${address}${phone}
</div>
<div class="top-start">
${headerText}
<p class="org">${company}</p>
${phone}
</div>
<div class="doc">
<div class="doc-type">فاکتور سند ته‌حساب</div>
<div>شماره: <strong>${toFaDigits(escapeHtml(group.no))}</strong></div>
<div class="top-center">${logo}</div>
<div class="top-end">
<div class="doc-date">تاریخ: ${toFaDigits(escapeHtml(group.date))}</div>
<div>شماره سند: <strong>${toFaDigits(escapeHtml(group.no))}</strong></div>
${subtitle}
${temporary ? '<span class="badge">سند موقت</span>' : ''}
</div>
</div>
${headerText}
<div class="meta">
<div><span class="lbl">مشتری</span><div class="val">${escapeHtml(customerName || '—')}</div></div>
<div><span class="lbl">کد مشتری</span><div class="val ltr">${
moshtariCode != null ? toFaDigits(String(moshtariCode)) : '—'
}</div></div>
<div><span class="lbl">تاریخ</span><div class="val">${toFaDigits(escapeHtml(group.date))}${
time ? ` · ${toFaDigits(escapeHtml(time))}` : ''
}</div></div>
<div><span class="lbl">کاربر ثبت</span><div class="val">${escapeHtml(user)}</div></div>
<div><span class="lbl">تعداد ردیف</span><div class="val">${toFaDigits(String(group.rows.length))}</div></div>
<div class="party">
<span class="party-item">حساب: <strong>${escapeHtml(customerName || '—')}</strong></span>
<span class="party-item">کد: <strong class="ltr">${codeFa}</strong></span>
<span class="party-item party-phone">موبایل: <strong class="ltr">${escapeHtml(phoneFa)}</strong></span>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>شرح</th>
<th>جنس فلز</th>
<th>تعداد</th>
<th class="col-radif">ردیف</th>
<th class="col-desc">شرح</th>
<th class="col-darsad">درصد</th>
<th>عیار</th>
<th>وزن</th>
<th class="col-tabdil">${escapeHtml(tabdilLabel)}</th>
<th>مظنه/فی</th>
<th>مالی</th>
<th>ارز</th>
<th class="col-mali">مبلغ (ریال)</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
<tfoot>
<tr>
<td colspan="3" class="totals-label">مجموع کل اجناس</td>
<td></td>
<td class="ltr weight-sum">${escapeHtml(totalVaznLabel)}</td>
<td class="ltr weight-sum col-tabdil">${escapeHtml(totalTabdilLabel)}</td>
<td></td>
<td class="ltr col-mali">${formatTahesabBedBes(total)}</td>
</tr>
</tfoot>
</table>
<div class="totals">
<div class="totals-box">
<div class="t-row"><span>جمع مالی</span><span class="ltr">${fmtNum(total)}</span></div>
<div class="invoice-balance">
<div class="ib-when"><span class="ib-k">تاریخ ایجاد:</span> ${escapeHtml(generatedAt)}</div>
<div class="ib-label">مانده این فاکتور:</div>
<div class="ib-cell">
<span class="ib-k">مانده وزنی:</span>
<strong class="ib-val" dir="ltr">${escapeHtml(totalTabdilLabel)}</strong>
</div>
<div class="ib-cell">
<span class="ib-k">مانده ریالی:</span>
<strong class="ib-val" dir="ltr">${formatTahesabBedBes(total)}</strong>
</div>
</div>
${footerText}
<div class="account-row">
${mesghalHtml}
${accountHtml}
</div>
<footer class="page-foot">
${footerText}
${signRow}
</footer>
</body>
</html>`;
+22
View File
@@ -1,3 +1,5 @@
import { toFaDigits } from '@/lib/jalali';
/** برچسب انواع سند ته‌حساب — کپی runtime برای Vite (بدون named export از shared) */
export const TAHSAB_SANAD_LABELS: Record<string, string> = {
buy_sale_gold: 'خرید/فروش آبشده',
@@ -6,3 +8,23 @@ export const TAHSAB_SANAD_LABELS: Record<string, string> = {
vkh_gold: 'واریز/خروج طلا',
vkh_bank: 'واریز/خروج بانکی',
};
/**
* قالب نمایش ته‌حساب برای مبالغ/مانده‌های علامت‌دار:
* منفی → قدرمطلق + «بد» (بدهکار)، مثبت → قدرمطلق + «بس» (بستانکار).
* صفر بدون پسوند.
*/
export function formatTahesabBedBes(v: unknown): string {
if (v == null) return '—';
const s = String(v).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return '—';
const n = Number(s.replace(/,/g, ''));
if (!Number.isFinite(n)) return toFaDigits(s);
const abs = toFaDigits(
new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(
Math.abs(n),
),
);
if (n === 0) return abs;
return `${abs} ${n < 0 ? 'بد' : 'بس'}`;
}