eaf80c6bb4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
4.4 KiB
TypeScript
120 lines
4.4 KiB
TypeScript
/**
|
||
* Para birimi yardımcıları — tutarlar HER ZAMAN tamsayı kuruştur (`amountMinor`).
|
||
* 125,50 TL → 12550. Float ile hesap yapılmaz; yuvarlama yalnızca gösterimde.
|
||
*
|
||
* Ayrıştırma kuralları (tr-TR):
|
||
* - Virgül varsa ondalık ayırıcıdır; noktalar ve boşluklar binlik ayırıcıdır: "1.250,50" → 125050.
|
||
* - Virgül yoksa ve noktalar geçerli binlik gruplarıysa (1.250 / 12.345.678) binlik ayırıcıdır.
|
||
* - Virgül yoksa ve tek nokta 1–2 ondalık içeriyorsa ondalıktır: "12.5" → 1250 (telefon klavyeleri).
|
||
* - En fazla 2 ondalık; "₺", "TL", boşluklar ve baştaki +/− kabul edilir. Belirsiz girdi → null.
|
||
*/
|
||
|
||
export const MAX_AMOUNT_MINOR = 100_000_000_000_00; // 100 milyar TL üst sınırı (güvenli tamsayı içinde)
|
||
|
||
const THOUSANDS_GROUPED = /^\d{1,3}(\.\d{3})+$/;
|
||
|
||
export function parseMoney(input: string): number | null {
|
||
let s = input
|
||
.trim()
|
||
.replace(/\s| | /g, "")
|
||
.replace(/₺|TL$/i, "");
|
||
if (!s) return null;
|
||
let sign = 1;
|
||
if (s.startsWith("-") || s.startsWith("−")) {
|
||
sign = -1;
|
||
s = s.slice(1);
|
||
} else if (s.startsWith("+")) {
|
||
s = s.slice(1);
|
||
}
|
||
s = s.replace(/₺/g, "");
|
||
|
||
let intPart: string;
|
||
let fracPart = "";
|
||
if (s.includes(",")) {
|
||
const parts = s.split(",");
|
||
if (parts.length !== 2) return null;
|
||
[intPart, fracPart] = parts as [string, string];
|
||
if (intPart.includes(".")) {
|
||
if (!THOUSANDS_GROUPED.test(intPart)) return null;
|
||
intPart = intPart.replace(/\./g, "");
|
||
}
|
||
} else if (s.includes(".")) {
|
||
if (THOUSANDS_GROUPED.test(s)) {
|
||
intPart = s.replace(/\./g, "");
|
||
} else {
|
||
const parts = s.split(".");
|
||
if (parts.length !== 2 || parts[1]!.length > 2) return null;
|
||
[intPart, fracPart] = parts as [string, string];
|
||
}
|
||
} else {
|
||
intPart = s;
|
||
}
|
||
|
||
if (intPart === "") intPart = "0";
|
||
if (!/^\d+$/.test(intPart) || !/^\d{0,2}$/.test(fracPart)) return null;
|
||
const minor = Number(intPart) * 100 + Number(fracPart.padEnd(2, "0"));
|
||
if (!Number.isSafeInteger(minor) || minor > MAX_AMOUNT_MINOR) return null;
|
||
return sign * minor === 0 ? 0 : sign * minor;
|
||
}
|
||
|
||
/** Form alanı için: 125050 → "1.250,50" (simgesiz). */
|
||
export function formatMoneyInput(amountMinor: number): string {
|
||
assertMinor(amountMinor);
|
||
const sign = amountMinor < 0 ? "-" : "";
|
||
const abs = Math.abs(amountMinor);
|
||
const int = Math.trunc(abs / 100)
|
||
.toString()
|
||
.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||
const frac = (abs % 100).toString().padStart(2, "0");
|
||
return `${sign}${int},${frac}`;
|
||
}
|
||
|
||
export function assertMinor(value: number): void {
|
||
if (!Number.isSafeInteger(value)) throw new TypeError(`Kuruş tutarı tamsayı olmalı: ${value}`);
|
||
}
|
||
|
||
/** Kuruş tutarlarını toplar (tamsayı garantili). */
|
||
export function sumMoney(values: readonly number[]): number {
|
||
let total = 0;
|
||
for (const v of values) {
|
||
assertMinor(v);
|
||
total += v;
|
||
}
|
||
assertMinor(total);
|
||
return total;
|
||
}
|
||
|
||
/**
|
||
* Oransal bölme (en büyük kalan yöntemi): kuruş kaybı olmadan `totalMinor`'u ağırlıklara böler.
|
||
* Parçaların toplamı her zaman tam olarak `totalMinor`'dur. Taksit ve bölünmüş işlem için.
|
||
* Eşit kalanlarda önceki parça önceliklidir (deterministik).
|
||
*/
|
||
export function allocate(totalMinor: number, weights: readonly number[]): number[] {
|
||
assertMinor(totalMinor);
|
||
if (weights.length === 0) throw new RangeError("En az bir ağırlık gerekli");
|
||
if (weights.some((w) => !Number.isFinite(w) || w < 0))
|
||
throw new RangeError("Ağırlıklar negatif olamaz");
|
||
const weightSum = weights.reduce((a, b) => a + b, 0);
|
||
if (weightSum <= 0) throw new RangeError("Ağırlıkların toplamı sıfırdan büyük olmalı");
|
||
|
||
const sign = totalMinor < 0 ? -1 : 1;
|
||
const abs = Math.abs(totalMinor);
|
||
const raw = weights.map((w) => (abs * w) / weightSum);
|
||
const parts = raw.map((r) => Math.floor(r));
|
||
let remainder = abs - parts.reduce((a, b) => a + b, 0);
|
||
const order = raw
|
||
.map((r, i) => ({ i, frac: r - Math.floor(r) }))
|
||
.sort((a, b) => b.frac - a.frac || a.i - b.i);
|
||
for (let k = 0; remainder > 0; k = (k + 1) % order.length, remainder--) parts[order[k]!.i]! += 1;
|
||
return parts.map((p) => (p === 0 ? 0 : sign * p));
|
||
}
|
||
|
||
/** Eşit bölme: 100,00 TL / 3 → [3334, 3333, 3333]. */
|
||
export function splitEvenly(totalMinor: number, count: number): number[] {
|
||
if (!Number.isInteger(count) || count < 1) throw new RangeError("Parça sayısı en az 1 olmalı");
|
||
return allocate(
|
||
totalMinor,
|
||
Array.from({ length: count }, () => 1),
|
||
);
|
||
}
|