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