feat(shared): para, miktar ve İstanbul günü yardımcıları

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:57:58 +03:00
parent 7fda139199
commit eaf80c6bb4
6 changed files with 374 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
/**
* 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),
);
}