feat: Faz 13b - alışveriş sonrası giriş, karşılaştırma ve alışverişi tamamla
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,20 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
## [0.13.0] - 2026-11-06
|
||||||
|
|
||||||
|
Faz 13: alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu ve alışveriş sonrası giriş (13a ve 13b alt fazları).
|
||||||
|
|
||||||
|
### Eklendi — Faz 13b: alışveriş sonrası giriş, karşılaştırma ve "Alışverişi tamamla"
|
||||||
|
|
||||||
|
- Alışveriş sonrası giriş (`/alisveris/:id/tamamla`): liste kalemleri tek tek işaretlenir; her biri için alınan ürün (aynı ya da aynı ürün tipinden muadil; ürün tipi kaleminde marka belirtmeden de), mağaza, paket adedi ve ödenen tutar. Tutar, bilinen paket fiyatıyla ön doldurulur. Listede olmayan ekstra ürünler eklenebilir. Mağaza başına ödeme hesabı (yemek kartı geçen mağazada uygun yemek kartı önseçili) ve fiş görselleri.
|
||||||
|
- "Alışverişi tamamla" tek MongoDB transaction'ı: stok alım hareketleri, fiyat kayıtları (kaynak `receipt`; ürün × mağaza başına, paket fiyatı = tutar / paket), mağaza başına bölünmüş gider işlemi (kalemler stok kategorisinin eşlendiği gider kategorisine; eşlenmemiş ve serbest metin kalemleri seçilen yedek kategoriye), fiş görselleri işlemin eki, alınmayanların taşınması ve listenin kapanması. Herhangi bir adım başarısızsa hiçbir şey yazılmaz.
|
||||||
|
- Alınmayanlar: başka aktif listeye taşı, yeni liste aç ya da bırak.
|
||||||
|
- Karşılaştırma raporu (tamamlanan listede): planlanan (tamamlama anındaki akıllı öneri) vs. gerçekleşen tutar ve fark; farkın nedenleri (aynı üründe fiyat farkı, miktar farkı, muadil, alınmayanlar, plansız ekstralar, planda fiyatı olmayanlar; toplamları farka eşit); kalem bazında Aynı / Muadil / Alınmadı / Plansız ekstra; mağaza giderleri ve kullanılan hesaplar. Etiketler tamamlama anındaki hâliyle saklanır.
|
||||||
|
- Liste sayfasında ve alışveriş modunda "Alışverişi tamamla" düğmesi.
|
||||||
|
- Stok hareketleri ve işlemlerde `shoppingListId` (kaynağı izlemek için).
|
||||||
|
- Testler: eşleşme ve fark ayrıştırması (birim); tamamlama ekranı verisi, doğrulamalar (muadil tipi, gelecek tarih, izleyici), ek dosya hatasında geri alma, stok/fiyat/mağaza başına bölünmüş gider, markasız kalem, kalanların taşınması ve rapor (entegrasyon).
|
||||||
|
|
||||||
### Eklendi — Faz 13a: alışveriş listeleri, stok önerileri, akıllı mağaza önerisi, alışveriş modu
|
### Eklendi — Faz 13a: alışveriş listeleri, stok önerileri, akıllı mağaza önerisi, alışveriş modu
|
||||||
|
|
||||||
- Alışveriş listeleri (`shoppingLists`, `/alisveris`): birden çok aktif liste; kalem olarak ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin. Miktar ürün tipinde tipin biriminde, üründe paket adedi; boşsa en az bir paket. Aynı ürün listede iki kez olamaz; kalem düzenleme, çıkarma ve başka aktif listeye taşıma. Liste silme soft delete ve denetim kaydı. Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve 30 saniyede bir yenilenir.
|
- Alışveriş listeleri (`shoppingLists`, `/alisveris`): birden çok aktif liste; kalem olarak ürün tipi (genel), belirli ürün (marka/paket) ya da serbest metin. Miktar ürün tipinde tipin biriminde, üründe paket adedi; boşsa en az bir paket. Aynı ürün listede iki kez olamaz; kalem düzenleme, çıkarma ve başka aktif listeye taşıma. Liste silme soft delete ve denetim kaydı. Aile üyeleri aynı listeyi kullanır: sayfa odaklanınca ve 30 saniyede bir yenilenir.
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
|||||||
- **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer.
|
- **Faz 10 kararları (2026-11-06):** 10a aylık bütçe, 10b gelir planı ve günlük plan; `v0.10.0` 10b sonunda. Limit her gider kategorisine verilebilir; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır (toplamda iki kez sayılmaz). Başkasının "yalnızca ben" hesabından yapılan harcama hane ayarına (gizli hesapları toplamlara kat) göre yalnızca tutar olarak bütçeye girer.
|
||||||
- **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür.
|
- **Faz 11 kararları (2026-11-06):** 11a stok kategorileri, ürün tipleri, hareketler, SKT ve %20 eşik; 11b periyodik sayım, fire ayarlı öneri ve tüketim analizi; `v0.11.0` 11b sonunda. Eşiğin altındaki ürünler Faz 11'de "Alınması önerilenler" listesinde gösterilir (koleksiyon yok); Faz 13'te aktif alışveriş listesine onayla/reddet önerisi olarak bağlanır. SKT alım hareketinde (isteğe bağlı) girilir; tüketim ve fire FIFO ile en eski alımdan düşülür.
|
||||||
- **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır.
|
- **Faz 12 kararları (2026-11-06):** 12a mağazalar ve ürünler (marka/paket, görseller), 12b fiyat kayıtları, birim fiyat ve fiyat geçmişi; `v0.12.0` 12b sonunda. Mağazalar, ürünler ve fiyatlar hane bazlıdır (hane içinde ortak, başka hane görmez). Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa ürün kaydında dönüşüm zorunludur ("5 kg = 33 yıkama"); her fiyat ürün tipinin biriminde karşılaştırılır.
|
||||||
|
- **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz.
|
||||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^7.0.2",
|
"@hookform/resolvers": "^7.0.2",
|
||||||
"@tanstack/react-table": "^9.3.1",
|
"@tanstack/react-table": "^9.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "0.12.0",
|
"version": "0.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { CompletionForm } from "@/features/shopping/components/completion-form";
|
||||||
|
import { getCompletionSheet } from "@/features/shopping/completion";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tr.shopping.complete.title };
|
||||||
|
|
||||||
|
/** /alisveris/:id/tamamla — alışveriş sonrası giriş: alınanlar, ödeme, fiş, alınmayanlar. */
|
||||||
|
export default async function CompleteShoppingPage({
|
||||||
|
params,
|
||||||
|
}: PageProps<"/alisveris/[id]/tamamla">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("member");
|
||||||
|
const { id } = await params;
|
||||||
|
const sheet = await getCompletionSheet(user.id, household.id, id);
|
||||||
|
if (!sheet) notFound();
|
||||||
|
return <CompletionForm sheet={sheet} />;
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 13.1;
|
export const CURRENT_PHASE = 13.2;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { tr } from "@/i18n/tr";
|
|||||||
import { fail, type ActionResult } from "@/lib/action-result";
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
import { getHouseholdActionContext } from "@/lib/dal";
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
import {
|
import {
|
||||||
|
shoppingCompletionSchema,
|
||||||
shoppingItemFormSchema,
|
shoppingItemFormSchema,
|
||||||
shoppingItemUpdateSchema,
|
shoppingItemUpdateSchema,
|
||||||
shoppingListFormSchema,
|
shoppingListFormSchema,
|
||||||
} from "@/shared/schemas/shopping";
|
} from "@/shared/schemas/shopping";
|
||||||
|
import { completeShopping } from "./completion";
|
||||||
import {
|
import {
|
||||||
addShoppingItem,
|
addShoppingItem,
|
||||||
approveSuggestions,
|
approveSuggestions,
|
||||||
@@ -118,3 +120,9 @@ export async function dismissSuggestionAction(productTypeId: unknown) {
|
|||||||
if (!id.success) return fail(tr.common.notFound);
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
return run(z.unknown(), null, (u, h) => dismissSuggestion(u, h, id.data));
|
return run(z.unknown(), null, (u, h) => dismissSuggestion(u, h, id.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function completeShoppingAction(listId: unknown, input: unknown) {
|
||||||
|
const id = idSchema.safeParse(listId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(shoppingCompletionSchema, input, (u, h, d) => completeShopping(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import { ShoppingListModel } from "@/models/shopping-list";
|
||||||
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
|
import { StoreModel } from "@/models/store";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { formatMoneyInput } from "@/shared/money";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { shoppingCompletionSchema } from "@/shared/schemas/shopping";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { completeShopping, getCompletionSheet } from "./completion";
|
||||||
|
import { getShoppingList } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let weekly = "";
|
||||||
|
let monthEnd = "";
|
||||||
|
const today = todayIstanbul();
|
||||||
|
|
||||||
|
const bySeed = (model: typeof ProductModel | typeof StoreModel, seedRef: string) =>
|
||||||
|
(model as typeof ProductModel)
|
||||||
|
.findOne({ seedRef })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
const accountId = (name: string) =>
|
||||||
|
AccountModel.findOne({ name })
|
||||||
|
.lean()
|
||||||
|
.then((x) => String(x!._id));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
weekly = String((await ShoppingListModel.findOne({ seedRef: "haftalik" }).lean())!._id);
|
||||||
|
monthEnd = String((await ShoppingListModel.findOne({ seedRef: "ay-sonu" }).lean())!._id);
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
async function scenario() {
|
||||||
|
const sheet = (await getCompletionSheet(ayse, cakir, weekly))!;
|
||||||
|
const item = (label: string) => sheet.items.find((i) => i.label === label)!;
|
||||||
|
const milk = item("Süt");
|
||||||
|
const eggs = item("Yumurta");
|
||||||
|
const detergent = item("Toz Deterjan");
|
||||||
|
const candle = item("Doğum günü mumu");
|
||||||
|
const tomato = item("Domates");
|
||||||
|
const sok = await bySeed(StoreModel, "sok");
|
||||||
|
const migros = await bySeed(StoreModel, "migros");
|
||||||
|
const a101 = await bySeed(StoreModel, "a101");
|
||||||
|
const milkPlan = sheet.planned[milk.id]!;
|
||||||
|
const detPlan = sheet.planned[detergent.id]!;
|
||||||
|
const market = (await CategoryModel.findOne({
|
||||||
|
defaultKey: "market",
|
||||||
|
householdId: cakir,
|
||||||
|
}).lean())!;
|
||||||
|
const accounts = {
|
||||||
|
bank: await accountId("Ziraat Maaş"),
|
||||||
|
meal: await accountId("Ayşe — Pluxee"),
|
||||||
|
card: await accountId("Garanti Bonus"),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
sheet,
|
||||||
|
milk,
|
||||||
|
eggs,
|
||||||
|
detergent,
|
||||||
|
candle,
|
||||||
|
tomato,
|
||||||
|
stores: { sok, migros, a101 },
|
||||||
|
form: (over: Record<string, unknown> = {}) =>
|
||||||
|
shoppingCompletionSchema.parse({
|
||||||
|
date: today,
|
||||||
|
lines: [
|
||||||
|
// Planlanan ürün, planlanan mağaza; paket fiyatı plandan 1 TL fazla.
|
||||||
|
{
|
||||||
|
itemId: milk.id,
|
||||||
|
productId: milkPlan.productId,
|
||||||
|
storeId: milkPlan.storeId,
|
||||||
|
packages: String(milkPlan.packages),
|
||||||
|
amount: formatMoneyInput((milkPlan.priceMinor + 100) * milkPlan.packages),
|
||||||
|
},
|
||||||
|
// Muadil: 15'li köy yumurtası, A101.
|
||||||
|
{
|
||||||
|
itemId: eggs.id,
|
||||||
|
productId: sheet.products.find((p) => p.label.includes("Köy"))!.id,
|
||||||
|
storeId: a101,
|
||||||
|
packages: "2",
|
||||||
|
amount: "189,80",
|
||||||
|
},
|
||||||
|
// Planlanan ürün başka mağazada (Migros, yemek kartı).
|
||||||
|
{
|
||||||
|
itemId: detergent.id,
|
||||||
|
productId: detPlan.productId,
|
||||||
|
storeId: migros,
|
||||||
|
packages: "1",
|
||||||
|
amount: "549,00",
|
||||||
|
},
|
||||||
|
// Markasız ürün tipi (pazardan domates): 2,5 kg, fiyat kaydı yok.
|
||||||
|
{ itemId: tomato.id, productId: "", storeId: sok, packages: "2,5", amount: "60" },
|
||||||
|
// Serbest metin kalemi: yedek gider kategorisi.
|
||||||
|
{ itemId: candle.id, productId: "", storeId: sok, packages: "1", amount: "25" },
|
||||||
|
// Plansız ekstra.
|
||||||
|
{
|
||||||
|
itemId: "",
|
||||||
|
productId: sheet.products.find((p) => p.label.startsWith("Fairy"))!.id,
|
||||||
|
storeId: sok,
|
||||||
|
packages: "1",
|
||||||
|
amount: "84,50",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stores: [
|
||||||
|
{ storeId: sok, accountId: accounts.bank, attachmentIds: [] },
|
||||||
|
{ storeId: migros, accountId: accounts.meal, attachmentIds: [] },
|
||||||
|
{ storeId: a101, accountId: accounts.card, attachmentIds: [] },
|
||||||
|
],
|
||||||
|
fallbackCategoryId: String(market._id),
|
||||||
|
leftover: { action: "move", targetListId: monthEnd, newListName: "" },
|
||||||
|
...over,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tamamlama ekranı", () => {
|
||||||
|
it("plan, fiyatlar, hesaplar ve eşleme bilgisi; izleyici açamaz", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
expect(s.sheet.items).toHaveLength(9);
|
||||||
|
expect(Object.keys(s.sheet.planned).length).toBe(7);
|
||||||
|
expect(Object.keys(s.sheet.prices).length).toBeGreaterThan(20);
|
||||||
|
expect(s.sheet.accounts.map((a) => a.name)).toContain("Ayşe — Pluxee");
|
||||||
|
expect(s.sheet.defaultFallbackCategoryId).not.toBeNull();
|
||||||
|
expect(s.sheet.otherLists.map((l) => l.name)).toEqual(["Ay sonu toptan"]);
|
||||||
|
expect(await getCompletionSheet(zeynep, cakir, weekly)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("alışverişi tamamla", () => {
|
||||||
|
it("doğrulama: muadil aynı tipten, gelecek tarih, izleyici, şema kuralları", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
const wrongType = s.form();
|
||||||
|
wrongType.lines[0]!.productId = await bySeed(ProductModel, "ariel-5kg");
|
||||||
|
expect((await completeShopping(ayse, cakir, weekly, wrongType)).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await completeShopping(ayse, cakir, weekly, s.form({ date: addDays(today, 1) }))).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect((await completeShopping(zeynep, cakir, weekly, s.form())).ok).toBe(false);
|
||||||
|
// Mağazası için ödeme hesabı girilmemiş satır.
|
||||||
|
expect(
|
||||||
|
shoppingCompletionSchema.safeParse({
|
||||||
|
date: today,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
itemId: "",
|
||||||
|
productId: s.milk.productTypeId,
|
||||||
|
storeId: s.stores.sok,
|
||||||
|
packages: "1",
|
||||||
|
amount: "1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stores: [],
|
||||||
|
leftover: { action: "drop" },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ek dosya hatasında hiçbir şey yazılmaz (transaction geri alınır)", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
const form = s.form();
|
||||||
|
form.stores[0]!.attachmentIds = [String(new Types.ObjectId())];
|
||||||
|
expect((await completeShopping(ayse, cakir, weekly, form)).ok).toBe(false);
|
||||||
|
expect(await TransactionModel.countDocuments({ shoppingListId: { $ne: null } })).toBe(0);
|
||||||
|
expect(await StockMovementModel.countDocuments({ shoppingListId: { $ne: null } })).toBe(0);
|
||||||
|
expect(await ProductPriceModel.countDocuments({ source: "receipt" })).toBe(0);
|
||||||
|
expect((await ShoppingListModel.findById(weekly).lean())!.status).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stok, fiyat, mağaza başına bölünmüş gider, kalanların taşınması ve rapor", async () => {
|
||||||
|
const s = await scenario();
|
||||||
|
const monthEndBefore = (await ShoppingListModel.findById(monthEnd).lean())!.items.length;
|
||||||
|
|
||||||
|
const r = await completeShopping(ayse, cakir, weekly, s.form());
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { transactions: 3 } });
|
||||||
|
|
||||||
|
// Mağaza başına gider; Şok: gıda + temizlik + yedek (mum) bölünmüş.
|
||||||
|
const txs = await TransactionModel.find({ shoppingListId: new Types.ObjectId(weekly) }).lean();
|
||||||
|
expect(txs).toHaveLength(3);
|
||||||
|
const sokTx = txs.find((t) => t.payeeName.startsWith("Şok"))!;
|
||||||
|
expect(sokTx.splits).toHaveLength(3);
|
||||||
|
expect(sokTx.splits.reduce((a, x) => a + x.amountMinor, 0)).toBe(sokTx.amountMinor);
|
||||||
|
const migrosTx = txs.find((t) => t.payeeName.startsWith("Migros"))!;
|
||||||
|
expect(migrosTx).toMatchObject({ amountMinor: 549_00, splits: [] });
|
||||||
|
expect(String(migrosTx.accountId)).toBe(await accountId("Ayşe — Pluxee"));
|
||||||
|
|
||||||
|
// Stok alımları (serbest metin hariç 5 kalem) ve fiş kaynaklı fiyatlar (markasız domates hariç 4).
|
||||||
|
expect(
|
||||||
|
await StockMovementModel.countDocuments({
|
||||||
|
shoppingListId: new Types.ObjectId(weekly),
|
||||||
|
kind: "purchase",
|
||||||
|
}),
|
||||||
|
).toBe(5);
|
||||||
|
expect(await ProductPriceModel.countDocuments({ source: "receipt" })).toBe(4);
|
||||||
|
const eggPrice = await ProductPriceModel.findOne({ source: "receipt", priceMinor: 94_90 });
|
||||||
|
expect(eggPrice).not.toBeNull();
|
||||||
|
|
||||||
|
// Liste kapandı; alınmayan 5 kalem "Ay sonu toptan"a taşındı.
|
||||||
|
const after = (await getShoppingList(ayse, cakir, weekly))!;
|
||||||
|
expect(after.list.status).toBe("completed");
|
||||||
|
expect(after.canWrite).toBe(false);
|
||||||
|
expect(after.report!.completion.movedTo?.name).toBe("Ay sonu toptan");
|
||||||
|
expect((await ShoppingListModel.findById(monthEnd).lean())!.items.length).toBe(
|
||||||
|
monthEndBefore + 4,
|
||||||
|
);
|
||||||
|
// Süt alımı: paket × 1 lt.
|
||||||
|
const milkMove = await StockMovementModel.findOne({
|
||||||
|
shoppingListId: new Types.ObjectId(weekly),
|
||||||
|
productTypeId: new Types.ObjectId(s.milk.productTypeId!),
|
||||||
|
}).lean();
|
||||||
|
expect(milkMove!.quantity).toBe(s.sheet.planned[s.milk.id]!.packages * 1000);
|
||||||
|
// Markasız domates: tipin biriminde 2,5 kg.
|
||||||
|
const tomatoMove = await StockMovementModel.findOne({
|
||||||
|
shoppingListId: new Types.ObjectId(weekly),
|
||||||
|
productTypeId: new Types.ObjectId(s.tomato.productTypeId!),
|
||||||
|
}).lean();
|
||||||
|
expect(tomatoMove!.quantity).toBe(2500);
|
||||||
|
|
||||||
|
// Rapor: aynı/muadil/alınmadı/ekstra; nedenlerin toplamı farka eşit.
|
||||||
|
const { summary, completion } = after.report!;
|
||||||
|
expect(summary.counts).toEqual({ same: 4, substitute: 1, skipped: 4, extra: 1 });
|
||||||
|
const sum = Object.values(summary.reasons).reduce((a, b) => a + b, 0);
|
||||||
|
expect(sum).toBe(summary.diffMinor);
|
||||||
|
// Süt: paket başına +1 TL; deterjan: aynı ürün başka mağazada (Migros) farklı fiyat.
|
||||||
|
expect(summary.reasons.price).toBe(
|
||||||
|
100 * s.sheet.planned[s.milk.id]!.packages +
|
||||||
|
(549_00 - s.sheet.planned[s.detergent.id]!.priceMinor),
|
||||||
|
);
|
||||||
|
expect(summary.reasons.extra).toBe(84_50);
|
||||||
|
expect(summary.reasons.unplanned).toBe(25_00 + 60_00);
|
||||||
|
expect(summary.reasons.skipped).toBeLessThan(0);
|
||||||
|
expect(completion.stores).toHaveLength(3);
|
||||||
|
|
||||||
|
// Tamamlanan liste değiştirilemez, ikinci kez tamamlanamaz.
|
||||||
|
expect((await completeShopping(ayse, cakir, weekly, s.form())).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { AttachmentPicker } from "@/features/attachments/components/attachment-picker";
|
||||||
|
import type { AttachmentInfo } from "@/features/attachments/service";
|
||||||
|
import { StoreDot } from "@/features/prices/components/offers-list";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { formatMoneyInput, parseMoney } from "@/shared/money";
|
||||||
|
import { parseQuantity } from "@/shared/quantity";
|
||||||
|
import type { LeftoverAction } from "@/shared/shopping";
|
||||||
|
import { quantityInput } from "@/shared/stock";
|
||||||
|
import { completeShoppingAction } from "../actions";
|
||||||
|
import type { CompletionSheet } from "../completion";
|
||||||
|
import type { ShoppingItemView } from "../service";
|
||||||
|
|
||||||
|
const t = tr.shopping.complete;
|
||||||
|
/** Ürün tipi kaleminde marka belirtmeden alım (Radix Select boş değer kabul etmez). */
|
||||||
|
const GENERIC = "__generic__";
|
||||||
|
|
||||||
|
type Line = {
|
||||||
|
productId: string;
|
||||||
|
storeId: string;
|
||||||
|
packages: string;
|
||||||
|
amount: string;
|
||||||
|
/** Kullanıcı tutarı elle değiştirdi: fiyattan yeniden hesaplanmaz. */
|
||||||
|
touched: boolean;
|
||||||
|
};
|
||||||
|
type ItemLine = Line & { bought: boolean };
|
||||||
|
type Extra = Line & { key: number };
|
||||||
|
|
||||||
|
/** Paket fiyatı biliniyorsa tutar = fiyat × paket. */
|
||||||
|
function priceAmount(sheet: CompletionSheet, l: Pick<Line, "productId" | "storeId" | "packages">) {
|
||||||
|
const price = sheet.prices[`${l.productId}:${l.storeId}`];
|
||||||
|
const q = parseQuantity(l.packages);
|
||||||
|
return price && q ? formatMoneyInput(Math.round((price * q) / 1000)) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPrice(sheet: CompletionSheet, l: Line, patch: Partial<Line>): Line {
|
||||||
|
const next = { ...l, ...patch };
|
||||||
|
if ("amount" in patch) return { ...next, touched: true };
|
||||||
|
if (next.touched) return next;
|
||||||
|
return { ...next, amount: priceAmount(sheet, next) || next.amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialLine(sheet: CompletionSheet, item: ShoppingItemView): Line {
|
||||||
|
const plan = sheet.planned[item.id];
|
||||||
|
const typeOf = (id: string | null) => sheet.products.find((p) => p.id === id)?.productTypeId;
|
||||||
|
const typeId = item.kind === "product" ? typeOf(item.productId) : item.productTypeId;
|
||||||
|
const productId =
|
||||||
|
item.kind === "text"
|
||||||
|
? ""
|
||||||
|
: (plan?.productId ??
|
||||||
|
item.productId ??
|
||||||
|
sheet.products.find((p) => p.productTypeId === typeId)?.id ??
|
||||||
|
GENERIC);
|
||||||
|
const packages = plan
|
||||||
|
? String(plan.packages)
|
||||||
|
: item.quantity && (item.kind === "product" || productId === GENERIC)
|
||||||
|
? quantityInput(item.quantity)
|
||||||
|
: "1";
|
||||||
|
const base = {
|
||||||
|
productId,
|
||||||
|
storeId: plan?.storeId ?? defaultStore(sheet),
|
||||||
|
packages,
|
||||||
|
amount: "",
|
||||||
|
touched: false,
|
||||||
|
};
|
||||||
|
return { ...base, amount: priceAmount(sheet, base) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Planda en çok kalemin gittiği mağaza; plan yoksa ilk mağaza. */
|
||||||
|
function defaultStore(sheet: CompletionSheet) {
|
||||||
|
const count = new Map<string, number>();
|
||||||
|
for (const p of Object.values(sheet.planned))
|
||||||
|
count.set(p.storeId, (count.get(p.storeId) ?? 0) + 1);
|
||||||
|
return [...count.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? sheet.stores[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mağazaya uygun varsayılan hesap: geçerli yemek kartı, yoksa banka/nakit, yoksa ilk hesap. */
|
||||||
|
function defaultAccount(sheet: CompletionSheet, storeId: string) {
|
||||||
|
const store = sheet.stores.find((s) => s.id === storeId);
|
||||||
|
const meal = sheet.accounts.find(
|
||||||
|
(a) =>
|
||||||
|
a.type === "meal_card" && a.mealCardProvider && store?.providers.includes(a.mealCardProvider),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
meal?.id ??
|
||||||
|
sheet.accounts.find((a) => a.type === "bank" || a.type === "cash")?.id ??
|
||||||
|
sheet.accounts[0]?.id ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineFields({
|
||||||
|
sheet,
|
||||||
|
line,
|
||||||
|
products,
|
||||||
|
text,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
idPrefix,
|
||||||
|
genericUnit,
|
||||||
|
}: {
|
||||||
|
sheet: CompletionSheet;
|
||||||
|
line: Line;
|
||||||
|
products: CompletionSheet["products"];
|
||||||
|
text: boolean;
|
||||||
|
onChange: (patch: Partial<Line>) => void;
|
||||||
|
errors: Record<string, string | undefined>;
|
||||||
|
idPrefix: string;
|
||||||
|
/** Ürün tipi kalemi: markasız seçeneği ve tipin birimi. */
|
||||||
|
genericUnit?: string;
|
||||||
|
}) {
|
||||||
|
const generic = line.productId === GENERIC;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_5rem_7rem]">
|
||||||
|
{text ? (
|
||||||
|
<p className="col-span-2 self-center text-xs text-muted-foreground sm:col-span-1">
|
||||||
|
{tr.shopping.complete.textItem}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Field data-invalid={Boolean(errors.productId)} className="col-span-2 sm:col-span-1">
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-p`} className="text-xs">
|
||||||
|
{t.product}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={line.productId} onValueChange={(x) => onChange({ productId: x })}>
|
||||||
|
<SelectTrigger id={`${idPrefix}-p`} className="w-full">
|
||||||
|
<SelectValue placeholder={tr.shopping.validation.product} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{genericUnit ? (
|
||||||
|
<SelectItem value={GENERIC}>{t.generic(genericUnit)}</SelectItem>
|
||||||
|
) : null}
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.label}{" "}
|
||||||
|
<span className="text-xs text-muted-foreground">({p.packageLabel})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.productId ? <FieldError>{errors.productId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field data-invalid={Boolean(errors.storeId)} className="col-span-2 sm:col-span-1">
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-s`} className="text-xs">
|
||||||
|
{t.store}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={line.storeId} onValueChange={(x) => onChange({ storeId: x })}>
|
||||||
|
<SelectTrigger id={`${idPrefix}-s`} className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{sheet.stores.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
<StoreDot slot={s.slot} />
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
{text ? (
|
||||||
|
<span className="hidden sm:block" />
|
||||||
|
) : (
|
||||||
|
<Field data-invalid={Boolean(errors.packages)}>
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-q`} className="text-xs">
|
||||||
|
{generic && genericUnit ? `${t.quantity} (${genericUnit})` : t.packages}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${idPrefix}-q`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={line.packages}
|
||||||
|
onChange={(e) => onChange({ packages: e.target.value })}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.packages)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(errors.amount)}
|
||||||
|
className={text ? "col-span-2 sm:col-span-1" : ""}
|
||||||
|
>
|
||||||
|
<FieldLabel htmlFor={`${idPrefix}-a`} className="text-xs">
|
||||||
|
{t.amount}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${idPrefix}-a`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={line.amount}
|
||||||
|
onChange={(e) => onChange({ amount: e.target.value })}
|
||||||
|
className="text-right tabular-nums"
|
||||||
|
aria-invalid={Boolean(errors.amount)}
|
||||||
|
/>
|
||||||
|
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /alisveris/:id/tamamla — alınanları gir, mağaza başına hesap ve fiş, alınmayanlar. */
|
||||||
|
export function CompletionForm({ sheet }: { sheet: CompletionSheet }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const anyChecked = sheet.items.some((i) => i.checked);
|
||||||
|
const [date, setDate] = useState(sheet.today);
|
||||||
|
const [rows, setRows] = useState<Record<string, ItemLine>>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
sheet.items.map((i) => [
|
||||||
|
i.id,
|
||||||
|
{ ...initialLine(sheet, i), bought: anyChecked ? i.checked : !i.archived },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [extras, setExtras] = useState<Extra[]>([]);
|
||||||
|
const [accounts, setAccounts] = useState<Record<string, string>>({});
|
||||||
|
const [receipts, setReceipts] = useState<Record<string, AttachmentInfo[]>>({});
|
||||||
|
const [uploading, setUploading] = useState(0);
|
||||||
|
const [fallback, setFallback] = useState(sheet.defaultFallbackCategoryId ?? "");
|
||||||
|
const [leftover, setLeftover] = useState<{
|
||||||
|
action: LeftoverAction;
|
||||||
|
target: string;
|
||||||
|
name: string;
|
||||||
|
}>({
|
||||||
|
action: sheet.otherLists.length ? "move" : "new",
|
||||||
|
target: sheet.otherLists[0]?.id ?? "",
|
||||||
|
name: t.newListDefault(sheet.list.name),
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const typeProducts = (item: ShoppingItemView) => {
|
||||||
|
const typeId =
|
||||||
|
item.kind === "product"
|
||||||
|
? sheet.products.find((p) => p.id === item.productId)?.productTypeId
|
||||||
|
: item.productTypeId;
|
||||||
|
return sheet.products.filter((p) => p.productTypeId === typeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bought = sheet.items.filter((i) => rows[i.id]!.bought);
|
||||||
|
const unbought = sheet.items.filter((i) => !rows[i.id]!.bought);
|
||||||
|
const allLines: (Line & { itemId: string; text: boolean })[] = [
|
||||||
|
...bought.map((i) => ({ ...rows[i.id]!, itemId: i.id, text: i.kind === "text" })),
|
||||||
|
...extras.map((e) => ({ ...e, itemId: "", text: false })),
|
||||||
|
];
|
||||||
|
const storeIds = [...new Set(allLines.map((l) => l.storeId).filter(Boolean))];
|
||||||
|
const unmapped = new Set(sheet.unmappedTypeIds);
|
||||||
|
const needsFallback = allLines.some(
|
||||||
|
(l) =>
|
||||||
|
l.text || unmapped.has(sheet.products.find((p) => p.id === l.productId)?.productTypeId ?? ""),
|
||||||
|
);
|
||||||
|
const total = allLines.reduce((a, l) => a + (parseMoney(l.amount) ?? 0), 0);
|
||||||
|
const plannedTotal = useMemo(
|
||||||
|
() => Object.values(sheet.planned).reduce((a, p) => a + p.priceMinor * p.packages, 0),
|
||||||
|
[sheet.planned],
|
||||||
|
);
|
||||||
|
|
||||||
|
function setRow(id: string, patch: Partial<ItemLine>) {
|
||||||
|
setRows((s) => {
|
||||||
|
const cur = s[id]!;
|
||||||
|
const { bought: b, ...rest } = patch;
|
||||||
|
const next = Object.keys(rest).length ? { ...cur, ...withPrice(sheet, cur, rest) } : cur;
|
||||||
|
return { ...s, [id]: { ...next, bought: b ?? cur.bought } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
const lineKeys = allLines.map((l, i) =>
|
||||||
|
l.itemId ? `item:${l.itemId}` : `extra:${extras[i - bought.length]?.key}`,
|
||||||
|
);
|
||||||
|
const r = await completeShoppingAction(sheet.list.id, {
|
||||||
|
date,
|
||||||
|
lines: allLines.map((l) => ({
|
||||||
|
itemId: l.itemId,
|
||||||
|
productId: l.text || l.productId === GENERIC ? "" : l.productId,
|
||||||
|
storeId: l.storeId,
|
||||||
|
packages: l.text ? "1" : l.packages,
|
||||||
|
amount: l.amount,
|
||||||
|
})),
|
||||||
|
stores: storeIds.map((id) => ({
|
||||||
|
storeId: id,
|
||||||
|
accountId: accounts[id] ?? defaultAccount(sheet, id),
|
||||||
|
attachmentIds: (receipts[id] ?? []).map((a) => a.id),
|
||||||
|
})),
|
||||||
|
fallbackCategoryId: needsFallback ? fallback : "",
|
||||||
|
leftover: unbought.length
|
||||||
|
? { action: leftover.action, targetListId: leftover.target, newListName: leftover.name }
|
||||||
|
: { action: "drop" },
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (r.ok) {
|
||||||
|
toast.success(t.saved);
|
||||||
|
router.push(`/alisveris/${sheet.list.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Satır hatalarını satır anahtarına çevir.
|
||||||
|
const mapped: Record<string, string | undefined> = {};
|
||||||
|
for (const [k, msg] of Object.entries(r.fieldErrors ?? {})) {
|
||||||
|
const m = /^lines\.(\d+)\.(\w+)$/.exec(k);
|
||||||
|
if (m) mapped[`${lineKeys[Number(m[1])]}.${m[2]}`] = msg;
|
||||||
|
else {
|
||||||
|
const s = /^stores\.(\d+)\.(\w+)$/.exec(k);
|
||||||
|
mapped[s ? `store:${storeIds[Number(s[1])]}` : k] = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setErrors({ ...mapped, root: r.error });
|
||||||
|
toast.error(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errs = (key: string) => ({
|
||||||
|
productId: errors[`${key}.productId`],
|
||||||
|
storeId: errors[`${key}.storeId`],
|
||||||
|
packages: errors[`${key}.packages`],
|
||||||
|
amount: errors[`${key}.amount`],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||||
|
<Link href={`/alisveris/${sheet.list.id}`}>
|
||||||
|
<ArrowLeft />
|
||||||
|
{tr.shopping.actions.backToList}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{t.title}: {sheet.list.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.description}</p>
|
||||||
|
</div>
|
||||||
|
{sheet.stores.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm">{t.noStores}</p>
|
||||||
|
) : null}
|
||||||
|
<Field className="max-w-48" data-invalid={Boolean(errors.date)}>
|
||||||
|
<FieldLabel htmlFor="sc-date">{t.date}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="sc-date"
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
max={sheet.today}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<section aria-labelledby="sc-items" className="flex flex-col gap-2">
|
||||||
|
<h2 id="sc-items" className="font-medium">
|
||||||
|
{t.itemsTitle}
|
||||||
|
</h2>
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{sheet.items.map((item) => {
|
||||||
|
const row = rows[item.id]!;
|
||||||
|
const plan = sheet.planned[item.id];
|
||||||
|
const planProduct = plan ? sheet.products.find((p) => p.id === plan.productId) : null;
|
||||||
|
const planStore = plan ? sheet.stores.find((s) => s.id === plan.storeId) : null;
|
||||||
|
const substitute =
|
||||||
|
row.bought &&
|
||||||
|
item.kind !== "text" &&
|
||||||
|
(item.kind === "product"
|
||||||
|
? row.productId !== item.productId
|
||||||
|
: plan && row.productId !== plan.productId);
|
||||||
|
const key = `item:${item.id}`;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={cn("flex flex-col gap-3 p-3", !row.bought && "bg-muted/30")}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`b-${item.id}`}
|
||||||
|
checked={row.bought}
|
||||||
|
onCheckedChange={(x) => setRow(item.id, { bought: x === true })}
|
||||||
|
className="mt-0.5 size-5"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`b-${item.id}`} className="min-w-0 flex-1 cursor-pointer">
|
||||||
|
<span
|
||||||
|
className={cn("block font-medium", !row.bought && "text-muted-foreground")}
|
||||||
|
>
|
||||||
|
{item.label}{" "}
|
||||||
|
{item.quantityLabel ? (
|
||||||
|
<span className="text-sm font-normal text-muted-foreground tabular-nums">
|
||||||
|
· {item.quantityLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{plan && planProduct && planStore
|
||||||
|
? t.planned(
|
||||||
|
`${planProduct.label} · ${planStore.label} · ${formatMoney(plan.priceMinor * plan.packages)}`,
|
||||||
|
)
|
||||||
|
: item.kind === "text"
|
||||||
|
? null
|
||||||
|
: t.noPlan}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{substitute ? <Badge variant="outline">{t.substitute}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
{row.bought ? (
|
||||||
|
<LineFields
|
||||||
|
sheet={sheet}
|
||||||
|
line={row}
|
||||||
|
products={typeProducts(item)}
|
||||||
|
text={item.kind === "text"}
|
||||||
|
genericUnit={item.kind === "type" ? item.unit : undefined}
|
||||||
|
onChange={(patch) => setRow(item.id, patch)}
|
||||||
|
errors={errs(key)}
|
||||||
|
idPrefix={`l-${item.id}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="sc-extras" className="flex flex-col gap-2">
|
||||||
|
<h2 id="sc-extras" className="font-medium">
|
||||||
|
{t.extrasTitle}
|
||||||
|
</h2>
|
||||||
|
{extras.length ? (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{extras.map((e) => (
|
||||||
|
<li key={e.key} className="flex items-start gap-2 p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<LineFields
|
||||||
|
sheet={sheet}
|
||||||
|
line={e}
|
||||||
|
products={sheet.products}
|
||||||
|
text={false}
|
||||||
|
onChange={(patch) =>
|
||||||
|
setExtras((s) =>
|
||||||
|
s.map((x) =>
|
||||||
|
x.key === e.key ? { ...x, ...withPrice(sheet, x, patch) } : x,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
errors={errs(`extra:${e.key}`)}
|
||||||
|
idPrefix={`e-${e.key}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-6"
|
||||||
|
aria-label={t.removeExtra}
|
||||||
|
onClick={() => setExtras((s) => s.filter((x) => x.key !== e.key))}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() =>
|
||||||
|
setExtras((s) => [
|
||||||
|
...s,
|
||||||
|
{
|
||||||
|
key: Date.now(),
|
||||||
|
productId: "",
|
||||||
|
storeId: storeIds[0] ?? defaultStore(sheet),
|
||||||
|
packages: "1",
|
||||||
|
amount: "",
|
||||||
|
touched: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{t.addExtra}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{storeIds.length ? (
|
||||||
|
<section aria-labelledby="sc-stores" className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="sc-stores" className="font-medium">
|
||||||
|
{t.storesTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.storesDescription}</p>
|
||||||
|
</div>
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
|
{storeIds.map((id) => {
|
||||||
|
const store = sheet.stores.find((s) => s.id === id);
|
||||||
|
const lines = allLines.filter((l) => l.storeId === id);
|
||||||
|
const sum = lines.reduce((a, l) => a + (parseMoney(l.amount) ?? 0), 0);
|
||||||
|
return (
|
||||||
|
<li key={id} className="flex flex-col gap-3 rounded-xl border bg-card p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex min-w-0 items-center font-medium">
|
||||||
|
<StoreDot slot={store?.slot ?? 1} />
|
||||||
|
<span className="truncate">{store?.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-sm tabular-nums">
|
||||||
|
{t.subtotal(lines.length)} · {formatMoney(sum)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Field data-invalid={Boolean(errors[`store:${id}`])}>
|
||||||
|
<FieldLabel htmlFor={`acc-${id}`}>{t.account}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={accounts[id] ?? defaultAccount(sheet, id)}
|
||||||
|
onValueChange={(x) => setAccounts((s) => ({ ...s, [id]: x }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`acc-${id}`} className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sheet.accounts.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors[`store:${id}`] ? (
|
||||||
|
<FieldError>{errors[`store:${id}`]}</FieldError>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{t.receipts}</FieldLabel>
|
||||||
|
<AttachmentPicker
|
||||||
|
value={receipts[id] ?? []}
|
||||||
|
onChange={(items) => setReceipts((s) => ({ ...s, [id]: items }))}
|
||||||
|
allowedKinds={["image"]}
|
||||||
|
onBusyChange={(b) => setUploading((n) => n + (b ? 1 : -1))}
|
||||||
|
addLabel={t.addReceipt}
|
||||||
|
hint={t.receiptHint}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{needsFallback ? (
|
||||||
|
<Field data-invalid={Boolean(errors.fallbackCategoryId)} className="max-w-md">
|
||||||
|
<FieldLabel htmlFor="sc-fallback">{t.fallbackCategory}</FieldLabel>
|
||||||
|
<Select value={fallback} onValueChange={setFallback}>
|
||||||
|
<SelectTrigger id="sc-fallback" className="w-full">
|
||||||
|
<SelectValue placeholder={t.fallbackCategory} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{sheet.expenseCategories.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.path}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>{t.fallbackHint}</FieldDescription>
|
||||||
|
{errors.fallbackCategoryId ? <FieldError>{errors.fallbackCategoryId}</FieldError> : null}
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{unbought.length ? (
|
||||||
|
<section aria-labelledby="sc-left" className="flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 id="sc-left" className="font-medium">
|
||||||
|
{t.leftoverTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.leftoverDescription(unbought.length)} {unbought.map((i) => i.label).join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div role="radiogroup" aria-labelledby="sc-left" className="flex flex-col gap-2">
|
||||||
|
{(["move", "new", "drop"] as const)
|
||||||
|
.filter((a) => a !== "move" || sheet.otherLists.length)
|
||||||
|
.map((a) => (
|
||||||
|
<label key={a} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="leftover"
|
||||||
|
value={a}
|
||||||
|
checked={leftover.action === a}
|
||||||
|
onChange={() => setLeftover((s) => ({ ...s, action: a }))}
|
||||||
|
className="size-4 accent-primary"
|
||||||
|
/>
|
||||||
|
{t.leftoverActions[a]}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{leftover.action === "move" ? (
|
||||||
|
<Select
|
||||||
|
value={leftover.target}
|
||||||
|
onValueChange={(x) => setLeftover((s) => ({ ...s, target: x }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full max-w-md" aria-label={tr.shopping.moveTarget}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sheet.otherLists.map((l) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>
|
||||||
|
{l.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : leftover.action === "new" ? (
|
||||||
|
<Input
|
||||||
|
aria-label={t.newListName}
|
||||||
|
value={leftover.name}
|
||||||
|
onChange={(e) => setLeftover((s) => ({ ...s, name: e.target.value }))}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{errors["leftover.targetListId"] || errors["leftover.newListName"] ? (
|
||||||
|
<FieldError>
|
||||||
|
{errors["leftover.targetListId"] ?? errors["leftover.newListName"]}
|
||||||
|
</FieldError>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="sticky bottom-[calc(4rem+env(safe-area-inset-bottom))] z-10 -mx-4 border-t bg-background/95 backdrop-blur md:bottom-0 md:mx-0 md:rounded-xl md:border">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1 text-sm">
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{t.total}: {formatMoney(total)}
|
||||||
|
</span>
|
||||||
|
{plannedTotal ? (
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{" · "}
|
||||||
|
{t.plannedTotal}: {formatMoney(plannedTotal)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{errors.root ? (
|
||||||
|
<span className="block text-xs text-destructive">{errors.root}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button onClick={submit} disabled={busy || uploading > 0 || allLines.length === 0}>
|
||||||
|
{t.submit}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
Check,
|
Check,
|
||||||
|
ClipboardCheck,
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
Package,
|
Package,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -62,6 +63,7 @@ import type { ShoppingItemView, ShoppingListDetail, SuggestionView } from "../se
|
|||||||
import { ShoppingItemDialog } from "./item-dialog";
|
import { ShoppingItemDialog } from "./item-dialog";
|
||||||
import { ShoppingListDialog } from "./list-dialog";
|
import { ShoppingListDialog } from "./list-dialog";
|
||||||
import { PlanPanel } from "./plan-panel";
|
import { PlanPanel } from "./plan-panel";
|
||||||
|
import { ShoppingReport } from "./report-view";
|
||||||
|
|
||||||
const t = tr.shopping;
|
const t = tr.shopping;
|
||||||
|
|
||||||
@@ -411,34 +413,50 @@ export function ShoppingListDetailView({ data }: { data: ShoppingListDetail }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{items.length && canWrite ? (
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href={`/alisveris/${list.id}/tamamla`}>
|
||||||
|
<ClipboardCheck />
|
||||||
|
{tr.shopping.complete.action}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] xl:items-start">
|
{data.report ? (
|
||||||
<div className="flex flex-col gap-4">
|
<ShoppingReport completion={data.report.completion} summary={data.report.summary} />
|
||||||
{data.suggestions.length ? (
|
) : (
|
||||||
<SuggestionsPanel listId={list.id} suggestions={data.suggestions} canWrite={canWrite} />
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] xl:items-start">
|
||||||
) : null}
|
<div className="flex flex-col gap-4">
|
||||||
{items.length ? (
|
{data.suggestions.length ? (
|
||||||
<ul className="divide-y rounded-xl border bg-card">
|
<SuggestionsPanel
|
||||||
{items.map((i) => (
|
listId={list.id}
|
||||||
<ItemRow
|
suggestions={data.suggestions}
|
||||||
key={i.id}
|
canWrite={canWrite}
|
||||||
item={i}
|
/>
|
||||||
canWrite={canWrite}
|
) : null}
|
||||||
onEdit={() => setEditing(i)}
|
{items.length ? (
|
||||||
onMove={() => setMoving(i)}
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
onRemove={() => remove(i)}
|
{items.map((i) => (
|
||||||
/>
|
<ItemRow
|
||||||
))}
|
key={i.id}
|
||||||
</ul>
|
item={i}
|
||||||
) : (
|
canWrite={canWrite}
|
||||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
onEdit={() => setEditing(i)}
|
||||||
{t.emptyList}
|
onMove={() => setMoving(i)}
|
||||||
</p>
|
onRemove={() => remove(i)}
|
||||||
)}
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t.emptyList}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PlanPanel plan={data.plan} items={items} />
|
||||||
</div>
|
</div>
|
||||||
<PlanPanel plan={data.plan} items={items} />
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{adding || editing ? (
|
{adding || editing ? (
|
||||||
<ShoppingItemDialog
|
<ShoppingItemDialog
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { ArrowRight, Receipt } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatDate, formatMoney } from "@/shared/format";
|
||||||
|
import { formatQuantity } from "@/shared/quantity";
|
||||||
|
import type { CompletionRow, ShoppingCompletion } from "@/shared/shopping";
|
||||||
|
import type { ComparisonReasons, ComparisonSummary, MatchKind } from "@/shared/shopping-compare";
|
||||||
|
|
||||||
|
const t = tr.shopping.report;
|
||||||
|
|
||||||
|
const MATCH_STYLE: Record<MatchKind, string> = {
|
||||||
|
same: "border-income/40 text-income",
|
||||||
|
substitute: "border-amber-500/50 text-amber-700 dark:text-amber-400",
|
||||||
|
skipped: "text-muted-foreground",
|
||||||
|
extra: "border-budget/40 text-budget",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** İşaretli tutar: "+₺12,00" / "−₺5,00". Renk tek başına anlam taşımaz. */
|
||||||
|
function Signed({ value, className }: { value: number; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"tabular-nums",
|
||||||
|
value > 0 ? "text-expense" : value < 0 ? "text-income" : "text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value > 0 ? "+" : value < 0 ? "−" : ""}
|
||||||
|
{formatMoney(Math.abs(value))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Cell({ label, sub, amount }: { label: string; sub: string; amount: number | null }) {
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate">{label || "—"}</span>
|
||||||
|
{sub ? <span className="truncate text-xs text-muted-foreground">{sub}</span> : null}
|
||||||
|
{amount !== null ? <span className="text-xs tabular-nums">{formatMoney(amount)}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkgs = (packages: number) => `${formatQuantity(packages)} ${tr.shopping.packageUnit}`;
|
||||||
|
|
||||||
|
function Row({ row }: { row: CompletionRow }) {
|
||||||
|
return (
|
||||||
|
<li className="grid grid-cols-2 gap-x-3 gap-y-2 p-3 text-sm md:grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_minmax(0,1.5fr)] md:items-start">
|
||||||
|
<span className="font-medium">{row.label}</span>
|
||||||
|
<span className="justify-self-end md:justify-self-start">
|
||||||
|
<Badge variant="outline" className={MATCH_STYLE[row.match]}>
|
||||||
|
{t.matches[row.match]}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-1.5 md:block">
|
||||||
|
<span className="text-xs text-muted-foreground md:hidden">{t.plan}:</span>
|
||||||
|
{row.plan ? (
|
||||||
|
<Cell
|
||||||
|
label={row.plan.productLabel}
|
||||||
|
sub={`${row.plan.storeLabel} · ${pkgs(row.plan.packages)}`}
|
||||||
|
amount={row.plan.costMinor}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t.noPlanCell}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-1.5 md:block">
|
||||||
|
<span className="text-xs text-muted-foreground md:hidden">{t.bought}:</span>
|
||||||
|
{row.amountMinor !== null ? (
|
||||||
|
<Cell
|
||||||
|
label={row.productLabel || row.label}
|
||||||
|
sub={[row.storeLabel, row.quantityLabel ?? (row.productId ? pkgs(row.packages) : "")]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
amount={row.amountMinor}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{t.noPlanCell}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tamamlanan liste: planlanan vs. gerçek, farkın nedenleri, kalem bazında eşleşme, giderler. */
|
||||||
|
export function ShoppingReport({
|
||||||
|
completion,
|
||||||
|
summary,
|
||||||
|
}: {
|
||||||
|
completion: ShoppingCompletion;
|
||||||
|
summary: ComparisonSummary;
|
||||||
|
}) {
|
||||||
|
const reasons = (Object.entries(summary.reasons) as [keyof ComparisonReasons, number][]).filter(
|
||||||
|
([, v]) => v !== 0,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="report-h" className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 id="report-h" className="font-medium">
|
||||||
|
{t.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.description(formatDate(`${completion.date}T12:00:00+03:00`))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: t.planned, node: formatMoney(summary.plannedMinor) },
|
||||||
|
{ label: t.actual, node: formatMoney(summary.actualMinor) },
|
||||||
|
{ label: t.diff, node: <Signed value={summary.diffMinor} /> },
|
||||||
|
].map((x) => (
|
||||||
|
<div key={x.label} className="rounded-xl border bg-card p-3">
|
||||||
|
<dt className="text-xs text-muted-foreground">{x.label}</dt>
|
||||||
|
<dd className="text-lg font-semibold tabular-nums">{x.node}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{reasons.length ? (
|
||||||
|
<div className="rounded-xl border bg-card p-3">
|
||||||
|
<h3 className="mb-2 text-sm font-medium">{t.reasonsTitle}</h3>
|
||||||
|
<ul className="flex flex-col gap-1 text-sm">
|
||||||
|
{reasons.map(([k, v]) => (
|
||||||
|
<li key={k} className="flex justify-between gap-3">
|
||||||
|
<span>{t.reasons[k]}</span>
|
||||||
|
<Signed value={v} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border bg-card">
|
||||||
|
<div className="hidden grid-cols-[minmax(0,1.2fr)_7rem_minmax(0,1.5fr)_minmax(0,1.5fr)] gap-3 border-b px-3 py-2 text-xs text-muted-foreground md:grid">
|
||||||
|
<span>{t.item}</span>
|
||||||
|
<span />
|
||||||
|
<span>{t.plan}</span>
|
||||||
|
<span>{t.bought}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{completion.rows.map((r, i) => (
|
||||||
|
<Row key={r.itemId ?? `extra-${i}`} row={r} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{t.expenses}</h3>
|
||||||
|
<ul className="grid gap-2 md:grid-cols-2">
|
||||||
|
{completion.stores.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.transactionId}
|
||||||
|
className="flex items-center gap-3 rounded-lg border bg-card p-3 text-sm"
|
||||||
|
>
|
||||||
|
<Receipt className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">{s.label}</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{t.expenseLine(s.accountName, s.attachmentCount)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/islemler?hesap=${s.accountId}`}
|
||||||
|
className="shrink-0 font-medium tabular-nums hover:underline"
|
||||||
|
>
|
||||||
|
{formatMoney(s.amountMinor)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{completion.movedTo ? (
|
||||||
|
<Link
|
||||||
|
href={`/alisveris/${completion.movedTo.listId}`}
|
||||||
|
className="flex w-fit items-center gap-1 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t.movedTo(completion.movedTo.name)}
|
||||||
|
<ArrowRight className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, Check, Globe } from "lucide-react";
|
import { ArrowLeft, Check, ClipboardCheck, Globe } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { startTransition, useOptimistic } from "react";
|
import { startTransition, useOptimistic } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -244,6 +244,14 @@ export function ShoppingModeView({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{canWrite && done > 0 ? (
|
||||||
|
<Button asChild size="lg" className="w-full">
|
||||||
|
<Link href={`/alisveris/${list.id}/tamamla`}>
|
||||||
|
<ClipboardCheck />
|
||||||
|
{t.complete.action}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ import {
|
|||||||
suggestionHidden,
|
suggestionHidden,
|
||||||
type ShoppingItemKind,
|
type ShoppingItemKind,
|
||||||
type ShoppingItemSource,
|
type ShoppingItemSource,
|
||||||
|
type ShoppingCompletion,
|
||||||
type ShoppingListStatus,
|
type ShoppingListStatus,
|
||||||
} from "@/shared/shopping";
|
} from "@/shared/shopping";
|
||||||
|
import { compareShopping, type ComparisonSummary } from "@/shared/shopping-compare";
|
||||||
import { planShopping, type PlanResult } from "@/shared/shopping-plan";
|
import { planShopping, type PlanResult } from "@/shared/shopping-plan";
|
||||||
import { compareTr } from "@/shared/search";
|
import { compareTr } from "@/shared/search";
|
||||||
import { formatStock, type PurchaseSuggestion } from "@/shared/stock";
|
import { formatStock, type PurchaseSuggestion } from "@/shared/stock";
|
||||||
@@ -44,7 +46,7 @@ import type { StoreKind } from "@/shared/stores";
|
|||||||
|
|
||||||
const t = tr.shopping;
|
const t = tr.shopping;
|
||||||
|
|
||||||
type ItemLean = {
|
export type ItemLean = {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
kind: ShoppingItemKind;
|
kind: ShoppingItemKind;
|
||||||
productTypeId: Types.ObjectId | null;
|
productTypeId: Types.ObjectId | null;
|
||||||
@@ -59,13 +61,14 @@ type ItemLean = {
|
|||||||
addedAt: Date;
|
addedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ListLean = {
|
export type ListLean = {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
name: string;
|
name: string;
|
||||||
status: ShoppingListStatus;
|
status: ShoppingListStatus;
|
||||||
note: string;
|
note: string;
|
||||||
items: ItemLean[];
|
items: ItemLean[];
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
|
completion: ShoppingCompletion | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
@@ -154,6 +157,8 @@ export type ShoppingListDetail = {
|
|||||||
/** Kalem taşımak için diğer aktif listeler. */
|
/** Kalem taşımak için diğer aktif listeler. */
|
||||||
otherLists: { id: string; name: string }[];
|
otherLists: { id: string; name: string }[];
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
|
/** Tamamlanmış listede karşılaştırma raporu. */
|
||||||
|
report: { completion: ShoppingCompletion; summary: ComparisonSummary } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Yardımcılar ---
|
// --- Yardımcılar ---
|
||||||
@@ -401,7 +406,7 @@ function itemView(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildPlan(
|
export async function buildPlan(
|
||||||
userId: string,
|
userId: string,
|
||||||
m: Membership,
|
m: Membership,
|
||||||
items: ItemLean[],
|
items: ItemLean[],
|
||||||
@@ -576,12 +581,27 @@ export async function getShoppingList(
|
|||||||
},
|
},
|
||||||
otherLists: others.map((o) => ({ id: String(o._id), name: o.name })),
|
otherLists: others.map((o) => ({ id: String(o._id), name: o.name })),
|
||||||
canWrite,
|
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 ---
|
// --- Yazma: listeler ---
|
||||||
|
|
||||||
async function writableList(actorId: string, householdId: string, listId: string) {
|
export async function writableList(actorId: string, householdId: string, listId: string) {
|
||||||
const m = await resolveMembership(actorId, householdId, "member");
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
if (!m) return { error: fail(tr.common.unauthorized) } as const;
|
if (!m) return { error: fail(tr.common.unauthorized) } as const;
|
||||||
const list = await loadList(m, listId);
|
const list = await loadList(m, listId);
|
||||||
|
|||||||
@@ -1491,6 +1491,95 @@ export const tr = {
|
|||||||
notActive: "Bu liste tamamlandı; değiştirilemez.",
|
notActive: "Bu liste tamamlandı; değiştirilemez.",
|
||||||
sameList: "Kalem zaten bu listede.",
|
sameList: "Kalem zaten bu listede.",
|
||||||
suggestionGone: "Öneri artık geçerli değil (listede ya da stok yenilenmiş).",
|
suggestionGone: "Öneri artık geçerli değil (listede ya da stok yenilenmiş).",
|
||||||
|
amount: "Geçerli bir tutar girin (ör. 89,90)",
|
||||||
|
noLines: "Alınan en az bir ürün seçin",
|
||||||
|
duplicateLine: "Bir liste kalemi yalnızca bir kez girilebilir",
|
||||||
|
duplicateStore: "Mağaza iki kez girilmiş",
|
||||||
|
account: "Her mağaza için ödeme hesabı seçin",
|
||||||
|
targetList: "Alınmayanlar için hedef liste seçin",
|
||||||
|
futureDate: "Gelecek tarihli alışveriş girilemez",
|
||||||
|
itemGone: "Listede olmayan bir kalem seçilmiş; sayfayı yenileyin",
|
||||||
|
substituteType: "Muadil aynı ürün tipinden olmalı",
|
||||||
|
textNoProduct: "Serbest metin kalemine ürün seçilemez",
|
||||||
|
conversion: (name: string) => `"${name}" ürününün tip birimine dönüşümü yok`,
|
||||||
|
fallbackCategory: "Eşlenmemiş kalemler için gider kategorisi seçin",
|
||||||
|
tooManySplits: "Bir mağazada çok fazla gider kategorisi var",
|
||||||
|
},
|
||||||
|
complete: {
|
||||||
|
action: "Alışverişi tamamla",
|
||||||
|
title: "Alışverişi tamamla",
|
||||||
|
description:
|
||||||
|
"Aldıklarınızı seçin: aynı ürün ya da muadili, paket adedi, ödenen tutar ve mağaza. Kaydedince stok, fiyatlar, mağaza başına gider ve fişler tek seferde işlenir.",
|
||||||
|
date: "Alışveriş tarihi",
|
||||||
|
bought: "Alındı",
|
||||||
|
itemsTitle: "Listedekiler",
|
||||||
|
extrasTitle: "Listede olmayanlar",
|
||||||
|
addExtra: "Listede olmayan ürün ekle",
|
||||||
|
removeExtra: "Ekstrayı çıkar",
|
||||||
|
product: "Alınan ürün",
|
||||||
|
store: "Mağaza",
|
||||||
|
packages: "Paket",
|
||||||
|
quantity: "Miktar",
|
||||||
|
generic: (unit: string) => `Marka belirtmeden (${unit})`,
|
||||||
|
amount: "Tutar (₺)",
|
||||||
|
amountHint: "Fişteki satır tutarı.",
|
||||||
|
planned: (text: string) => `Plan: ${text}`,
|
||||||
|
substitute: "Muadil",
|
||||||
|
noPlan: "Planda fiyat yoktu",
|
||||||
|
textItem: "Serbest metin: stok ve fiyata işlenmez.",
|
||||||
|
storesTitle: "Ödeme ve fiş",
|
||||||
|
storesDescription:
|
||||||
|
"Her mağaza ayrı gider işlemi olur; kalemler stok kategorisinin gider kategorisine bölünür.",
|
||||||
|
account: "Ödeme hesabı",
|
||||||
|
receipts: "Fiş görselleri",
|
||||||
|
addReceipt: "Fiş ekle",
|
||||||
|
receiptHint: "Fiş fotoğrafı; işlemin ekine kaydedilir.",
|
||||||
|
subtotal: (n: number) => `${n} kalem`,
|
||||||
|
fallbackCategory: "Eşlenmemiş kalemlerin gider kategorisi",
|
||||||
|
fallbackHint:
|
||||||
|
"Stok kategorisi bir gider kategorisine eşlenmemiş ürünler ve serbest metin kalemleri buraya yazılır.",
|
||||||
|
leftoverTitle: "Alınmayanlar",
|
||||||
|
leftoverDescription: (n: number) => `${n} kalem alınmadı.`,
|
||||||
|
leftoverActions: {
|
||||||
|
move: "Başka aktif listeye taşı",
|
||||||
|
new: "Yeni liste aç",
|
||||||
|
drop: "Bırak (listeyle kapanır)",
|
||||||
|
},
|
||||||
|
newListName: "Yeni liste adı",
|
||||||
|
newListDefault: (name: string) => `${name} – kalanlar`,
|
||||||
|
total: "Toplam",
|
||||||
|
plannedTotal: "Planlanan",
|
||||||
|
submit: "Alışverişi tamamla",
|
||||||
|
saved: "Alışveriş tamamlandı; stok, fiyatlar ve giderler işlendi.",
|
||||||
|
txDescription: (list: string) => `Alışveriş: ${list}`,
|
||||||
|
splitNote: (n: number) => `${n} kalem`,
|
||||||
|
noStores: "Mağaza yok. Önce bir mağaza ekleyin.",
|
||||||
|
},
|
||||||
|
report: {
|
||||||
|
title: "Karşılaştırma raporu",
|
||||||
|
description: (date: string) =>
|
||||||
|
`Alışveriş tarihi ${date}. Plan, tamamlama anındaki akıllı öneridir.`,
|
||||||
|
planned: "Planlanan",
|
||||||
|
actual: "Gerçekleşen",
|
||||||
|
diff: "Fark",
|
||||||
|
reasonsTitle: "Farkın nedenleri",
|
||||||
|
reasons: {
|
||||||
|
price: "Fiyat farkı (aynı ürün)",
|
||||||
|
quantity: "Miktar farkı",
|
||||||
|
substitute: "Muadil ürün",
|
||||||
|
skipped: "Alınmayanlar",
|
||||||
|
extra: "Plansız ekstralar",
|
||||||
|
unplanned: "Planda fiyatı olmayanlar",
|
||||||
|
},
|
||||||
|
matches: { same: "Aynı", substitute: "Muadil", skipped: "Alınmadı", extra: "Plansız ekstra" },
|
||||||
|
item: "Kalem",
|
||||||
|
plan: "Plan",
|
||||||
|
bought: "Alınan",
|
||||||
|
expenses: "Mağaza giderleri",
|
||||||
|
expenseLine: (account: string, receipts: number) =>
|
||||||
|
receipts ? `${account} · ${receipts} fiş` : account,
|
||||||
|
movedTo: (name: string) => `Alınmayanlar "${name}" listesine taşındı.`,
|
||||||
|
noPlanCell: "—",
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
title: "Alışveriş",
|
title: "Alışveriş",
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const shoppingListSchema = new Schema(
|
|||||||
note: { type: String, default: "", trim: true, maxlength: 300 },
|
note: { type: String, default: "", trim: true, maxlength: 300 },
|
||||||
items: { type: [shoppingItemSchema], default: [] },
|
items: { type: [shoppingItemSchema], default: [] },
|
||||||
completedAt: { type: Date, default: null },
|
completedAt: { type: Date, default: null },
|
||||||
|
/** Tamamlama anındaki karşılaştırma görüntüsü (ShoppingCompletion; etiketler sabitlenir). */
|
||||||
|
completion: { type: Schema.Types.Mixed, default: null },
|
||||||
deletedAt: { type: Date, default: null },
|
deletedAt: { type: Date, default: null },
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const stockMovementSchema = new Schema(
|
|||||||
initial: { type: Boolean, default: false },
|
initial: { type: Boolean, default: false },
|
||||||
/** Sayım oturumu (Faz 11b). */
|
/** Sayım oturumu (Faz 11b). */
|
||||||
countId: { type: Schema.Types.ObjectId, default: null },
|
countId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
/** Alışverişi tamamlamadan gelen alım (Faz 13b). */
|
||||||
|
shoppingListId: { type: Schema.Types.ObjectId, default: null },
|
||||||
deletedAt: { type: Date, default: null },
|
deletedAt: { type: Date, default: null },
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ const transactionSchema = new Schema(
|
|||||||
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
|
reconciledAccountIds: { type: [Schema.Types.ObjectId], default: [] },
|
||||||
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
|
/** Mutabakat düzeltme işlemi: kategorisiz; düzenlenemez, yalnızca silinebilir. */
|
||||||
isAdjustment: { type: Boolean, default: false },
|
isAdjustment: { type: Boolean, default: false },
|
||||||
|
/** Alışverişi tamamlamadan gelen mağaza gideri (Faz 13b). */
|
||||||
|
shoppingListId: { type: Schema.Types.ObjectId, default: null },
|
||||||
deletedAt: { type: Date, default: null },
|
deletedAt: { type: Date, default: null },
|
||||||
/** Seed verisinin idempotent yüklenmesi için sabit anahtar. */
|
/** Seed verisinin idempotent yüklenmesi için sabit anahtar. */
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { tr } from "../../i18n/tr";
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { parseMoney } from "../money";
|
||||||
import { parseQuantity } from "../quantity";
|
import { parseQuantity } from "../quantity";
|
||||||
|
|
||||||
const s = tr.shopping.validation;
|
const s = tr.shopping.validation;
|
||||||
@@ -72,3 +73,91 @@ export const shoppingItemUpdateSchema = z.object({
|
|||||||
export type ShoppingItemUpdate = z.output<typeof shoppingItemUpdateSchema>;
|
export type ShoppingItemUpdate = z.output<typeof shoppingItemUpdateSchema>;
|
||||||
|
|
||||||
export const shoppingItemRefSchema = z.object({ listId: id, itemId: id });
|
export const shoppingItemRefSchema = z.object({ listId: id, itemId: id });
|
||||||
|
|
||||||
|
const positiveQuantity = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
const q = parseQuantity(x);
|
||||||
|
if (q === null || q <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: s.quantity });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
});
|
||||||
|
|
||||||
|
const amount = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
const minor = parseMoney(x);
|
||||||
|
if (minor === null || minor <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: s.amount });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return minor;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alışverişi tamamla. Satır: liste kalemi (itemId) ya da plansız ekstra (itemId boş). Ürün
|
||||||
|
* kaleminde/ekstrada ürün zorunlu; serbest metin kaleminde ürün yok. Tutar satırın ödenen tutarı.
|
||||||
|
*/
|
||||||
|
export const shoppingCompletionSchema = z
|
||||||
|
.object({
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, tr.validation.date),
|
||||||
|
lines: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
itemId: optionalId.default(""),
|
||||||
|
productId: optionalId.default(""),
|
||||||
|
storeId: id,
|
||||||
|
packages: positiveQuantity,
|
||||||
|
amount,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1, s.noLines)
|
||||||
|
.max(200),
|
||||||
|
stores: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
storeId: id,
|
||||||
|
accountId: z.string().regex(/^[a-f0-9]{24}$/i, s.account),
|
||||||
|
attachmentIds: z.array(id).max(10).default([]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(10),
|
||||||
|
/** Gider kategorisine eşlenmemiş stok kategorilerindeki kalemler için. */
|
||||||
|
fallbackCategoryId: optionalId.default(""),
|
||||||
|
leftover: z.object({
|
||||||
|
action: z.enum(["move", "new", "drop"]),
|
||||||
|
targetListId: optionalId.default(""),
|
||||||
|
newListName: z.string().trim().max(80).default(""),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
const items = x.lines.map((l) => l.itemId).filter(Boolean);
|
||||||
|
if (new Set(items).size !== items.length)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["lines"], message: s.duplicateLine });
|
||||||
|
x.lines.forEach((l, i) => {
|
||||||
|
if (!l.itemId && !l.productId)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["lines", i, "productId"], message: s.product });
|
||||||
|
});
|
||||||
|
const stores = new Set(x.stores.map((st) => st.storeId));
|
||||||
|
if (stores.size !== x.stores.length)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["stores"], message: s.duplicateStore });
|
||||||
|
for (const l of x.lines)
|
||||||
|
if (!stores.has(l.storeId)) {
|
||||||
|
ctx.addIssue({ code: "custom", path: ["stores"], message: s.account });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (x.leftover.action === "move" && !x.leftover.targetListId)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["leftover", "targetListId"], message: s.targetList });
|
||||||
|
if (x.leftover.action === "new" && !x.leftover.newListName)
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["leftover", "newListName"],
|
||||||
|
message: tr.common.required,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
export type ShoppingCompletionInput = z.input<typeof shoppingCompletionSchema>;
|
||||||
|
export type ShoppingCompletionForm = z.output<typeof shoppingCompletionSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { compareShopping, matchKind, type ComparisonRow } from "./shopping-compare";
|
||||||
|
|
||||||
|
const plan = (productId: string, packages: number, priceMinor: number) => ({
|
||||||
|
productId,
|
||||||
|
packages: packages * 1000,
|
||||||
|
priceMinor,
|
||||||
|
costMinor: packages * priceMinor,
|
||||||
|
});
|
||||||
|
const actual = (productId: string | null, packages: number, amountMinor: number) => ({
|
||||||
|
productId,
|
||||||
|
packages: packages * 1000,
|
||||||
|
amountMinor,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matchKind", () => {
|
||||||
|
it("ürün tipi kalemi: planlanan marka aynı, başka marka muadil, plan yoksa aynı", () => {
|
||||||
|
const item = { kind: "type" as const, productId: null };
|
||||||
|
expect(matchKind(item, plan("a", 1, 100), actual("a", 1, 100))).toBe("same");
|
||||||
|
expect(matchKind(item, plan("a", 1, 100), actual("b", 1, 100))).toBe("substitute");
|
||||||
|
expect(matchKind(item, null, actual("b", 1, 100))).toBe("same");
|
||||||
|
expect(matchKind(item, plan("a", 1, 100), null)).toBe("skipped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ürün kalemi başka ürünle muadil; serbest metin alındıysa aynı; listede yoksa ekstra", () => {
|
||||||
|
expect(matchKind({ kind: "product", productId: "a" }, null, actual("c", 1, 100))).toBe(
|
||||||
|
"substitute",
|
||||||
|
);
|
||||||
|
expect(matchKind({ kind: "text", productId: null }, null, actual(null, 1, 100))).toBe("same");
|
||||||
|
expect(matchKind(null, null, actual("x", 1, 100))).toBe("extra");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compareShopping", () => {
|
||||||
|
it("fark nedenlere ayrışır ve toplamı farka eşittir", () => {
|
||||||
|
const rows: ComparisonRow[] = [
|
||||||
|
// Aynı ürün: 2 paket planlandı (100), 3 alındı ve paket 110 → fiyat +30, miktar +100.
|
||||||
|
{ match: "same", plan: plan("a", 2, 100), actual: actual("a", 3, 330) },
|
||||||
|
// Muadil: plan 200, gerçek 180.
|
||||||
|
{ match: "substitute", plan: plan("b", 1, 200), actual: actual("b2", 1, 180) },
|
||||||
|
// Alınmadı: −50.
|
||||||
|
{ match: "skipped", plan: plan("c", 1, 50), actual: null },
|
||||||
|
// Fiyatı bilinmeyen kalem alındı: +40.
|
||||||
|
{ match: "same", plan: null, actual: actual("d", 1, 40) },
|
||||||
|
// Ekstra: +75.
|
||||||
|
{ match: "extra", plan: null, actual: actual("e", 1, 75) },
|
||||||
|
];
|
||||||
|
const s = compareShopping(rows);
|
||||||
|
expect(s.plannedMinor).toBe(200 + 200 + 50);
|
||||||
|
expect(s.actualMinor).toBe(330 + 180 + 40 + 75);
|
||||||
|
expect(s.reasons).toEqual({
|
||||||
|
price: 30,
|
||||||
|
quantity: 100,
|
||||||
|
substitute: -20,
|
||||||
|
skipped: -50,
|
||||||
|
extra: 75,
|
||||||
|
unplanned: 40,
|
||||||
|
});
|
||||||
|
const sum = Object.values(s.reasons).reduce((a, b) => a + b, 0);
|
||||||
|
expect(sum).toBe(s.diffMinor);
|
||||||
|
expect(s.counts).toEqual({ same: 2, substitute: 1, skipped: 1, extra: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kesirli paket: plan fiyatıyla beklenen yuvarlanır, toplam yine tam", () => {
|
||||||
|
const s = compareShopping([
|
||||||
|
{
|
||||||
|
match: "same",
|
||||||
|
plan: plan("a", 1, 999),
|
||||||
|
actual: { productId: "a", packages: 1500, amountMinor: 1500 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(s.reasons.price + s.reasons.quantity).toBe(s.diffMinor);
|
||||||
|
expect(s.reasons.quantity).toBe(1499 - 999);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Alışveriş sonrası karşılaştırma (Bölüm 6.12). Saf fonksiyonlar.
|
||||||
|
*
|
||||||
|
* Her liste kalemi için: aynı (planlanan ürün alındı), muadil (aynı ürün tipinde başka marka/paket),
|
||||||
|
* alınmadı; listede olmayanlar plansız ekstra. Planlanan tutar, tamamlama anındaki akıllı önerinin
|
||||||
|
* (önerilen dağılım) kalem maliyetidir; fiyatı bilinmeyen kalemin planı yoktur.
|
||||||
|
*
|
||||||
|
* Fark (gerçek − plan) nedenlere ayrıştırılır ve toplamları farka tam eşittir:
|
||||||
|
* - fiyat: aynı ürünün paket fiyatı farkı × alınan paket,
|
||||||
|
* - miktar: (alınan − planlanan paket) × planlanan paket fiyatı,
|
||||||
|
* - muadil: muadil kalemlerde gerçek − plan,
|
||||||
|
* - alınmayan: alınmayan planlı kalemlerin planı (eksi),
|
||||||
|
* - ekstra: plansız ekstraların tutarı,
|
||||||
|
* - fiyatsız: planı olmayan (fiyatı bilinmeyen) kalemlerin tutarı.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MATCH_KINDS = ["same", "substitute", "skipped", "extra"] as const;
|
||||||
|
export type MatchKind = (typeof MATCH_KINDS)[number];
|
||||||
|
|
||||||
|
/** Liste kalemi veya ekstra için gerçekleşen alım. Paket adedi binde bir. */
|
||||||
|
export type ActualPurchase = { productId: string | null; packages: number; amountMinor: number };
|
||||||
|
|
||||||
|
/** Akıllı önerinin kalem için planı. */
|
||||||
|
export type PlannedPurchase = {
|
||||||
|
productId: string;
|
||||||
|
packages: number;
|
||||||
|
priceMinor: number;
|
||||||
|
costMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalemin eşleşmesi. Ürün kalemi: aynı ürün → aynı. Ürün tipi kalemi: planlanan markayla aynı
|
||||||
|
* ürün (ya da plan yoksa herhangi bir ürün) → aynı; başka marka/paket → muadil. Serbest metin
|
||||||
|
* kalemi alındıysa aynı.
|
||||||
|
*/
|
||||||
|
export function matchKind(
|
||||||
|
item: { kind: "type" | "product" | "text"; productId: string | null } | null,
|
||||||
|
plan: PlannedPurchase | null,
|
||||||
|
actual: ActualPurchase | null,
|
||||||
|
): MatchKind {
|
||||||
|
if (!item) return "extra";
|
||||||
|
if (!actual) return "skipped";
|
||||||
|
if (item.kind === "text") return "same";
|
||||||
|
const expected = item.kind === "product" ? item.productId : (plan?.productId ?? null);
|
||||||
|
return expected === null || expected === actual.productId ? "same" : "substitute";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComparisonRow = {
|
||||||
|
match: MatchKind;
|
||||||
|
plan: PlannedPurchase | null;
|
||||||
|
actual: ActualPurchase | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComparisonReasons = {
|
||||||
|
price: number;
|
||||||
|
quantity: number;
|
||||||
|
substitute: number;
|
||||||
|
skipped: number;
|
||||||
|
extra: number;
|
||||||
|
unplanned: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComparisonSummary = {
|
||||||
|
plannedMinor: number;
|
||||||
|
actualMinor: number;
|
||||||
|
diffMinor: number;
|
||||||
|
reasons: ComparisonReasons;
|
||||||
|
counts: Record<MatchKind, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function compareShopping(rows: readonly ComparisonRow[]): ComparisonSummary {
|
||||||
|
const reasons: ComparisonReasons = {
|
||||||
|
price: 0,
|
||||||
|
quantity: 0,
|
||||||
|
substitute: 0,
|
||||||
|
skipped: 0,
|
||||||
|
extra: 0,
|
||||||
|
unplanned: 0,
|
||||||
|
};
|
||||||
|
const counts: Record<MatchKind, number> = { same: 0, substitute: 0, skipped: 0, extra: 0 };
|
||||||
|
let plannedMinor = 0;
|
||||||
|
let actualMinor = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
counts[r.match]++;
|
||||||
|
if (r.plan) plannedMinor += r.plan.costMinor;
|
||||||
|
if (r.actual) actualMinor += r.actual.amountMinor;
|
||||||
|
switch (r.match) {
|
||||||
|
case "extra":
|
||||||
|
reasons.extra += r.actual?.amountMinor ?? 0;
|
||||||
|
break;
|
||||||
|
case "skipped":
|
||||||
|
reasons.skipped -= r.plan?.costMinor ?? 0;
|
||||||
|
break;
|
||||||
|
case "substitute":
|
||||||
|
if (r.plan) reasons.substitute += r.actual!.amountMinor - r.plan.costMinor;
|
||||||
|
else reasons.unplanned += r.actual!.amountMinor;
|
||||||
|
break;
|
||||||
|
case "same": {
|
||||||
|
const a = r.actual!;
|
||||||
|
if (!r.plan || a.productId !== r.plan.productId) {
|
||||||
|
// Plansız (fiyatı bilinmeyen) ya da serbest metin.
|
||||||
|
if (r.plan) reasons.substitute += a.amountMinor - r.plan.costMinor;
|
||||||
|
else reasons.unplanned += a.amountMinor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Paket adedi binde bir: plan fiyatıyla beklenen = paket × fiyat.
|
||||||
|
const expectedAtPlanPrice = Math.round((a.packages * r.plan.priceMinor) / 1000);
|
||||||
|
reasons.price += a.amountMinor - expectedAtPlanPrice;
|
||||||
|
reasons.quantity += expectedAtPlanPrice - r.plan.costMinor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { plannedMinor, actualMinor, diffMinor: actualMinor - plannedMinor, reasons, counts };
|
||||||
|
}
|
||||||
@@ -39,3 +39,52 @@ export function suggestionHidden(dismissedAt: Date | null, lastPurchaseAt: Date
|
|||||||
if (!dismissedAt) return false;
|
if (!dismissedAt) return false;
|
||||||
return !lastPurchaseAt || lastPurchaseAt.getTime() <= dismissedAt.getTime();
|
return !lastPurchaseAt || lastPurchaseAt.getTime() <= dismissedAt.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Alınmayan kalemler: başka aktif listeye taşı, yeni liste aç ya da bırak. */
|
||||||
|
export const LEFTOVER_ACTIONS = ["move", "new", "drop"] as const;
|
||||||
|
export type LeftoverAction = (typeof LEFTOVER_ACTIONS)[number];
|
||||||
|
|
||||||
|
/** Tamamlanan listede saklanan görüntü (etiketler tamamlama anındaki hâliyle). */
|
||||||
|
export type CompletionRow = {
|
||||||
|
itemId: string | null;
|
||||||
|
match: "same" | "substitute" | "skipped" | "extra";
|
||||||
|
/** Kalem adı (liste kalemi ya da ekstra ürün). */
|
||||||
|
label: string;
|
||||||
|
/** Alınan: ürün etiketi ve paketi; serbest metinde boş. */
|
||||||
|
productId: string | null;
|
||||||
|
productLabel: string;
|
||||||
|
storeId: string | null;
|
||||||
|
storeLabel: string;
|
||||||
|
/** Paket adedi (binde bir). */
|
||||||
|
packages: number;
|
||||||
|
/** Alınan miktar metni ("4 paket", markasızda "2,5 kg"); alınmadıysa boş. */
|
||||||
|
quantityLabel?: string;
|
||||||
|
amountMinor: number | null;
|
||||||
|
plan: {
|
||||||
|
productId: string;
|
||||||
|
productLabel: string;
|
||||||
|
storeId: string;
|
||||||
|
storeLabel: string;
|
||||||
|
packages: number;
|
||||||
|
priceMinor: number;
|
||||||
|
costMinor: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompletionStore = {
|
||||||
|
storeId: string;
|
||||||
|
label: string;
|
||||||
|
accountId: string;
|
||||||
|
accountName: string;
|
||||||
|
transactionId: string;
|
||||||
|
amountMinor: number;
|
||||||
|
attachmentCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShoppingCompletion = {
|
||||||
|
date: string;
|
||||||
|
completedBy: string;
|
||||||
|
rows: CompletionRow[];
|
||||||
|
stores: CompletionStore[];
|
||||||
|
movedTo: { listId: string; name: string } | null;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user