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:
2026-11-06 11:48:04 +03:00
parent 8e7e374778
commit f0b570284e
23 changed files with 1893 additions and 1 deletions
+12
View File
@@ -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();
},
};
+2
View File
@@ -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";
+4
View File
@@ -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);
+12
View File
@@ -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<typeof getAuth>["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 = [
+328
View File
@@ -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<string, Types.ObjectId>,
users: Map<string, string>,
): 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 };
}
+4
View File
@@ -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;
}
+50
View File
@@ -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<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
async function run<T, R>(
schema: z.ZodType<T>,
input: unknown,
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
): Promise<ActionResult<R>> {
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));
}
+179
View File
@@ -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<ReturnType<typeof setupSeededDb>>;
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<ProductFormInput> = {}) {
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);
});
});
+358
View File
@@ -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<string, AttachmentInfo[]>();
for (const a of rows) {
const key = String(a.ownerId);
by.set(key, [
...(by.get(key) ?? []),
toAttachmentInfo(a as Parameters<typeof toAttachmentInfo>[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<ProductLean[]>();
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<TypeLean[]>();
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<ProductTypeOption[] | null> {
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<TypeLean[]>();
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<never> } | { error: null; typeQuantity: number | null }> {
const type = await ProductTypeModel.findOne({
_id: new Types.ObjectId(input.productTypeId),
householdId: m.householdId,
}).lean<TypeLean>();
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<never>) {
super(result.ok ? "" : result.error);
}
}
async function save(
m: Membership,
actorId: string,
input: ProductForm,
typeQuantity: number | null,
existing: Types.ObjectId | null,
): Promise<ActionResult<{ id: string }>> {
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<ActionResult<{ id: string }>> {
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<ActionResult<{ id: string }>> {
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<ProductLean>()
: 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<ActionResult> {
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<ProductLean>()
: 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);
}
+50
View File
@@ -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<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
async function run<T, R>(
schema: z.ZodType<T>,
input: unknown,
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
): Promise<ActionResult<R>> {
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));
}
+111
View File
@@ -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<ReturnType<typeof setupSeededDb>>;
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<StoreFormInput> = {}) =>
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);
});
});
+189
View File
@@ -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<StoreLean[]>();
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<StoreForm, "name" | "branch">,
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<ActionResult<{ id: string }>> {
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<ActionResult> {
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<ActionResult> {
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<StoreLean>()
: 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);
}
+152
View File
@@ -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<number, string>,
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:
+1 -1
View File
@@ -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];
/**
+4
View File
@@ -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];
+45
View File
@@ -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<typeof productSchema>;
export const ProductModel: Model<Product> =
(models.Product as Model<Product> | undefined) ?? model<Product>("Product", productSchema);
+43
View File
@@ -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<typeof storeSchema>;
export const StoreModel: Model<Store> =
(models.Store as Model<Store> | undefined) ?? model<Store>("Store", storeSchema);
+42
View File
@@ -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);
});
});
+53
View File
@@ -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}$/;
+47
View File
@@ -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>;
+72
View File
@@ -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>;
+56
View File
@@ -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");
});
});
+79
View File
@@ -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`;
}