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>
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import { syncOwnerAttachments } from "@/features/attachments/service";
|
||||
import type { CategoryOption } from "@/features/categories/tree";
|
||||
import { buildTree, toOptions } from "@/features/categories/tree";
|
||||
import { loadTypeOffers } from "@/features/prices/service";
|
||||
import { listProducts, type ProductView } from "@/features/products/service";
|
||||
import { getStock } from "@/features/stock/service";
|
||||
import { buildDoc, loadContext } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { connectMongoose } from "@/lib/db/mongoose";
|
||||
import { AccountModel } from "@/models/account";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { ProductPriceModel } from "@/models/product-price";
|
||||
import { ShoppingListModel } from "@/models/shopping-list";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { allowedAttachmentKinds } from "@/shared/attachments";
|
||||
import type { MealCardProvider } from "@/shared/accounts";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatQuantity, QUANTITY_SCALE } from "@/shared/quantity";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import type { ShoppingCompletionForm } from "@/shared/schemas/shopping";
|
||||
import type { CompletionRow, CompletionStore, ShoppingCompletion } from "@/shared/shopping";
|
||||
import { matchKind, type PlannedPurchase } from "@/shared/shopping-compare";
|
||||
import { MAX_SPLITS } from "@/shared/transactions";
|
||||
import type { TransactionForm } from "@/shared/schemas/transaction";
|
||||
import {
|
||||
getShoppingList,
|
||||
writableList,
|
||||
type ItemLean,
|
||||
type ShoppingItemView,
|
||||
type ShoppingListSummary,
|
||||
} from "./service";
|
||||
|
||||
/**
|
||||
* "Alışverişi tamamla" (Bölüm 6.12). Tek MongoDB transaction içinde: stok alım hareketleri, fiyat
|
||||
* kayıtları (kaynak: fiş), mağaza başına bölünmüş gider (stok → gider kategorisi eşlemesi), fiş
|
||||
* görselleri ve alınmayan kalemlerin taşınması; liste kapanır ve karşılaştırma görüntüsü saklanır.
|
||||
*/
|
||||
|
||||
const t = tr.shopping;
|
||||
const v = t.validation;
|
||||
|
||||
export type CompletionSheet = {
|
||||
list: ShoppingListSummary;
|
||||
items: ShoppingItemView[];
|
||||
/** Kalem başına akıllı önerinin planı (önerilen dağılım). */
|
||||
planned: Record<
|
||||
string,
|
||||
{ productId: string; storeId: string; packages: number; priceMinor: number }
|
||||
>;
|
||||
products: {
|
||||
id: string;
|
||||
label: string;
|
||||
packageLabel: string;
|
||||
productTypeId: string;
|
||||
typeName: string;
|
||||
}[];
|
||||
stores: { id: string; label: string; slot: number; mealCard: boolean; providers: string[] }[];
|
||||
/** Geçerli paket fiyatları: `${productId}:${storeId}` → kuruş (tutar ön doldurması için). */
|
||||
prices: Record<string, number>;
|
||||
accounts: { id: string; name: string; type: string; mealCardProvider: MealCardProvider | null }[];
|
||||
expenseCategories: CategoryOption[];
|
||||
/** Gider kategorisine eşlenmemiş ürün tipleri (yedek kategori gerekir). */
|
||||
unmappedTypeIds: string[];
|
||||
defaultFallbackCategoryId: string | null;
|
||||
otherLists: { id: string; name: string }[];
|
||||
today: string;
|
||||
};
|
||||
|
||||
async function expenseCategoryOptions(householdId: Types.ObjectId) {
|
||||
const rows = await CategoryModel.find({ householdId, kind: "expense" }).lean<
|
||||
{
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
parentId: Types.ObjectId | null;
|
||||
order: number;
|
||||
archivedAt: Date | null;
|
||||
defaultKey: string | null;
|
||||
}[]
|
||||
>();
|
||||
const options = toOptions(
|
||||
buildTree(
|
||||
rows.map((c) => ({
|
||||
id: String(c._id),
|
||||
kind: "expense" as const,
|
||||
name: c.name,
|
||||
parentId: c.parentId ? String(c.parentId) : null,
|
||||
order: c.order,
|
||||
archived: Boolean(c.archivedAt),
|
||||
})),
|
||||
),
|
||||
);
|
||||
const market = rows.find((c) => c.defaultKey === "market" && !c.archivedAt);
|
||||
return { options, marketId: market ? String(market._id) : null };
|
||||
}
|
||||
|
||||
/** Ürün tipi → etkin gider kategorisi (stok kategorisinden, üstten devralınarak). */
|
||||
async function typeExpenseMap(userId: string, householdId: string) {
|
||||
const stock = await getStock(userId, householdId);
|
||||
if (!stock) return null;
|
||||
const catExpense = new Map(stock.categories.map((c) => [c.id, c.expense?.id ?? null]));
|
||||
return new Map(stock.items.map((i) => [i.id, catExpense.get(i.categoryId) ?? null]));
|
||||
}
|
||||
|
||||
/** /alisveris/:id/tamamla — alınanları girme ekranının verisi. */
|
||||
export async function getCompletionSheet(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
): Promise<CompletionSheet | null> {
|
||||
const detail = await getShoppingList(userId, householdId, listId);
|
||||
if (!detail || !detail.canWrite) return null;
|
||||
const ctx = await loadContext(userId, householdId, "member");
|
||||
if (!ctx) return null;
|
||||
const [productList, expense, categories, accounts] = await Promise.all([
|
||||
listProducts(userId, householdId),
|
||||
typeExpenseMap(userId, householdId),
|
||||
expenseCategoryOptions(ctx.m.householdId),
|
||||
AccountModel.find({ householdId: ctx.m.householdId, archivedAt: null })
|
||||
.select({ name: 1, type: 1, order: 1, isPrivate: 1, ownerUserId: 1, mealCardProvider: 1 })
|
||||
.sort({ order: 1 })
|
||||
.lean<
|
||||
{
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
type: string;
|
||||
isPrivate: boolean;
|
||||
ownerUserId: Types.ObjectId | null;
|
||||
mealCardProvider: MealCardProvider | null;
|
||||
}[]
|
||||
>(),
|
||||
]);
|
||||
if (!productList || !expense) return null;
|
||||
const products = productList.products.filter((p) => p.inTypeUnit !== null);
|
||||
const offers = await loadTypeOffers(
|
||||
ctx.m.householdId,
|
||||
[...new Set(products.map((p) => p.productTypeId))].map((x) => new Types.ObjectId(x)),
|
||||
{ activeOnly: true },
|
||||
);
|
||||
const planned: CompletionSheet["planned"] = {};
|
||||
for (const g of detail.plan.recommended?.stores ?? [])
|
||||
for (const l of g.lines)
|
||||
planned[l.itemId] = {
|
||||
productId: l.productId,
|
||||
storeId: l.storeId,
|
||||
packages: l.packages,
|
||||
priceMinor: l.priceMinor,
|
||||
};
|
||||
const { stores: storeRefs } = detail.plan;
|
||||
const storeRows = await StoreModel.find({ householdId: ctx.m.householdId, archivedAt: null })
|
||||
.select({ mealCardProviders: 1, mealCardAccepted: 1 })
|
||||
.lean<{ _id: Types.ObjectId; mealCardAccepted: boolean; mealCardProviders: string[] }[]>();
|
||||
return {
|
||||
list: detail.list,
|
||||
items: detail.items,
|
||||
planned,
|
||||
products: products.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
packageLabel: p.packageLabel,
|
||||
productTypeId: p.productTypeId,
|
||||
typeName: p.productTypeName,
|
||||
})),
|
||||
stores: storeRows
|
||||
.map((s) => {
|
||||
const ref = storeRefs[String(s._id)]!;
|
||||
return {
|
||||
id: ref.id,
|
||||
label: ref.label,
|
||||
slot: ref.slot,
|
||||
mealCard: s.mealCardAccepted,
|
||||
providers: s.mealCardAccepted ? s.mealCardProviders : [],
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "tr")),
|
||||
prices: Object.fromEntries(offers.map((o) => [`${o.productId}:${o.storeId}`, o.priceMinor])),
|
||||
accounts: accounts
|
||||
.filter((a) => !a.isPrivate || String(a.ownerUserId) === userId)
|
||||
.map((a) => ({
|
||||
id: String(a._id),
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
mealCardProvider: a.mealCardProvider ?? null,
|
||||
})),
|
||||
expenseCategories: categories.options,
|
||||
unmappedTypeIds: [...expense.entries()].filter(([, e]) => !e).map(([id]) => id),
|
||||
defaultFallbackCategoryId: categories.marketId,
|
||||
otherLists: detail.otherLists,
|
||||
today: todayIstanbul(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Transaction içindeki doğrulama hatası: geri alıp kullanıcıya döndürmek için. */
|
||||
class CompletionError extends Error {
|
||||
constructor(readonly result: ActionResult<never>) {
|
||||
super(result.ok ? "" : result.error);
|
||||
}
|
||||
}
|
||||
|
||||
type ResolvedLine = {
|
||||
item: ItemLean | null;
|
||||
product: ProductView | null;
|
||||
storeId: string;
|
||||
/** Üründe paket adedi; markasız ürün tipi kaleminde tipin birimindeki miktar (binde bir). */
|
||||
packages: number;
|
||||
amountMinor: number;
|
||||
categoryId: string;
|
||||
/** Stoğa işlenecek ürün tipi ve miktar (serbest metinde null). */
|
||||
stock: { productTypeId: string; quantity: number } | null;
|
||||
};
|
||||
|
||||
export async function completeShopping(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
listId: string,
|
||||
input: ShoppingCompletionForm,
|
||||
): Promise<ActionResult<{ transactions: number }>> {
|
||||
const r = await writableList(actorId, householdId, listId);
|
||||
if (r.error) return r.error;
|
||||
const { m, list, actor } = r;
|
||||
if (input.date > todayIstanbul()) return fail(v.futureDate, { date: v.futureDate });
|
||||
const ctx = await loadContext(actorId, householdId, "member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
|
||||
const [productList, expense, stockData, activeStores] = await Promise.all([
|
||||
listProducts(actorId, householdId, { includeArchived: true }),
|
||||
typeExpenseMap(actorId, householdId),
|
||||
getShoppingList(actorId, householdId, listId),
|
||||
StoreModel.find({ householdId: m.householdId, archivedAt: null })
|
||||
.select({ _id: 1 })
|
||||
.lean<{ _id: Types.ObjectId }[]>(),
|
||||
]);
|
||||
const activeStoreIds = new Set(activeStores.map((x) => String(x._id)));
|
||||
if (!productList || !expense || !stockData) return fail(tr.common.notFound);
|
||||
const products = new Map(productList.products.map((p) => [p.id, p]));
|
||||
const views = new Map(stockData.items.map((i) => [i.id, i]));
|
||||
const storeRefs = stockData.plan.stores;
|
||||
const itemById = new Map(list.items.map((i) => [String(i._id), i]));
|
||||
|
||||
// Yedek gider kategorisi (eşlenmemiş ve serbest metin kalemleri için).
|
||||
const fallback = input.fallbackCategoryId;
|
||||
const fallbackOk =
|
||||
fallback &&
|
||||
ctx.categoryMap.get(fallback)?.kind === "expense" &&
|
||||
!ctx.categoryMap.get(fallback)?.archived;
|
||||
|
||||
// --- Satırları doğrula ---
|
||||
const lines: ResolvedLine[] = [];
|
||||
for (const [i, l] of input.lines.entries()) {
|
||||
const at = (field: string) => `lines.${i}.${field}`;
|
||||
const item = l.itemId ? (itemById.get(l.itemId) ?? null) : null;
|
||||
if (l.itemId && !item) return fail(v.itemGone);
|
||||
if (!activeStoreIds.has(l.storeId))
|
||||
return fail(tr.productPrices.validation.store, {
|
||||
[at("storeId")]: tr.productPrices.validation.store,
|
||||
});
|
||||
|
||||
let product: ProductView | null = null;
|
||||
let typeId: string | null = null;
|
||||
if (item?.kind === "text") {
|
||||
if (l.productId) return fail(v.textNoProduct, { [at("productId")]: v.textNoProduct });
|
||||
} else if (item?.kind === "type" && !l.productId) {
|
||||
// Markasız (ör. pazardan domates): miktar tipin biriminde, fiyat kaydı yok.
|
||||
typeId = String(item.productTypeId);
|
||||
} else {
|
||||
product = l.productId ? (products.get(l.productId) ?? null) : null;
|
||||
if (!product || product.archived) return fail(v.product, { [at("productId")]: v.product });
|
||||
if (product.inTypeUnit === null)
|
||||
return fail(v.conversion(product.label), {
|
||||
[at("productId")]: v.conversion(product.label),
|
||||
});
|
||||
const expectedType =
|
||||
item?.kind === "type"
|
||||
? String(item.productTypeId)
|
||||
: item?.kind === "product"
|
||||
? (products.get(String(item.productId))?.productTypeId ?? null)
|
||||
: null;
|
||||
if (expectedType && product.productTypeId !== expectedType)
|
||||
return fail(v.substituteType, { [at("productId")]: v.substituteType });
|
||||
typeId = product.productTypeId;
|
||||
}
|
||||
const mapped = typeId ? (expense.get(typeId) ?? null) : null;
|
||||
const categoryId = mapped ?? (fallbackOk ? fallback : null);
|
||||
if (!categoryId) return fail(v.fallbackCategory, { fallbackCategoryId: v.fallbackCategory });
|
||||
lines.push({
|
||||
item,
|
||||
product,
|
||||
storeId: l.storeId,
|
||||
packages: l.packages,
|
||||
amountMinor: l.amount,
|
||||
categoryId,
|
||||
stock: typeId
|
||||
? {
|
||||
productTypeId: typeId,
|
||||
quantity: product
|
||||
? Math.max(1, Math.round((l.packages * product.inTypeUnit!) / QUANTITY_SCALE))
|
||||
: l.packages,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mağaza başına gider işlemleri (doğrulama buildDoc ile) ---
|
||||
const accountOf = new Map(input.stores.map((s) => [s.storeId, s]));
|
||||
const byStore = new Map<string, ResolvedLine[]>();
|
||||
for (const l of lines) byStore.set(l.storeId, [...(byStore.get(l.storeId) ?? []), l]);
|
||||
const txDocs: { storeId: string; doc: Record<string, unknown>; attachmentIds: string[] }[] = [];
|
||||
for (const [storeId, group] of byStore) {
|
||||
const entry = accountOf.get(storeId)!;
|
||||
const amount = group.reduce((a, l) => a + l.amountMinor, 0);
|
||||
const perCategory = new Map<string, { amount: number; count: number }>();
|
||||
for (const l of group) {
|
||||
const c = perCategory.get(l.categoryId) ?? { amount: 0, count: 0 };
|
||||
c.amount += l.amountMinor;
|
||||
c.count++;
|
||||
perCategory.set(l.categoryId, c);
|
||||
}
|
||||
if (perCategory.size > MAX_SPLITS) return fail(v.tooManySplits);
|
||||
const split = perCategory.size >= 2;
|
||||
const form: TransactionForm = {
|
||||
type: "expense",
|
||||
amount,
|
||||
date: input.date,
|
||||
accountId: entry.accountId,
|
||||
toAccountId: null,
|
||||
categoryId: split ? null : [...perCategory.keys()][0]!,
|
||||
splits: split
|
||||
? [...perCategory.entries()].map(([categoryId, c]) => ({
|
||||
categoryId,
|
||||
amount: c.amount,
|
||||
note: t.complete.splitNote(c.count),
|
||||
}))
|
||||
: [],
|
||||
payee: storeRefs[storeId]!.label,
|
||||
description: t.complete.txDescription(list.name),
|
||||
tags: [],
|
||||
installments: 1,
|
||||
attachmentIds: entry.attachmentIds,
|
||||
};
|
||||
const built = buildDoc(ctx, form);
|
||||
if (!built.ok) {
|
||||
const msg = built.error;
|
||||
return fail(msg, { [`stores.${input.stores.indexOf(entry)}.accountId`]: msg });
|
||||
}
|
||||
txDocs.push({ storeId, doc: built.data, attachmentIds: entry.attachmentIds });
|
||||
}
|
||||
|
||||
// --- Alınmayanlar ---
|
||||
const boughtIds = new Set(lines.flatMap((l) => (l.item ? [String(l.item._id)] : [])));
|
||||
const leftover = list.items.filter((i) => !boughtIds.has(String(i._id)));
|
||||
let target: { _id: Types.ObjectId; name: string; items: ItemLean[] } | null = null;
|
||||
if (leftover.length && input.leftover.action === "move") {
|
||||
const tl = await writableList(actorId, householdId, input.leftover.targetListId!);
|
||||
if (tl.error || tl.list._id.equals(list._id))
|
||||
return fail(v.targetList, { "leftover.targetListId": v.targetList });
|
||||
target = tl.list;
|
||||
}
|
||||
|
||||
// --- Plan ve karşılaştırma görüntüsü ---
|
||||
// Kullanıcının gördüğü akıllı öneri (önerilen dağılım).
|
||||
const plan = stockData.plan;
|
||||
const planned = new Map<string, PlannedPurchase & { storeId: string }>();
|
||||
for (const g of plan.recommended?.stores ?? [])
|
||||
for (const l of g.lines)
|
||||
planned.set(l.itemId, {
|
||||
productId: l.productId,
|
||||
storeId: l.storeId,
|
||||
packages: l.packages * QUANTITY_SCALE,
|
||||
priceMinor: l.priceMinor,
|
||||
costMinor: l.costMinor,
|
||||
});
|
||||
const label = (id: string) => products.get(id)?.label ?? "—";
|
||||
const pkgLabel = (id: string) => {
|
||||
const p = products.get(id);
|
||||
return p ? `${p.label} · ${p.packageLabel}` : "—";
|
||||
};
|
||||
const planOf = (itemId: string): CompletionRow["plan"] => {
|
||||
const p = planned.get(itemId);
|
||||
return p
|
||||
? {
|
||||
...p,
|
||||
productLabel: pkgLabel(p.productId),
|
||||
storeLabel: storeRefs[p.storeId]?.label ?? "—",
|
||||
}
|
||||
: null;
|
||||
};
|
||||
const rows: CompletionRow[] = [];
|
||||
const lineByItem = new Map(lines.flatMap((l) => (l.item ? [[String(l.item._id), l]] : [])));
|
||||
for (const item of list.items) {
|
||||
const id = String(item._id);
|
||||
const l = lineByItem.get(id) ?? null;
|
||||
const p = planned.get(id) ?? null;
|
||||
rows.push({
|
||||
itemId: id,
|
||||
match: matchKind(
|
||||
{ kind: item.kind, productId: item.productId ? String(item.productId) : null },
|
||||
p,
|
||||
l
|
||||
? { productId: l.product?.id ?? null, packages: l.packages, amountMinor: l.amountMinor }
|
||||
: null,
|
||||
),
|
||||
label: views.get(id)?.label ?? item.text,
|
||||
productId: l?.product?.id ?? null,
|
||||
productLabel: l?.product ? pkgLabel(l.product.id) : "",
|
||||
storeId: l?.storeId ?? null,
|
||||
storeLabel: l ? (storeRefs[l.storeId]?.label ?? "—") : "",
|
||||
packages: l?.packages ?? 0,
|
||||
quantityLabel: !l
|
||||
? ""
|
||||
: l.product
|
||||
? `${formatQuantity(l.packages)} ${t.packageUnit}`
|
||||
: l.stock
|
||||
? formatStock(l.packages, views.get(id)?.unit ?? "")
|
||||
: "",
|
||||
amountMinor: l?.amountMinor ?? null,
|
||||
plan: planOf(id),
|
||||
});
|
||||
}
|
||||
for (const l of lines.filter((x) => !x.item))
|
||||
rows.push({
|
||||
itemId: null,
|
||||
match: "extra",
|
||||
label: label(l.product!.id),
|
||||
productId: l.product!.id,
|
||||
productLabel: pkgLabel(l.product!.id),
|
||||
storeId: l.storeId,
|
||||
storeLabel: storeRefs[l.storeId]?.label ?? "—",
|
||||
packages: l.packages,
|
||||
quantityLabel: `${formatQuantity(l.packages)} ${t.packageUnit}`,
|
||||
amountMinor: l.amountMinor,
|
||||
plan: null,
|
||||
});
|
||||
|
||||
// --- Yaz (tek transaction) ---
|
||||
const mongoose = await connectMongoose();
|
||||
const accountNames = new Map([...ctx.accountMap.values()].map((a) => [String(a.id), a.name]));
|
||||
try {
|
||||
const stores: CompletionStore[] = [];
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
stores.length = 0;
|
||||
for (const { storeId, doc, attachmentIds } of txDocs) {
|
||||
const [tx] = await TransactionModel.create(
|
||||
[
|
||||
{
|
||||
...doc,
|
||||
householdId: m.householdId,
|
||||
status: "posted",
|
||||
shoppingListId: list._id,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
},
|
||||
],
|
||||
{ session },
|
||||
);
|
||||
const synced = await syncOwnerAttachments({
|
||||
householdId: m.householdId,
|
||||
actorId,
|
||||
ownerType: "transaction",
|
||||
ownerId: tx!._id,
|
||||
ids: attachmentIds,
|
||||
allowedKinds: allowedAttachmentKinds("expense"),
|
||||
session,
|
||||
});
|
||||
if (!synced.ok) throw new CompletionError(synced);
|
||||
await TransactionModel.updateOne(
|
||||
{ _id: tx!._id },
|
||||
{ $set: { attachmentCount: synced.data } },
|
||||
{ session },
|
||||
);
|
||||
const accountId = String(doc.accountId);
|
||||
stores.push({
|
||||
storeId,
|
||||
label: storeRefs[storeId]!.label,
|
||||
accountId,
|
||||
accountName: accountNames.get(accountId) ?? "—",
|
||||
transactionId: String(tx!._id),
|
||||
amountMinor: doc.amountMinor as number,
|
||||
attachmentCount: synced.data,
|
||||
});
|
||||
}
|
||||
|
||||
const stockLines = lines.filter((l) => l.stock);
|
||||
if (stockLines.length)
|
||||
await StockMovementModel.insertMany(
|
||||
stockLines.map((l) => ({
|
||||
householdId: m.householdId,
|
||||
productTypeId: new Types.ObjectId(l.stock!.productTypeId),
|
||||
kind: "purchase",
|
||||
date: input.date,
|
||||
quantity: l.stock!.quantity,
|
||||
note: t.complete.txDescription(list.name),
|
||||
shoppingListId: list._id,
|
||||
createdBy: actor,
|
||||
})),
|
||||
{ session },
|
||||
);
|
||||
const productLines = lines.filter((l) => l.product);
|
||||
if (productLines.length) {
|
||||
// Fiyat kaydı: ürün × mağaza başına tek kayıt; paket fiyatı = tutar / paket adedi.
|
||||
const prices = new Map<string, ResolvedLine[]>();
|
||||
for (const l of productLines) {
|
||||
const key = `${l.product!.id}:${l.storeId}`;
|
||||
prices.set(key, [...(prices.get(key) ?? []), l]);
|
||||
}
|
||||
await ProductPriceModel.insertMany(
|
||||
[...prices.values()].map((group) => {
|
||||
const packages = group.reduce((a, l) => a + l.packages, 0);
|
||||
const amount = group.reduce((a, l) => a + l.amountMinor, 0);
|
||||
return {
|
||||
householdId: m.householdId,
|
||||
productId: new Types.ObjectId(group[0]!.product!.id),
|
||||
storeId: new Types.ObjectId(group[0]!.storeId),
|
||||
date: input.date,
|
||||
priceMinor: Math.max(1, Math.round((amount * QUANTITY_SCALE) / packages)),
|
||||
isPromo: false,
|
||||
promoUntil: null,
|
||||
source: "receipt",
|
||||
note: "",
|
||||
createdBy: actor,
|
||||
};
|
||||
}),
|
||||
{ session },
|
||||
);
|
||||
}
|
||||
|
||||
let movedTo: ShoppingCompletion["movedTo"] = null;
|
||||
const carry = leftover.map((i) => ({
|
||||
...i,
|
||||
checked: false,
|
||||
checkedBy: null,
|
||||
checkedAt: null,
|
||||
}));
|
||||
if (leftover.length && input.leftover.action === "move" && target) {
|
||||
await ShoppingListModel.updateOne(
|
||||
{ _id: target._id },
|
||||
{ $push: { items: { $each: carry } }, $set: { updatedBy: actor } },
|
||||
{ session },
|
||||
);
|
||||
movedTo = { listId: String(target._id), name: target.name };
|
||||
} else if (leftover.length && input.leftover.action === "new") {
|
||||
const [created] = await ShoppingListModel.create(
|
||||
[
|
||||
{
|
||||
householdId: m.householdId,
|
||||
name: input.leftover.newListName,
|
||||
items: carry,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
},
|
||||
],
|
||||
{ session },
|
||||
);
|
||||
movedTo = { listId: String(created!._id), name: input.leftover.newListName };
|
||||
}
|
||||
|
||||
const completion: ShoppingCompletion = {
|
||||
date: input.date,
|
||||
completedBy: actorId,
|
||||
rows,
|
||||
stores,
|
||||
movedTo,
|
||||
};
|
||||
const res = await ShoppingListModel.updateOne(
|
||||
{ _id: list._id, status: "active", deletedAt: null },
|
||||
{
|
||||
$set: {
|
||||
status: "completed",
|
||||
completedAt: new Date(),
|
||||
completion,
|
||||
updatedBy: actor,
|
||||
},
|
||||
},
|
||||
{ session },
|
||||
);
|
||||
if (!res.modifiedCount) throw new CompletionError(fail(v.notActive));
|
||||
});
|
||||
return ok({ transactions: txDocs.length });
|
||||
} catch (error) {
|
||||
if (error instanceof CompletionError) return error.result;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user