Files
butcem/src/features/shopping/service.ts
T
suphicakir bd5a20ce64 feat(shopping): alışverişi tamamla transaction'ı ve karşılaştırma motoru
- tek transaction: stok alımı, fiş kaynaklı fiyat, mağaza başına bölünmüş gider, fiş ekleri, kalanların taşınması
- muadil, markasız ürün tipi ve plansız ekstra satırları; stok → gider kategorisi eşlemesi ve yedek kategori
- compareShopping saf fonksiyonu: aynı/muadil/alınmadı/ekstra, farkın nedenlere tam ayrışması
- tamamlanan listede rapor görüntüsü; testler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:57:50 +03:00

899 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ShoppingCompletion,
type ShoppingListStatus,
} from "@/shared/shopping";
import { compareShopping, type ComparisonSummary } from "@/shared/shopping-compare";
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;
export 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;
};
export type ListLean = {
_id: Types.ObjectId;
name: string;
status: ShoppingListStatus;
note: string;
items: ItemLean[];
completedAt: Date | null;
completion: ShoppingCompletion | 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;
/** Tamamlanmış listede karşılaştırma raporu. */
report: { completion: ShoppingCompletion; summary: ComparisonSummary } | null;
};
// --- 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,
};
}
export 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,
report: list.completion
? {
completion: list.completion,
summary: compareShopping(
list.completion.rows.map((r) => ({
match: r.match,
plan: r.plan,
actual:
r.amountMinor === null
? null
: { productId: r.productId, packages: r.packages, amountMinor: r.amountMinor },
})),
),
}
: null,
};
}
// --- Yazma: listeler ---
export 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);
}