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:
@@ -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<Record<string, unknown>[]>([]);
|
||||
const invoiceSettingsOpen = ref(false);
|
||||
const invoiceSettings = ref<TahesabInvoiceSettingsDto | null>(null);
|
||||
const invoicePreviewOpen = ref(false);
|
||||
const invoicePreviewGroup = ref<TahesabInvoiceGroup | null>(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<string, unknown>) {
|
||||
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<string, unknown>[];
|
||||
}) {
|
||||
error.value = '';
|
||||
// باز کردن همزمان با کلیک — وگرنه بعد از await پاپآپ بلاک/خالی میشود
|
||||
const preview = window.open('about:blank', '_blank');
|
||||
if (preview) {
|
||||
try {
|
||||
preview.document.write(
|
||||
'<!DOCTYPE html><html lang="fa" dir="rtl"><head><meta charset="utf-8" /><title>فاکتور</title></head><body style="font-family:Tahoma,sans-serif;padding:24px">در حال آمادهسازی فاکتور…</body></html>',
|
||||
);
|
||||
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<string, unknown>,
|
||||
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<number | null>(null);
|
||||
const invoicePriorRial = ref<number | null>(null);
|
||||
const invoiceFinalWeight = ref<number | null>(null);
|
||||
const invoiceFinalRial = ref<number | null>(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 () => {
|
||||
<div v-if="mandehFields?.length" class="balance-grid">
|
||||
<article v-for="f in mandehFields" :key="f.key" class="balance-box">
|
||||
<span>{{ mandehFieldLabel(f.key) }}</span>
|
||||
<strong dir="ltr">{{ showValue(f.value) }}</strong>
|
||||
<strong dir="ltr">{{ formatTahesabBedBes(f.value) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="balance-grid">
|
||||
<article class="balance-box">
|
||||
<span>مانده وزنی (طلا)</span>
|
||||
<strong dir="ltr">{{ showValue(assets.goldBalance) }}</strong>
|
||||
<strong dir="ltr">{{ formatTahesabBedBes(assets.goldBalance) }}</strong>
|
||||
</article>
|
||||
<article class="balance-box">
|
||||
<span>مانده مالی</span>
|
||||
<strong dir="ltr">{{ showValue(assets.rialBalance) }}</strong>
|
||||
<strong dir="ltr">{{ formatTahesabBedBes(assets.rialBalance) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<p class="muted tiny">
|
||||
@@ -746,7 +798,7 @@ onMounted(async () => {
|
||||
>
|
||||
<span role="cell" class="raw-name">{{ d.Name }}</span>
|
||||
<span v-for="c in detailCols" :key="c.key" role="cell" dir="ltr" class="raw-num">
|
||||
{{ showValue(d[c.key]) }}
|
||||
{{ formatTahesabBedBes(d[c.key]) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -755,7 +807,7 @@ onMounted(async () => {
|
||||
<div class="name">{{ item.Name }}:</div>
|
||||
<div class="vals">
|
||||
<template v-for="(v, vi) in item.values" :key="vi">
|
||||
<span dir="ltr">{{ showValue(v.amount) }}</span>
|
||||
<span dir="ltr">{{ formatTahesabBedBes(v.amount) }}</span>
|
||||
<span class="unit">{{ v.unit }}</span>
|
||||
<span v-if="vi < item.values.length - 1" class="sep">|</span>
|
||||
</template>
|
||||
@@ -817,10 +869,10 @@ onMounted(async () => {
|
||||
<button
|
||||
type="button"
|
||||
class="btn inv-pdf-btn"
|
||||
title="خروجی فاکتور PDF"
|
||||
title="پیشنمایش و چاپ فاکتور"
|
||||
@click="exportAsnadPdf(g)"
|
||||
>
|
||||
خروجی فاکتور PDF
|
||||
پیشنمایش فاکتور
|
||||
</button>
|
||||
</div>
|
||||
<dl class="inv-meta">
|
||||
@@ -904,7 +956,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="sc-row cash">
|
||||
<span>مالی</span>
|
||||
<strong dir="ltr">{{ dash(item.Mali) }}</strong>
|
||||
<strong dir="ltr">{{ formatTahesabBedBes(item.Mali) }}</strong>
|
||||
</div>
|
||||
<div class="sc-row">
|
||||
<span>شرح</span>
|
||||
@@ -1033,6 +1085,18 @@ onMounted(async () => {
|
||||
v-model:open="invoiceSettingsOpen"
|
||||
@saved="onInvoiceSettingsSaved"
|
||||
/>
|
||||
<TahesabInvoicePreviewModal
|
||||
v-model:open="invoicePreviewOpen"
|
||||
:group="invoicePreviewGroup"
|
||||
:settings="invoiceSettings"
|
||||
:customer-name="personName?.trim() || 'مشتری'"
|
||||
:customer-phone="personPhone?.trim() || ''"
|
||||
:moshtari-code="currentLink?.moshtariCode ?? null"
|
||||
:prior-weight="invoicePriorWeight"
|
||||
:prior-rial="invoicePriorRial"
|
||||
:final-weight="invoiceFinalWeight"
|
||||
:final-rial="invoiceFinalRial"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { TahesabInvoiceSettingsDto } from '@mento/shared';
|
||||
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
||||
import { useEscClose } from '@/lib/modal-stack';
|
||||
import { formatTahesabBedBes } from '@/lib/tahesab';
|
||||
import {
|
||||
aggregateSameNameInvoiceRows,
|
||||
dashInvoice,
|
||||
fmtInvoiceNum,
|
||||
pickInvoiceDarsad,
|
||||
pickInvoiceMazaneh,
|
||||
printTahesabInvoicePdf,
|
||||
rowInvoiceDesc,
|
||||
formatInvoiceWeightBalance,
|
||||
sumInvoiceMali,
|
||||
tabdilVaznColumnLabel,
|
||||
type TahesabInvoiceGroup,
|
||||
} from '@/lib/tahesab-invoice-pdf';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
group: TahesabInvoiceGroup | null;
|
||||
settings: TahesabInvoiceSettingsDto | null;
|
||||
customerName: string;
|
||||
customerPhone?: string;
|
||||
moshtariCode?: number | null;
|
||||
priorWeight?: number | null;
|
||||
priorRial?: number | null;
|
||||
finalWeight?: number | null;
|
||||
finalRial?: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
}>();
|
||||
|
||||
useEscClose(
|
||||
() => props.open,
|
||||
() => close(),
|
||||
);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
const first = computed(() => props.group?.rows[0] ?? {});
|
||||
const temporary = computed(() => first.value.isSanadMovaghat === true);
|
||||
const issuerName = computed(
|
||||
() => dashInvoice(first.value.User, '') || '—',
|
||||
);
|
||||
const invoiceRows = computed(() =>
|
||||
props.group ? aggregateSameNameInvoiceRows(props.group.rows) : [],
|
||||
);
|
||||
const total = computed(() => sumInvoiceMali(invoiceRows.value));
|
||||
const sourceRows = computed(
|
||||
() => props.group?.rows ?? invoiceRows.value,
|
||||
);
|
||||
const totalVaznLabel = computed(() =>
|
||||
formatInvoiceWeightBalance(sourceRows.value, 'Vazn', false),
|
||||
);
|
||||
const totalTabdilLabel = computed(() =>
|
||||
formatInvoiceWeightBalance(sourceRows.value, 'TabdilVazn', true),
|
||||
);
|
||||
const tabdilColLabel = computed(() =>
|
||||
tabdilVaznColumnLabel(sourceRows.value),
|
||||
);
|
||||
const company = computed(
|
||||
() => props.settings?.companyName?.trim() || 'فاکتور تهحساب',
|
||||
);
|
||||
const generatedAtLabel = computed(() =>
|
||||
props.open ? formatJalaliDateTime(new Date()) : '',
|
||||
);
|
||||
function printPdf() {
|
||||
if (!props.group) return;
|
||||
const ok = printTahesabInvoicePdf({
|
||||
group: props.group,
|
||||
settings: props.settings,
|
||||
customerName: props.customerName,
|
||||
customerPhone: props.customerPhone,
|
||||
moshtariCode: props.moshtariCode,
|
||||
priorWeight: props.priorWeight,
|
||||
priorRial: props.priorRial,
|
||||
finalWeight: props.finalWeight,
|
||||
finalRial: props.finalRial,
|
||||
});
|
||||
if (!ok) {
|
||||
// در مرورگر Cursor پاپآپ کار نمیکند؛ پیشنمایش همینجا کافی است
|
||||
window.alert(
|
||||
'چاپ در این مرورگر باز نشد. از Chrome استفاده کنید یا از منوی چاپ سیستم روی همین پیشنمایش استفاده کنید.',
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="open && group" class="backdrop" @click.self="close">
|
||||
<div
|
||||
class="sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="پیشنمایش فاکتور تهحساب"
|
||||
>
|
||||
<header class="sheet-head">
|
||||
<div>
|
||||
<h3>پیشنمایش فاکتور تهحساب</h3>
|
||||
<p class="muted">
|
||||
سند {{ toFaDigits(group.no) }} · همینجا قابل بررسی است (بدون پاپآپ)
|
||||
</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button type="button" class="btn" @click="printPdf">چاپ / PDF</button>
|
||||
<button type="button" class="btn ghost" @click="close">بستن</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="paper">
|
||||
<div class="top">
|
||||
<div class="top-side top-start">
|
||||
<p v-if="settings?.headerText?.trim()" class="header-text">
|
||||
{{ settings.headerText }}
|
||||
</p>
|
||||
<p class="org">{{ company }}</p>
|
||||
<div v-if="settings?.phone?.trim()" class="dim">
|
||||
تلفن: <bdi dir="ltr">{{ settings.phone }}</bdi>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-center">
|
||||
<img
|
||||
v-if="settings?.logoDataUrl"
|
||||
class="logo"
|
||||
:src="settings.logoDataUrl"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="top-side top-end">
|
||||
<div class="doc-date">
|
||||
تاریخ: {{ toFaDigits(group.date) }}
|
||||
</div>
|
||||
<div>
|
||||
شماره سند:
|
||||
<strong>{{ toFaDigits(group.no) }}</strong>
|
||||
</div>
|
||||
<div
|
||||
v-if="settings?.companySubtitle?.trim()"
|
||||
class="sub"
|
||||
dir="ltr"
|
||||
>
|
||||
{{ settings.companySubtitle }}
|
||||
</div>
|
||||
<span v-if="temporary" class="badge">سند موقت</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="party">
|
||||
<span class="party-item">
|
||||
حساب:
|
||||
<strong>{{ customerName || '—' }}</strong>
|
||||
</span>
|
||||
<span class="party-item">
|
||||
کد:
|
||||
<strong class="ltr">{{
|
||||
moshtariCode != null ? toFaDigits(String(moshtariCode)) : '—'
|
||||
}}</strong>
|
||||
</span>
|
||||
<span class="party-item party-phone">
|
||||
موبایل:
|
||||
<strong class="ltr">{{
|
||||
customerPhone?.trim()
|
||||
? toFaDigits(customerPhone.trim())
|
||||
: '—'
|
||||
}}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-radif">ردیف</th>
|
||||
<th class="col-desc">شرح</th>
|
||||
<th class="col-darsad">درصد</th>
|
||||
<th>عیار</th>
|
||||
<th>وزن</th>
|
||||
<th class="col-tabdil">{{ tabdilColLabel }}</th>
|
||||
<th>مظنه/فی</th>
|
||||
<th class="col-mali">مبلغ (ریال)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(r, i) in invoiceRows"
|
||||
:key="String(r.ID ?? r.No_Kar ?? i)"
|
||||
>
|
||||
<td class="col-radif">
|
||||
{{ toFaDigits(dashInvoice(r.Radif, String(i + 1))) }}
|
||||
</td>
|
||||
<td class="col-desc">{{ rowInvoiceDesc(r) }}</td>
|
||||
<td class="ltr col-darsad">
|
||||
{{ fmtInvoiceNum(pickInvoiceDarsad(r)) }}
|
||||
</td>
|
||||
<td class="ltr">{{ fmtInvoiceNum(r.Ayar) }}</td>
|
||||
<td class="ltr">{{ fmtInvoiceNum(r.Vazn) }}</td>
|
||||
<td class="ltr col-tabdil">
|
||||
{{ formatTahesabBedBes(r.TabdilVazn) }}
|
||||
</td>
|
||||
<td class="ltr">{{ fmtInvoiceNum(pickInvoiceMazaneh(r)) }}</td>
|
||||
<td class="ltr col-mali">
|
||||
{{ formatTahesabBedBes(r.Mali) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="totals-row">
|
||||
<td colspan="3" class="totals-label">مجموع کل اجناس</td>
|
||||
<td></td>
|
||||
<td class="ltr weight-sum">{{ totalVaznLabel }}</td>
|
||||
<td class="ltr weight-sum col-tabdil">{{ totalTabdilLabel }}</td>
|
||||
<td></td>
|
||||
<td class="ltr col-mali">{{ formatTahesabBedBes(total) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="invoice-balance">
|
||||
<div class="ib-when">
|
||||
<span class="ib-k">تاریخ ایجاد:</span>
|
||||
{{ generatedAtLabel }}
|
||||
</div>
|
||||
<div class="ib-label">مانده این فاکتور:</div>
|
||||
<div class="ib-cell">
|
||||
<span class="ib-k">مانده وزنی:</span>
|
||||
<strong class="ib-val" dir="ltr">{{ totalTabdilLabel }}</strong>
|
||||
</div>
|
||||
<div class="ib-cell">
|
||||
<span class="ib-k">مانده ریالی:</span>
|
||||
<strong class="ib-val" dir="ltr">{{
|
||||
formatTahesabBedBes(total)
|
||||
}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="account-row">
|
||||
<div class="mesghal-box">
|
||||
<span class="ib-k">قیمت یک مثقال نقره خام:</span>
|
||||
<span class="mesghal-blank" aria-hidden="true" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<footer class="page-foot">
|
||||
<p v-if="settings?.footerText?.trim()" class="note foot">
|
||||
{{ settings.footerText }}
|
||||
</p>
|
||||
<div class="sign-row">
|
||||
<div class="sign-col">
|
||||
<span class="ib-k">صادر کننده سند:</span>
|
||||
<strong class="sign-name">{{ issuerName }}</strong>
|
||||
<div class="sign-space" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="sign-addr">
|
||||
{{ settings?.address?.trim() || '—' }}
|
||||
</div>
|
||||
<div class="sign-col">
|
||||
<span class="ib-k">گیرنده سند:</span>
|
||||
<strong class="sign-name">{{ customerName || '—' }}</strong>
|
||||
<div class="sign-space" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 85;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
.sheet {
|
||||
width: min(920px, 100%);
|
||||
max-height: min(92vh, 960px);
|
||||
overflow: auto;
|
||||
background: #f3f4f6;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.sheet-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.sheet-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.muted {
|
||||
margin: 4px 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.paper {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 20px 22px;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
}
|
||||
.top {
|
||||
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;
|
||||
}
|
||||
.top-side {
|
||||
min-width: 0;
|
||||
}
|
||||
.top-start {
|
||||
text-align: right;
|
||||
}
|
||||
.top-end {
|
||||
text-align: left;
|
||||
}
|
||||
.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;
|
||||
color: #111827;
|
||||
}
|
||||
.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;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #d97706;
|
||||
color: #b45309;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.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-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
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;
|
||||
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-row 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;
|
||||
gap: 0;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ib-label {
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
text-align: left;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.8rem;
|
||||
background: var(--accent, #0d9488);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.party-phone {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
.top {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: stretch;
|
||||
}
|
||||
.top-center {
|
||||
order: -1;
|
||||
}
|
||||
.top-end {
|
||||
text-align: right;
|
||||
}
|
||||
.invoice-balance {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.account-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sign-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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<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>`;
|
||||
|
||||
|
||||
@@ -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 ? 'بد' : 'بس'}`;
|
||||
}
|
||||
|
||||
@@ -1046,6 +1046,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
<PersonAssetsPanel
|
||||
:person-id="id"
|
||||
:person-name="fullName"
|
||||
:person-phone="phoneNumber"
|
||||
@link-changed="onTahesabLinkChanged"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<TahesabAsnadLineDto> = [
|
||||
|
||||
Reference in New Issue
Block a user