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
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { istanbulDayStart, toIstanbulYmd, todayIstanbul } from "./dates";
describe("İstanbul günü", () => {
it("gün başlangıcı UTC+3'e göre hesaplanır", () => {
expect(istanbulDayStart("2026-11-05")?.toISOString()).toBe("2026-11-04T21:00:00.000Z");
expect(istanbulDayStart("2027-01-01")?.toISOString()).toBe("2026-12-31T21:00:00.000Z");
});
it("geçersiz tarihleri reddeder", () => {
expect(istanbulDayStart("2026-02-30")).toBeNull();
expect(istanbulDayStart("05.11.2026")).toBeNull();
});
it("anı İstanbul gününe çevirir (gece yarısı sınırı)", () => {
expect(toIstanbulYmd("2026-12-31T20:59:59Z")).toBe("2026-12-31");
expect(toIstanbulYmd("2026-12-31T21:00:00Z")).toBe("2027-01-01");
expect(todayIstanbul(new Date("2026-11-04T22:30:00Z"))).toBe("2026-11-05");
});
it("gidiş-dönüş tutarlıdır", () => {
for (const ymd of ["2026-03-31", "2026-10-25", "2028-02-29"]) {
expect(toIstanbulYmd(istanbulDayStart(ymd)!)).toBe(ymd);
}
});
});
+51
View File
@@ -0,0 +1,51 @@
import { TIME_ZONE } from "./format";
/**
* İstanbul günü yardımcıları. Veritabanında UTC saklanır; gün sınırları İstanbul'a göredir.
* Türkiye 2016'dan beri sabit UTC+3 kullanır; yine de dönüşüm Intl ile yapılır (kural değişirse
* yalnızca ofset hesabı güncellenir).
*/
const ymdFmt = new Intl.DateTimeFormat("en-CA", {
timeZone: TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
/** Bir anın İstanbul'daki takvim günü: "2026-11-05". */
export function toIstanbulYmd(date: Date | string | number): string {
return ymdFmt.format(new Date(date));
}
/** Bugünün İstanbul günü. */
export function todayIstanbul(now: Date = new Date()): string {
return toIstanbulYmd(now);
}
/** İstanbul'un verilen andaki UTC ofseti (dakika; +180). */
function istanbulOffsetMinutes(at: Date): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: TIME_ZONE,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).formatToParts(at);
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value);
const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"));
return Math.round((asUtc - at.getTime()) / 60_000);
}
/** "2026-11-05" → o günün İstanbul'daki başlangıç anı (UTC Date). Geçersizse null. */
export function istanbulDayStart(ymd: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
if (!m) return null;
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
const guess = new Date(Date.UTC(y, mo - 1, d));
if (guess.getUTCFullYear() !== y || guess.getUTCMonth() !== mo - 1 || guess.getUTCDate() !== d)
return null;
return new Date(guess.getTime() - istanbulOffsetMinutes(guess) * 60_000);
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { allocate, formatMoneyInput, parseMoney, splitEvenly, sumMoney } from "./money";
describe("parseMoney", () => {
it.each([
["1.250,50", 125050],
["1250,50", 125050],
["1250,5", 125050],
["1250", 125000],
["0,01", 1],
[",5", 50],
["1.250", 125000],
["12.345.678,90", 1234567890],
["12.5", 1250],
["12.50", 1250],
["₺1.250,50", 125050],
["1.250,50 TL", 125050],
[" 1 250,50 ", 125050],
["-12,30", -1230],
["−12,30", -1230],
["+7", 700],
["0", 0],
["-0", 0],
])("%s → %i", (input, expected) => {
expect(parseMoney(input)).toBe(expected);
});
it.each([
"",
"abc",
"1,2,3",
"12,345",
"1.25,00",
"1.2345",
"12.345.6",
"1,5e3",
"--5",
"100000000000,01",
])("geçersiz: %s", (input) => {
expect(parseMoney(input)).toBeNull();
});
});
describe("formatMoneyInput", () => {
it("Türkçe biçimde simgesiz yazar ve parseMoney ile geri döner", () => {
for (const v of [0, 1, 99, 100, 125050, 1234567890, -1230]) {
expect(parseMoney(formatMoneyInput(v))).toBe(v);
}
expect(formatMoneyInput(125050)).toBe("1.250,50");
expect(formatMoneyInput(-5)).toBe("-0,05");
});
});
describe("allocate", () => {
it("toplam her zaman korunur (kuruş kaybı yok)", () => {
for (const total of [1, 100, 10000, 99999, 120000, -10001]) {
for (const weights of [[1], [1, 1, 1], [800, 250, 150], [1, 2, 3, 4, 5, 6, 7], [0, 1]]) {
const parts = allocate(total, weights);
expect(parts.reduce((a, b) => a + b, 0)).toBe(total);
expect(parts).toHaveLength(weights.length);
}
}
});
it("ağırlıklara orantılıdır ve deterministiktir", () => {
expect(allocate(120000, [800, 250, 150])).toEqual([80000, 25000, 15000]);
expect(allocate(100, [1, 1, 1])).toEqual([34, 33, 33]);
expect(allocate(-100, [1, 1, 1])).toEqual([-34, -33, -33]);
expect(allocate(5, [0, 1])).toEqual([0, 5]);
});
it("geçersiz girdiyi reddeder", () => {
expect(() => allocate(100, [])).toThrow(RangeError);
expect(() => allocate(100, [0, 0])).toThrow(RangeError);
expect(() => allocate(100, [-1, 2])).toThrow(RangeError);
expect(() => allocate(1.5, [1])).toThrow(TypeError);
});
});
describe("splitEvenly", () => {
it("9 taksit: 1.000,00 TL", () => {
const parts = splitEvenly(100000, 9);
expect(sumMoney(parts)).toBe(100000);
expect(parts[0]).toBe(11112);
expect(parts.slice(1).every((p) => p === 11111)).toBe(true);
});
});
+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),
);
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { convertQuantity, formatQuantity, parseQuantity } from "./quantity";
describe("parseQuantity", () => {
it.each([
["1,5", 1500],
["1.5", 1500],
["2", 2000],
["0,125", 125],
["5", 5000],
[" 10,25 ", 10250],
])("%s → %i", (input, expected) => {
expect(parseQuantity(input)).toBe(expected);
});
it.each(["", "-1", "1,2345", "1,2,3", "abc", "1e3"])("geçersiz: %s", (input) => {
expect(parseQuantity(input)).toBeNull();
});
});
describe("formatQuantity", () => {
it("gereksiz sıfırları atar, birim ekler", () => {
expect(formatQuantity(1500, "kg")).toBe("1,5 kg");
expect(formatQuantity(5000, "gr")).toBe("5 gr");
expect(formatQuantity(125)).toBe("0,125");
expect(formatQuantity(1234500)).toBe("1.234,5");
expect(formatQuantity(-250)).toBe("-0,25");
});
});
describe("convertQuantity", () => {
it("aynı boyutta dönüştürür", () => {
expect(convertQuantity(1500, "kg", "g")).toBe(1500000); // 1,5 kg = 1500 g
expect(convertQuantity(500000, "g", "kg")).toBe(500); // 500 g = 0,5 kg
expect(convertQuantity(2000, "lt", "ml")).toBe(2000000);
});
it("uyumsuz birimde hata verir", () => {
expect(() => convertQuantity(1000, "kg", "lt")).toThrow(RangeError);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Miktar yardımcıları (stok, gram altın, döviz, adet).
*
* KURAL: Tüm miktarlar birimin BİNDE BİRİ cinsinden tamsayı saklanır (QUANTITY_SCALE = 1000):
* 1,5 kg → 1500 · 5 gr altın → 5000 · 2 adet çeyrek → 2000 · 0,125 lt → 125 · 100 USD → 100000
* Böylece 3 ondalık hassasiyet sağlanır, toplama/çıkarma tamsayıdır. Birimler arası dönüşüm
* (kg↔g, lt↔ml) aynı boyuttaki birimler için tanımlıdır; özel birimler ("yıkama", "tablet")
* ürün bazında dönüşüm oranıyla (Faz 12) çevrilir.
*/
export const QUANTITY_SCALE = 1000;
export const UNITS = {
g: { label: "g", dimension: "mass", factor: 1 },
kg: { label: "kg", dimension: "mass", factor: 1000 },
ml: { label: "ml", dimension: "volume", factor: 1 },
lt: { label: "lt", dimension: "volume", factor: 1000 },
adet: { label: "adet", dimension: "count", factor: 1 },
} as const;
export type Unit = keyof typeof UNITS;
/** "1,5" / "1.5" / "2" / "0,125" → binde bir tamsayı. En fazla 3 ondalık; geçersizse null. */
export function parseQuantity(input: string): number | null {
const s = input.trim().replace(/\s/g, "");
const m = /^(\d+)(?:[.,](\d{1,3}))?$/.exec(s);
if (!m) return null;
const value = Number(m[1]) * QUANTITY_SCALE + Number((m[2] ?? "").padEnd(3, "0"));
return Number.isSafeInteger(value) ? value : null;
}
/** 1500 → "1,5"; 5000 → "5"; 125 → "0,125" (gereksiz sıfırlar atılır). */
export function formatQuantity(quantity: number, unit?: string): string {
if (!Number.isSafeInteger(quantity)) throw new TypeError(`Miktar tamsayı olmalı: ${quantity}`);
const sign = quantity < 0 ? "-" : "";
const abs = Math.abs(quantity);
const int = Math.trunc(abs / QUANTITY_SCALE).toLocaleString("tr-TR");
const frac = (abs % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, "");
const text = `${sign}${int}${frac ? `,${frac}` : ""}`;
return unit ? `${text} ${unit}` : text;
}
/** Aynı boyuttaki birimler arasında dönüşüm (kg → g vb.). Uyumsuz birimde hata. */
export function convertQuantity(quantity: number, from: Unit, to: Unit): number {
const a = UNITS[from];
const b = UNITS[to];
if (a.dimension !== b.dimension) throw new RangeError(`${from} → ${to} dönüştürülemez`);
const result = (quantity * a.factor) / b.factor;
// Hassasiyet kaybı olursa (ör. 1 ml → lt = 0,001 lt = 1) yuvarlanır; 3 ondalık sınırı.
return Math.round(result);
}