feat(shopping): mağazalar, zahmet skoru ve ürünler (marka/paket, dönüşüm, görseller)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { autoConvertible, BARCODE_PATTERN, describePackage, packageInTypeUnit } from "./products";
|
||||
|
||||
const p = (
|
||||
packageQuantity: number,
|
||||
packageUnit: string,
|
||||
typeUnit: string,
|
||||
typeQuantity: number | null = null,
|
||||
) => ({
|
||||
packageQuantity,
|
||||
packageUnit,
|
||||
typeUnit,
|
||||
typeQuantity,
|
||||
});
|
||||
|
||||
describe("paketin ürün tipi birimindeki karşılığı", () => {
|
||||
it("aynı birim ve aynı boyut kendiliğinden", () => {
|
||||
expect(packageInTypeUnit(p(5_000, "kg", "kg"))).toBe(5_000);
|
||||
expect(packageInTypeUnit(p(500_000, "g", "kg"))).toBe(500);
|
||||
expect(packageInTypeUnit(p(1_500, "lt", "ml"))).toBe(1_500_000);
|
||||
expect(packageInTypeUnit(p(40_000, "tablet", "tablet"))).toBe(40_000);
|
||||
expect(autoConvertible("g", "kg")).toBe(true);
|
||||
expect(autoConvertible("kg", "lt")).toBe(false);
|
||||
expect(autoConvertible("paket", "adet")).toBe(false);
|
||||
});
|
||||
|
||||
it("farklı boyut veya özel birimde elle dönüşüm, yoksa null", () => {
|
||||
expect(packageInTypeUnit(p(5_000, "kg", "yıkama", 50_000))).toBe(50_000);
|
||||
expect(packageInTypeUnit(p(1_000, "paket", "tablet", 40_000))).toBe(40_000);
|
||||
expect(packageInTypeUnit(p(5_000, "kg", "yıkama"))).toBeNull();
|
||||
// Kendiliğinden çevrilebiliyorsa elle girilen yok sayılır.
|
||||
expect(packageInTypeUnit(p(500_000, "g", "kg", 999))).toBe(500);
|
||||
});
|
||||
|
||||
it("paket açıklaması ve barkod", () => {
|
||||
expect(describePackage(p(5_000, "kg", "yıkama", 50_000))).toBe("5 kg · 50 yıkama");
|
||||
expect(describePackage(p(500_000, "g", "kg"))).toBe("500 g · 0,5 kg");
|
||||
expect(describePackage(p(2_000, "kg", "kg"))).toBe("2 kg");
|
||||
expect(BARCODE_PATTERN.test("8690000000001")).toBe(true);
|
||||
expect(BARCODE_PATTERN.test("123")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { convertQuantity, formatQuantity, UNITS, type Unit } from "./quantity";
|
||||
|
||||
/**
|
||||
* Ürün (marka/varyant) motoru (Bölüm 6.11). Bir paketin ürün tipinin birimindeki karşılığı:
|
||||
* - paket birimi ürün tipinin birimiyle aynıysa paket miktarı,
|
||||
* - aynı boyuttaysa (kg↔g, lt↔ml) çevrilmiş miktar,
|
||||
* - değilse elle girilen dönüşüm ("5 kg = 50 yıkama", "1 paket = 40 tablet"); yoksa null
|
||||
* (kayıtta zorunlu; ürün tipinin birimi sonradan değişirse "dönüşüm gerekli" uyarısı).
|
||||
* Miktarlar binde bir.
|
||||
*/
|
||||
|
||||
const isStandard = (u: string): u is Unit => Object.hasOwn(UNITS, u);
|
||||
|
||||
/** Paket birimi ürün tipinin birimine kendiliğinden çevrilebiliyor mu? */
|
||||
export function autoConvertible(packageUnit: string, typeUnit: string): boolean {
|
||||
if (packageUnit === typeUnit) return true;
|
||||
return (
|
||||
isStandard(packageUnit) &&
|
||||
isStandard(typeUnit) &&
|
||||
UNITS[packageUnit].dimension === UNITS[typeUnit].dimension
|
||||
);
|
||||
}
|
||||
|
||||
/** Bir paketin ürün tipinin birimindeki miktarı (çevrilemiyorsa null). */
|
||||
export function packageInTypeUnit(input: {
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
typeUnit: string;
|
||||
/** Elle girilen dönüşüm: bir paket = bu kadar ürün tipi birimi. */
|
||||
typeQuantity: number | null;
|
||||
}): number | null {
|
||||
const { packageQuantity, packageUnit, typeUnit } = input;
|
||||
if (packageUnit === typeUnit) return packageQuantity;
|
||||
if (autoConvertible(packageUnit, typeUnit))
|
||||
return convertQuantity(packageQuantity, packageUnit as Unit, typeUnit as Unit);
|
||||
return input.typeQuantity && input.typeQuantity > 0 ? input.typeQuantity : null;
|
||||
}
|
||||
|
||||
/** "5 kg", dönüşüm varsa "5 kg · 50 yıkama". */
|
||||
export function describePackage(input: {
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
typeUnit: string;
|
||||
typeQuantity: number | null;
|
||||
}): string {
|
||||
const pkg = formatQuantity(input.packageQuantity, input.packageUnit);
|
||||
if (input.packageUnit === input.typeUnit) return pkg;
|
||||
const inType = packageInTypeUnit(input);
|
||||
return inType === null ? pkg : `${pkg} · ${formatQuantity(inType, input.typeUnit)}`;
|
||||
}
|
||||
|
||||
/** Barkod: 8–14 rakam (EAN-8, UPC-A, EAN-13, GTIN-14). */
|
||||
export const BARCODE_PATTERN = /^\d{8,14}$/;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
||||
import { BARCODE_PATTERN } from "../products";
|
||||
import { parseQuantity } from "../quantity";
|
||||
|
||||
const v = tr.validation;
|
||||
const s = tr.products.validation;
|
||||
|
||||
const quantity = (message: string) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x, ctx) => {
|
||||
const q = parseQuantity(x);
|
||||
if (q === null || q <= 0) {
|
||||
ctx.addIssue({ code: "custom", message });
|
||||
return z.NEVER;
|
||||
}
|
||||
return q;
|
||||
});
|
||||
|
||||
/**
|
||||
* Ürün (marka/varyant). Dönüşümün zorunlu olup olmadığı ürün tipinin birimine bağlı olduğundan
|
||||
* serviste denetlenir; burada yalnızca biçim.
|
||||
*/
|
||||
export const productFormSchema = z.object({
|
||||
productTypeId: z.string().regex(/^[a-f0-9]{24}$/i, s.productType),
|
||||
brand: z.string().trim().min(1, tr.common.required).max(60, v.nameMax),
|
||||
name: z.string().trim().max(120).default(""),
|
||||
barcode: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x) => x.replace(/\s/g, ""))
|
||||
.refine((x) => x === "" || BARCODE_PATTERN.test(x), s.barcode)
|
||||
.transform((x) => (x === "" ? null : x)),
|
||||
packageQuantity: quantity(s.packageQuantity),
|
||||
packageUnit: z.string().trim().min(1, s.packageUnit).max(20, s.packageUnit),
|
||||
typeQuantity: z.preprocess(
|
||||
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
|
||||
quantity(s.typeQuantity).nullable(),
|
||||
),
|
||||
note: z.string().trim().max(300).default(""),
|
||||
imageIds: z.array(z.string().regex(/^[a-f0-9]{24}$/i)).max(MAX_ATTACHMENTS_PER_RECORD),
|
||||
});
|
||||
export type ProductFormInput = z.input<typeof productFormSchema>;
|
||||
export type ProductForm = z.output<typeof productFormSchema>;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { MEAL_CARD_PROVIDERS } from "../accounts";
|
||||
import { parseMoney } from "../money";
|
||||
import { parseQuantity } from "../quantity";
|
||||
import { STORE_KINDS } from "../stores";
|
||||
|
||||
const v = tr.validation;
|
||||
const s = tr.stores.validation;
|
||||
|
||||
/** Boş → null; dolu → kuruş (≥ 0). */
|
||||
const optionalMoney = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x, ctx) => {
|
||||
if (x === "") return null;
|
||||
const minor = parseMoney(x);
|
||||
if (minor === null || minor < 0) {
|
||||
ctx.addIssue({ code: "custom", message: v.money });
|
||||
return z.NEVER;
|
||||
}
|
||||
return minor;
|
||||
});
|
||||
|
||||
/** "2,5" km → 2500 m (3 ondalık = metre). Boş → null. */
|
||||
const distance = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x, ctx) => {
|
||||
if (x === "") return null;
|
||||
const m = parseQuantity(x);
|
||||
if (m === null || m > 1_000_000) {
|
||||
ctx.addIssue({ code: "custom", message: s.distance });
|
||||
return z.NEVER;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
export const storeFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
|
||||
kind: z.enum(STORE_KINDS),
|
||||
branch: z.string().trim().max(80).default(""),
|
||||
distanceKm: distance,
|
||||
carryDifficulty: z.coerce.number().int().min(1, s.carry).max(5, s.carry),
|
||||
deliveryFee: optionalMoney,
|
||||
freeDeliveryMin: optionalMoney,
|
||||
mealCardAccepted: z.boolean(),
|
||||
mealCardProviders: z.array(z.enum(MEAL_CARD_PROVIDERS)).max(MEAL_CARD_PROVIDERS.length),
|
||||
note: z.string().trim().max(300).default(""),
|
||||
})
|
||||
.superRefine((x, ctx) => {
|
||||
if (x.mealCardAccepted && x.mealCardProviders.length === 0)
|
||||
ctx.addIssue({ code: "custom", path: ["mealCardProviders"], message: s.providers });
|
||||
})
|
||||
.transform((x) => {
|
||||
const online = x.kind === "online";
|
||||
return {
|
||||
name: x.name,
|
||||
kind: x.kind,
|
||||
branch: x.branch,
|
||||
distanceM: online ? null : x.distanceKm,
|
||||
carryDifficulty: x.carryDifficulty,
|
||||
deliveryFeeMinor: online ? x.deliveryFee : null,
|
||||
freeDeliveryMinMinor: online ? x.freeDeliveryMin : null,
|
||||
mealCardAccepted: x.mealCardAccepted,
|
||||
mealCardProviders: x.mealCardAccepted ? [...new Set(x.mealCardProviders)] : [],
|
||||
note: x.note,
|
||||
};
|
||||
});
|
||||
export type StoreFormInput = z.input<typeof storeFormSchema>;
|
||||
export type StoreForm = z.output<typeof storeFormSchema>;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { effortLevel, effortScore, formatDistance, sortStores, type StoreKind } from "./stores";
|
||||
|
||||
const s = (name: string, kind: StoreKind, distanceM: number | null, carryDifficulty: number) => ({
|
||||
name,
|
||||
branch: "",
|
||||
kind,
|
||||
distanceM,
|
||||
carryDifficulty,
|
||||
});
|
||||
|
||||
describe("zahmet skoru", () => {
|
||||
it("uzaklık ve taşıma eşit ağırlıklı; online uzaklık saymaz", () => {
|
||||
expect(effortScore(s("A", "market", 0, 1))).toBe(0);
|
||||
expect(effortScore(s("A", "market", 20_000, 5))).toBe(100);
|
||||
expect(effortScore(s("A", "market", 50_000, 5))).toBe(100);
|
||||
expect(effortScore(s("A", "market", 2_000, 3))).toBe(30);
|
||||
expect(effortScore(s("A", "bazaar", 1_000, 4))).toBe(40);
|
||||
expect(effortScore(s("A", "online", 30_000, 1))).toBe(0);
|
||||
expect(effortScore(s("A", "market", null, 2))).toBe(13);
|
||||
expect([effortLevel(0), effortLevel(33), effortLevel(34), effortLevel(67)]).toEqual([
|
||||
"low",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sıralama", () => {
|
||||
const list = [
|
||||
s("Migros", "market", 1_200, 2),
|
||||
s("Pazar", "bazaar", 600, 4),
|
||||
s("Getir", "online", null, 1),
|
||||
s("BİM", "market", 300, 2),
|
||||
s("Metro", "market", 9_000, 3),
|
||||
s("Bakkal", "market", null, 1),
|
||||
];
|
||||
const names = (by: Parameters<typeof sortStores>[1]) => sortStores(list, by).map((x) => x.name);
|
||||
|
||||
it("en yakın / en uzak: online ve uzaklığı bilinmeyenler sonda", () => {
|
||||
expect(names("nearest")).toEqual(["BİM", "Pazar", "Migros", "Metro", "Bakkal", "Getir"]);
|
||||
expect(names("farthest")).toEqual(["Metro", "Migros", "Pazar", "BİM", "Bakkal", "Getir"]);
|
||||
});
|
||||
|
||||
it("en zahmetli ve ada göre (Türkçe)", () => {
|
||||
expect(names("effort")).toEqual(["Metro", "Pazar", "Migros", "BİM", "Bakkal", "Getir"]);
|
||||
expect(names("name")).toEqual(["Bakkal", "BİM", "Getir", "Metro", "Migros", "Pazar"]);
|
||||
});
|
||||
|
||||
it("uzaklık gösterimi", () => {
|
||||
expect(formatDistance(800)).toBe("800 m");
|
||||
expect(formatDistance(2_500)).toBe("2,5 km");
|
||||
expect(formatDistance(12_000)).toBe("12 km");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { compareTr } from "./search";
|
||||
|
||||
/**
|
||||
* Mağaza motoru (Bölüm 6.11). Saf fonksiyonlar.
|
||||
*
|
||||
* Zahmet skoru (0–100): uzaklık ve taşıma güçlüğünün eşit ağırlıklı bileşimi.
|
||||
* uzaklık payı = min(uzaklık, 20 km) / 20 km × 50 (online mağazada 0: kapıya teslim)
|
||||
* taşıma payı = (taşıma puanı − 1) / 4 × 50 (1 = kolay … 5 = çok zor)
|
||||
* Faz 13'te akıllı öneri bu skoru maliyet cezasına çevirir.
|
||||
*/
|
||||
|
||||
export const STORE_KINDS = ["market", "online", "bazaar"] as const;
|
||||
export type StoreKind = (typeof STORE_KINDS)[number];
|
||||
|
||||
/** Uzaklık metre cinsinden tamsayı saklanır (2,5 km → 2500). */
|
||||
export const EFFORT_MAX_DISTANCE_M = 20_000;
|
||||
export const CARRY_MIN = 1;
|
||||
export const CARRY_MAX = 5;
|
||||
|
||||
export type StoreEffortInput = {
|
||||
kind: StoreKind;
|
||||
/** Metre; bilinmiyorsa null. */
|
||||
distanceM: number | null;
|
||||
/** 1–5. */
|
||||
carryDifficulty: number;
|
||||
};
|
||||
|
||||
export function effortScore(s: StoreEffortInput): number {
|
||||
const distance =
|
||||
s.kind === "online" ? 0 : Math.min(Math.max(0, s.distanceM ?? 0), EFFORT_MAX_DISTANCE_M);
|
||||
const carry = Math.min(CARRY_MAX, Math.max(CARRY_MIN, s.carryDifficulty));
|
||||
return Math.round(
|
||||
(distance / EFFORT_MAX_DISTANCE_M) * 50 + ((carry - CARRY_MIN) / (CARRY_MAX - CARRY_MIN)) * 50,
|
||||
);
|
||||
}
|
||||
|
||||
export type EffortLevel = "low" | "medium" | "high";
|
||||
|
||||
export function effortLevel(score: number): EffortLevel {
|
||||
if (score < 34) return "low";
|
||||
if (score < 67) return "medium";
|
||||
return "high";
|
||||
}
|
||||
|
||||
export const STORE_SORTS = ["name", "nearest", "farthest", "effort"] as const;
|
||||
export type StoreSort = (typeof STORE_SORTS)[number];
|
||||
|
||||
type Sortable = StoreEffortInput & { name: string; branch: string };
|
||||
|
||||
/**
|
||||
* Sıralama. "En yakın/en uzak" yalnızca uzaklığı bilinen fiziksel mağazaları sıralar; online ve
|
||||
* uzaklığı bilinmeyenler sonda (ada göre). "En zahmetli": skor büyükten küçüğe.
|
||||
*/
|
||||
export function sortStores<T extends Sortable>(stores: readonly T[], by: StoreSort): T[] {
|
||||
const byName = (a: T, b: T) => compareTr(a.name, b.name) || compareTr(a.branch, b.branch);
|
||||
const known = (s: T) => s.kind !== "online" && s.distanceM !== null;
|
||||
return [...stores].sort((a, b) => {
|
||||
switch (by) {
|
||||
case "name":
|
||||
return byName(a, b);
|
||||
case "effort":
|
||||
return effortScore(b) - effortScore(a) || byName(a, b);
|
||||
case "nearest":
|
||||
case "farthest": {
|
||||
if (known(a) !== known(b)) return known(a) ? -1 : 1;
|
||||
if (!known(a)) return byName(a, b);
|
||||
const d = a.distanceM! - b.distanceM!;
|
||||
return (by === "nearest" ? d : -d) || byName(a, b);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 2500 → "2,5 km"; 800 → "800 m". */
|
||||
export function formatDistance(meters: number): string {
|
||||
if (meters < 1000) return `${meters} m`;
|
||||
const km = meters / 1000;
|
||||
return `${km.toLocaleString("tr-TR", { maximumFractionDigits: 1 })} km`;
|
||||
}
|
||||
Reference in New Issue
Block a user