feat(shopping): alışveriş listeleri, stok eşiği önerileri ve akıllı mağaza önerisi motoru
- shoppingLists koleksiyonu (ürün tipi / marka / serbest metin kalemleri), migration 015 - planShopping saf fonksiyonu: paket yuvarlama, zahmet ve hacimli cezası, teslimat, yemek kartı önceliği, ek mağaza eşiği - eşik önerileri onay/red (red stok yenilenene kadar), ürün tipine hacimli işareti - fiyat servisinde ortak loadTypeOffers; seed listeleri ve testler Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -293,17 +293,35 @@ export async function getTypePriceComparison(
|
||||
};
|
||||
}
|
||||
|
||||
/** Ürün tipi birimine çevrilebilen ürünlerin mağaza başına geçerli fiyatı (planlayıcı girdisi). */
|
||||
export type TypeOffer = {
|
||||
productId: string;
|
||||
productTypeId: string;
|
||||
storeId: string;
|
||||
priceMinor: number;
|
||||
inTypeUnit: number;
|
||||
unitPriceMinor: number;
|
||||
date: string;
|
||||
stale: boolean;
|
||||
isPromo: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
|
||||
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
|
||||
* Ürün tiplerinin tüm (arşivsiz) ürünlerinin mağaza başına geçerli fiyatları. Dönüşümü eksik
|
||||
* ürünler atlanır. `activeOnly`: arşivli ürün ve mağazaları dışarıda bırak.
|
||||
*/
|
||||
export async function latestTypeUnitPrices(
|
||||
export async function loadTypeOffers(
|
||||
householdId: Types.ObjectId,
|
||||
productTypeIds: Types.ObjectId[],
|
||||
today = todayIstanbul(),
|
||||
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
|
||||
const [products, types] = await Promise.all([
|
||||
ProductModel.find({ householdId, productTypeId: { $in: productTypeIds } })
|
||||
{ today = todayIstanbul(), activeOnly = false } = {},
|
||||
): Promise<TypeOffer[]> {
|
||||
if (!productTypeIds.length) return [];
|
||||
const [products, types, archivedStores] = await Promise.all([
|
||||
ProductModel.find({
|
||||
householdId,
|
||||
productTypeId: { $in: productTypeIds },
|
||||
...(activeOnly ? { archivedAt: null } : {}),
|
||||
})
|
||||
.select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||||
.lean<
|
||||
{
|
||||
@@ -317,7 +335,13 @@ export async function latestTypeUnitPrices(
|
||||
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
|
||||
.select({ unit: 1 })
|
||||
.lean<{ _id: Types.ObjectId; unit: string }[]>(),
|
||||
activeOnly
|
||||
? StoreModel.find({ householdId, archivedAt: { $ne: null } })
|
||||
.select({ _id: 1 })
|
||||
.lean<{ _id: Types.ObjectId }[]>()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const skipStore = new Set(archivedStores.map((x) => String(x._id)));
|
||||
const unitOf = new Map(types.map((x) => [String(x._id), x.unit]));
|
||||
const prices = await loadPrices(
|
||||
householdId,
|
||||
@@ -325,11 +349,12 @@ export async function latestTypeUnitPrices(
|
||||
);
|
||||
const groups = new Map<string, PriceLean[]>();
|
||||
for (const r of prices) {
|
||||
if (skipStore.has(String(r.storeId))) continue;
|
||||
const key = `${r.productId}:${r.storeId}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||
}
|
||||
const productById = new Map(products.map((p) => [String(p._id), p]));
|
||||
const out = new Map<string, { unitPriceMinor: number; date: string }>();
|
||||
const out: TypeOffer[] = [];
|
||||
for (const records of groups.values()) {
|
||||
const p = productById.get(String(records[0]!.productId))!;
|
||||
const typeId = String(p.productTypeId);
|
||||
@@ -341,12 +366,35 @@ export async function latestTypeUnitPrices(
|
||||
});
|
||||
if (!inType) continue;
|
||||
const eff = effectivePrice(records, today)!;
|
||||
const prev = out.get(typeId);
|
||||
if (!prev || eff.record.date > prev.date)
|
||||
out.set(typeId, {
|
||||
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
|
||||
date: eff.record.date,
|
||||
});
|
||||
out.push({
|
||||
productId: String(p._id),
|
||||
productTypeId: typeId,
|
||||
storeId: String(records[0]!.storeId),
|
||||
priceMinor: eff.record.priceMinor,
|
||||
inTypeUnit: inType,
|
||||
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
|
||||
date: eff.record.date,
|
||||
stale: eff.stale,
|
||||
isPromo: eff.record.isPromo && !eff.promoEnded,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
|
||||
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
|
||||
*/
|
||||
export async function latestTypeUnitPrices(
|
||||
householdId: Types.ObjectId,
|
||||
productTypeIds: Types.ObjectId[],
|
||||
today = todayIstanbul(),
|
||||
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
|
||||
const out = new Map<string, { unitPriceMinor: number; date: string }>();
|
||||
for (const o of await loadTypeOffers(householdId, productTypeIds, { today })) {
|
||||
const prev = out.get(o.productTypeId);
|
||||
if (!prev || o.date > prev.date)
|
||||
out.set(o.productTypeId, { unitPriceMinor: o.unitPriceMinor, date: o.date });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"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 {
|
||||
shoppingItemFormSchema,
|
||||
shoppingItemUpdateSchema,
|
||||
shoppingListFormSchema,
|
||||
} from "@/shared/schemas/shopping";
|
||||
import {
|
||||
addShoppingItem,
|
||||
approveSuggestions,
|
||||
createShoppingList,
|
||||
deleteShoppingList,
|
||||
dismissSuggestion,
|
||||
moveShoppingItem,
|
||||
removeShoppingItem,
|
||||
setShoppingItemChecked,
|
||||
updateShoppingItem,
|
||||
updateShoppingList,
|
||||
} 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;
|
||||
}
|
||||
|
||||
const ids = (...keys: string[]) => z.object(Object.fromEntries(keys.map((k) => [k, idSchema])));
|
||||
|
||||
export async function saveShoppingListAction(listId: string | null, input: unknown) {
|
||||
if (listId === null)
|
||||
return run(shoppingListFormSchema, input, (u, h, d) => createShoppingList(u, h, d));
|
||||
const id = idSchema.safeParse(listId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(shoppingListFormSchema, input, (u, h, d) => updateShoppingList(u, h, id.data, d));
|
||||
}
|
||||
|
||||
export async function deleteShoppingListAction(listId: unknown) {
|
||||
const id = idSchema.safeParse(listId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => deleteShoppingList(u, h, id.data));
|
||||
}
|
||||
|
||||
export async function addShoppingItemAction(listId: unknown, input: unknown) {
|
||||
const id = idSchema.safeParse(listId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(shoppingItemFormSchema, input, (u, h, d) => addShoppingItem(u, h, id.data, d));
|
||||
}
|
||||
|
||||
export async function updateShoppingItemAction(listId: unknown, itemId: unknown, input: unknown) {
|
||||
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||
if (!ref.success) return fail(tr.common.notFound);
|
||||
return run(shoppingItemUpdateSchema, input, (u, h, d) =>
|
||||
updateShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, d),
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeShoppingItemAction(listId: unknown, itemId: unknown) {
|
||||
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||
if (!ref.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) =>
|
||||
removeShoppingItem(u, h, ref.data.listId!, ref.data.itemId!),
|
||||
);
|
||||
}
|
||||
|
||||
export async function setShoppingItemCheckedAction(
|
||||
listId: unknown,
|
||||
itemId: unknown,
|
||||
checked: unknown,
|
||||
) {
|
||||
const ref = ids("listId", "itemId").safeParse({ listId, itemId });
|
||||
if (!ref.success || typeof checked !== "boolean") return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) =>
|
||||
setShoppingItemChecked(u, h, ref.data.listId!, ref.data.itemId!, checked),
|
||||
);
|
||||
}
|
||||
|
||||
export async function moveShoppingItemAction(listId: unknown, itemId: unknown, target: unknown) {
|
||||
const ref = ids("listId", "itemId", "target").safeParse({ listId, itemId, target });
|
||||
if (!ref.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) =>
|
||||
moveShoppingItem(u, h, ref.data.listId!, ref.data.itemId!, ref.data.target!),
|
||||
);
|
||||
}
|
||||
|
||||
export async function approveSuggestionsAction(listId: unknown, productTypeIds: unknown) {
|
||||
const id = idSchema.safeParse(listId);
|
||||
const types = z.array(idSchema).min(1).max(200).safeParse(productTypeIds);
|
||||
if (!id.success || !types.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => approveSuggestions(u, h, id.data, types.data));
|
||||
}
|
||||
|
||||
export async function dismissSuggestionAction(productTypeId: unknown) {
|
||||
const id = idSchema.safeParse(productTypeId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => dismissSuggestion(u, h, id.data));
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { ShoppingListModel } from "@/models/shopping-list";
|
||||
import { shoppingItemFormSchema, shoppingItemUpdateSchema } from "@/shared/schemas/shopping";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
addShoppingItem,
|
||||
approveSuggestions,
|
||||
createShoppingList,
|
||||
deleteShoppingList,
|
||||
dismissSuggestion,
|
||||
getShoppingList,
|
||||
listShoppingLists,
|
||||
moveShoppingItem,
|
||||
removeShoppingItem,
|
||||
setShoppingItemChecked,
|
||||
updateShoppingItem,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
let weekly = "";
|
||||
let monthEnd = "";
|
||||
|
||||
const typeId = (seedRef: string) =>
|
||||
ProductTypeModel.findOne({ seedRef })
|
||||
.lean()
|
||||
.then((x) => String(x!._id));
|
||||
const productId = (seedRef: string) =>
|
||||
ProductModel.findOne({ seedRef })
|
||||
.lean()
|
||||
.then((x) => String(x!._id));
|
||||
const item = (input: Record<string, unknown>) => shoppingItemFormSchema.parse(input);
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||||
monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id);
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
describe("liste okuma ve akıllı öneri", () => {
|
||||
it("aktif listeler, bekleyen öneri sayısı; başka hane göremez", async () => {
|
||||
const data = (await listShoppingLists(ayse, cakir))!;
|
||||
expect(data.active.map((l) => l.name).sort()).toEqual(["Ay sonu toptan", "Haftalık market"]);
|
||||
expect(data.suggestions).toBeGreaterThanOrEqual(3);
|
||||
expect(data.canWrite).toBe(true);
|
||||
expect(await listShoppingLists(fatma, cakir)).toBeNull();
|
||||
expect(await getShoppingList(fatma, cakir, weekly)).toBeNull();
|
||||
});
|
||||
|
||||
it("kalem görünümleri, eşik önerileri ve plan", async () => {
|
||||
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||
expect(d.items).toHaveLength(9);
|
||||
const milk = d.items.find((i) => i.label === "Süt")!;
|
||||
expect(milk).toMatchObject({ kind: "type", unit: "lt", quantityLabel: "4 lt" });
|
||||
expect(milk.stock).not.toBeNull();
|
||||
const yumos = d.items.find((i) => i.kind === "product")!;
|
||||
expect(yumos.quantityLabel).toBe("1 paket");
|
||||
const candle = d.items.find((i) => i.kind === "text")!;
|
||||
expect(candle).toMatchObject({ label: "Doğum günü mumu", note: "Rakam mum: 7" });
|
||||
|
||||
// Eşiğin altındaki mercimek listede yok: öneri olarak gelir.
|
||||
expect(d.suggestions.map((s) => s.name)).toContain("Kırmızı Mercimek");
|
||||
expect(d.suggestions.every((s) => s.suggestedQuantity >= 0)).toBe(true);
|
||||
|
||||
// Plan: domatesin fiyatı yok; serbest metin plana katılmaz; diğerleri karşılanır.
|
||||
const tomato = d.items.find((i) => i.label === "Domates")!;
|
||||
expect(d.plan.unpriced).toEqual([tomato.id]);
|
||||
const rec = d.plan.recommended!;
|
||||
expect(rec.missing).toEqual([]);
|
||||
const planned = rec.stores.flatMap((g) => g.lines.map((l) => l.itemId));
|
||||
expect(planned).toHaveLength(7);
|
||||
expect(planned).not.toContain(candle.id);
|
||||
for (const g of rec.stores) {
|
||||
expect(d.plan.stores[g.storeId]).toBeDefined();
|
||||
for (const l of g.lines) expect(d.plan.products[l.productId]).toBeDefined();
|
||||
}
|
||||
// Belirli marka kalemi yalnızca o ürünle planlanır.
|
||||
const yumosLine = rec.stores.flatMap((g) => g.lines).find((l) => l.itemId === yumos.id)!;
|
||||
expect(yumosLine.productId).toBe(yumos.productId);
|
||||
expect(d.plan.single).not.toBeNull();
|
||||
expect(d.plan.thresholdMinor).toBe(10_000);
|
||||
// Ayşe'nin yemek kartı bakiyesi plana girer (seed: Multinet).
|
||||
expect(d.plan.mealCardBalanceMinor).toBeGreaterThan(0);
|
||||
expect(d.options.types.length).toBeGreaterThan(10);
|
||||
expect(d.otherLists.map((o) => o.name)).toEqual(["Ay sonu toptan"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("kalem işlemleri", () => {
|
||||
it("ekle (çift kayıt reddi, izleyici yazamaz), düzenle, işaretle, taşı, çıkar", async () => {
|
||||
const milk = await typeId("sut");
|
||||
expect(shoppingItemFormSchema.safeParse({ kind: "type", productTypeId: "" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shoppingItemFormSchema.safeParse({ kind: "text", text: "" }).success).toBe(false);
|
||||
expect(
|
||||
(await addShoppingItem(ayse, cakir, weekly, item({ kind: "type", productTypeId: milk }))).ok,
|
||||
).toBe(false);
|
||||
const text = item({ kind: "text", text: "Pil", quantity: "4", unit: "adet" });
|
||||
expect((await addShoppingItem(zeynep, cakir, weekly, text)).ok).toBe(false);
|
||||
|
||||
const added = await addShoppingItem(ayse, cakir, weekly, text);
|
||||
expect(added.ok).toBe(true);
|
||||
const id = added.ok ? added.data.id : "";
|
||||
const brand = await addShoppingItem(
|
||||
ayse,
|
||||
cakir,
|
||||
weekly,
|
||||
item({ kind: "product", productId: await productId("ariel-5kg"), quantity: "2" }),
|
||||
);
|
||||
expect(brand.ok).toBe(true);
|
||||
|
||||
expect(
|
||||
(
|
||||
await updateShoppingItem(
|
||||
ayse,
|
||||
cakir,
|
||||
weekly,
|
||||
id,
|
||||
shoppingItemUpdateSchema.parse({ quantity: "6", text: "AA pil", unit: "adet" }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
let d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||
expect(d.items.find((i) => i.id === id)).toMatchObject({
|
||||
label: "AA pil",
|
||||
quantityLabel: "6 adet",
|
||||
});
|
||||
const ariel = d.items.find((i) => i.label.startsWith("Ariel"))!;
|
||||
expect(ariel.quantityLabel).toBe("2 paket");
|
||||
|
||||
expect((await setShoppingItemChecked(ayse, cakir, weekly, id, true)).ok).toBe(true);
|
||||
d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||
// Sepete alınanlar sona.
|
||||
expect(d.items.at(-1)).toMatchObject({ id, checked: true });
|
||||
expect(d.list.checkedCount).toBe(1);
|
||||
|
||||
expect((await moveShoppingItem(ayse, cakir, weekly, id, weekly)).ok).toBe(false);
|
||||
expect((await moveShoppingItem(ayse, cakir, weekly, id, monthEnd)).ok).toBe(true);
|
||||
const target = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||||
expect(target.items.find((i) => i.id === id)).toMatchObject({ checked: false });
|
||||
|
||||
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(true);
|
||||
expect((await removeShoppingItem(ayse, cakir, monthEnd, id)).ok).toBe(false);
|
||||
expect((await removeShoppingItem(ayse, cakir, weekly, ariel.id)).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eşik önerileri", () => {
|
||||
it("onayla: önerilen miktarla eklenir, öneriden düşer; tekrar onay reddedilir", async () => {
|
||||
const lentil = await typeId("kirmizi-mercimek");
|
||||
const r = await approveSuggestions(ayse, cakir, weekly, [lentil]);
|
||||
expect(r).toMatchObject({ ok: true, data: { added: 1 } });
|
||||
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||
const row = d.items.find((i) => i.productTypeId === lentil)!;
|
||||
expect(row.source).toBe("suggestion");
|
||||
expect(row.quantity).toBeGreaterThan(0);
|
||||
expect(d.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||||
expect((await approveSuggestions(ayse, cakir, weekly, [lentil])).ok).toBe(false);
|
||||
// Başka aktif listede de öneri olarak görünmez.
|
||||
const other = (await getShoppingList(ayse, cakir, monthEnd))!;
|
||||
expect(other.suggestions.some((s) => s.productTypeId === lentil)).toBe(false);
|
||||
await removeShoppingItem(ayse, cakir, weekly, row.id);
|
||||
});
|
||||
|
||||
it("reddet: stok yenilenene kadar gizli; izleyici reddedemez", async () => {
|
||||
const tablet = await typeId("tablet-deterjan");
|
||||
expect((await dismissSuggestion(zeynep, cakir, tablet)).ok).toBe(false);
|
||||
expect((await dismissSuggestion(ayse, cakir, tablet)).ok).toBe(true);
|
||||
const d = (await getShoppingList(ayse, cakir, weekly))!;
|
||||
expect(d.suggestions.some((s) => s.productTypeId === tablet)).toBe(false);
|
||||
await ProductTypeModel.updateOne(
|
||||
{ seedRef: "tablet-deterjan" },
|
||||
{ $set: { suggestionDismissedAt: null } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liste yönetimi", () => {
|
||||
it("oluştur ve sil (soft delete + denetim)", async () => {
|
||||
const r = await createShoppingList(ayse, cakir, { name: "Deneme", note: "" });
|
||||
expect(r.ok).toBe(true);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect((await deleteShoppingList(zeynep, cakir, id)).ok).toBe(false);
|
||||
expect((await deleteShoppingList(ayse, cakir, id)).ok).toBe(true);
|
||||
expect(await getShoppingList(ayse, cakir, id)).toBeNull();
|
||||
expect(
|
||||
await AuditLogModel.exists({ action: "shoppingList.delete", targetId: id }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,878 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import { listAccounts } from "@/features/accounts/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||
import { loadTypeOffers } from "@/features/prices/service";
|
||||
import { listProducts, type ProductView } from "@/features/products/service";
|
||||
import { getStock, type ProductTypeView } from "@/features/stock/service";
|
||||
import { storeLabel } from "@/features/stores/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 { HouseholdModel } from "@/models/household";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { ShoppingListModel } from "@/models/shopping-list";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import { seriesSlot } from "@/shared/prices";
|
||||
import { formatQuantity, QUANTITY_SCALE } from "@/shared/quantity";
|
||||
import type {
|
||||
ShoppingItemForm,
|
||||
ShoppingItemUpdate,
|
||||
ShoppingListForm,
|
||||
} from "@/shared/schemas/shopping";
|
||||
import {
|
||||
requiredQuantity,
|
||||
suggestionHidden,
|
||||
type ShoppingItemKind,
|
||||
type ShoppingItemSource,
|
||||
type ShoppingListStatus,
|
||||
} from "@/shared/shopping";
|
||||
import { planShopping, type PlanResult } from "@/shared/shopping-plan";
|
||||
import { compareTr } from "@/shared/search";
|
||||
import { formatStock, type PurchaseSuggestion } from "@/shared/stock";
|
||||
import type { StoreKind } from "@/shared/stores";
|
||||
|
||||
/**
|
||||
* Alışveriş listeleri (Bölüm 6.12). Hane içinde ortak; okuma viewer, yazma member.
|
||||
* Stok eşiğinin altındaki ürün tipleri aktif listelere onayla/reddet önerisi olarak gelir.
|
||||
* Akıllı mağaza önerisi saf motorla (shared/shopping-plan) okuma sırasında hesaplanır.
|
||||
*/
|
||||
|
||||
const t = tr.shopping;
|
||||
|
||||
type ItemLean = {
|
||||
_id: Types.ObjectId;
|
||||
kind: ShoppingItemKind;
|
||||
productTypeId: Types.ObjectId | null;
|
||||
productId: Types.ObjectId | null;
|
||||
text: string;
|
||||
quantity: number | null;
|
||||
unit: string;
|
||||
note: string;
|
||||
source: ShoppingItemSource;
|
||||
checked: boolean;
|
||||
checkedAt: Date | null;
|
||||
addedAt: Date;
|
||||
};
|
||||
|
||||
type ListLean = {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
status: ShoppingListStatus;
|
||||
note: string;
|
||||
items: ItemLean[];
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type ShoppingListSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ShoppingListStatus;
|
||||
note: string;
|
||||
itemCount: number;
|
||||
checkedCount: number;
|
||||
/** İlk birkaç kalemin adı (kart önizlemesi). */
|
||||
preview: string[];
|
||||
updatedAt: string;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
export type ShoppingItemView = {
|
||||
id: string;
|
||||
kind: ShoppingItemKind;
|
||||
productTypeId: string | null;
|
||||
productId: string | null;
|
||||
text: string;
|
||||
label: string;
|
||||
/** Ürün: paket ve ürün tipi; ürün tipi: kategori yolu. */
|
||||
detail: string;
|
||||
quantity: number | null;
|
||||
/** Ürün tipinde tipin birimi, üründe "paket", serbest metinde girilen birim. */
|
||||
unit: string;
|
||||
/** "2 kg", "3 paket"; boş miktarda ürün tipinde "" (en az 1 paket). */
|
||||
quantityLabel: string;
|
||||
note: string;
|
||||
source: ShoppingItemSource;
|
||||
checked: boolean;
|
||||
/** Ürün tipi/ürün arşivlendi. */
|
||||
archived: boolean;
|
||||
/** Ürün tipi kaleminde evdeki stok. */
|
||||
stock: { current: string; target: string; low: boolean } | null;
|
||||
};
|
||||
|
||||
export type SuggestionView = {
|
||||
productTypeId: string;
|
||||
name: string;
|
||||
categoryPath: string;
|
||||
unit: string;
|
||||
current: number;
|
||||
target: number;
|
||||
pct: number | null;
|
||||
suggestedQuantity: number;
|
||||
suggestion: PurchaseSuggestion;
|
||||
};
|
||||
|
||||
export type PlanStoreRef = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: StoreKind;
|
||||
slot: number;
|
||||
mealCard: boolean;
|
||||
};
|
||||
|
||||
export type ShoppingPlanView = PlanResult & {
|
||||
stores: Record<string, PlanStoreRef>;
|
||||
products: Record<string, { label: string; packageLabel: string; typeUnit: string }>;
|
||||
thresholdMinor: number;
|
||||
/** Bu kullanıcının görebildiği yemek kartı bakiyelerinin toplamı. */
|
||||
mealCardBalanceMinor: number;
|
||||
};
|
||||
|
||||
export type ItemOptions = {
|
||||
types: { id: string; name: string; unit: string; path: string }[];
|
||||
products: {
|
||||
id: string;
|
||||
label: string;
|
||||
packageLabel: string;
|
||||
productTypeId: string;
|
||||
typeName: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ShoppingListDetail = {
|
||||
list: ShoppingListSummary;
|
||||
items: ShoppingItemView[];
|
||||
suggestions: SuggestionView[];
|
||||
plan: ShoppingPlanView;
|
||||
options: ItemOptions;
|
||||
/** Kalem taşımak için diğer aktif listeler. */
|
||||
otherLists: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
// --- Yardımcılar ---
|
||||
|
||||
const iso = (d: Date | null) => (d ? d.toISOString() : null);
|
||||
|
||||
function summary(l: ListLean, labels: Map<string, string>): ShoppingListSummary {
|
||||
return {
|
||||
id: String(l._id),
|
||||
name: l.name,
|
||||
status: l.status,
|
||||
note: l.note,
|
||||
itemCount: l.items.length,
|
||||
checkedCount: l.items.filter((i) => i.checked).length,
|
||||
preview: l.items.slice(0, 4).map((i) => itemName(i, labels)),
|
||||
updatedAt: l.updatedAt.toISOString(),
|
||||
completedAt: iso(l.completedAt),
|
||||
};
|
||||
}
|
||||
|
||||
const refKey = (i: Pick<ItemLean, "kind" | "productTypeId" | "productId">) =>
|
||||
i.kind === "type" ? `t:${i.productTypeId}` : i.kind === "product" ? `p:${i.productId}` : null;
|
||||
|
||||
function itemName(i: ItemLean, labels: Map<string, string>) {
|
||||
const key = refKey(i);
|
||||
return key ? (labels.get(key) ?? "—") : i.text;
|
||||
}
|
||||
|
||||
/** Kalemlerin ürün tipi ve ürün adları (arşivliler dahil). */
|
||||
async function loadLabels(householdId: Types.ObjectId, lists: ListLean[]) {
|
||||
const typeIds = new Set<string>();
|
||||
const productIds = new Set<string>();
|
||||
for (const l of lists)
|
||||
for (const i of l.items) {
|
||||
if (i.productTypeId) typeIds.add(String(i.productTypeId));
|
||||
if (i.productId) productIds.add(String(i.productId));
|
||||
}
|
||||
const [types, products] = await Promise.all([
|
||||
ProductTypeModel.find({
|
||||
householdId,
|
||||
_id: { $in: [...typeIds].map((x) => new Types.ObjectId(x)) },
|
||||
})
|
||||
.select({ name: 1 })
|
||||
.lean<{ _id: Types.ObjectId; name: string }[]>(),
|
||||
ProductModel.find({
|
||||
householdId,
|
||||
_id: { $in: [...productIds].map((x) => new Types.ObjectId(x)) },
|
||||
})
|
||||
.select({ brand: 1, name: 1 })
|
||||
.lean<{ _id: Types.ObjectId; brand: string; name: string }[]>(),
|
||||
]);
|
||||
const labels = new Map<string, string>();
|
||||
for (const x of types) labels.set(`t:${x._id}`, x.name);
|
||||
for (const x of products) labels.set(`p:${x._id}`, x.name ? `${x.brand} ${x.name}` : x.brand);
|
||||
return labels;
|
||||
}
|
||||
|
||||
async function loadList(m: Membership, listId: string) {
|
||||
const id = toObjectId(listId);
|
||||
if (!id) return null;
|
||||
return ShoppingListModel.findOne({
|
||||
_id: id,
|
||||
householdId: m.householdId,
|
||||
deletedAt: null,
|
||||
}).lean<ListLean>();
|
||||
}
|
||||
|
||||
/** Ürün tipi başına son alım hareketinin giriş zamanı (öneri reddi için). */
|
||||
async function lastPurchaseAt(householdId: Types.ObjectId, typeIds: Types.ObjectId[]) {
|
||||
if (!typeIds.length) return new Map<string, Date>();
|
||||
const rows = await StockMovementModel.aggregate<{ _id: Types.ObjectId; at: Date }>([
|
||||
{
|
||||
$match: {
|
||||
householdId,
|
||||
productTypeId: { $in: typeIds },
|
||||
kind: "purchase",
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
{ $group: { _id: "$productTypeId", at: { $max: "$createdAt" } } },
|
||||
]);
|
||||
return new Map(rows.map((r) => [String(r._id), r.at]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bekleyen eşik önerileri: eşiğin altındaki ürün tipleri; hiçbir aktif listede (alınmamış kalem
|
||||
* olarak, tip ya da o tipin ürünü) yoksa ve reddedilmemişse.
|
||||
*/
|
||||
async function pendingSuggestions(
|
||||
m: Membership,
|
||||
low: ProductTypeView[],
|
||||
): Promise<SuggestionView[]> {
|
||||
if (!low.length) return [];
|
||||
const lowIds = low.map((x) => new Types.ObjectId(x.id));
|
||||
const [active, dismissed, purchases] = await Promise.all([
|
||||
ShoppingListModel.find({ householdId: m.householdId, deletedAt: null, status: "active" })
|
||||
.select({ items: 1 })
|
||||
.lean<{ items: ItemLean[] }[]>(),
|
||||
ProductTypeModel.find({ householdId: m.householdId, _id: { $in: lowIds } })
|
||||
.select({ suggestionDismissedAt: 1 })
|
||||
.lean<{ _id: Types.ObjectId; suggestionDismissedAt: Date | null }[]>(),
|
||||
lastPurchaseAt(m.householdId, lowIds),
|
||||
]);
|
||||
const listed = new Set<string>();
|
||||
const productIds: Types.ObjectId[] = [];
|
||||
for (const l of active)
|
||||
for (const i of l.items) {
|
||||
if (i.checked) continue;
|
||||
if (i.productTypeId) listed.add(String(i.productTypeId));
|
||||
if (i.productId) productIds.push(i.productId);
|
||||
}
|
||||
if (productIds.length) {
|
||||
const rows = await ProductModel.find({ householdId: m.householdId, _id: { $in: productIds } })
|
||||
.select({ productTypeId: 1 })
|
||||
.lean<{ productTypeId: Types.ObjectId }[]>();
|
||||
for (const r of rows) listed.add(String(r.productTypeId));
|
||||
}
|
||||
const dismissedAt = new Map(dismissed.map((d) => [String(d._id), d.suggestionDismissedAt]));
|
||||
return low
|
||||
.filter(
|
||||
(x) =>
|
||||
!listed.has(x.id) &&
|
||||
!suggestionHidden(dismissedAt.get(x.id) ?? null, purchases.get(x.id) ?? null),
|
||||
)
|
||||
.map((x) => ({
|
||||
productTypeId: x.id,
|
||||
name: x.name,
|
||||
categoryPath: x.categoryPath,
|
||||
unit: x.unit,
|
||||
current: x.current,
|
||||
target: x.targetQuantity,
|
||||
pct: x.level.pct,
|
||||
suggestedQuantity: x.suggestedQuantity,
|
||||
suggestion: x.suggestion,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Eşik önerileri (dashboard ve stok sayfası özetleri için). */
|
||||
export async function getPendingSuggestions(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<SuggestionView[] | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const stock = await getStock(userId, householdId);
|
||||
return stock ? pendingSuggestions(m, stock.suggestions) : null;
|
||||
}
|
||||
|
||||
// --- Okuma ---
|
||||
|
||||
export async function listShoppingLists(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<{
|
||||
active: ShoppingListSummary[];
|
||||
completed: ShoppingListSummary[];
|
||||
suggestions: number;
|
||||
canWrite: boolean;
|
||||
} | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const [lists, suggestions] = await Promise.all([
|
||||
ShoppingListModel.find({ householdId: m.householdId, deletedAt: null })
|
||||
.sort({ updatedAt: -1 })
|
||||
.lean<ListLean[]>(),
|
||||
getPendingSuggestions(userId, householdId),
|
||||
]);
|
||||
const labels = await loadLabels(m.householdId, lists);
|
||||
return {
|
||||
active: lists.filter((l) => l.status === "active").map((l) => summary(l, labels)),
|
||||
completed: lists
|
||||
.filter((l) => l.status === "completed")
|
||||
.slice(0, 20)
|
||||
.map((l) => summary(l, labels)),
|
||||
suggestions: suggestions?.length ?? 0,
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Dashboard: aktif listelerin özeti. */
|
||||
export async function getActiveListsSummary(userId: string, householdId: string) {
|
||||
const data = await listShoppingLists(userId, householdId);
|
||||
if (!data) return null;
|
||||
return { lists: data.active, suggestions: data.suggestions };
|
||||
}
|
||||
|
||||
function itemView(
|
||||
i: ItemLean,
|
||||
types: Map<string, ProductTypeView>,
|
||||
products: Map<string, ProductView>,
|
||||
archivedTypes: Map<string, { name: string; unit: string }>,
|
||||
): ShoppingItemView {
|
||||
const base = {
|
||||
id: String(i._id),
|
||||
kind: i.kind,
|
||||
productTypeId: i.productTypeId ? String(i.productTypeId) : null,
|
||||
productId: i.productId ? String(i.productId) : null,
|
||||
text: i.text,
|
||||
quantity: i.quantity,
|
||||
note: i.note,
|
||||
source: i.source,
|
||||
checked: i.checked,
|
||||
};
|
||||
if (i.kind === "type") {
|
||||
const ty = types.get(String(i.productTypeId));
|
||||
const fallback = archivedTypes.get(String(i.productTypeId));
|
||||
const unit = ty?.unit ?? fallback?.unit ?? "";
|
||||
return {
|
||||
...base,
|
||||
label: ty?.name ?? fallback?.name ?? "—",
|
||||
detail: ty?.categoryPath ?? "",
|
||||
unit,
|
||||
quantityLabel: i.quantity === null ? "" : formatStock(i.quantity, unit),
|
||||
archived: !ty,
|
||||
stock: ty
|
||||
? {
|
||||
current: formatStock(Math.max(0, ty.current), ty.unit),
|
||||
target: formatStock(ty.targetQuantity, ty.unit),
|
||||
low: ty.level.low,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
if (i.kind === "product") {
|
||||
const p = products.get(String(i.productId));
|
||||
const packages = i.quantity ?? QUANTITY_SCALE;
|
||||
return {
|
||||
...base,
|
||||
label: p?.label ?? "—",
|
||||
detail: p ? `${p.packageLabel} · ${p.productTypeName}` : "",
|
||||
unit: t.packageUnit,
|
||||
quantityLabel: `${formatQuantity(packages)} ${t.packageUnit}`,
|
||||
archived: !p || p.archived,
|
||||
stock: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
label: i.text,
|
||||
detail: "",
|
||||
unit: i.unit,
|
||||
quantityLabel: i.quantity === null ? "" : formatQuantity(i.quantity, i.unit || undefined),
|
||||
archived: false,
|
||||
stock: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPlan(
|
||||
userId: string,
|
||||
m: Membership,
|
||||
items: ItemLean[],
|
||||
products: Map<string, ProductView>,
|
||||
types: Map<string, ProductTypeView>,
|
||||
): Promise<ShoppingPlanView> {
|
||||
const planItems = items
|
||||
.filter((i) => i.kind !== "text")
|
||||
.map((i) => {
|
||||
const p = i.productId ? products.get(String(i.productId)) : undefined;
|
||||
const typeId = i.kind === "product" ? (p?.productTypeId ?? "") : String(i.productTypeId);
|
||||
return {
|
||||
id: String(i._id),
|
||||
productTypeId: typeId,
|
||||
quantity: requiredQuantity(i, p?.inTypeUnit ?? null),
|
||||
productId: i.kind === "product" ? String(i.productId) : null,
|
||||
bulky: types.get(typeId)?.bulky ?? false,
|
||||
};
|
||||
})
|
||||
.filter((i) => i.productTypeId);
|
||||
const typeIds = [...new Set(planItems.map((i) => i.productTypeId))].map(
|
||||
(x) => new Types.ObjectId(x),
|
||||
);
|
||||
const [offers, stores, household, accounts] = await Promise.all([
|
||||
loadTypeOffers(m.householdId, typeIds, { activeOnly: true }),
|
||||
StoreModel.find({ householdId: m.householdId }).sort({ createdAt: 1, _id: 1 }).lean<
|
||||
{
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
branch: string;
|
||||
kind: StoreKind;
|
||||
distanceM: number | null;
|
||||
carryDifficulty: number;
|
||||
deliveryFeeMinor: number | null;
|
||||
freeDeliveryMinMinor: number | null;
|
||||
mealCardAccepted: boolean;
|
||||
mealCardProviders: string[];
|
||||
archivedAt: Date | null;
|
||||
}[]
|
||||
>(),
|
||||
HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(),
|
||||
listAccounts(userId, String(m.householdId)),
|
||||
]);
|
||||
const mealCards = (accounts?.accounts ?? [])
|
||||
.filter(
|
||||
(a) =>
|
||||
a.type === "meal_card" &&
|
||||
!a.archived &&
|
||||
a.mealCardProvider &&
|
||||
a.balanceMinor !== null &&
|
||||
a.balanceMinor > 0,
|
||||
)
|
||||
.map((a) => ({ provider: a.mealCardProvider!, balanceMinor: a.balanceMinor! }));
|
||||
const active = stores.filter((s) => !s.archivedAt);
|
||||
const threshold = household?.settings?.extraStoreSavingThresholdMinor ?? 10_000;
|
||||
const result = planShopping({
|
||||
items: planItems,
|
||||
offers,
|
||||
stores: active.map((s) => ({
|
||||
id: String(s._id),
|
||||
kind: s.kind,
|
||||
distanceM: s.distanceM,
|
||||
carryDifficulty: s.carryDifficulty,
|
||||
deliveryFeeMinor: s.deliveryFeeMinor,
|
||||
freeDeliveryMinMinor: s.freeDeliveryMinMinor,
|
||||
mealCardProviders: s.mealCardAccepted ? s.mealCardProviders : [],
|
||||
})),
|
||||
mealCards,
|
||||
extraStoreThresholdMinor: threshold,
|
||||
});
|
||||
// Planda geçen ürünlerin etiketleri (listede olmayan muadil markalar dahil).
|
||||
const used = new Set<string>();
|
||||
for (const o of [result.single, result.recommended])
|
||||
for (const g of o?.stores ?? []) for (const l of g.lines) used.add(l.productId);
|
||||
for (const l of Object.values(result.cheapest)) used.add(l.productId);
|
||||
const missing = [...used].filter((id) => !products.has(id));
|
||||
if (missing.length) {
|
||||
const extra = await listProducts(userId, String(m.householdId), { includeArchived: true });
|
||||
for (const p of extra?.products ?? []) products.set(p.id, p);
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
stores: Object.fromEntries(
|
||||
stores.map((s, i) => [
|
||||
String(s._id),
|
||||
{
|
||||
id: String(s._id),
|
||||
label: storeLabel(s),
|
||||
kind: s.kind,
|
||||
slot: seriesSlot(i),
|
||||
mealCard: s.mealCardAccepted,
|
||||
},
|
||||
]),
|
||||
),
|
||||
products: Object.fromEntries(
|
||||
[...used].flatMap((id) => {
|
||||
const p = products.get(id);
|
||||
return p
|
||||
? [[id, { label: p.label, packageLabel: p.packageLabel, typeUnit: p.typeUnit }]]
|
||||
: [];
|
||||
}),
|
||||
),
|
||||
thresholdMinor: threshold,
|
||||
mealCardBalanceMinor: mealCards.reduce((a, c) => a + c.balanceMinor, 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getShoppingList(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
): Promise<ShoppingListDetail | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const list = await loadList(m, listId);
|
||||
if (!list) return null;
|
||||
const [stock, productList, others, labels] = await Promise.all([
|
||||
getStock(userId, householdId),
|
||||
listProducts(userId, householdId, { includeArchived: true }),
|
||||
ShoppingListModel.find({
|
||||
householdId: m.householdId,
|
||||
deletedAt: null,
|
||||
status: "active",
|
||||
_id: { $ne: list._id },
|
||||
})
|
||||
.select({ name: 1 })
|
||||
.sort({ updatedAt: -1 })
|
||||
.lean<{ _id: Types.ObjectId; name: string }[]>(),
|
||||
loadLabels(m.householdId, [list]),
|
||||
]);
|
||||
if (!stock || !productList) return null;
|
||||
const types = new Map(stock.items.map((x) => [x.id, x]));
|
||||
const products = new Map(productList.products.map((p) => [p.id, p]));
|
||||
// Arşivlenmiş ürün tiplerinin adı/birimi (getStock yalnızca aktifleri döner).
|
||||
const archivedTypeIds = list.items
|
||||
.filter((i) => i.productTypeId && !types.has(String(i.productTypeId)))
|
||||
.map((i) => i.productTypeId!);
|
||||
const archivedTypes = new Map(
|
||||
(
|
||||
await ProductTypeModel.find({ householdId: m.householdId, _id: { $in: archivedTypeIds } })
|
||||
.select({ name: 1, unit: 1 })
|
||||
.lean<{ _id: Types.ObjectId; name: string; unit: string }[]>()
|
||||
).map((x) => [String(x._id), { name: x.name, unit: x.unit }]),
|
||||
);
|
||||
const items = list.items
|
||||
.map((i) => itemView(i, types, products, archivedTypes))
|
||||
.sort((a, b) => Number(a.checked) - Number(b.checked));
|
||||
const isActive = list.status === "active";
|
||||
const canWrite = hasHouseholdRole(m.role, "member") && isActive;
|
||||
const [suggestions, plan] = await Promise.all([
|
||||
isActive ? pendingSuggestions(m, stock.suggestions) : Promise.resolve([]),
|
||||
buildPlan(userId, m, list.items, products, types),
|
||||
]);
|
||||
return {
|
||||
list: summary(list, labels),
|
||||
items,
|
||||
suggestions,
|
||||
plan,
|
||||
options: {
|
||||
types: stock.items
|
||||
.map((x) => ({ id: x.id, name: x.name, unit: x.unit, path: x.categoryPath }))
|
||||
.sort((a, b) => compareTr(a.name, b.name)),
|
||||
products: productList.products
|
||||
.filter((p) => !p.archived && !p.conversionMissing && types.has(p.productTypeId))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
packageLabel: p.packageLabel,
|
||||
productTypeId: p.productTypeId,
|
||||
typeName: p.productTypeName,
|
||||
})),
|
||||
},
|
||||
otherLists: others.map((o) => ({ id: String(o._id), name: o.name })),
|
||||
canWrite,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Yazma: listeler ---
|
||||
|
||||
async function writableList(actorId: string, householdId: string, listId: string) {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return { error: fail(tr.common.unauthorized) } as const;
|
||||
const list = await loadList(m, listId);
|
||||
if (!list) return { error: fail(tr.common.notFound) } as const;
|
||||
if (list.status !== "active") return { error: fail(t.validation.notActive) } as const;
|
||||
return { error: null, m, list, actor: new Types.ObjectId(actorId) } as const;
|
||||
}
|
||||
|
||||
export async function createShoppingList(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: ShoppingListForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await ShoppingListModel.create({
|
||||
householdId: m.householdId,
|
||||
name: input.name,
|
||||
note: input.note,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateShoppingList(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
input: ShoppingListForm,
|
||||
): Promise<ActionResult> {
|
||||
const r = await writableList(actorId, householdId, listId);
|
||||
if (r.error) return r.error;
|
||||
await ShoppingListModel.updateOne(
|
||||
{ _id: r.list._id },
|
||||
{ $set: { name: input.name, note: input.note, updatedBy: r.actor } },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Liste silme (soft delete, audit). Tamamlanmış liste de silinebilir. */
|
||||
export async function deleteShoppingList(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const list = await loadList(m, listId);
|
||||
if (!list) return fail(tr.common.notFound);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await ShoppingListModel.updateOne(
|
||||
{ _id: list._id },
|
||||
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: "shoppingList.delete",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "shoppingList",
|
||||
targetId: listId,
|
||||
diff: { name: list.name, status: list.status, items: list.items.length },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Yazma: kalemler ---
|
||||
|
||||
/** Ürün tipi / ürün aktif mi; aynı kalem listede alınmamış olarak var mı? */
|
||||
async function checkItemRef(
|
||||
m: Membership,
|
||||
list: ListLean,
|
||||
input: Pick<ShoppingItemForm, "kind" | "productTypeId" | "productId">,
|
||||
): Promise<ActionResult<never> | null> {
|
||||
if (input.kind === "type") {
|
||||
const ok = await ProductTypeModel.exists({
|
||||
_id: new Types.ObjectId(input.productTypeId!),
|
||||
householdId: m.householdId,
|
||||
archivedAt: null,
|
||||
});
|
||||
if (!ok) return fail(t.validation.productType, { productTypeId: t.validation.productType });
|
||||
}
|
||||
if (input.kind === "product") {
|
||||
const ok = await ProductModel.exists({
|
||||
_id: new Types.ObjectId(input.productId!),
|
||||
householdId: m.householdId,
|
||||
archivedAt: null,
|
||||
});
|
||||
if (!ok) return fail(t.validation.product, { productId: t.validation.product });
|
||||
}
|
||||
const key = refKey({
|
||||
kind: input.kind,
|
||||
productTypeId: input.productTypeId ? new Types.ObjectId(input.productTypeId) : null,
|
||||
productId: input.productId ? new Types.ObjectId(input.productId) : null,
|
||||
});
|
||||
if (key && list.items.some((i) => !i.checked && refKey(i) === key))
|
||||
return fail(t.validation.duplicate);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function addShoppingItem(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
input: ShoppingItemForm,
|
||||
source: ShoppingItemSource = "manual",
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const r = await writableList(actorId, householdId, listId);
|
||||
if (r.error) return r.error;
|
||||
const bad = await checkItemRef(r.m, r.list, input);
|
||||
if (bad) return bad;
|
||||
const _id = new Types.ObjectId();
|
||||
await ShoppingListModel.updateOne(
|
||||
{ _id: r.list._id },
|
||||
{
|
||||
$push: {
|
||||
items: {
|
||||
_id,
|
||||
kind: input.kind,
|
||||
productTypeId: input.productTypeId ? new Types.ObjectId(input.productTypeId) : null,
|
||||
productId: input.productId ? new Types.ObjectId(input.productId) : null,
|
||||
text: input.text,
|
||||
quantity: input.quantity,
|
||||
unit: input.unit,
|
||||
note: input.note,
|
||||
source,
|
||||
addedBy: r.actor,
|
||||
addedAt: new Date(),
|
||||
},
|
||||
},
|
||||
$set: { updatedBy: r.actor },
|
||||
},
|
||||
);
|
||||
return ok({ id: String(_id) });
|
||||
}
|
||||
|
||||
export async function updateShoppingItem(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
itemId: string,
|
||||
input: ShoppingItemUpdate,
|
||||
): Promise<ActionResult> {
|
||||
const r = await writableList(actorId, householdId, listId);
|
||||
if (r.error) return r.error;
|
||||
const item = r.list.items.find((i) => String(i._id) === itemId);
|
||||
if (!item) return fail(tr.common.notFound);
|
||||
if (item.kind === "text" && !input.text)
|
||||
return fail(t.validation.text, { text: t.validation.text });
|
||||
const set: Record<string, unknown> = {
|
||||
"items.$.quantity": input.quantity,
|
||||
"items.$.note": input.note,
|
||||
updatedBy: r.actor,
|
||||
};
|
||||
if (item.kind === "text") {
|
||||
set["items.$.text"] = input.text;
|
||||
set["items.$.unit"] = input.unit;
|
||||
}
|
||||
await ShoppingListModel.updateOne({ _id: r.list._id, "items._id": item._id }, { $set: set });
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export async function removeShoppingItem(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
itemId: string,
|
||||
): Promise<ActionResult> {
|
||||
const r = await writableList(actorId, householdId, listId);
|
||||
if (r.error) return r.error;
|
||||
const id = toObjectId(itemId);
|
||||
if (!id || !r.list.items.some((i) => i._id.equals(id))) return fail(tr.common.notFound);
|
||||
await ShoppingListModel.updateOne(
|
||||
{ _id: r.list._id },
|
||||
{ $pull: { items: { _id: id } }, $set: { updatedBy: r.actor } },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Alışveriş modunda sepete alındı / geri alındı. */
|
||||
export async function setShoppingItemChecked(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
itemId: string,
|
||||
checked: boolean,
|
||||
): Promise<ActionResult> {
|
||||
const r = await writableList(actorId, householdId, listId);
|
||||
if (r.error) return r.error;
|
||||
const id = toObjectId(itemId);
|
||||
if (!id) return fail(tr.common.notFound);
|
||||
const res = await ShoppingListModel.updateOne(
|
||||
{ _id: r.list._id, "items._id": id },
|
||||
{
|
||||
$set: {
|
||||
"items.$.checked": checked,
|
||||
"items.$.checkedBy": checked ? r.actor : null,
|
||||
"items.$.checkedAt": checked ? new Date() : null,
|
||||
},
|
||||
},
|
||||
);
|
||||
return res.matchedCount ? ok(undefined) : fail(tr.common.notFound);
|
||||
}
|
||||
|
||||
/** Kalemi başka bir aktif listeye taşır. */
|
||||
export async function moveShoppingItem(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
itemId: string,
|
||||
targetListId: string,
|
||||
): Promise<ActionResult> {
|
||||
const r = await writableList(actorId, householdId, listId);
|
||||
if (r.error) return r.error;
|
||||
const target = await writableList(actorId, householdId, targetListId);
|
||||
if (target.error) return target.error;
|
||||
if (target.list._id.equals(r.list._id)) return fail(t.validation.sameList);
|
||||
const item = r.list.items.find((i) => String(i._id) === itemId);
|
||||
if (!item) return fail(tr.common.notFound);
|
||||
const key = refKey(item);
|
||||
if (key && target.list.items.some((i) => !i.checked && refKey(i) === key))
|
||||
return fail(t.validation.duplicateIn(target.list.name));
|
||||
await ShoppingListModel.updateOne(
|
||||
{ _id: target.list._id },
|
||||
{
|
||||
$push: { items: { ...item, checked: false, checkedBy: null, checkedAt: null } },
|
||||
$set: { updatedBy: r.actor },
|
||||
},
|
||||
);
|
||||
await ShoppingListModel.updateOne(
|
||||
{ _id: r.list._id },
|
||||
{ $pull: { items: { _id: item._id } }, $set: { updatedBy: r.actor } },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Eşik önerileri ---
|
||||
|
||||
/** Önerileri onayla: önerilen miktarla ürün tipi kalemi olarak eklenir. */
|
||||
export async function approveSuggestions(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
productTypeIds: string[],
|
||||
): Promise<ActionResult<{ added: number }>> {
|
||||
const r = await writableList(actorId, householdId, listId);
|
||||
if (r.error) return r.error;
|
||||
const stock = await getStock(actorId, householdId);
|
||||
if (!stock) return fail(tr.common.unauthorized);
|
||||
const pending = new Map(
|
||||
(await pendingSuggestions(r.m, stock.suggestions)).map((x) => [x.productTypeId, x]),
|
||||
);
|
||||
const items = productTypeIds
|
||||
.map((id) => pending.get(id))
|
||||
.filter((x): x is SuggestionView => Boolean(x))
|
||||
.map((x) => ({
|
||||
_id: new Types.ObjectId(),
|
||||
kind: "type" as const,
|
||||
productTypeId: new Types.ObjectId(x.productTypeId),
|
||||
productId: null,
|
||||
text: "",
|
||||
quantity: x.suggestedQuantity > 0 ? x.suggestedQuantity : null,
|
||||
unit: "",
|
||||
note: "",
|
||||
source: "suggestion" as const,
|
||||
addedBy: r.actor,
|
||||
addedAt: new Date(),
|
||||
}));
|
||||
if (!items.length) return fail(t.validation.suggestionGone);
|
||||
await ShoppingListModel.updateOne(
|
||||
{ _id: r.list._id },
|
||||
{ $push: { items: { $each: items } }, $set: { updatedBy: r.actor } },
|
||||
);
|
||||
return ok({ added: items.length });
|
||||
}
|
||||
|
||||
/** Öneriyi reddet: stok yenilenene (yeni alım) kadar gösterilmez. */
|
||||
export async function dismissSuggestion(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
productTypeId: string,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(productTypeId);
|
||||
const res = id
|
||||
? await ProductTypeModel.updateOne(
|
||||
{ _id: id, householdId: m.householdId, archivedAt: null },
|
||||
{ $set: { suggestionDismissedAt: new Date() } },
|
||||
)
|
||||
: null;
|
||||
return res?.matchedCount ? ok(undefined) : fail(tr.common.notFound);
|
||||
}
|
||||
@@ -87,6 +87,7 @@ type ProductLean = {
|
||||
step: number;
|
||||
countEveryDays: number | null;
|
||||
note: string;
|
||||
bulky?: boolean;
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
@@ -132,6 +133,7 @@ export type ProductTypeView = {
|
||||
step: number;
|
||||
countEveryDays: number | null;
|
||||
note: string;
|
||||
bulky: boolean;
|
||||
archived: boolean;
|
||||
current: number;
|
||||
level: StockLevel;
|
||||
@@ -331,6 +333,7 @@ function toView(p: ProductLean, moves: MoveLean[], ctx: ViewContext): ProductTyp
|
||||
step: p.step,
|
||||
countEveryDays: p.countEveryDays,
|
||||
note: p.note,
|
||||
bulky: Boolean(p.bulky),
|
||||
archived: Boolean(p.archivedAt),
|
||||
current,
|
||||
level,
|
||||
@@ -807,6 +810,7 @@ const productFields = (input: ProductTypeForm) => ({
|
||||
step: input.step,
|
||||
countEveryDays: input.countEveryDays,
|
||||
note: input.note,
|
||||
bulky: input.bulky,
|
||||
});
|
||||
|
||||
export async function createProductType(
|
||||
|
||||
Reference in New Issue
Block a user