From f0b570284e0e2fe10bedff749d473205cbb26fe7 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 11:48:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(shopping):=20ma=C4=9Fazalar,=20zahmet=20sk?= =?UTF-8?q?oru=20ve=20=C3=BCr=C3=BCnler=20(marka/paket,=20d=C3=B6n=C3=BC?= =?UTF-8?q?=C5=9F=C3=BCm,=20g=C3=B6rseller)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/013-stores-products.ts | 12 + scripts/migrations/index.ts | 2 + scripts/seed/run.int.test.ts | 4 + scripts/seed/run.ts | 12 + scripts/seed/shopping.ts | 328 ++++++++++++++++++++ src/features/attachments/service.ts | 4 + src/features/products/actions.ts | 50 +++ src/features/products/service.int.test.ts | 179 +++++++++++ src/features/products/service.ts | 358 ++++++++++++++++++++++ src/features/stores/actions.ts | 50 +++ src/features/stores/service.int.test.ts | 111 +++++++ src/features/stores/service.ts | 189 ++++++++++++ src/i18n/tr.ts | 152 +++++++++ src/models/attachment.ts | 2 +- src/models/audit-log.ts | 4 + src/models/product.ts | 45 +++ src/models/store.ts | 43 +++ src/shared/products.test.ts | 42 +++ src/shared/products.ts | 53 ++++ src/shared/schemas/product.ts | 47 +++ src/shared/schemas/store.ts | 72 +++++ src/shared/stores.test.ts | 56 ++++ src/shared/stores.ts | 79 +++++ 23 files changed, 1893 insertions(+), 1 deletion(-) create mode 100644 scripts/migrations/013-stores-products.ts create mode 100644 scripts/seed/shopping.ts create mode 100644 src/features/products/actions.ts create mode 100644 src/features/products/service.int.test.ts create mode 100644 src/features/products/service.ts create mode 100644 src/features/stores/actions.ts create mode 100644 src/features/stores/service.int.test.ts create mode 100644 src/features/stores/service.ts create mode 100644 src/models/product.ts create mode 100644 src/models/store.ts create mode 100644 src/shared/products.test.ts create mode 100644 src/shared/products.ts create mode 100644 src/shared/schemas/product.ts create mode 100644 src/shared/schemas/store.ts create mode 100644 src/shared/stores.test.ts create mode 100644 src/shared/stores.ts diff --git a/scripts/migrations/013-stores-products.ts b/scripts/migrations/013-stores-products.ts new file mode 100644 index 0000000..a239b20 --- /dev/null +++ b/scripts/migrations/013-stores-products.ts @@ -0,0 +1,12 @@ +import { ProductModel } from "@/models/product"; +import { StoreModel } from "@/models/store"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "013-stores-products", + description: "mağaza ve ürün (marka/paket) koleksiyonlarının index'leri", + async up() { + await StoreModel.createIndexes(); + await ProductModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 57cccf7..d90024a 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -12,6 +12,7 @@ import { migration as m009 } from "./009-assets"; import { migration as m010 } from "./010-budgets"; import { migration as m011 } from "./011-stock"; import { migration as m012 } from "./012-stock-counts"; +import { migration as m013 } from "./013-stores-products"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ @@ -28,6 +29,7 @@ export const MIGRATIONS: Migration[] = [ m010, m011, m012, + m013, ]; const COLLECTION = "migrations"; diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index 55d6f25..92b7dc6 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -35,6 +35,8 @@ describe("seed", () => { expect(summary.created.assets).toBe(4); expect(summary.created.budgets).toBe(2); expect(summary.created.stock).toBe(19); + expect(summary.created.stores).toBe(8); + expect(summary.created.products).toBe(22); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); }); @@ -55,6 +57,8 @@ describe("seed", () => { assets: 0, budgets: 0, stock: 0, + stores: 0, + products: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 6279713..284a447 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -13,6 +13,7 @@ import { runMigrations } from "../migrations"; import { seedContacts } from "./contacts"; import { seedAssets } from "./assets"; import { seedBudgets } from "./budgets"; +import { seedShopping } from "./shopping"; import { seedStock } from "./stock"; import { seedInKind } from "./in-kind"; import { seedInstallments } from "./installments"; @@ -57,6 +58,8 @@ export type SeedSummary = { assets: number; budgets: number; stock: number; + stores: number; + products: number; }; accounts: SeedAccountRow[]; }; @@ -84,6 +87,8 @@ const DOMAIN_COLLECTIONS = [ "productTypes", "stockMovements", "stockCounts", + "stores", + "products", ]; type AuthApi = ReturnType["api"]; @@ -140,6 +145,8 @@ export async function runSeed({ assets: 0, budgets: 0, stock: 0, + stores: 0, + products: 0, }, accounts: [], }; @@ -324,6 +331,11 @@ export async function runSeed({ if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`); summary.created.stock = await seedStock(householdIds, userIdMap); if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`); + const shopping = await seedShopping(householdIds, userIdMap); + summary.created.stores = shopping.stores; + summary.created.products = shopping.products; + if (shopping.stores || shopping.products) + log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`); // 7) Geliştirme hesapları tablosu const all = [ diff --git a/scripts/seed/shopping.ts b/scripts/seed/shopping.ts new file mode 100644 index 0000000..cdabf25 --- /dev/null +++ b/scripts/seed/shopping.ts @@ -0,0 +1,328 @@ +import { Types } from "mongoose"; +import { ProductModel } from "@/models/product"; +import { ProductTypeModel } from "@/models/product-type"; +import { StoreModel } from "@/models/store"; +import type { MealCardProvider } from "@/shared/accounts"; +import { autoConvertible } from "@/shared/products"; +import { foldTr } from "@/shared/search"; +import type { StoreKind } from "@/shared/stores"; + +/** + * Örnek mağazalar ve ürünler (Faz 12). Çakır Ailesi (Kadıköy) için marketler, pazar ve online + * platform; stok ürün tiplerine bağlı marka/paketler. Elle dönüşüm gereken örnekler: deterjan + * (kg → yıkama), tablet paketi (paket → tablet), diş macunu/şampuan (ml → adet). Görsel yok. + * Idempotent (`seedRef`). Miktarlar binde bir, tutarlar kuruş. + */ + +type SeedStore = { + ref: string; + name: string; + kind: StoreKind; + branch?: string; + distanceM?: number; + carry: number; + deliveryFee?: number; + freeDeliveryMin?: number; + mealCards?: MealCardProvider[]; + note?: string; +}; + +export const STORES: SeedStore[] = [ + { + ref: "migros", + name: "Migros", + kind: "market", + branch: "Bahariye", + distanceM: 1_200, + carry: 2, + mealCards: ["pluxee", "multinet", "edenred", "setcard"], + }, + { ref: "a101", name: "A101", kind: "market", branch: "Moda", distanceM: 400, carry: 1 }, + { ref: "bim", name: "BİM", kind: "market", branch: "Yeldeğirmeni", distanceM: 650, carry: 2 }, + { + ref: "sok", + name: "Şok", + kind: "market", + branch: "Rasimpaşa", + distanceM: 300, + carry: 1, + mealCards: ["multinet", "pluxee"], + }, + { + ref: "carrefoursa", + name: "CarrefourSA", + kind: "market", + branch: "Acıbadem", + distanceM: 3_800, + carry: 4, + mealCards: ["pluxee", "multinet", "edenred", "setcard", "metropol"], + note: "Hafta sonu arabayla; büyük alışveriş.", + }, + { + ref: "sali-pazari", + name: "Salı Pazarı", + kind: "bazaar", + branch: "Kadıköy", + distanceM: 1_900, + carry: 4, + note: "Salı ve cuma; sebze-meyve.", + }, + { + ref: "getir-buyuk", + name: "Getir Büyük", + kind: "online", + carry: 1, + deliveryFee: 39_90, + freeDeliveryMin: 750_00, + mealCards: ["multinet"], + }, + { + ref: "metro", + name: "Metro Toptancı", + kind: "market", + branch: "Kozyatağı", + distanceM: 9_500, + carry: 5, + }, +]; + +type SeedProduct = { + ref: string; + /** Stok seed'indeki ürün tipi `seedRef`'i. */ + type: string; + brand: string; + name?: string; + barcode?: string; + qty: number; + unit: string; + /** Elle dönüşüm (bir paket = bu kadar ürün tipi birimi). */ + typeQty?: number; +}; + +const K = 1000; + +export const PRODUCTS: SeedProduct[] = [ + { + ref: "ariel-5kg", + type: "toz-deterjan", + brand: "Ariel", + name: "Matik Dağ Esintisi", + qty: 5 * K, + unit: "kg", + typeQty: 33 * K, + barcode: "8690000100015", + }, + { + ref: "omo-6kg", + type: "toz-deterjan", + brand: "Omo", + name: "Matik Active Fresh", + qty: 6 * K, + unit: "kg", + typeQty: 40 * K, + }, + { + ref: "bingo-4kg", + type: "toz-deterjan", + brand: "Bingo", + name: "Matik", + qty: 4 * K, + unit: "kg", + typeQty: 26 * K, + }, + { + ref: "fairy-50", + type: "tablet-deterjan", + brand: "Fairy", + name: "Platinum Plus", + qty: 1 * K, + unit: "paket", + typeQty: 50 * K, + barcode: "8690000100022", + }, + { + ref: "finish-40", + type: "tablet-deterjan", + brand: "Finish", + name: "Quantum", + qty: 40 * K, + unit: "tablet", + }, + { + ref: "yayla-mercimek", + type: "kirmizi-mercimek", + brand: "Yayla", + name: "Kırmızı Mercimek", + qty: 1 * K, + unit: "kg", + }, + { + ref: "reis-mercimek", + type: "kirmizi-mercimek", + brand: "Reis", + name: "Kırmızı Mercimek", + qty: 1 * K, + unit: "kg", + }, + { + ref: "duru-mercimek", + type: "kirmizi-mercimek", + brand: "Duru", + name: "Kırmızı Mercimek", + qty: 2_500, + unit: "kg", + }, + { ref: "yayla-baldo", type: "pirinc", brand: "Yayla", name: "Baldo", qty: 2_500, unit: "kg" }, + { ref: "reis-baldo", type: "pirinc", brand: "Reis", name: "Baldo", qty: 1 * K, unit: "kg" }, + { + ref: "pinar-sut", + type: "sut", + brand: "Pınar", + name: "Tam Yağlı Süt", + qty: 1 * K, + unit: "lt", + barcode: "8690000100039", + }, + { ref: "sutas-sut", type: "sut", brand: "Sütaş", name: "Tam Yağlı Süt", qty: 1 * K, unit: "lt" }, + { + ref: "icim-sut-500", + type: "sut", + brand: "İçim", + name: "Yarım Yağlı Süt", + qty: 500 * K, + unit: "ml", + }, + { + ref: "keskinoglu-30", + type: "yumurta", + brand: "Keskinoğlu", + name: "L Boy 30'lu", + qty: 30 * K, + unit: "adet", + }, + { + ref: "koy-yumurta-15", + type: "yumurta", + brand: "Köy Yumurtası", + name: "15'li", + qty: 15 * K, + unit: "adet", + }, + { + ref: "pinar-peynir", + type: "beyaz-peynir", + brand: "Pınar", + name: "Beyaz Peynir", + qty: 500 * K, + unit: "g", + }, + { + ref: "yumos-1440", + type: "yumusatici", + brand: "Yumoş", + name: "Extra Lilyum", + qty: 1_440 * K, + unit: "ml", + }, + { + ref: "selpak-32", + type: "tuvalet-kagidi", + brand: "Selpak", + name: "32'li", + qty: 32 * K, + unit: "rulo", + }, + { + ref: "solo-24", + type: "tuvalet-kagidi", + brand: "Solo", + name: "24'lü", + qty: 24 * K, + unit: "rulo", + }, + { + ref: "colgate-75", + type: "dis-macunu", + brand: "Colgate", + name: "Total 75 ml", + qty: 75 * K, + unit: "ml", + typeQty: 1 * K, + }, + { + ref: "sensodyne-75", + type: "dis-macunu", + brand: "Sensodyne", + name: "Repair 75 ml", + qty: 75 * K, + unit: "ml", + typeQty: 1 * K, + }, + { + ref: "elidor-500", + type: "sampuan", + brand: "Elidor", + name: "500 ml", + qty: 500 * K, + unit: "ml", + typeQty: 1 * K, + }, +]; + +export async function seedShopping( + householdIds: Map, + users: Map, +): Promise<{ stores: number; products: number }> { + const actor = new Types.ObjectId(users.get("ayse")!); + const householdId = householdIds.get("Çakır Ailesi")!; + let stores = 0; + for (const s of STORES) { + if (await StoreModel.exists({ householdId, seedRef: s.ref })) continue; + await StoreModel.create({ + householdId, + name: s.name, + nameKey: foldTr(s.name), + kind: s.kind, + branch: s.branch ?? "", + distanceM: s.kind === "online" ? null : (s.distanceM ?? null), + carryDifficulty: s.carry, + deliveryFeeMinor: s.kind === "online" ? (s.deliveryFee ?? null) : null, + freeDeliveryMinMinor: s.kind === "online" ? (s.freeDeliveryMin ?? null) : null, + mealCardAccepted: Boolean(s.mealCards?.length), + mealCardProviders: s.mealCards ?? [], + note: s.note ?? "", + seedRef: s.ref, + createdBy: actor, + updatedBy: actor, + }); + stores++; + } + + const types = await ProductTypeModel.find({ householdId, seedRef: { $type: "string" } }) + .select({ seedRef: 1, unit: 1 }) + .lean<{ _id: Types.ObjectId; seedRef: string; unit: string }[]>(); + const typeByRef = new Map(types.map((x) => [x.seedRef, x])); + let products = 0; + for (const p of PRODUCTS) { + const type = typeByRef.get(p.type); + if (!type || (await ProductModel.exists({ householdId, seedRef: p.ref }))) continue; + if (!autoConvertible(p.unit, type.unit) && !p.typeQty) + throw new Error(`Seed ürünü ${p.ref}: ${p.unit} → ${type.unit} dönüşümü eksik`); + await ProductModel.create({ + householdId, + productTypeId: type._id, + brand: p.brand, + name: p.name ?? "", + searchKey: foldTr(`${p.brand} ${p.name ?? ""}`), + barcode: p.barcode ?? null, + packageQuantity: p.qty, + packageUnit: p.unit, + typeQuantity: autoConvertible(p.unit, type.unit) ? null : p.typeQty, + seedRef: p.ref, + createdBy: actor, + updatedBy: actor, + }); + products++; + } + return { stores, products }; +} diff --git a/src/features/attachments/service.ts b/src/features/attachments/service.ts index bd8925d..0b2d52f 100644 --- a/src/features/attachments/service.ts +++ b/src/features/attachments/service.ts @@ -11,6 +11,7 @@ import { getStorage } from "@/lib/storage"; import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process"; import { AccountModel } from "@/models/account"; import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment"; +import { ProductModel } from "@/models/product"; import { TransactionModel } from "@/models/transaction"; import { detectFileType, @@ -267,6 +268,9 @@ export async function readAttachmentForUser( ownerUserId: { $ne: new Types.ObjectId(userId) }, }); if (hidden) return null; + } else if (a.ownerType === "product") { + // Ürün görselleri hane ortaktır (arşivli ürün dahil). + if (!(await ProductModel.exists({ _id: a.ownerId, householdId: a.householdId }))) return null; } else { return null; } diff --git a/src/features/products/actions.ts b/src/features/products/actions.ts new file mode 100644 index 0000000..6da202e --- /dev/null +++ b/src/features/products/actions.ts @@ -0,0 +1,50 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { productFormSchema } from "@/shared/schemas/product"; +import { createProduct, setProductArchived, updateProduct } from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = schema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await fn(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +/** Yeni (id boş) veya mevcut ürün. */ +export async function saveProductAction(productId: unknown, input: unknown) { + if (productId) { + const id = idSchema.safeParse(productId); + if (!id.success) return fail(tr.common.notFound); + return run(productFormSchema, input, (u, h, d) => updateProduct(u, h, id.data, d)); + } + return run(productFormSchema, input, createProduct); +} + +export async function setProductArchivedAction(productId: unknown, archived: unknown) { + const id = idSchema.safeParse(productId); + if (!id.success) return fail(tr.common.notFound); + return run(z.boolean(), archived, (u, h, a) => setProductArchived(u, h, id.data, a)); +} diff --git a/src/features/products/service.int.test.ts b/src/features/products/service.int.test.ts new file mode 100644 index 0000000..fd6e362 --- /dev/null +++ b/src/features/products/service.int.test.ts @@ -0,0 +1,179 @@ +import { rm } from "node:fs/promises"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { readAttachmentForUser, uploadAttachment } from "@/features/attachments/service"; +import { env } from "@/lib/env"; +import { AttachmentModel } from "@/models/attachment"; +import { ProductTypeModel } from "@/models/product-type"; +import { productFormSchema, type ProductFormInput } from "@/shared/schemas/product"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { + createProduct, + listProducts, + listProductTypeOptions, + setProductArchived, + updateProduct, +} from "./service"; + +let ids: Awaited>; +let cakir = ""; +let ayse = ""; +let emre = ""; +let zeynep = ""; +let fatma = ""; + +beforeAll(async () => { + await rm(env().UPLOAD_DIR, { recursive: true, force: true }); + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + ayse = ids.user("ayse"); + emre = ids.user("emre"); + zeynep = ids.user("zeynep"); + fatma = ids.user("fatma"); +}); +afterAll(async () => { + await teardownDb(); + await rm(env().UPLOAD_DIR, { recursive: true, force: true }); +}); + +const typeId = (ref: string) => + ProductTypeModel.findOne({ seedRef: ref }) + .lean() + .then((x) => String(x!._id)); + +const png = () => + sharp({ create: { width: 40, height: 30, channels: 3, background: "#cc6633" } }) + .png() + .toBuffer(); + +async function form(over: Partial = {}) { + return productFormSchema.parse({ + productTypeId: await typeId("toz-deterjan"), + brand: "Persil", + name: "Matik", + barcode: "", + packageQuantity: "4,5", + packageUnit: "kg", + typeQuantity: "30", + note: "", + imageIds: [], + ...over, + }); +} + +describe("ürünler (marka/paket)", () => { + it("seed: dönüşümler ve paket açıklaması; izolasyon", async () => { + const all = (await listProducts(ayse, cakir))!; + expect(all.products).toHaveLength(22); + const ariel = all.products.find((p) => p.brand === "Ariel")!; + expect(ariel).toMatchObject({ + productTypeName: "Toz Deterjan", + typeUnit: "yıkama", + inTypeUnit: 33_000, + packageLabel: "5 kg · 33 yıkama", + conversionMissing: false, + }); + const icim = all.products.find((p) => p.brand === "İçim")!; + expect(icim).toMatchObject({ typeQuantity: null, inTypeUnit: 500 }); + const peynir = (await listProducts(ayse, cakir, { + productTypeId: await typeId("beyaz-peynir"), + }))!; + expect(peynir.products.map((p) => p.packageLabel)).toEqual(["500 g · 0,5 kg"]); + expect(await listProducts(fatma, cakir)).toBeNull(); + expect((await listProductTypeOptions(ayse, cakir))!.length).toBe(19); + }); + + it("dönüşüm zorunlu; kendiliğinden çevrilebilende yok sayılır; barkod tek", async () => { + const missing = await createProduct(ayse, cakir, await form({ typeQuantity: "" })); + expect(missing).toMatchObject({ ok: false, fieldErrors: { typeQuantity: expect.any(String) } }); + + const ok = await createProduct(ayse, cakir, await form({ barcode: "8690 0000 0099 9" })); + expect(ok.ok).toBe(true); + const sut = await createProduct( + ayse, + cakir, + await form({ + productTypeId: await typeId("sut"), + brand: "Torku", + packageQuantity: "1000", + packageUnit: "ml", + typeQuantity: "7", + }), + ); + expect(sut.ok).toBe(true); + const list = (await listProducts(ayse, cakir))!.products; + expect(list.find((p) => p.brand === "Persil")).toMatchObject({ + barcode: "8690000000999", + inTypeUnit: 30_000, + }); + expect(list.find((p) => p.brand === "Torku")).toMatchObject({ + typeQuantity: null, + inTypeUnit: 1_000, + }); + + const dup = await createProduct( + ayse, + cakir, + await form({ brand: "Başka", barcode: "8690000000999" }), + ); + expect(dup).toMatchObject({ ok: false, fieldErrors: { barcode: expect.any(String) } }); + expect(productFormSchema.safeParse({ ...(await form()), barcode: "12a" }).success).toBe(false); + expect((await createProduct(zeynep, cakir, await form({ brand: "İzleyici" }))).ok).toBe(false); + }); + + it("görseller: bağlanır, sıralanır, çıkarılır; okuma hane üyeleriyle sınırlı", async () => { + const a1 = await uploadAttachment(ayse, cakir, { name: "on.png", bytes: await png() }); + const a2 = await uploadAttachment(ayse, cakir, { name: "arka.png", bytes: await png() }); + const id1 = a1.ok ? a1.data.id : ""; + const id2 = a2.ok ? a2.data.id : ""; + const r = await createProduct( + ayse, + cakir, + await form({ brand: "Peros", imageIds: [id2, id1] }), + ); + expect(r.ok).toBe(true); + const pid = r.ok ? r.data.id : ""; + let p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!; + expect(p.images.map((i) => i.id)).toEqual([id2, id1]); + + // Aynı hanenin başka üyesi görür; başka hane görmez. + expect(await readAttachmentForUser(emre, id1, "thumb")).not.toBeNull(); + expect(await readAttachmentForUser(fatma, id1, "full")).toBeNull(); + + // Başkasının yüklediği bekleyen görsel bağlanamaz. + const foreign = await uploadAttachment(emre, cakir, { name: "x.png", bytes: await png() }); + const bad = await updateProduct( + ayse, + cakir, + pid, + await form({ brand: "Peros", imageIds: [id1, foreign.ok ? foreign.data.id : ""] }), + ); + expect(bad.ok).toBe(false); + p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!; + expect(p.images).toHaveLength(2); + + expect( + (await updateProduct(ayse, cakir, pid, await form({ brand: "Peros", imageIds: [id1] }))).ok, + ).toBe(true); + expect(await AttachmentModel.exists({ _id: id2, deletedAt: { $ne: null } })).toBeTruthy(); + p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!; + expect(p.images.map((i) => i.id)).toEqual([id1]); + }); + + it("arşiv: listeden çıkar, düzenlenemez, barkod çakışırsa geri alınamaz", async () => { + const r = await createProduct(ayse, cakir, await form({ brand: "Arşiv", barcode: "12345678" })); + const id = r.ok ? r.data.id : ""; + expect((await setProductArchived(ayse, cakir, id, true)).ok).toBe(true); + expect((await listProducts(ayse, cakir))!.products.some((p) => p.id === id)).toBe(false); + expect( + (await listProducts(ayse, cakir, { includeArchived: true }))!.products.find( + (p) => p.id === id, + )!.archived, + ).toBe(true); + expect((await updateProduct(ayse, cakir, id, await form({ brand: "Arşiv" }))).ok).toBe(false); + expect( + (await createProduct(ayse, cakir, await form({ brand: "Yeni", barcode: "12345678" }))).ok, + ).toBe(true); + expect((await setProductArchived(ayse, cakir, id, false)).ok).toBe(false); + }); +}); diff --git a/src/features/products/service.ts b/src/features/products/service.ts new file mode 100644 index 0000000..eac2db3 --- /dev/null +++ b/src/features/products/service.ts @@ -0,0 +1,358 @@ +import "server-only"; +import { Types } from "mongoose"; +import { + syncOwnerAttachments, + toAttachmentInfo, + type AttachmentInfo, +} from "@/features/attachments/service"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { resolveMembership, type Membership } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeAudit } from "@/lib/audit"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { toObjectId } from "@/lib/object-id"; +import { AttachmentModel } from "@/models/attachment"; +import { ProductModel } from "@/models/product"; +import { ProductTypeModel } from "@/models/product-type"; +import { autoConvertible, describePackage, packageInTypeUnit } from "@/shared/products"; +import type { ProductForm } from "@/shared/schemas/product"; +import { compareTr, foldTr } from "@/shared/search"; + +/** + * Ürünler (marka/varyant, Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma: + * member. Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa dönüşüm zorunludur. + * Görseller ek dosya altyapısıyla (ownerType "product", yalnızca görsel). + */ + +const t = tr.products; + +type ProductLean = { + _id: Types.ObjectId; + productTypeId: Types.ObjectId; + brand: string; + name: string; + barcode: string | null; + packageQuantity: number; + packageUnit: string; + typeQuantity: number | null; + note: string; + imageCount: number; + archivedAt: Date | null; +}; + +type TypeLean = { _id: Types.ObjectId; name: string; unit: string; archivedAt: Date | null }; + +export type ProductView = { + id: string; + productTypeId: string; + productTypeName: string; + typeUnit: string; + brand: string; + name: string; + /** "Ariel Matik Dağ Esintisi". */ + label: string; + barcode: string | null; + packageQuantity: number; + packageUnit: string; + /** Elle girilen dönüşüm (kendiliğinden çevrilebiliyorsa null). */ + typeQuantity: number | null; + /** Bir paketin ürün tipi birimindeki karşılığı (çevrilemiyorsa null). */ + inTypeUnit: number | null; + /** Ürün tipinin birimi sonradan değişti ve dönüşüm yok: fiyat karşılaştırmasına katılamaz. */ + conversionMissing: boolean; + /** "5 kg · 50 yıkama". */ + packageLabel: string; + note: string; + archived: boolean; + images: AttachmentInfo[]; +}; + +export type ProductTypeOption = { id: string; name: string; unit: string }; + +export const productLabel = (p: { brand: string; name: string }) => + p.name ? `${p.brand} ${p.name}` : p.brand; + +function toView(p: ProductLean, type: TypeLean | undefined, images: AttachmentInfo[]): ProductView { + const typeUnit = type?.unit ?? ""; + const conv = { + packageQuantity: p.packageQuantity, + packageUnit: p.packageUnit, + typeUnit, + typeQuantity: p.typeQuantity, + }; + const inTypeUnit = packageInTypeUnit(conv); + return { + id: String(p._id), + productTypeId: String(p.productTypeId), + productTypeName: type?.name ?? "", + typeUnit, + brand: p.brand, + name: p.name, + label: productLabel(p), + barcode: p.barcode, + packageQuantity: p.packageQuantity, + packageUnit: p.packageUnit, + typeQuantity: p.typeQuantity, + inTypeUnit, + conversionMissing: inTypeUnit === null, + packageLabel: describePackage(conv), + note: p.note, + archived: Boolean(p.archivedAt), + images, + }; +} + +async function loadImages(householdId: Types.ObjectId, productIds: Types.ObjectId[]) { + const rows = await AttachmentModel.find({ + householdId, + ownerType: "product", + ownerId: { $in: productIds }, + deletedAt: null, + }) + .sort({ order: 1, createdAt: 1 }) + .lean(); + const by = new Map(); + for (const a of rows) { + const key = String(a.ownerId); + by.set(key, [ + ...(by.get(key) ?? []), + toAttachmentInfo(a as Parameters[0]), + ]); + } + return by; +} + +/** + * Ürün listesi: ürün tipine göre (verilirse) veya tüm katalog. Sıra: ürün tipi adı, marka, + * tanım. Arşivliler `includeArchived` ile. + */ +export async function listProducts( + userId: string, + householdId: string, + opts: { productTypeId?: string; includeArchived?: boolean } = {}, +): Promise<{ products: ProductView[]; canWrite: boolean } | null> { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const typeId = opts.productTypeId ? toObjectId(opts.productTypeId) : null; + if (opts.productTypeId && !typeId) return null; + const rows = await ProductModel.find({ + householdId: m.householdId, + ...(typeId ? { productTypeId: typeId } : {}), + ...(opts.includeArchived ? {} : { archivedAt: null }), + }).lean(); + const types = await ProductTypeModel.find({ + householdId: m.householdId, + _id: { + $in: [...new Set(rows.map((r) => String(r.productTypeId)))].map( + (id) => new Types.ObjectId(id), + ), + }, + }) + .select({ name: 1, unit: 1, archivedAt: 1 }) + .lean(); + const typeById = new Map(types.map((x) => [String(x._id), x])); + const images = await loadImages( + m.householdId, + rows.filter((r) => r.imageCount > 0).map((r) => r._id), + ); + const products = rows + .map((r) => toView(r, typeById.get(String(r.productTypeId)), images.get(String(r._id)) ?? [])) + .sort( + (a, b) => + compareTr(a.productTypeName, b.productTypeName) || + compareTr(a.brand, b.brand) || + compareTr(a.name, b.name), + ); + return { products, canWrite: hasHouseholdRole(m.role, "member") }; +} + +/** Ürün formu için aktif ürün tipleri (ada göre). */ +export async function listProductTypeOptions( + userId: string, + householdId: string, +): Promise { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const rows = await ProductTypeModel.find({ householdId: m.householdId, archivedAt: null }) + .select({ name: 1, unit: 1 }) + .lean(); + return rows + .map((r) => ({ id: String(r._id), name: r.name, unit: r.unit })) + .sort((a, b) => compareTr(a.name, b.name)); +} + +/** Ürün tipi, dönüşüm ve barkod denetimi; geçerliyse kaydedilecek dönüşümü döner. */ +async function checkInput( + m: Membership, + input: ProductForm, + exceptId?: Types.ObjectId, +): Promise<{ error: ActionResult } | { error: null; typeQuantity: number | null }> { + const type = await ProductTypeModel.findOne({ + _id: new Types.ObjectId(input.productTypeId), + householdId: m.householdId, + }).lean(); + if (!type || type.archivedAt) + return { error: fail(t.validation.productType, { productTypeId: t.validation.productType }) }; + const auto = autoConvertible(input.packageUnit, type.unit); + if (!auto && !input.typeQuantity) { + const msg = t.validation.conversionRequired(input.packageUnit, type.unit); + return { error: fail(msg, { typeQuantity: msg }) }; + } + if (input.barcode) { + const dup = await ProductModel.exists({ + householdId: m.householdId, + barcode: input.barcode, + archivedAt: null, + ...(exceptId ? { _id: { $ne: exceptId } } : {}), + }); + if (dup) + return { error: fail(t.validation.barcodeTaken, { barcode: t.validation.barcodeTaken }) }; + } + return { error: null, typeQuantity: auto ? null : input.typeQuantity }; +} + +/** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */ +class ImageError extends Error { + constructor(readonly result: ActionResult) { + super(result.ok ? "" : result.error); + } +} + +async function save( + m: Membership, + actorId: string, + input: ProductForm, + typeQuantity: number | null, + existing: Types.ObjectId | null, +): Promise> { + const actor = new Types.ObjectId(actorId); + const fields = { + productTypeId: new Types.ObjectId(input.productTypeId), + brand: input.brand, + name: input.name, + searchKey: foldTr(`${input.brand} ${input.name}`), + barcode: input.barcode, + packageQuantity: input.packageQuantity, + packageUnit: input.packageUnit, + typeQuantity, + note: input.note, + updatedBy: actor, + }; + const mongoose = await connectMongoose(); + let id = existing; + try { + await mongoose.connection.transaction(async (session) => { + if (existing) { + await ProductModel.updateOne( + { _id: existing, householdId: m.householdId }, + { $set: fields }, + { session }, + ); + } else { + const [doc] = await ProductModel.create( + [{ householdId: m.householdId, ...fields, createdBy: actor }], + { session }, + ); + id = doc!._id; + } + const synced = await syncOwnerAttachments({ + householdId: m.householdId, + actorId, + ownerType: "product", + ownerId: id!, + ids: input.imageIds, + allowedKinds: ["image"], + session, + }); + if (!synced.ok) throw new ImageError(synced); + await ProductModel.updateOne( + { _id: id!, householdId: m.householdId }, + { $set: { imageCount: synced.data } }, + { session }, + ); + }); + } catch (error) { + if (error instanceof ImageError) return error.result; + throw error; + } + return ok({ id: String(id) }); +} + +export async function createProduct( + actorId: string, + householdId: string, + input: ProductForm, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const c = await checkInput(m, input); + if (c.error) return c.error; + return save(m, actorId, input, c.typeQuantity, null); +} + +export async function updateProduct( + actorId: string, + householdId: string, + productId: string, + input: ProductForm, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(productId); + const current = id + ? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean() + : null; + if (!current) return fail(tr.common.notFound); + if (current.archivedAt) return fail(t.validation.archived); + const c = await checkInput(m, input, current._id); + if (c.error) return c.error; + return save(m, actorId, input, c.typeQuantity, current._id); +} + +export async function setProductArchived( + actorId: string, + householdId: string, + productId: string, + archived: boolean, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(productId); + const current = id + ? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean() + : null; + if (!current) return fail(tr.common.notFound); + if (!archived) { + const type = await ProductTypeModel.exists({ + _id: current.productTypeId, + householdId: m.householdId, + archivedAt: null, + }); + if (!type) return fail(t.validation.productType); + if ( + current.barcode && + (await ProductModel.exists({ + householdId: m.householdId, + barcode: current.barcode, + archivedAt: null, + _id: { $ne: current._id }, + })) + ) + return fail(t.validation.barcodeTaken); + } + const actor = new Types.ObjectId(actorId); + await ProductModel.updateOne( + { _id: current._id, householdId: m.householdId }, + { $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } }, + ); + await writeAudit({ + action: archived ? "product.archive" : "product.unarchive", + actorId: actor, + householdId: m.householdId, + targetType: "product", + targetId: productId, + diff: { label: productLabel(current) }, + }); + return ok(undefined); +} diff --git a/src/features/stores/actions.ts b/src/features/stores/actions.ts new file mode 100644 index 0000000..46c6b32 --- /dev/null +++ b/src/features/stores/actions.ts @@ -0,0 +1,50 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { storeFormSchema } from "@/shared/schemas/store"; +import { createStore, setStoreArchived, updateStore } from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = schema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await fn(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +/** Yeni (id boş) veya mevcut mağaza. */ +export async function saveStoreAction(storeId: unknown, input: unknown) { + if (storeId) { + const id = idSchema.safeParse(storeId); + if (!id.success) return fail(tr.common.notFound); + return run(storeFormSchema, input, (u, h, d) => updateStore(u, h, id.data, d)); + } + return run(storeFormSchema, input, createStore); +} + +export async function setStoreArchivedAction(storeId: unknown, archived: unknown) { + const id = idSchema.safeParse(storeId); + if (!id.success) return fail(tr.common.notFound); + return run(z.boolean(), archived, (u, h, a) => setStoreArchived(u, h, id.data, a)); +} diff --git a/src/features/stores/service.int.test.ts b/src/features/stores/service.int.test.ts new file mode 100644 index 0000000..8cdc33e --- /dev/null +++ b/src/features/stores/service.int.test.ts @@ -0,0 +1,111 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { AuditLogModel } from "@/models/audit-log"; +import { storeFormSchema, type StoreFormInput } from "@/shared/schemas/store"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { createStore, listStores, setStoreArchived, updateStore } from "./service"; + +let ids: Awaited>; +let cakir = ""; +let ayse = ""; +let zeynep = ""; +let fatma = ""; + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + ayse = ids.user("ayse"); + zeynep = ids.user("zeynep"); + fatma = ids.user("fatma"); +}); +afterAll(teardownDb); + +const form = (over: Partial = {}) => + storeFormSchema.parse({ + name: "Macrocenter", + kind: "market", + branch: "Caddebostan", + distanceKm: "4,5", + carryDifficulty: "3", + deliveryFee: "", + freeDeliveryMin: "", + mealCardAccepted: false, + mealCardProviders: [], + note: "", + ...over, + }); + +describe("mağazalar", () => { + it("seed: zahmet skoru hesaplanır; izolasyon", async () => { + const s = (await listStores(ayse, cakir))!; + expect(s.stores).toHaveLength(8); + const carrefour = s.stores.find((x) => x.name === "CarrefourSA")!; + // 3,8 km → 9,5; taşıma 4 → 37,5 → 47. + expect(carrefour).toMatchObject({ effort: 47, effortLevel: "medium", mealCardAccepted: true }); + const getir = s.stores.find((x) => x.kind === "online")!; + expect(getir).toMatchObject({ distanceM: null, effort: 0, deliveryFeeMinor: 39_90 }); + expect(s.stores.find((x) => x.name === "Metro Toptancı")!.effortLevel).toBe("high"); + expect(await listStores(fatma, cakir)).toBeNull(); + expect((await listStores(zeynep, cakir))!.canWrite).toBe(false); + }); + + it("şema: online mağazada uzaklık yok; yemek kartı seçilince sağlayıcı zorunlu", () => { + expect(form({ kind: "online", deliveryFee: "29,90", distanceKm: "3" })).toMatchObject({ + distanceM: null, + deliveryFeeMinor: 29_90, + }); + expect(form({ deliveryFee: "29,90" })).toMatchObject({ + distanceM: 4_500, + deliveryFeeMinor: null, + }); + expect( + storeFormSchema.safeParse({ ...form(), mealCardAccepted: true, mealCardProviders: [] }) + .success, + ).toBe(false); + expect( + storeFormSchema.safeParse({ + name: "X", + kind: "market", + branch: "", + distanceKm: "abc", + carryDifficulty: "6", + deliveryFee: "", + freeDeliveryMin: "", + mealCardAccepted: false, + mealCardProviders: [], + }).success, + ).toBe(false); + }); + + it("oluştur, aynı ad + şube reddi, düzenle, arşiv denetimi", async () => { + const r = await createStore(ayse, cakir, form()); + expect(r.ok).toBe(true); + const id = r.ok ? r.data.id : ""; + expect((await createStore(ayse, cakir, form({ name: "macrocenter" }))).ok).toBe(false); + // Farklı şube serbest. + expect((await createStore(ayse, cakir, form({ branch: "Ataşehir" }))).ok).toBe(true); + expect( + ( + await updateStore( + ayse, + cakir, + id, + form({ mealCardAccepted: true, mealCardProviders: ["pluxee", "pluxee"] }), + ) + ).ok, + ).toBe(true); + let s = (await listStores(ayse, cakir))!.stores.find((x) => x.id === id)!; + expect(s.mealCardProviders).toEqual(["pluxee"]); + expect((await createStore(zeynep, cakir, form({ name: "İzleyici" }))).ok).toBe(false); + + expect((await setStoreArchived(ayse, cakir, id, true)).ok).toBe(true); + expect(await AuditLogModel.exists({ action: "store.archive", targetId: id })).toBeTruthy(); + expect((await updateStore(ayse, cakir, id, form())).ok).toBe(false); + // Arşivliyken aynı ad + şube yeniden eklenebilir; arşivden çıkarma çakışırsa reddedilir. + expect((await createStore(ayse, cakir, form())).ok).toBe(true); + expect((await setStoreArchived(ayse, cakir, id, false)).ok).toBe(false); + s = (await listStores(ayse, cakir))!.stores.find((x) => x.id === id)!; + expect(s.archived).toBe(true); + // Başka hanenin kimliğiyle işlem yok. + expect((await setStoreArchived(fatma, cakir, id, true)).ok).toBe(false); + }); +}); diff --git a/src/features/stores/service.ts b/src/features/stores/service.ts new file mode 100644 index 0000000..4d69388 --- /dev/null +++ b/src/features/stores/service.ts @@ -0,0 +1,189 @@ +import "server-only"; +import { Types } from "mongoose"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { resolveMembership } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeAudit } from "@/lib/audit"; +import { toObjectId } from "@/lib/object-id"; +import { StoreModel } from "@/models/store"; +import type { MealCardProvider } from "@/shared/accounts"; +import type { StoreForm } from "@/shared/schemas/store"; +import { foldTr } from "@/shared/search"; +import { + effortLevel, + effortScore, + sortStores, + type EffortLevel, + type StoreKind, +} from "@/shared/stores"; + +/** + * Mağazalar (Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma: member. + * Zahmet skoru okuma sırasında hesaplanır (saf motor: shared/stores). + */ + +const t = tr.stores; + +type StoreLean = { + _id: Types.ObjectId; + name: string; + kind: StoreKind; + branch: string; + distanceM: number | null; + carryDifficulty: number; + deliveryFeeMinor: number | null; + freeDeliveryMinMinor: number | null; + mealCardAccepted: boolean; + mealCardProviders: MealCardProvider[]; + note: string; + archivedAt: Date | null; +}; + +export type StoreView = { + id: string; + name: string; + kind: StoreKind; + branch: string; + /** "Migros · Kadıköy". */ + label: string; + distanceM: number | null; + carryDifficulty: number; + deliveryFeeMinor: number | null; + freeDeliveryMinMinor: number | null; + mealCardAccepted: boolean; + mealCardProviders: MealCardProvider[]; + note: string; + archived: boolean; + effort: number; + effortLevel: EffortLevel; +}; + +export const storeLabel = (s: { name: string; branch: string }) => + s.branch ? `${s.name} · ${s.branch}` : s.name; + +function toView(s: StoreLean): StoreView { + const effort = effortScore(s); + return { + id: String(s._id), + name: s.name, + kind: s.kind, + branch: s.branch, + label: storeLabel(s), + distanceM: s.distanceM, + carryDifficulty: s.carryDifficulty, + deliveryFeeMinor: s.deliveryFeeMinor, + freeDeliveryMinMinor: s.freeDeliveryMinMinor, + mealCardAccepted: s.mealCardAccepted, + mealCardProviders: s.mealCardProviders, + note: s.note, + archived: Boolean(s.archivedAt), + effort, + effortLevel: effortLevel(effort), + }; +} + +export async function listStores( + userId: string, + householdId: string, +): Promise<{ stores: StoreView[]; canWrite: boolean } | null> { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const rows = await StoreModel.find({ householdId: m.householdId }).lean(); + return { + stores: sortStores(rows.map(toView), "name"), + canWrite: hasHouseholdRole(m.role, "member"), + }; +} + +/** Aynı ad + şube (Türkçe katlanmış) aktif mağazalarda tektir. */ +async function isDuplicate( + householdId: Types.ObjectId, + input: Pick, + except?: Types.ObjectId, +) { + const rows = await StoreModel.find({ + householdId, + nameKey: foldTr(input.name), + archivedAt: null, + ...(except ? { _id: { $ne: except } } : {}), + }) + .select({ branch: 1 }) + .lean<{ branch: string }[]>(); + return rows.some((r) => foldTr(r.branch) === foldTr(input.branch)); +} + +const fields = (input: StoreForm) => ({ ...input, nameKey: foldTr(input.name) }); + +export async function createStore( + actorId: string, + householdId: string, + input: StoreForm, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + if (await isDuplicate(m.householdId, input)) + return fail(t.validation.duplicate, { name: t.validation.duplicate }); + const actor = new Types.ObjectId(actorId); + const doc = await StoreModel.create({ + householdId: m.householdId, + ...fields(input), + createdBy: actor, + updatedBy: actor, + }); + return ok({ id: String(doc._id) }); +} + +export async function updateStore( + actorId: string, + householdId: string, + storeId: string, + input: StoreForm, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(storeId); + const current = id + ? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean() + : null; + if (!current) return fail(tr.common.notFound); + if (current.archivedAt) return fail(t.validation.archived); + if (await isDuplicate(m.householdId, input, current._id)) + return fail(t.validation.duplicate, { name: t.validation.duplicate }); + await StoreModel.updateOne( + { _id: current._id, householdId: m.householdId }, + { $set: { ...fields(input), updatedBy: new Types.ObjectId(actorId) } }, + ); + return ok(undefined); +} + +export async function setStoreArchived( + actorId: string, + householdId: string, + storeId: string, + archived: boolean, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(storeId); + const current = id + ? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean() + : null; + if (!current) return fail(tr.common.notFound); + if (!archived && (await isDuplicate(m.householdId, current, current._id))) + return fail(t.validation.duplicate); + const actor = new Types.ObjectId(actorId); + await StoreModel.updateOne( + { _id: current._id, householdId: m.householdId }, + { $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } }, + ); + await writeAudit({ + action: archived ? "store.archive" : "store.unarchive", + actorId: actor, + householdId: m.householdId, + targetType: "store", + targetId: storeId, + diff: { name: storeLabel(current) }, + }); + return ok(undefined); +} diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 0ea2d92..6e10a0b 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -66,6 +66,7 @@ export const tr = { contacts: "Cari hesaplar", savings: "Birikim", stock: "Ev stoğu", + stores: "Mağazalar", shopping: "Alışveriş", reports: "Raporlar", settings: "Ayarlar", @@ -1196,6 +1197,157 @@ export const tr = { open: "Ev stoğuna git", }, }, + stores: { + title: "Mağazalar", + description: + "Alışveriş yaptığınız marketler, online platformlar ve pazarlar. Uzaklık ve taşıma güçlüğünden zahmet skoru hesaplanır; alışveriş önerisi bunu dikkate alır.", + new: "Yeni mağaza", + createTitle: "Yeni mağaza", + editTitle: "Mağazayı düzenle", + empty: "Henüz mağaza yok. “Yeni mağaza” ile ekleyin.", + readOnly: "İzleyici rolündesiniz; mağazaları değiştiremezsiniz.", + sort: "Sırala", + sorts: { + name: "Ada göre", + nearest: "En yakın", + farthest: "En uzak", + effort: "En zahmetli", + }, + showArchived: "Arşivi göster", + archived: "Arşivli", + kinds: { + market: "Fiziksel market", + online: "Online platform", + bazaar: "Pazar", + }, + kindShort: { + market: "Market", + online: "Online", + bazaar: "Pazar", + }, + fields: { + name: "Ad", + namePlaceholder: "ör. Migros, A101, Semt pazarı", + kind: "Tür", + branch: "Şube", + branchPlaceholder: "ör. Kadıköy", + distance: "Eve uzaklık (km)", + distanceHint: "ör. 1,5. Online mağazada gerekmez.", + carry: "Taşıma güçlüğü", + carryHint: "1 = kolay (kapının önü), 5 = çok zor (uzak, yokuş, araç gerekir).", + deliveryFee: "Teslimat ücreti", + freeDeliveryMin: "Ücretsiz teslimat alt limiti", + mealCard: "Yemek kartı geçerli", + mealCardProviders: "Geçerli yemek kartları", + note: "Not", + }, + carryLevels: { + 1: "Kolay", + 2: "Rahat", + 3: "Orta", + 4: "Zor", + 5: "Çok zor", + } as Record, + effort: "Zahmet", + effortLevels: { low: "Düşük", medium: "Orta", high: "Yüksek" }, + effortValue: (score: number) => `Zahmet ${score}/100`, + effortHint: + "Zahmet skoru 0–100: uzaklık (20 km'ye kadar) ve taşıma güçlüğü eşit ağırlıkla. Online mağazada uzaklık sayılmaz.", + distanceUnknown: "Uzaklık girilmedi", + delivery: (fee: string) => `Teslimat ${fee}`, + freeDeliveryFrom: (min: string) => `${min} üzeri ücretsiz`, + freeDelivery: "Ücretsiz teslimat", + mealCardBadge: "Yemek kartı", + actions: { + edit: "Düzenle", + archive: "Arşivle", + unarchive: "Arşivden çıkar", + more: "Mağaza işlemleri", + }, + archiveTitle: "Mağaza arşivlensin mi?", + archiveDescription: (name: string) => + `“${name}” yeni fiyat ve alışverişlerde seçilemez. Geçmiş kayıtları silinmez.`, + toast: { + saved: "Mağaza kaydedildi.", + archived: "Mağaza arşivlendi.", + unarchived: "Mağaza arşivden çıkarıldı.", + }, + validation: { + distance: "Geçerli bir uzaklık girin (ör. 2,5)", + carry: "1 ile 5 arasında seçin", + providers: "En az bir yemek kartı seçin", + duplicate: "Bu ad ve şubeyle bir mağaza zaten var.", + archived: "Mağaza arşivli.", + }, + }, + products: { + title: "Ürünler", + description: + "Marka ve paket düzeyinde ürünler (ör. “Ariel Matik 5 kg”). Her ürün bir ürün tipine bağlıdır; stok ürün tipinde tutulur, fiyatlar üründe.", + catalogLink: "Ürün kataloğu", + back: "Ev stoğuna dön", + new: "Yeni ürün", + createTitle: "Yeni ürün", + editTitle: "Ürünü düzenle", + sectionTitle: "Ürünler (marka / paket)", + sectionDescription: + "Bu ürün tipinin markaları ve paketleri. Fiyatlar ve birim fiyat karşılaştırması bu ürünler üzerinden yapılır.", + empty: "Henüz ürün yok.", + emptyType: "Bu ürün tipine henüz marka/paket eklenmedi.", + search: "Marka, tanım veya barkod ara", + noMatch: "Aramaya uyan ürün yok.", + showArchived: "Arşivi göster", + archived: "Arşivli", + barcode: (code: string) => `Barkod ${code}`, + images: (n: number) => `${n} görsel`, + conversionMissing: "Dönüşüm gerekli", + conversionMissingHint: + "Ürün tipinin birimi değişti; bu paketin karşılığını girmeden fiyatı karşılaştırılamaz.", + fields: { + productType: "Ürün tipi", + brand: "Marka", + brandPlaceholder: "ör. Ariel, Fairy, Yayla", + name: "Tanım", + namePlaceholder: "ör. Matik Dağ Esintisi", + barcode: "Barkod", + barcodeHint: "İsteğe bağlı, 8–14 rakam.", + packageQuantity: "Paket miktarı", + packageUnit: "Paket birimi", + typeQuantity: (unit: string) => `Bir paket kaç ${unit}?`, + typeQuantityHint: (pkg: string, unit: string) => + `${pkg} ürün tipinin birimine (${unit}) kendiliğinden çevrilemiyor; ör. 5 kg = 50 yıkama.`, + autoConversion: (pkg: string) => `Bir paket = ${pkg}`, + images: "Ürün görselleri", + addImage: "Görsel ekle", + imagesHint: "Ambalajın önü, arkası veya raf etiketi", + note: "Not", + }, + actions: { + edit: "Düzenle", + archive: "Arşivle", + unarchive: "Arşivden çıkar", + more: "Ürün işlemleri", + }, + archiveTitle: "Ürün arşivlensin mi?", + archiveDescription: (name: string) => + `“${name}” yeni fiyat ve alışveriş listelerinde seçilemez. Görselleri ve geçmişi silinmez.`, + toast: { + saved: "Ürün kaydedildi.", + archived: "Ürün arşivlendi.", + unarchived: "Ürün arşivden çıkarıldı.", + }, + validation: { + productType: "Aktif bir ürün tipi seçin", + barcode: "Barkod 8–14 rakam olmalı", + barcodeTaken: "Bu barkod başka bir üründe kayıtlı.", + packageQuantity: "Geçerli bir paket miktarı girin (ör. 5 veya 0,5)", + packageUnit: "Paket birimi 1–20 karakter olmalı", + typeQuantity: "Geçerli bir miktar girin", + conversionRequired: (pkgUnit: string, typeUnit: string) => + `${pkgUnit} → ${typeUnit} dönüşümünü girin (bir paket kaç ${typeUnit}?).`, + archived: "Ürün arşivli.", + }, + }, prices: { title: "Fiyat tablosu", description: diff --git a/src/models/attachment.ts b/src/models/attachment.ts index abc9199..93cdf2a 100644 --- a/src/models/attachment.ts +++ b/src/models/attachment.ts @@ -2,7 +2,7 @@ import { model, models, Schema, type InferSchemaType, type Model } from "mongoos import { ATTACHMENT_KINDS } from "@/shared/attachments"; /** Ek dosyanın bağlanabileceği kayıt türleri (ileride: alışveriş fişi, cari hareket…). */ -export const ATTACHMENT_OWNER_TYPES = ["transaction"] as const; +export const ATTACHMENT_OWNER_TYPES = ["transaction", "product"] as const; export type AttachmentOwnerType = (typeof ATTACHMENT_OWNER_TYPES)[number]; /** diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index b9bdc76..1abbc93 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -40,6 +40,10 @@ export const AUDIT_ACTIONS = [ "stock.product.archive", "stock.product.unarchive", "stock.movement.delete", + "store.archive", + "store.unarchive", + "product.archive", + "product.unarchive", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/product.ts b/src/models/product.ts new file mode 100644 index 0000000..0019f81 --- /dev/null +++ b/src/models/product.ts @@ -0,0 +1,45 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; + +/** + * Ürün (marka/varyant, Bölüm 6.11): "Ariel Matik 5 kg / 50 yıkama". Bir ürün tipine bağlıdır; + * fiyatlar (Faz 12b) bu seviyede tutulur. Paket miktarı binde bir. `typeQuantity`: paket birimi + * ürün tipinin birimine kendiliğinden çevrilemiyorsa bir paketin ürün tipi birimindeki karşılığı + * (zorunlu); çevrilebiliyorsa null. Görseller `attachments` (ownerType "product"). + */ +const productSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + productTypeId: { type: Schema.Types.ObjectId, required: true }, + brand: { type: String, required: true, trim: true, maxlength: 60 }, + /** Tanım (ör. "Matik Dağ Esintisi"). */ + name: { type: String, default: "", trim: true, maxlength: 120 }, + /** Arama anahtarı: marka + tanım (Türkçe katlanmış). */ + searchKey: { type: String, required: true }, + barcode: { type: String, default: null }, + packageQuantity: { type: Number, required: true, min: 1 }, + packageUnit: { type: String, required: true, trim: true, maxlength: 20 }, + typeQuantity: { type: Number, default: null, min: 1 }, + note: { type: String, default: "", maxlength: 300 }, + imageCount: { type: Number, default: 0 }, + archivedAt: { type: Date, default: null }, + seedRef: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "products" }, +); + +productSchema.index({ householdId: 1, productTypeId: 1, archivedAt: 1 }); +productSchema.index( + { householdId: 1, barcode: 1 }, + { partialFilterExpression: { barcode: { $type: "string" } } }, +); +productSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type Product = InferSchemaType; + +export const ProductModel: Model = + (models.Product as Model | undefined) ?? model("Product", productSchema); diff --git a/src/models/store.ts b/src/models/store.ts new file mode 100644 index 0000000..9413332 --- /dev/null +++ b/src/models/store.ts @@ -0,0 +1,43 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { MEAL_CARD_PROVIDERS } from "@/shared/accounts"; +import { STORE_KINDS } from "@/shared/stores"; + +/** + * Mağaza (Bölüm 6.11): fiziksel market, online platform veya pazar. Hane bazlı (hane içinde + * ortak). Uzaklık metre; teslimat ücretleri kuruş. Zahmet skoru okuma sırasında hesaplanır. + */ +const storeSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + name: { type: String, required: true, trim: true, maxlength: 80 }, + nameKey: { type: String, required: true }, + kind: { type: String, enum: STORE_KINDS, required: true }, + branch: { type: String, default: "", maxlength: 80 }, + /** Eve uzaklık (metre); online mağazada null. */ + distanceM: { type: Number, default: null, min: 0 }, + /** Taşıma güçlüğü 1 (kolay) – 5 (çok zor). */ + carryDifficulty: { type: Number, default: 1, min: 1, max: 5 }, + /** Online: teslimat ücreti ve ücretsiz teslimat alt limiti (kuruş). */ + deliveryFeeMinor: { type: Number, default: null, min: 0 }, + freeDeliveryMinMinor: { type: Number, default: null, min: 0 }, + mealCardAccepted: { type: Boolean, default: false }, + mealCardProviders: { type: [{ type: String, enum: MEAL_CARD_PROVIDERS }], default: [] }, + note: { type: String, default: "", maxlength: 300 }, + archivedAt: { type: Date, default: null }, + seedRef: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "stores" }, +); + +storeSchema.index({ householdId: 1, archivedAt: 1, nameKey: 1 }); +storeSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type Store = InferSchemaType; + +export const StoreModel: Model = + (models.Store as Model | undefined) ?? model("Store", storeSchema); diff --git a/src/shared/products.test.ts b/src/shared/products.test.ts new file mode 100644 index 0000000..7d654d8 --- /dev/null +++ b/src/shared/products.test.ts @@ -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); + }); +}); diff --git a/src/shared/products.ts b/src/shared/products.ts new file mode 100644 index 0000000..23748b5 --- /dev/null +++ b/src/shared/products.ts @@ -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}$/; diff --git a/src/shared/schemas/product.ts b/src/shared/schemas/product.ts new file mode 100644 index 0000000..9aea047 --- /dev/null +++ b/src/shared/schemas/product.ts @@ -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; +export type ProductForm = z.output; diff --git a/src/shared/schemas/store.ts b/src/shared/schemas/store.ts new file mode 100644 index 0000000..31886ea --- /dev/null +++ b/src/shared/schemas/store.ts @@ -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; +export type StoreForm = z.output; diff --git a/src/shared/stores.test.ts b/src/shared/stores.test.ts new file mode 100644 index 0000000..c253b8e --- /dev/null +++ b/src/shared/stores.test.ts @@ -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[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"); + }); +}); diff --git a/src/shared/stores.ts b/src/shared/stores.ts new file mode 100644 index 0000000..966d48b --- /dev/null +++ b/src/shared/stores.ts @@ -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(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`; +}