diff --git a/src/shared/dates.test.ts b/src/shared/dates.test.ts new file mode 100644 index 0000000..3ad9a52 --- /dev/null +++ b/src/shared/dates.test.ts @@ -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); + } + }); +}); diff --git a/src/shared/dates.ts b/src/shared/dates.ts new file mode 100644 index 0000000..f804af0 --- /dev/null +++ b/src/shared/dates.ts @@ -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); +} diff --git a/src/shared/money.test.ts b/src/shared/money.test.ts new file mode 100644 index 0000000..228ee47 --- /dev/null +++ b/src/shared/money.test.ts @@ -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); + }); +}); diff --git a/src/shared/money.ts b/src/shared/money.ts new file mode 100644 index 0000000..a963fb2 --- /dev/null +++ b/src/shared/money.ts @@ -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), + ); +} diff --git a/src/shared/quantity.test.ts b/src/shared/quantity.test.ts new file mode 100644 index 0000000..5817912 --- /dev/null +++ b/src/shared/quantity.test.ts @@ -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); + }); +}); diff --git a/src/shared/quantity.ts b/src/shared/quantity.ts new file mode 100644 index 0000000..bafbaac --- /dev/null +++ b/src/shared/quantity.ts @@ -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); +}