221 lines
7.0 KiB
TypeScript
221 lines
7.0 KiB
TypeScript
/** طبقهبندی و خالصکردن فروش/خرید بعد از مرجوعی، برای گزارش تهحساب. */
|
|||
|
|
|
||
|
|
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<SalesFactorKey, string> = {
|
||
|
|
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<string, { gross: Piece[]; ret: Piece[] }>();
|
||
|
|
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),
|
||
|
|
};
|
||
|
|
}
|