diff --git a/apps/web/src/components/person-assets/PersonAssetsPanel.vue b/apps/web/src/components/person-assets/PersonAssetsPanel.vue index a325492..efa2d5f 100644 --- a/apps/web/src/components/person-assets/PersonAssetsPanel.vue +++ b/apps/web/src/components/person-assets/PersonAssetsPanel.vue @@ -18,14 +18,17 @@ import type { TahesabInvoiceSettingsDto, TahesabSanadType, } from '@mento/shared'; -import { TAHSAB_SANAD_LABELS } from '@/lib/tahesab'; -import { printTahesabInvoicePdf } from '@/lib/tahesab-invoice-pdf'; +import { formatTahesabBedBes, TAHSAB_SANAD_LABELS } from '@/lib/tahesab'; +import type { TahesabInvoiceGroup } from '@/lib/tahesab-invoice-pdf'; import TahesabInvoiceSettingsModal from '@/components/person-assets/TahesabInvoiceSettingsModal.vue'; +import TahesabInvoicePreviewModal from '@/components/person-assets/TahesabInvoicePreviewModal.vue'; const props = defineProps<{ personId: string; /** نام نمایشی مشتری برای فاکتور PDF */ personName?: string; + /** موبایل مشتری برای فاکتور */ + personPhone?: string; }>(); const emit = defineEmits<{ @@ -44,6 +47,8 @@ const asnadLoading = ref(false); const asnadItems = ref[]>([]); const invoiceSettingsOpen = ref(false); const invoiceSettings = ref(null); +const invoicePreviewOpen = ref(false); +const invoicePreviewGroup = ref(null); /** مدل JalaliDateInput = تاریخ محلی میلادی YYYY-MM-DD */ const fromDate = ref(''); const toDate = ref(''); @@ -181,6 +186,8 @@ const ASNAD_ORDER = Object.keys(ASNAD_FIELD_LABELS); const ASNAD_HEAD_KEYS = new Set(['Sh_Factor', 'Sh_factor', 'Tarikh', 'NO']); /** مقادیر کمّی که جداکنندهٔ هزارگان می‌گیرند؛ بقیه (کدها، شناسه‌ها، عیار، تاریخ) خام */ const ASNAD_AMOUNT_KEYS = new Set(['Vazn', 'TabdilVazn', 'Mali', 'Fi', 'Mazaneh', 'Count']); +/** مبالغ علامت‌دار اسناد/مانده — منفی=بد، مثبت=بس (مثل ته‌حساب) */ +const ASNAD_SIGNED_KEYS = new Set(['Mali', 'TabdilVazn']); function asnadFields(item: Record) { const keys = Object.keys(item).filter((k) => !ASNAD_HEAD_KEYS.has(k)); @@ -209,6 +216,7 @@ function asnadCell(k: string, v: unknown): string { if (!s) return '—'; if (k === 'Jens_Felez') return JENS_FELEZ_LABELS[s] ?? showRaw(s); if (typeof v === 'boolean') return v ? 'بله' : 'خیر'; + if (ASNAD_SIGNED_KEYS.has(k)) return formatTahesabBedBes(s); return ASNAD_AMOUNT_KEYS.has(k) ? showValue(s) : showRaw(s); } @@ -405,41 +413,85 @@ function onInvoiceSettingsSaved(dto: TahesabInvoiceSettingsDto) { invoiceSettings.value = dto; } -async function exportAsnadPdf(g: { - no: string; - date: string; - rows: Record[]; -}) { - error.value = ''; - // باز کردن هم‌زمان با کلیک — وگرنه بعد از await پاپ‌آپ بلاک/خالی می‌شود - const preview = window.open('about:blank', '_blank'); - if (preview) { - try { - preview.document.write( - 'فاکتوردر حال آماده‌سازی فاکتور…', - ); - preview.document.close(); - } catch { - /* ignore */ - } - } +function parseBalanceNum(v: unknown): number | null { + if (v == null) return null; + const s = String(v).trim().replace(/,/g, ''); + if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return null; + const n = Number(s); + return Number.isFinite(n) ? n : null; +} +function sumGroupField(g: TahesabInvoiceGroup, key: string): number { + let t = 0; + for (const r of g.rows) { + const n = parseBalanceNum(r[key]); + if (n != null) t += n; + } + return t; +} + +function normalizeAsnadDate(d: string): string { + const s = d + .replace(/-/g, '/') + .trim() + .replace(/[۰-۹]/g, (ch) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(ch))); + const m = /^(\d{4})\/(\d{1,2})\/(\d{1,2})/.exec(s); + if (!m) return s; + return `${m[1]}/${m[2]!.padStart(2, '0')}/${m[3]!.padStart(2, '0')}`; +} + +function asnadDocNoNum(no: string): number { + const s = String(no) + .trim() + .replace(/[۰-۹]/g, (ch) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(ch))); + const n = Number(s.replace(/[^\d.-]/g, '')); + return Number.isFinite(n) ? n : 0; +} + +/** سندهای قبل از این فاکتور (تاریخ، و در همان روز شماره سند کوچک‌تر) */ +function isAsnadBeforeInvoice( + item: Record, + g: TahesabInvoiceGroup, +): boolean { + const d = normalizeAsnadDate(dash(item.Tarikh, '')); + const gd = normalizeAsnadDate(g.date); + if (!d || d === '—') return false; + if (d < gd) return true; + if (d > gd) return false; + return asnadDocNoNum(sanadNo(item)) < asnadDocNoNum(g.no); +} + +function sumAsnadBeforeInvoice(g: TahesabInvoiceGroup, key: string): number { + let t = 0; + for (const item of asnadItems.value) { + if (!isAsnadBeforeInvoice(item, g)) continue; + const n = parseBalanceNum(item[key]); + if (n != null) t += n; + } + return t; +} + +const invoicePriorWeight = ref(null); +const invoicePriorRial = ref(null); +const invoiceFinalWeight = ref(null); +const invoiceFinalRial = ref(null); + +async function exportAsnadPdf(g: TahesabInvoiceGroup) { + error.value = ''; + // حساب قبل = جمع اثر اسناد قبل از تاریخ/شماره این سند (نه مانده فعلی ته‌حساب) + const priorW = sumAsnadBeforeInvoice(g, 'TabdilVazn'); + const priorR = sumAsnadBeforeInvoice(g, 'Mali'); + const invW = sumGroupField(g, 'TabdilVazn'); + const invR = sumGroupField(g, 'Mali'); + invoicePriorWeight.value = priorW; + invoicePriorRial.value = priorR; + invoiceFinalWeight.value = priorW + invW; + invoiceFinalRial.value = priorR + invR; + invoicePreviewGroup.value = g; + invoicePreviewOpen.value = true; if (!invoiceSettings.value) { await loadInvoiceSettings(); } - - const okPrint = printTahesabInvoicePdf({ - group: g, - settings: invoiceSettings.value, - customerName: props.personName?.trim() || 'مشتری', - moshtariCode: currentLink.value?.moshtariCode ?? null, - targetWindow: preview, - }); - if (!okPrint) { - error.value = - 'خروجی فاکتور باز نشد. پاپ‌آپ مرورگر را برای این سایت مجاز کنید و دوباره بزنید.'; - preview?.close(); - } } async function loadAsnad() { @@ -699,17 +751,17 @@ onMounted(async () => {
{{ mandehFieldLabel(f.key) }} - {{ showValue(f.value) }} + {{ formatTahesabBedBes(f.value) }}
مانده وزنی (طلا) - {{ showValue(assets.goldBalance) }} + {{ formatTahesabBedBes(assets.goldBalance) }}
مانده مالی - {{ showValue(assets.rialBalance) }} + {{ formatTahesabBedBes(assets.rialBalance) }}

@@ -746,7 +798,7 @@ onMounted(async () => { > {{ d.Name }} - {{ showValue(d[c.key]) }} + {{ formatTahesabBedBes(d[c.key]) }} @@ -755,7 +807,7 @@ onMounted(async () => {

{{ item.Name }}:
@@ -817,10 +869,10 @@ onMounted(async () => {
@@ -904,7 +956,7 @@ onMounted(async () => {
مالی - {{ dash(item.Mali) }} + {{ formatTahesabBedBes(item.Mali) }}
شرح @@ -1033,6 +1085,18 @@ onMounted(async () => { v-model:open="invoiceSettingsOpen" @saved="onInvoiceSettingsSaved" /> +
diff --git a/apps/web/src/components/person-assets/TahesabInvoicePreviewModal.vue b/apps/web/src/components/person-assets/TahesabInvoicePreviewModal.vue new file mode 100644 index 0000000..e55d63d --- /dev/null +++ b/apps/web/src/components/person-assets/TahesabInvoicePreviewModal.vue @@ -0,0 +1,705 @@ + + + + + diff --git a/apps/web/src/lib/tahesab-invoice-pdf.ts b/apps/web/src/lib/tahesab-invoice-pdf.ts index 0b6a4b1..81af581 100644 --- a/apps/web/src/lib/tahesab-invoice-pdf.ts +++ b/apps/web/src/lib/tahesab-invoice-pdf.ts @@ -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, '"'); } -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): 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 { - 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 { + 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[]): number { +/** اولین مقدار غیرخالی از کلیدها (رشتهٔ "null" ته‌حساب نادیده گرفته می‌شود؛ صفر معتبر است) */ +export function pickInvoiceField( + r: Record, + ...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): unknown { + return pickInvoiceField(r, 'DarsadSood', 'DarsadVazni'); +} + +export function pickInvoiceMazaneh(r: Record): unknown { + return pickInvoiceField(r, 'Mazaneh', 'Fi'); +} + +export function sumInvoiceField( + rows: Record[], + 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[]): number { + return sumInvoiceField(rows, 'Mali'); +} + +/** 0=طلا، 2=نقره، null=نامشخص */ +export function invoiceRowMetal( + r: Record, +): '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[]): { + 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 { + 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[], + 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[], + 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[], +): Record[] { + const map = new Map>(); + 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 = `
قیمت یک مثقال نقره خام:
`; + const accountHtml = ``; - const rowsHtml = group.rows + const rowsHtml = invoiceRows .map((r, i) => { const idx = dash(r.Radif, String(i + 1)); return ` - ${toFaDigits(idx)} - ${escapeHtml(rowDesc(r))} - ${escapeHtml(metalLabel(r.Jens_Felez))} - ${fmtNum(r.Count)} + ${toFaDigits(idx)} + ${escapeHtml(rowDesc(r))} + ${fmtNum(pickInvoiceDarsad(r))} + ${fmtNum(r.Ayar)} ${fmtNum(r.Vazn)} - ${fmtNum(r.Mazaneh ?? r.Fi)} - ${fmtNum(r.Mali)} - ${escapeHtml(dash(r.Arz, 'IRR'))} + ${formatTahesabBedBes(r.TabdilVazn)} + ${fmtNum(pickInvoiceMazaneh(r))} + ${formatTahesabBedBes(r.Mali)} `; }) .join(''); @@ -93,20 +321,33 @@ export function printTahesabInvoicePdf(opts: { : ''; const company = escapeHtml(settings?.companyName?.trim() || 'فاکتور ته‌حساب'); const subtitle = settings?.companySubtitle?.trim() - ? `
${escapeHtml(settings.companySubtitle.trim())}
` - : ''; - const address = settings?.address?.trim() - ? `
${escapeHtml(settings.address.trim())}
` + ? `
${escapeHtml(settings.companySubtitle.trim())}
` : ''; const phone = settings?.phone?.trim() - ? `
${escapeHtml(settings.phone.trim())}
` + ? `
تلفن: ${escapeHtml(settings.phone.trim())}
` : ''; const headerText = settings?.headerText?.trim() - ? `

${escapeHtml(settings.headerText.trim())}

` + ? `

${escapeHtml(settings.headerText.trim())}

` : ''; const footerText = settings?.footerText?.trim() - ? `

${escapeHtml(settings.footerText.trim())}

` + ? `

${escapeHtml(settings.footerText.trim())}

` : ''; + const signAddr = escapeHtml(settings?.address?.trim() || '—'); + const issuerName = escapeHtml(dash(first.User, '') || '—'); + const receiverName = escapeHtml(customerName || '—'); + const signRow = `
+
+ صادر کننده سند: + ${issuerName} +
+
+
${signAddr}
+
+ گیرنده سند: + ${receiverName} +
+
+
`; const html = ` @@ -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: {
-
- ${logo} -
-

${company}

- ${subtitle}${address}${phone} -
+
+ ${headerText} +

${company}

+ ${phone}
-
-
فاکتور سند ته‌حساب
-
شماره: ${toFaDigits(escapeHtml(group.no))}
+
${logo}
+
+
تاریخ: ${toFaDigits(escapeHtml(group.date))}
+
شماره سند: ${toFaDigits(escapeHtml(group.no))}
+ ${subtitle} ${temporary ? 'سند موقت' : ''}
- ${headerText} -
-
مشتری
${escapeHtml(customerName || '—')}
-
کد مشتری
${ - moshtariCode != null ? toFaDigits(String(moshtariCode)) : '—' - }
-
تاریخ
${toFaDigits(escapeHtml(group.date))}${ - time ? ` · ${toFaDigits(escapeHtml(time))}` : '' - }
-
کاربر ثبت
${escapeHtml(user)}
-
تعداد ردیف
${toFaDigits(String(group.rows.length))}
+
+ حساب: ${escapeHtml(customerName || '—')} + کد: ${codeFa} + موبایل: ${escapeHtml(phoneFa)}
- - - - + + + + + - - + ${rowsHtml} + + + + + + + + + +
#شرحجنس فلزتعدادردیفشرحدرصدعیار وزن${escapeHtml(tabdilLabel)} مظنه/فیمالیارزمبلغ (ریال)
مجموع کل اجناس${escapeHtml(totalVaznLabel)}${escapeHtml(totalTabdilLabel)}${formatTahesabBedBes(total)}
-
-
-
جمع مالی${fmtNum(total)}
+
+
تاریخ ایجاد: ${escapeHtml(generatedAt)}
+
مانده این فاکتور:
+
+ مانده وزنی: + ${escapeHtml(totalTabdilLabel)} +
+
+ مانده ریالی: + ${formatTahesabBedBes(total)}
- ${footerText} + +
+ ${footerText} + ${signRow} +
`; diff --git a/apps/web/src/lib/tahesab.ts b/apps/web/src/lib/tahesab.ts index a72d6bd..195349b 100644 --- a/apps/web/src/lib/tahesab.ts +++ b/apps/web/src/lib/tahesab.ts @@ -1,3 +1,5 @@ +import { toFaDigits } from '@/lib/jalali'; + /** برچسب انواع سند ته‌حساب — کپی runtime برای Vite (بدون named export از shared) */ export const TAHSAB_SANAD_LABELS: Record = { buy_sale_gold: 'خرید/فروش آبشده', @@ -6,3 +8,23 @@ export const TAHSAB_SANAD_LABELS: Record = { 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 ? 'بد' : 'بس'}`; +} diff --git a/apps/web/src/views/PersonProfileView.vue b/apps/web/src/views/PersonProfileView.vue index 5f2e379..d914286 100644 --- a/apps/web/src/views/PersonProfileView.vue +++ b/apps/web/src/views/PersonProfileView.vue @@ -1046,6 +1046,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
diff --git a/apps/web/src/views/TahesabSalesReportView.vue b/apps/web/src/views/TahesabSalesReportView.vue index 9caca99..6c6f6f4 100644 --- a/apps/web/src/views/TahesabSalesReportView.vue +++ b/apps/web/src/views/TahesabSalesReportView.vue @@ -53,7 +53,7 @@ import { parseLocalInput, toFaDigits, } from '@/lib/jalali'; -import { formatMoney } from '@/lib/money'; +import { formatTahesabBedBes } from '@/lib/tahesab'; import { useCustomerGroups } from '@/lib/customer-groups'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; @@ -124,8 +124,7 @@ function formatNum(n: number | null | undefined) { } function formatMali(n: number | null | undefined) { - if (n == null) return '—'; - return formatMoney(n, 'IRR'); + return formatTahesabBedBes(n); } const columns: TableColumnsType = [