/** طبقه‌بندی و خالص‌کردن فروش/خرید بعد از مرجوعی، برای گزارش ته‌حساب. */ export const SALES_FACTOR_KEYS = [ 'karsakhte', 'etiket_gold', 'etiket_jewel', 'bullion', 'other', ] as const; export type SalesFactorKey = (typeof SALES_FACTOR_KEYS)[number]; export const SALES_FACTOR_LABELS: Record = { karsakhte: 'کار ساخته', etiket_gold: 'اتیکت طلا', etiket_jewel: 'اتیکت جواهر', bullion: 'شمش و سکه', other: 'سایر', }; export type DocDirection = 'in' | 'out' | 'purchase_return' | 'sale_return' | 'other'; export type SalesDoc = { bucketKey: string; factor: SalesFactorKey; direction: DocDirection; weight: number; mali: number; count: number; percent: number | null; tarikh: string; }; export type PercentStats = { first: number | null; last: number | null; min: number | null; max: number | null; avg: number | null; }; export type MovementStats = { grossWeight: number; returnWeight: number; netWeight: number; grossMali: number; returnMali: number; netMali: number; grossCount: number; returnCount: number; netCount: number; percent: PercentStats | null; }; const EMPTY_MOVE: MovementStats = { grossWeight: 0, returnWeight: 0, netWeight: 0, grossMali: 0, returnMali: 0, netMali: 0, grossCount: 0, returnCount: 0, netCount: 0, percent: null, }; function norm(raw: string): string { return raw.replace(/\u064A/g, '\u06CC').replace(/\u0643/g, '\u06A9').replace(/\s+/g, ' ').trim(); } export function classifyDirection(noRaw: string): DocDirection { const no = norm(noRaw); if (no.includes('ورود کارساخته مرجوع')) return 'sale_return'; if (no.includes('خروج کارساخته مرجوع')) return 'purchase_return'; if (no.includes('ورود کارساخته')) return 'in'; if (no.includes('خروج کارساخته')) return 'out'; const isReturn = /مرجوع|برگشت/.test(no); const isBuy = /خرید/.test(no); const isSell = /فروش/.test(no); if (isReturn && isBuy) return 'purchase_return'; if (isReturn) return 'sale_return'; if (isBuy || (/ورود/.test(no) && !isReturn)) return 'in'; if (isSell || (/خروج/.test(no) && !isReturn)) return 'out'; return 'other'; } export function classifyFactor(noRaw: string, noKar: string | null | undefined): SalesFactorKey { const blob = norm(`${noRaw} ${noKar ?? ''}`); if (blob.includes('کارساخته') || blob.includes('کار ساخته')) return 'karsakhte'; if (/شمش|سکه/.test(blob)) return 'bullion'; if (/جواهر|ساچمه/.test(blob)) return 'etiket_jewel'; if (blob.includes('اتیکت')) return 'etiket_gold'; return 'other'; } export function combinedPercent( darsad: number | null | undefined, talayee: number | null | undefined, maliat: number | null | undefined, ): number | null { const parts = [darsad, talayee, maliat].filter( (n): n is number => n != null && Number.isFinite(n), ); if (!parts.length) return null; return parts.reduce((sum, n) => sum + n, 0); } export function positiveAmount(vazn: number | null | undefined, tabdil: number | null | undefined): number { if (vazn != null && Number.isFinite(vazn) && vazn !== 0) return Math.abs(vazn); if (tabdil != null && Number.isFinite(tabdil)) return Math.abs(tabdil); return 0; } type Piece = { tarikh: string; seq: number; weight: number; mali: number; count: number; percent: number | null; }; /** مرجوعی از آخرین سندهای همان نام کم می‌شود تا فروش/خریدِ برگشتی در آمار نماند. */ function remainingAfterReturns(gross: Piece[], returns: Piece[]): Piece[] { const docs = gross.map((d) => ({ ...d })); let leftW = returns.reduce((s, d) => s + d.weight, 0); let leftM = returns.reduce((s, d) => s + d.mali, 0); let leftC = returns.reduce((s, d) => s + d.count, 0); const order = [...docs].sort((a, b) => { if (a.tarikh !== b.tarikh) return b.tarikh.localeCompare(a.tarikh); return b.seq - a.seq; }); for (const doc of order) { if (leftW <= 0 && leftM <= 0 && leftC <= 0) break; const takeW = Math.min(doc.weight, Math.max(leftW, 0)); const takeM = Math.min(doc.mali, Math.max(leftM, 0)); const takeC = Math.min(doc.count, Math.max(leftC, 0)); doc.weight -= takeW; doc.mali -= takeM; doc.count -= takeC; leftW -= takeW; leftM -= takeM; leftC -= takeC; } return docs.filter((d) => d.weight > 1e-9 || d.mali > 1e-6 || d.count > 1e-9); } function percentOf(docs: Piece[]): PercentStats | null { const withPercent = docs.filter((d) => d.percent != null && d.weight > 1e-9); if (!withPercent.length) return null; const ordered = [...withPercent].sort((a, b) => { if (a.tarikh !== b.tarikh) return a.tarikh.localeCompare(b.tarikh); return a.seq - b.seq; }); const values = ordered.map((d) => d.percent!); const weight = ordered.reduce((s, d) => s + d.weight, 0); const avg = weight > 0 ? ordered.reduce((s, d) => s + d.percent! * d.weight, 0) / weight : null; return { first: ordered[0]?.percent ?? null, last: ordered[ordered.length - 1]?.percent ?? null, min: Math.min(...values), max: Math.max(...values), avg, }; } export function movementStats(lines: SalesDoc[], side: 'in' | 'out'): MovementStats { const returnDir: DocDirection = side === 'in' ? 'purchase_return' : 'sale_return'; const groups = new Map(); let seq = 0; for (const line of lines) { if (line.direction !== side && line.direction !== returnDir) continue; const bag = groups.get(line.bucketKey) ?? { gross: [], ret: [] }; const piece: Piece = { tarikh: line.tarikh, seq: seq++, weight: Math.abs(line.weight), mali: Math.abs(line.mali), count: Math.abs(line.count), percent: line.percent, }; if (line.direction === side) bag.gross.push(piece); else bag.ret.push(piece); groups.set(line.bucketKey, bag); } if (!groups.size) return { ...EMPTY_MOVE }; let grossWeight = 0; let returnWeight = 0; let grossMali = 0; let returnMali = 0; let grossCount = 0; let returnCount = 0; const kept: Piece[] = []; for (const bag of groups.values()) { grossWeight += bag.gross.reduce((s, d) => s + d.weight, 0); returnWeight += bag.ret.reduce((s, d) => s + d.weight, 0); grossMali += bag.gross.reduce((s, d) => s + d.mali, 0); returnMali += bag.ret.reduce((s, d) => s + d.mali, 0); grossCount += bag.gross.reduce((s, d) => s + d.count, 0); returnCount += bag.ret.reduce((s, d) => s + d.count, 0); kept.push(...remainingAfterReturns(bag.gross, bag.ret)); } return { grossWeight, returnWeight, netWeight: Math.max(0, grossWeight - returnWeight), grossMali, returnMali, netMali: Math.max(0, grossMali - returnMali), grossCount, returnCount, netCount: Math.max(0, grossCount - returnCount), percent: percentOf(kept), }; }