bd5a20ce64
- 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>
587 lines
22 KiB
TypeScript
587 lines
22 KiB
TypeScript
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;
|
||
}
|
||
}
|