Compare commits
3 Commits
abf2ea6dd7
...
5e0f1b5901
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e0f1b5901 | |||
| 868bb23fd9 | |||
| d9de29c18d |
@@ -6,6 +6,28 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
||||
|
||||
## [Yayınlanmamış]
|
||||
|
||||
### Eklendi — Faz 11a: ev stoğu
|
||||
|
||||
- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç; varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanede ve migration ile mevcut hanelere yüklenir. Her kategori bir gider kategorisine eşlenebilir (alt kategoriler üstten devralır; Faz 13'te alışveriş gideri için) ve sayım sıklığı tutar. Taşıma, arşiv (içinde aktif ürün yoksa) ve arşivden çıkarma; `/stok/kategoriler`.
|
||||
- Ürün tipleri (`productTypes`): kategori, temel ölçü birimi (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik yüzdesi (varsayılan %20), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki başlangıç miktarı.
|
||||
- Stok hareketleri (`stockMovements`): alım (isteğe bağlı SKT), tüketim, fire/zarar (nedenli) ve sayım düzeltmesi (gerçek miktar girilir, fark işlenir). Mevcut miktar hareketlerden türetilir; stokta olandan fazla tüketim/fire reddedilir. Hareket silme denetim kaydına yazılır.
|
||||
- SKT takibi FIFO partileriyle: çıkışlar en eski girişten düşer; evde kalan miktarın SKT'si 7 gün içinde dolan veya geçmiş partiler uyarılır.
|
||||
- `/stok`: özet, "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT uyarıları, kategoriye göre ürün listesi (mevcut/hedef çubuğu ve eşik çizgisi, arama, "yalnızca azalanlar"), tek dokunuşla "1 azalt". `/stok/:id`: partiler ve hareket geçmişi.
|
||||
- Dashboard "Azalan stoklar" kartı.
|
||||
- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık alım/tüketim/fire/sayım simülasyonu.
|
||||
- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, izolasyon, hareket kuralları, sayım, kategori kuralları (entegrasyon).
|
||||
|
||||
### Eklendi — Faz 11a: ev stoğu
|
||||
|
||||
- Stok kategorileri (`stockCategories`): gider kategorilerinden ayrı, sınırsız derinlikte ağaç (en fazla 5 seviye). Varsayılan set (Mutfak › Manav, Şarküteri, Bakliyat; Genel Temizlik › Bulaşık, Çamaşır, Genel; Kişisel Temizlik › Şampuan, Tıraş, Kişisel Bakım) yeni hanelerde ve migration ile mevcut hanelerde kurulur. Her kategori bir gider kategorisine eşlenebilir (üstten devralınır; alışveriş tamamlama için), sayım sıklığı alanı (üstten devralınır). `/stok/kategoriler`: ekle, düzenle, taşı, arşivle (içinde aktif ürün varken arşivlenemez).
|
||||
- Ürün tipleri (`productTypes`): kategori, temel birim (kg, lt, adet … veya özel birim: "yıkama", "tablet"), hedef stok, kritik eşik (%20 varsayılan), saklama yeri, "1 azalt" miktarı, not; ilk kurulumda evdeki mevcut miktar. Arşivleme.
|
||||
- Stok hareketleri (`stockMovements`): alım (isteğe bağlı son kullanma tarihi), tüketim, fire/zarar (neden ile), sayım düzeltmesi (gerçek miktar girilir, fark otomatik). Mevcut miktar hareketlerden türetilir; stoktan fazla tüketim/fire reddedilir; hareket silme denetim kaydına yazılır.
|
||||
- SKT takibi FIFO: çıkışlar en eski alımdan düşülür; evde kalan partilerin SKT'si 7 gün içinde dolan veya geçmiş olanlar uyarılır.
|
||||
- `/stok`: özet (ürün, azalan, SKT yaklaşan/geçmiş), "Alınması önerilenler" (eşiğin altındakiler ve hedefe tamamlayan miktar), SKT listesi, kategoriye göre ürünler (mevcut/hedef çubuğu ve eşik çizgisi), arama, "yalnızca azalanlar", tek dokunuşla "1 azalt". `/stok/:id`: durum, partiler, hareketler, alım/tüketim/fire/sayım.
|
||||
- Dashboard "Azalan stoklar" kartı.
|
||||
- Seed: Çakır Ailesi için 19 ürün tipi ve son 6 ayın haftalık tüketim, alım, fire ve sayım hareketleri.
|
||||
- Testler: mevcut miktar, FIFO partileri, SKT durumu, eşik ve öneri (birim); görünüm, gider eşlemesi, izolasyon, hareket kuralları, sayım, arşiv ve kategori kuralları (entegrasyon).
|
||||
|
||||
## [0.10.0] - 2026-11-06
|
||||
|
||||
Faz 10: aylık bütçe, gelir planı ve günlük plan (10a ve 10b alt fazları).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Types } from "mongoose";
|
||||
import { installDefaultStockCategories } from "@/features/stock/service";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StockCategoryModel } from "@/models/stock-category";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
export const migration: Migration = {
|
||||
name: "011-stock",
|
||||
description: "stok koleksiyonları index'leri; mevcut hanelere varsayılan stok kategorileri",
|
||||
async up() {
|
||||
await StockCategoryModel.createIndexes();
|
||||
await ProductTypeModel.createIndexes();
|
||||
await StockMovementModel.createIndexes();
|
||||
const households = await HouseholdModel.find({}).select({ createdBy: 1 }).lean();
|
||||
for (const h of households)
|
||||
await installDefaultStockCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId);
|
||||
},
|
||||
};
|
||||
@@ -10,10 +10,23 @@ import { migration as m007 } from "./007-contacts";
|
||||
import { migration as m008 } from "./008-in-kind";
|
||||
import { migration as m009 } from "./009-assets";
|
||||
import { migration as m010 } from "./010-budgets";
|
||||
import { migration as m011 } from "./011-stock";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010];
|
||||
export const MIGRATIONS: Migration[] = [
|
||||
m001,
|
||||
m002,
|
||||
m003,
|
||||
m004,
|
||||
m005,
|
||||
m006,
|
||||
m007,
|
||||
m008,
|
||||
m009,
|
||||
m010,
|
||||
m011,
|
||||
];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("seed", () => {
|
||||
expect(summary.created.transactions).toBeGreaterThan(150);
|
||||
expect(summary.created.assets).toBe(4);
|
||||
expect(summary.created.budgets).toBe(2);
|
||||
expect(summary.created.stock).toBe(19);
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||
});
|
||||
@@ -53,6 +54,7 @@ describe("seed", () => {
|
||||
inKind: 0,
|
||||
assets: 0,
|
||||
budgets: 0,
|
||||
stock: 0,
|
||||
});
|
||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { runMigrations } from "../migrations";
|
||||
import { seedContacts } from "./contacts";
|
||||
import { seedAssets } from "./assets";
|
||||
import { seedBudgets } from "./budgets";
|
||||
import { seedStock } from "./stock";
|
||||
import { seedInKind } from "./in-kind";
|
||||
import { seedInstallments } from "./installments";
|
||||
import { seedRecurring } from "./recurring";
|
||||
@@ -55,6 +56,7 @@ export type SeedSummary = {
|
||||
inKind: number;
|
||||
assets: number;
|
||||
budgets: number;
|
||||
stock: number;
|
||||
};
|
||||
accounts: SeedAccountRow[];
|
||||
};
|
||||
@@ -78,6 +80,9 @@ const DOMAIN_COLLECTIONS = [
|
||||
"assets",
|
||||
"assetMovements",
|
||||
"budgets",
|
||||
"stockCategories",
|
||||
"productTypes",
|
||||
"stockMovements",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
@@ -133,6 +138,7 @@ export async function runSeed({
|
||||
inKind: 0,
|
||||
assets: 0,
|
||||
budgets: 0,
|
||||
stock: 0,
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
@@ -315,6 +321,8 @@ export async function runSeed({
|
||||
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
|
||||
summary.created.budgets = await seedBudgets(householdIds, userIdMap);
|
||||
if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`);
|
||||
summary.created.stock = await seedStock(householdIds, userIdMap);
|
||||
if (summary.created.stock) log(`✓ Örnek stok ürün tipleri: ${summary.created.stock}`);
|
||||
|
||||
// 7) Geliştirme hesapları tablosu
|
||||
const all = [
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { Types } from "mongoose";
|
||||
import { installDefaultStockCategories } from "@/features/stock/service";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StockCategoryModel } from "@/models/stock-category";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { foldTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Örnek ev stoğu (Faz 11). Varsayılan stok kategorileri her haneye; Çakır Ailesi'ne ürün tipleri
|
||||
* ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, sayım, SKT). Deterministik;
|
||||
* idempotent: ürün tipi `seedRef` ile bir kez oluşturulur. Miktarlar binde bir.
|
||||
*/
|
||||
|
||||
type SeedProduct = {
|
||||
ref: string;
|
||||
category: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
target: number;
|
||||
/** Alımda paket miktarı (alım = hedefe tamamlayan paket sayısı × paket). */
|
||||
pack: number;
|
||||
perDay: number;
|
||||
location: string;
|
||||
step?: number;
|
||||
threshold?: number;
|
||||
shelfDays?: number;
|
||||
/** Her n. alımdan sonra fire (oran %). */
|
||||
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
|
||||
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
|
||||
stopRefillDaysAgo?: number;
|
||||
/** Her n haftada bir küçük sayım farkı (−%). */
|
||||
countDrift?: { everyWeeks: number; pct: number };
|
||||
};
|
||||
|
||||
const K = 1000;
|
||||
|
||||
export const PRODUCTS: SeedProduct[] = [
|
||||
// Mutfak › Bakliyat
|
||||
{
|
||||
ref: "kirmizi-mercimek",
|
||||
category: "mutfak.bakliyat",
|
||||
name: "Kırmızı Mercimek",
|
||||
unit: "kg",
|
||||
target: 2 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 40,
|
||||
location: "Kiler",
|
||||
step: 100,
|
||||
stopRefillDaysAgo: 40,
|
||||
},
|
||||
{
|
||||
ref: "kuru-fasulye",
|
||||
category: "mutfak.bakliyat",
|
||||
name: "Kuru Fasulye",
|
||||
unit: "kg",
|
||||
target: 2 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 25,
|
||||
location: "Kiler",
|
||||
step: 250,
|
||||
},
|
||||
{
|
||||
ref: "nohut",
|
||||
category: "mutfak.bakliyat",
|
||||
name: "Nohut",
|
||||
unit: "kg",
|
||||
target: 2 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 20,
|
||||
location: "Kiler",
|
||||
step: 250,
|
||||
},
|
||||
{
|
||||
ref: "pirinc",
|
||||
category: "mutfak.bakliyat",
|
||||
name: "Baldo Pirinç",
|
||||
unit: "kg",
|
||||
target: 5 * K,
|
||||
pack: 2_500,
|
||||
perDay: 90,
|
||||
location: "Kiler",
|
||||
step: 500,
|
||||
},
|
||||
// Mutfak › Manav
|
||||
{
|
||||
ref: "domates",
|
||||
category: "mutfak.manav",
|
||||
name: "Domates",
|
||||
unit: "kg",
|
||||
target: 3 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 300,
|
||||
location: "Buzdolabı",
|
||||
step: 500,
|
||||
shelfDays: 8,
|
||||
waste: { every: 2, pct: 30, reason: "spoiled" },
|
||||
},
|
||||
{
|
||||
ref: "patates",
|
||||
category: "mutfak.manav",
|
||||
name: "Patates",
|
||||
unit: "kg",
|
||||
target: 5 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 250,
|
||||
location: "Kiler",
|
||||
step: 500,
|
||||
waste: { every: 4, pct: 10, reason: "spoiled" },
|
||||
},
|
||||
{
|
||||
ref: "sogan",
|
||||
category: "mutfak.manav",
|
||||
name: "Kuru Soğan",
|
||||
unit: "kg",
|
||||
target: 3 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 120,
|
||||
location: "Kiler",
|
||||
step: 500,
|
||||
},
|
||||
// Mutfak › Şarküteri
|
||||
{
|
||||
ref: "beyaz-peynir",
|
||||
category: "mutfak.sarkuteri",
|
||||
name: "Beyaz Peynir",
|
||||
unit: "kg",
|
||||
target: 1 * K,
|
||||
pack: 500,
|
||||
perDay: 30,
|
||||
location: "Buzdolabı",
|
||||
step: 100,
|
||||
shelfDays: 45,
|
||||
},
|
||||
{
|
||||
ref: "yumurta",
|
||||
category: "mutfak.sarkuteri",
|
||||
name: "Yumurta",
|
||||
unit: "adet",
|
||||
target: 30 * K,
|
||||
pack: 15 * K,
|
||||
perDay: 3 * K,
|
||||
location: "Buzdolabı",
|
||||
shelfDays: 25,
|
||||
},
|
||||
{
|
||||
ref: "sut",
|
||||
category: "mutfak.sarkuteri",
|
||||
name: "Süt",
|
||||
unit: "lt",
|
||||
target: 6 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 700,
|
||||
location: "Buzdolabı",
|
||||
shelfDays: 12,
|
||||
waste: { every: 6, pct: 15, reason: "expired" },
|
||||
},
|
||||
// Genel Temizlik
|
||||
{
|
||||
ref: "tablet-deterjan",
|
||||
category: "genel-temizlik.bulasik",
|
||||
name: "Tablet Deterjan",
|
||||
unit: "tablet",
|
||||
target: 60 * K,
|
||||
pack: 40 * K,
|
||||
perDay: 720,
|
||||
location: "Lavabo altı",
|
||||
stopRefillDaysAgo: 40,
|
||||
},
|
||||
{
|
||||
ref: "elde-yikama",
|
||||
category: "genel-temizlik.bulasik",
|
||||
name: "Elde Yıkama Deterjanı",
|
||||
unit: "lt",
|
||||
target: 1_500,
|
||||
pack: 750,
|
||||
perDay: 15,
|
||||
location: "Lavabo altı",
|
||||
step: 100,
|
||||
},
|
||||
{
|
||||
ref: "toz-deterjan",
|
||||
category: "genel-temizlik.camasir",
|
||||
name: "Toz Deterjan",
|
||||
unit: "yıkama",
|
||||
target: 80 * K,
|
||||
pack: 50 * K,
|
||||
perDay: 700,
|
||||
location: "Banyo dolabı",
|
||||
countDrift: { everyWeeks: 8, pct: 4 },
|
||||
},
|
||||
{
|
||||
ref: "yumusatici",
|
||||
category: "genel-temizlik.camasir",
|
||||
name: "Yumuşatıcı",
|
||||
unit: "lt",
|
||||
target: 3 * K,
|
||||
pack: 1_440,
|
||||
perDay: 40,
|
||||
location: "Banyo dolabı",
|
||||
step: 100,
|
||||
},
|
||||
{
|
||||
ref: "camasir-suyu",
|
||||
category: "genel-temizlik.genel",
|
||||
name: "Çamaşır Suyu",
|
||||
unit: "lt",
|
||||
target: 3 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 25,
|
||||
location: "Balkon",
|
||||
step: 250,
|
||||
},
|
||||
{
|
||||
ref: "tuvalet-kagidi",
|
||||
category: "genel-temizlik.genel",
|
||||
name: "Tuvalet Kağıdı",
|
||||
unit: "rulo",
|
||||
target: 32 * K,
|
||||
pack: 16 * K,
|
||||
perDay: 600,
|
||||
location: "Banyo dolabı",
|
||||
countDrift: { everyWeeks: 10, pct: 6 },
|
||||
},
|
||||
// Kişisel Temizlik
|
||||
{
|
||||
ref: "sampuan",
|
||||
category: "kisisel-temizlik.sampuan",
|
||||
name: "Şampuan",
|
||||
unit: "adet",
|
||||
target: 3 * K,
|
||||
pack: 1 * K,
|
||||
perDay: 50,
|
||||
location: "Banyo dolabı",
|
||||
},
|
||||
{
|
||||
ref: "tiras-bicagi",
|
||||
category: "kisisel-temizlik.tiras",
|
||||
name: "Tıraş Bıçağı",
|
||||
unit: "adet",
|
||||
target: 8 * K,
|
||||
pack: 4 * K,
|
||||
perDay: 150,
|
||||
location: "Banyo dolabı",
|
||||
},
|
||||
{
|
||||
ref: "dis-macunu",
|
||||
category: "kisisel-temizlik.kisisel-bakim",
|
||||
name: "Diş Macunu",
|
||||
unit: "adet",
|
||||
target: 4 * K,
|
||||
pack: 2 * K,
|
||||
perDay: 70,
|
||||
location: "Banyo dolabı",
|
||||
stopRefillDaysAgo: 60,
|
||||
},
|
||||
];
|
||||
|
||||
const START_DAYS_AGO = 182;
|
||||
|
||||
type Row = {
|
||||
kind: "purchase" | "consume" | "waste" | "count_adjust";
|
||||
daysAgo: number;
|
||||
quantity: number;
|
||||
expiryDate?: string | null;
|
||||
wasteReason?: string | null;
|
||||
note?: string;
|
||||
initial?: boolean;
|
||||
};
|
||||
|
||||
/** Haftalık simülasyon: tüketim, eşikte alım, fire ve sayım farkı. Stok asla eksiye düşmez. */
|
||||
export function simulate(p: SeedProduct, today: string): Row[] {
|
||||
const rows: Row[] = [];
|
||||
let stock = p.target;
|
||||
let purchases = 0;
|
||||
rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true });
|
||||
let week = 0;
|
||||
for (let d = START_DAYS_AGO - 7; d >= 0; d -= 7) {
|
||||
week++;
|
||||
// Haftalık tüketim azaltma adımının katı (ör. tam tablet, 0,25 kg).
|
||||
const step = p.step ?? K;
|
||||
const consume = Math.min(stock, Math.max(step, Math.round((p.perDay * 7) / step) * step));
|
||||
if (consume > 0) {
|
||||
rows.push({ kind: "consume", daysAgo: d, quantity: consume });
|
||||
stock -= consume;
|
||||
}
|
||||
if (p.countDrift && week % p.countDrift.everyWeeks === 0 && stock > 0) {
|
||||
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100));
|
||||
if (drift > 0) {
|
||||
rows.push({ kind: "count_adjust", daysAgo: d, quantity: -drift, note: "Sayım" });
|
||||
stock -= drift;
|
||||
}
|
||||
}
|
||||
const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo;
|
||||
if (canRefill && stock < (p.target * 35) / 100) {
|
||||
const packs = Math.max(1, Math.ceil((p.target - stock) / p.pack));
|
||||
const quantity = packs * p.pack;
|
||||
purchases++;
|
||||
rows.push({
|
||||
kind: "purchase",
|
||||
daysAgo: d,
|
||||
quantity,
|
||||
expiryDate: p.shelfDays ? addDays(addDays(today, -d), p.shelfDays) : null,
|
||||
});
|
||||
stock += quantity;
|
||||
if (p.waste && purchases % p.waste.every === 0 && d >= 3) {
|
||||
const w = Math.min(stock, Math.round((quantity * p.waste.pct) / 100));
|
||||
rows.push({ kind: "waste", daysAgo: d - 3, quantity: w, wasteReason: p.waste.reason });
|
||||
stock -= w;
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function seedStock(
|
||||
householdIds: Map<string, Types.ObjectId>,
|
||||
users: Map<string, string>,
|
||||
): Promise<number> {
|
||||
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||
for (const [, id] of householdIds) await installDefaultStockCategories(id, actor);
|
||||
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||
const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } })
|
||||
.select({ defaultKey: 1 })
|
||||
.lean<{ _id: Types.ObjectId; defaultKey: string }[]>();
|
||||
const catByKey = new Map(cats.map((c) => [c.defaultKey, c._id]));
|
||||
const today = todayIstanbul();
|
||||
let created = 0;
|
||||
for (const p of PRODUCTS) {
|
||||
if (await ProductTypeModel.exists({ householdId, seedRef: p.ref })) continue;
|
||||
const doc = await ProductTypeModel.create({
|
||||
householdId,
|
||||
categoryId: catByKey.get(p.category)!,
|
||||
name: p.name,
|
||||
nameKey: foldTr(p.name),
|
||||
unit: p.unit,
|
||||
targetQuantity: p.target,
|
||||
thresholdPct: p.threshold ?? 20,
|
||||
location: p.location,
|
||||
step: p.step ?? K,
|
||||
seedRef: p.ref,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
const base = Date.now() - START_DAYS_AGO * 86_400_000;
|
||||
// Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur.
|
||||
await StockMovementModel.collection.insertMany(
|
||||
simulate(p, today).map((r, i) => {
|
||||
const at = new Date(base + i * 1000);
|
||||
return {
|
||||
householdId,
|
||||
productTypeId: doc._id,
|
||||
kind: r.kind,
|
||||
date: addDays(today, -r.daysAgo),
|
||||
quantity: r.quantity,
|
||||
wasteReason: r.wasteReason ?? null,
|
||||
expiryDate: r.expiryDate ?? null,
|
||||
note: r.note ?? "",
|
||||
initial: r.initial ?? false,
|
||||
countId: null,
|
||||
deletedAt: null,
|
||||
seedRef: `${p.ref}:${i}`,
|
||||
createdBy: actor,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
}),
|
||||
);
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { getBudget } from "@/features/budgets/service";
|
||||
import { ContactsDueBanner } from "@/features/contacts/components/due-banner";
|
||||
import { getNetWorth } from "@/features/net-worth/service";
|
||||
import { OverdueBanner } from "@/features/recurring/components/overdue-banner";
|
||||
import { LowStockCard } from "@/features/stock/components/low-stock-card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -105,6 +106,9 @@ export default async function DashboardPage() {
|
||||
<Suspense fallback={null}>
|
||||
<DailyPlanCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<LowStockCard userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ProductDetailView } from "@/features/stock/components/product-detail-view";
|
||||
import { getProductType, getStockOptions } from "@/features/stock/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.stock.detail.movements };
|
||||
|
||||
/** /stok/:id — ürün tipi: mevcut durum, partiler ve hareketler (başka hanenin kaydı → 404). */
|
||||
export default async function ProductTypePage({ params }: PageProps<"/stok/[id]">) {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const { id } = await params;
|
||||
const [data, options] = await Promise.all([
|
||||
getProductType(user.id, household.id, id),
|
||||
getStockOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !options) notFound();
|
||||
return <ProductDetailView data={data} categories={options.categories} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StockCategoriesManager } from "@/features/stock/components/stock-categories-manager";
|
||||
import { getStockOptions, listStockCategories } from "@/features/stock/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.stock.categories.title };
|
||||
|
||||
/** /stok/kategoriler — stok kategori ağacı, gider eşlemesi ve sayım sıklığı. */
|
||||
export default async function StockCategoriesPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [data, options] = await Promise.all([
|
||||
listStockCategories(user.id, household.id),
|
||||
getStockOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !options) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.stock.categories.title} description={tr.stock.categories.description} />
|
||||
<StockCategoriesManager
|
||||
categories={data.categories}
|
||||
expenseCategories={options.expenseCategories}
|
||||
canWrite={data.canWrite}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { StockView } from "@/features/stock/components/stock-view";
|
||||
import { getStock, getStockOptions } from "@/features/stock/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { requireActiveHousehold } from "@/lib/dal";
|
||||
|
||||
export const metadata: Metadata = { title: tr.stock.title };
|
||||
|
||||
/** /stok — ev stoğu: özet, alınması önerilenler, SKT uyarıları, kategoriye göre ürünler. */
|
||||
export default async function StockPage() {
|
||||
const { user, household } = await requireActiveHousehold("viewer");
|
||||
const [data, options] = await Promise.all([
|
||||
getStock(user.id, household.id),
|
||||
getStockOptions(user.id, household.id),
|
||||
]);
|
||||
if (!data || !options) notFound();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={tr.stock.title} description={tr.stock.description} />
|
||||
{!data.canWrite ? <p className="text-sm text-muted-foreground">{tr.stock.readOnly}</p> : null}
|
||||
<StockView data={data} options={options.categories} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export type NavItem = {
|
||||
|
||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||
export const CURRENT_PHASE = 10.2;
|
||||
export const CURRENT_PHASE = 11.1;
|
||||
|
||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||
|
||||
@@ -40,7 +40,7 @@ export const MAIN_NAV: NavItem[] = [
|
||||
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
|
||||
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
|
||||
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 },
|
||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
|
||||
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11.1 },
|
||||
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
|
||||
{ href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 },
|
||||
{ href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getDb } from "@/lib/db/mongo";
|
||||
import { connectMongoose } from "@/lib/db/mongoose";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { installDefaultCategories } from "@/features/categories/service";
|
||||
import { installDefaultStockCategories } from "@/features/stock/service";
|
||||
import { HouseholdModel } from "@/models/household";
|
||||
import { HouseholdMemberModel } from "@/models/household-member";
|
||||
import type { HouseholdSettings } from "@/shared/schemas/household";
|
||||
@@ -324,6 +325,8 @@ export async function adminCreateHousehold(
|
||||
);
|
||||
// Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2).
|
||||
await installDefaultCategories(household!._id, actor, session);
|
||||
// Varsayılan stok kategorileri (gider eşlemesi yukarıdaki kategorilerden).
|
||||
await installDefaultStockCategories(household!._id, actor, session);
|
||||
});
|
||||
return ok({ id: createdId });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import {
|
||||
productTypeFormSchema,
|
||||
stockCategoryFormSchema,
|
||||
stockCountFormSchema,
|
||||
stockMovementFormSchema,
|
||||
} from "@/shared/schemas/stock";
|
||||
import {
|
||||
addStockMovement,
|
||||
countStock,
|
||||
createProductType,
|
||||
createStockCategory,
|
||||
deleteStockMovement,
|
||||
getStockOptions,
|
||||
quickConsume,
|
||||
setProductTypeArchived,
|
||||
setStockCategoryArchived,
|
||||
updateProductType,
|
||||
updateStockCategory,
|
||||
type StockOption,
|
||||
} from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function run<T, R>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||
): Promise<ActionResult<R>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
return result;
|
||||
}
|
||||
|
||||
function withId<R>(
|
||||
raw: unknown,
|
||||
fn: (id: string) => Promise<ActionResult<R>>,
|
||||
): Promise<ActionResult<R>> {
|
||||
const id = idSchema.safeParse(raw);
|
||||
if (!id.success) return Promise.resolve(fail(tr.common.notFound));
|
||||
return fn(id.data);
|
||||
}
|
||||
|
||||
/** Yeni (id boş) veya mevcut stok kategorisi. */
|
||||
export async function saveStockCategoryAction(categoryId: unknown, input: unknown) {
|
||||
if (categoryId)
|
||||
return withId(categoryId, (id) =>
|
||||
run(stockCategoryFormSchema, input, (u, h, d) => updateStockCategory(u, h, id, d)),
|
||||
);
|
||||
return run(stockCategoryFormSchema, input, createStockCategory);
|
||||
}
|
||||
|
||||
export async function setStockCategoryArchivedAction(categoryId: unknown, archived: unknown) {
|
||||
return withId(categoryId, (id) =>
|
||||
run(z.boolean(), archived, (u, h, a) => setStockCategoryArchived(u, h, id, a)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Yeni (id boş) veya mevcut ürün tipi. */
|
||||
export async function saveProductTypeAction(productTypeId: unknown, input: unknown) {
|
||||
if (productTypeId)
|
||||
return withId(productTypeId, (id) =>
|
||||
run(productTypeFormSchema, input, (u, h, d) => updateProductType(u, h, id, d)),
|
||||
);
|
||||
return run(productTypeFormSchema, input, createProductType);
|
||||
}
|
||||
|
||||
export async function setProductTypeArchivedAction(productTypeId: unknown, archived: unknown) {
|
||||
return withId(productTypeId, (id) =>
|
||||
run(z.boolean(), archived, (u, h, a) => setProductTypeArchived(u, h, id, a)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function addStockMovementAction(productTypeId: unknown, input: unknown) {
|
||||
return withId(productTypeId, (id) =>
|
||||
run(stockMovementFormSchema, input, (u, h, d) => addStockMovement(u, h, id, d)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function quickConsumeAction(productTypeId: unknown) {
|
||||
return withId(productTypeId, (id) => run(z.unknown(), null, (u, h) => quickConsume(u, h, id)));
|
||||
}
|
||||
|
||||
export async function countStockAction(productTypeId: unknown, input: unknown) {
|
||||
return withId(productTypeId, (id) =>
|
||||
run(stockCountFormSchema, input, (u, h, d) => countStock(u, h, id, d)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteStockMovementAction(movementId: unknown) {
|
||||
return withId(movementId, (id) =>
|
||||
run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Ürün tipi ve stok kategorisi formları için seçenekler. */
|
||||
export async function getStockOptionsAction(): Promise<
|
||||
ActionResult<{ categories: StockOption[]; expenseCategories: StockOption[] }>
|
||||
> {
|
||||
const ctx = await getHouseholdActionContext("viewer");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const data = await getStockOptions(ctx.user.id, ctx.household.id);
|
||||
return data ? ok(data) : fail(tr.common.unauthorized);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Clock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import { getStock } from "../service";
|
||||
|
||||
const t = tr.stock;
|
||||
const LIMIT = 5;
|
||||
|
||||
/** Dashboard: eşiğin altındaki ürünler ve SKT'si yaklaşan partiler (yoksa görünmez). */
|
||||
export async function LowStockCard({
|
||||
userId,
|
||||
householdId,
|
||||
}: {
|
||||
userId: string;
|
||||
householdId: string;
|
||||
}) {
|
||||
const data = await getStock(userId, householdId);
|
||||
if (!data || (!data.suggestions.length && !data.expiring.length)) return null;
|
||||
const low = data.suggestions.slice(0, LIMIT);
|
||||
const expiring = data.expiring.slice(0, Math.max(0, LIMIT - low.length) || 2);
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.dashboard.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{t.dashboard.description(data.suggestions.length, data.expiring.length)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<ul className="divide-y text-sm">
|
||||
{low.map((i) => (
|
||||
<li key={i.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<Link href={`/stok/${i.id}`} className="min-w-0 truncate hover:underline">
|
||||
{i.name}
|
||||
</Link>
|
||||
<span className="shrink-0 text-xs tabular-nums">
|
||||
<span className="text-expense">{formatStock(Math.max(0, i.current), i.unit)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" · "}
|
||||
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{expiring.map(({ item, lot }) => (
|
||||
<li
|
||||
key={`${item.id}-${lot.index}`}
|
||||
className="flex items-center justify-between gap-3 py-1.5"
|
||||
>
|
||||
<Link href={`/stok/${item.id}`} className="min-w-0 truncate hover:underline">
|
||||
{item.name}
|
||||
</Link>
|
||||
<span
|
||||
className={
|
||||
lot.status === "expired"
|
||||
? "flex shrink-0 items-center gap-1 text-xs text-expense"
|
||||
: "flex shrink-0 items-center gap-1 text-xs text-amber-700 dark:text-amber-400"
|
||||
}
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{t.expiring.on(
|
||||
new Intl.DateTimeFormat("tr-TR", { day: "numeric", month: "short" }).format(
|
||||
new Date(`${lot.expiryDate}T12:00:00+03:00`),
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data.suggestions.length > low.length ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.dashboard.more(data.suggestions.length - low.length)}
|
||||
</p>
|
||||
) : null}
|
||||
<Button size="sm" variant="outline" className="w-fit" asChild>
|
||||
<Link href="/stok">{t.dashboard.open}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowLeft,
|
||||
ClipboardCheck,
|
||||
EllipsisVertical,
|
||||
Minus,
|
||||
Pencil,
|
||||
ShoppingBag,
|
||||
Trash,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatStock } from "@/shared/stock";
|
||||
import { deleteStockMovementAction, setProductTypeArchivedAction } from "../actions";
|
||||
import type { ProductTypeDetail, StockMovementRow, StockOption } from "../service";
|
||||
import {
|
||||
ProductTypeFormDialog,
|
||||
StockCountDialog,
|
||||
StockMovementDialog,
|
||||
type MovementKindInput,
|
||||
} from "./stock-dialogs";
|
||||
import { ExpiryBadge, LevelBadge, QuickConsumeButton, StockBar, ymdLabel } from "./stock-view";
|
||||
|
||||
const t = tr.stock;
|
||||
|
||||
type Pending =
|
||||
| { kind: "edit" }
|
||||
| { kind: "movement"; movement: MovementKindInput }
|
||||
| { kind: "count" }
|
||||
| { kind: "archive" }
|
||||
| { kind: "deleteMovement"; row: StockMovementRow }
|
||||
| null;
|
||||
|
||||
function Stat({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className="font-semibold tabular-nums">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** /stok/:id — mevcut durum, partiler (SKT) ve hareketler. */
|
||||
export function ProductDetailView({
|
||||
data,
|
||||
categories,
|
||||
}: {
|
||||
data: ProductTypeDetail;
|
||||
categories: StockOption[];
|
||||
}) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const close = () => setPending(null);
|
||||
const q = (x: number) => formatStock(x, data.unit);
|
||||
const active = data.canWrite && !data.archived;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/stok">
|
||||
<ArrowLeft />
|
||||
{t.detail.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={data.name}
|
||||
description={data.categoryPath}
|
||||
actions={
|
||||
data.canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="outline" aria-label={tr.recurring.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={data.archived}
|
||||
onSelect={() => setPending({ kind: "edit" })}
|
||||
>
|
||||
<Pencil />
|
||||
{tr.common.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{data.archived ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={async () => {
|
||||
const r = await setProductTypeArchivedAction(data.id, false);
|
||||
if (r.ok) toast.success(t.toast.unarchived);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
<ArchiveRestore />
|
||||
{t.detail.unarchive}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setPending({ kind: "archive" })}
|
||||
>
|
||||
<Archive />
|
||||
{t.detail.archive}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className="-mt-2 flex flex-wrap gap-1.5">
|
||||
{data.archived ? <Badge variant="secondary">{t.detail.archived}</Badge> : null}
|
||||
<LevelBadge item={data} />
|
||||
</div>
|
||||
|
||||
<Card size="sm">
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label={t.detail.current}>
|
||||
<span className={cn(data.level.low && "text-expense")}>
|
||||
{q(Math.max(0, data.current))}
|
||||
</span>
|
||||
</Stat>
|
||||
<Stat label={t.detail.target}>{q(data.targetQuantity)}</Stat>
|
||||
<Stat label={t.detail.threshold}>
|
||||
{q(data.level.thresholdQuantity)}{" "}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
(%{data.thresholdPct})
|
||||
</span>
|
||||
</Stat>
|
||||
<Stat label={t.detail.suggested}>{q(data.suggestedQuantity)}</Stat>
|
||||
</div>
|
||||
<StockBar item={data} />
|
||||
{data.location ? (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">{t.detail.location}:</span> {data.location}
|
||||
</p>
|
||||
) : null}
|
||||
{data.note ? <p className="text-sm text-muted-foreground">{data.note}</p> : null}
|
||||
{active ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<QuickConsumeButton item={data} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPending({ kind: "movement", movement: "consume" })}
|
||||
>
|
||||
<Minus />
|
||||
{t.actions.consume}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPending({ kind: "movement", movement: "waste" })}
|
||||
>
|
||||
<Trash />
|
||||
{t.actions.waste}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setPending({ kind: "count" })}>
|
||||
<ClipboardCheck />
|
||||
{t.actions.count}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPending({ kind: "movement", movement: "purchase" })}
|
||||
>
|
||||
<ShoppingBag />
|
||||
{t.actions.purchase}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.detail.lots}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t.detail.lotsHint}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.lots.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.detail.noLots}</p>
|
||||
) : (
|
||||
<ul className="divide-y text-sm">
|
||||
{data.lots.map((lot, i) => (
|
||||
<li key={i} className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium tabular-nums">{q(lot.remaining)}</span>
|
||||
<span className="text-xs text-muted-foreground"> · {ymdLabel(lot.date)}</span>
|
||||
</span>
|
||||
{lot.expiryDate && lot.status && lot.status !== "ok" ? (
|
||||
<ExpiryBadge lot={{ expiryDate: lot.expiryDate, status: lot.status }} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{lot.expiryDate ? t.expiring.on(ymdLabel(lot.expiryDate)) : t.detail.noExpiry}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="flex flex-col gap-2" aria-labelledby="stock-movements-title">
|
||||
<h2 id="stock-movements-title" className="text-base font-medium">
|
||||
{t.detail.movements}
|
||||
</h2>
|
||||
{data.movements.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.detail.noMovements}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{data.movements.map((row) => (
|
||||
<li key={row.id} className="flex items-start gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-x-2 text-sm font-medium">
|
||||
<span>{row.initial ? t.initial : t.kinds[row.kind]}</span>
|
||||
<span
|
||||
className={cn("tabular-nums", row.delta > 0 ? "text-income" : "text-expense")}
|
||||
>
|
||||
{row.delta > 0 ? "+" : "−"}
|
||||
{q(Math.abs(row.delta))}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ymdLabel(row.date)}
|
||||
{row.wasteReason ? ` · ${t.wasteReasons[row.wasteReason]}` : ""}
|
||||
{row.expiryDate ? ` · ${t.expiring.on(ymdLabel(row.expiryDate))}` : ""}
|
||||
{row.note ? ` · ${row.note}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{t.detail.after(q(row.quantityAfter))}
|
||||
</span>
|
||||
{data.canWrite ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={t.detail.deleteMovement}
|
||||
onClick={() => setPending({ kind: "deleteMovement", row })}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{pending?.kind === "edit" ? (
|
||||
<ProductTypeFormDialog item={data} categories={categories} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "movement" ? (
|
||||
<StockMovementDialog item={data} initialKind={pending.movement} onClose={close} />
|
||||
) : null}
|
||||
{pending?.kind === "count" ? <StockCountDialog item={data} onClose={close} /> : null}
|
||||
<AlertDialog
|
||||
open={pending?.kind === "archive" || pending?.kind === "deleteMovement"}
|
||||
onOpenChange={(o) => !o && close()}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pending?.kind === "archive" ? t.detail.archiveTitle : t.detail.deleteMovementTitle}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pending?.kind === "archive"
|
||||
? t.detail.archiveDescription(data.name)
|
||||
: t.detail.deleteMovementDescription}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const r =
|
||||
pending?.kind === "archive"
|
||||
? await setProductTypeArchivedAction(data.id, true)
|
||||
: pending?.kind === "deleteMovement"
|
||||
? await deleteStockMovementAction(pending.row.id)
|
||||
: null;
|
||||
const wasArchive = pending?.kind === "archive";
|
||||
close();
|
||||
if (!r) return;
|
||||
if (r.ok) toast.success(wasArchive ? t.toast.archived : t.toast.deleted);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
{pending?.kind === "archive" ? t.detail.archive : t.detail.deleteMovement}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowLeft,
|
||||
EllipsisVertical,
|
||||
FolderPlus,
|
||||
Pencil,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { saveStockCategoryAction, setStockCategoryArchivedAction } from "../actions";
|
||||
import type { StockCategoryView, StockOption } from "../service";
|
||||
|
||||
const t = tr.stock.categories;
|
||||
const NONE = "__none__";
|
||||
type Errors = Record<string, string | undefined>;
|
||||
|
||||
type Pending =
|
||||
| { kind: "create"; parentId: string | null }
|
||||
| { kind: "edit"; category: StockCategoryView }
|
||||
| { kind: "archive"; category: StockCategoryView }
|
||||
| null;
|
||||
|
||||
/** /stok/kategoriler: ağaç, gider eşlemesi, sayım sıklığı, arşiv. */
|
||||
export function StockCategoriesManager({
|
||||
categories,
|
||||
expenseCategories,
|
||||
canWrite,
|
||||
}: {
|
||||
categories: StockCategoryView[];
|
||||
expenseCategories: StockOption[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [pending, setPending] = useState<Pending>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const close = () => setPending(null);
|
||||
const visible = categories.filter((c) => showArchived || !c.archived);
|
||||
|
||||
async function unarchive(c: StockCategoryView) {
|
||||
const r = await setStockCategoryArchivedAction(c.id, false);
|
||||
if (r.ok) toast.success(tr.stock.toast.unarchived);
|
||||
else toast.error(r.error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
|
||||
<Link href="/stok">
|
||||
<ArrowLeft />
|
||||
{t.back}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={showArchived} onCheckedChange={(x) => setShowArchived(x === true)} />
|
||||
{t.showArchived}
|
||||
</label>
|
||||
{canWrite ? (
|
||||
<Button size="sm" onClick={() => setPending({ kind: "create", parentId: null })}>
|
||||
<Plus />
|
||||
{t.new}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.empty}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{visible.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className={cn("flex items-center gap-2 p-3", c.archived && "opacity-60")}
|
||||
style={{ paddingLeft: `${12 + c.depth * 20}px` }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-sm font-medium">
|
||||
<span className="truncate">{c.name}</span>
|
||||
{c.isDefault ? <Badge variant="secondary">{t.defaultBadge}</Badge> : null}
|
||||
{c.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{[
|
||||
c.expense
|
||||
? c.expense.inherited
|
||||
? t.inherited(c.expense.path)
|
||||
: c.expense.path
|
||||
: t.noExpense,
|
||||
c.effectiveCountDays ? t.everyDays(c.effectiveCountDays) : null,
|
||||
c.productCount ? t.productCount(c.productCount) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
{canWrite ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="ghost" aria-label={tr.common.actions}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{c.archived ? (
|
||||
<DropdownMenuItem onSelect={() => unarchive(c)}>
|
||||
<ArchiveRestore />
|
||||
{t.unarchive}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setPending({ kind: "create", parentId: c.id })}
|
||||
>
|
||||
<FolderPlus />
|
||||
{t.addChild}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setPending({ kind: "edit", category: c })}
|
||||
>
|
||||
<Pencil />
|
||||
{t.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setPending({ kind: "archive", category: c })}
|
||||
>
|
||||
<Archive />
|
||||
{t.archive}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pending?.kind === "create" || pending?.kind === "edit" ? (
|
||||
<StockCategoryDialog
|
||||
category={pending.kind === "edit" ? pending.category : null}
|
||||
parentId={
|
||||
pending.kind === "create" ? pending.parentId : (pending.category.parentId ?? null)
|
||||
}
|
||||
categories={categories}
|
||||
expenseCategories={expenseCategories}
|
||||
onClose={close}
|
||||
/>
|
||||
) : null}
|
||||
<AlertDialog open={pending?.kind === "archive"} onOpenChange={(o) => !o && close()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.archiveTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pending?.kind === "archive" ? t.archiveDescription(pending.category.name) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (pending?.kind !== "archive") return;
|
||||
const r = await setStockCategoryArchivedAction(pending.category.id, true);
|
||||
close();
|
||||
if (r.ok) toast.success(tr.stock.toast.archived);
|
||||
else toast.error(r.error);
|
||||
}}
|
||||
>
|
||||
{t.archive}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StockCategoryDialog({
|
||||
category,
|
||||
parentId,
|
||||
categories,
|
||||
expenseCategories,
|
||||
onClose,
|
||||
}: {
|
||||
category: StockCategoryView | null;
|
||||
parentId: string | null;
|
||||
categories: StockCategoryView[];
|
||||
expenseCategories: StockOption[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
name: category?.name ?? "",
|
||||
parentId: parentId ?? "",
|
||||
expenseCategoryId: category?.expenseCategoryId ?? "",
|
||||
countEveryDays: category?.countEveryDays ? String(category.countEveryDays) : "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
|
||||
// Kendisi ve alt ağacı üst olarak seçilemez.
|
||||
const parents = useMemo(() => {
|
||||
if (!category) return categories.filter((c) => !c.archived);
|
||||
const blocked = new Set([category.id]);
|
||||
for (const c of categories) if (c.parentId && blocked.has(c.parentId)) blocked.add(c.id);
|
||||
return categories.filter((c) => !c.archived && !blocked.has(c.id));
|
||||
}, [categories, category]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveStockCategoryAction(category?.id ?? null, v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(tr.stock.toast.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={category ? t.editTitle : t.createTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="stock-category-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="stock-category-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.name)}>
|
||||
<FieldLabel htmlFor="sc-name">{t.name}</FieldLabel>
|
||||
<Input
|
||||
id="sc-name"
|
||||
value={v.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.parentId)}>
|
||||
<FieldLabel htmlFor="sc-parent">{t.parent}</FieldLabel>
|
||||
<Select
|
||||
value={v.parentId || NONE}
|
||||
onValueChange={(x) => set("parentId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="sc-parent" className="w-full">
|
||||
<SelectValue>
|
||||
{categories.find((c) => c.id === v.parentId)?.path ?? t.noParent}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value={NONE}>{t.noParent}</SelectItem>
|
||||
{parents.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.parentId ? <FieldError>{errors.parentId}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.expenseCategoryId)}>
|
||||
<FieldLabel htmlFor="sc-expense">{t.expenseCategory}</FieldLabel>
|
||||
<Select
|
||||
value={v.expenseCategoryId || NONE}
|
||||
onValueChange={(x) => set("expenseCategoryId", x === NONE ? "" : x)}
|
||||
>
|
||||
<SelectTrigger id="sc-expense" className="w-full">
|
||||
<SelectValue>
|
||||
{expenseCategories.find((c) => c.id === v.expenseCategoryId)?.path ??
|
||||
t.expenseInherit}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
<SelectItem value={NONE}>{t.expenseInherit}</SelectItem>
|
||||
{expenseCategories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>{t.expenseHint}</FieldDescription>
|
||||
{errors.expenseCategoryId ? <FieldError>{errors.expenseCategoryId}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.countEveryDays)}>
|
||||
<FieldLabel htmlFor="sc-count">{t.countEveryDays}</FieldLabel>
|
||||
<Input
|
||||
id="sc-count"
|
||||
inputMode="numeric"
|
||||
value={v.countEveryDays}
|
||||
onChange={(e) => set("countEveryDays", e.target.value)}
|
||||
className="w-32 text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.countEveryDays)}
|
||||
/>
|
||||
<FieldDescription>{t.countEveryDaysHint}</FieldDescription>
|
||||
{errors.countEveryDays ? <FieldError>{errors.countEveryDays}</FieldError> : null}
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import {
|
||||
formatStock,
|
||||
quantityInput,
|
||||
STOCK_UNITS,
|
||||
WASTE_REASONS,
|
||||
type WasteReason,
|
||||
} from "@/shared/stock";
|
||||
import { addStockMovementAction, countStockAction, saveProductTypeAction } from "../actions";
|
||||
import type { ProductTypeView, StockOption } from "../service";
|
||||
|
||||
const t = tr.stock;
|
||||
type Errors = Record<string, string | undefined>;
|
||||
const CUSTOM = "__custom__";
|
||||
|
||||
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function errorsOf(r: { fieldErrors?: Partial<Record<string, string>>; error: string }): Errors {
|
||||
return { ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error };
|
||||
}
|
||||
|
||||
/** Yeni ürün tipi / düzenle. */
|
||||
export function ProductTypeFormDialog({
|
||||
item,
|
||||
categories,
|
||||
defaultCategoryId,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
item: ProductTypeView | null;
|
||||
categories: StockOption[];
|
||||
defaultCategoryId?: string;
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
}) {
|
||||
const standard = (STOCK_UNITS as readonly string[]).includes(item?.unit ?? "adet");
|
||||
const [v, setV] = useState({
|
||||
name: item?.name ?? "",
|
||||
categoryId: item?.categoryId ?? defaultCategoryId ?? "",
|
||||
unitChoice: standard ? (item?.unit ?? "adet") : CUSTOM,
|
||||
customUnit: standard ? "" : (item?.unit ?? ""),
|
||||
targetQuantity: item ? quantityInput(item.targetQuantity) : "",
|
||||
thresholdPct: String(item?.thresholdPct ?? 20),
|
||||
location: item?.location ?? "",
|
||||
step: item ? quantityInput(item.step) : "1",
|
||||
countEveryDays: item?.countEveryDays ? String(item.countEveryDays) : "",
|
||||
note: item?.note ?? "",
|
||||
initialQuantity: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
const unit = v.unitChoice === CUSTOM ? v.customUnit.trim() : v.unitChoice;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await saveProductTypeAction(item?.id ?? null, {
|
||||
name: v.name,
|
||||
categoryId: v.categoryId,
|
||||
unit,
|
||||
targetQuantity: v.targetQuantity,
|
||||
thresholdPct: v.thresholdPct,
|
||||
location: v.location,
|
||||
step: v.step,
|
||||
countEveryDays: v.countEveryDays,
|
||||
note: v.note,
|
||||
initialQuantity: item ? "" : v.initialQuantity,
|
||||
});
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.saved);
|
||||
onClose();
|
||||
if (!item) onCreated?.((r.data as { id: string }).id);
|
||||
} else setErrors(errorsOf(r));
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
const unitSuffix = unit ? ` (${unit})` : "";
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={item ? t.editTitle : t.createTitle}
|
||||
footer={<Footer busy={busy} formId="product-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="product-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errors.name)}>
|
||||
<FieldLabel htmlFor="pt-name">{f.name}</FieldLabel>
|
||||
<Input
|
||||
id="pt-name"
|
||||
value={v.name}
|
||||
placeholder={f.namePlaceholder}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
autoFocus={!item}
|
||||
/>
|
||||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.categoryId)}>
|
||||
<FieldLabel htmlFor="pt-cat">{f.category}</FieldLabel>
|
||||
<Select value={v.categoryId} onValueChange={(x) => set("categoryId", x)}>
|
||||
<SelectTrigger
|
||||
id="pt-cat"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.categoryId)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.selectCategory}>
|
||||
{categories.find((c) => c.id === v.categoryId)?.path}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span style={{ paddingLeft: `${c.depth * 12}px` }}>{c.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.categoryId ? <FieldError>{errors.categoryId}</FieldError> : null}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.unit)}>
|
||||
<FieldLabel htmlFor="pt-unit">{f.unit}</FieldLabel>
|
||||
<Select value={v.unitChoice} onValueChange={(x) => set("unitChoice", x)}>
|
||||
<SelectTrigger id="pt-unit" className="w-full">
|
||||
<SelectValue>{v.unitChoice === CUSTOM ? f.customUnit : v.unitChoice}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOCK_UNITS.map((u) => (
|
||||
<SelectItem key={u} value={u}>
|
||||
{u}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM}>{f.customUnitOption}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.unit && v.unitChoice !== CUSTOM ? (
|
||||
<FieldError>{errors.unit}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
{v.unitChoice === CUSTOM ? (
|
||||
<Field data-invalid={Boolean(errors.unit)}>
|
||||
<FieldLabel htmlFor="pt-custom">{f.customUnit}</FieldLabel>
|
||||
<Input
|
||||
id="pt-custom"
|
||||
value={v.customUnit}
|
||||
placeholder={f.customUnitPlaceholder}
|
||||
onChange={(e) => set("customUnit", e.target.value)}
|
||||
aria-invalid={Boolean(errors.unit)}
|
||||
/>
|
||||
{errors.unit ? <FieldError>{errors.unit}</FieldError> : null}
|
||||
</Field>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pt-location">{f.location}</FieldLabel>
|
||||
<Input
|
||||
id="pt-location"
|
||||
value={v.location}
|
||||
placeholder={f.locationPlaceholder}
|
||||
onChange={(e) => set("location", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{v.unitChoice === CUSTOM ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pt-location">{f.location}</FieldLabel>
|
||||
<Input
|
||||
id="pt-location"
|
||||
value={v.location}
|
||||
placeholder={f.locationPlaceholder}
|
||||
onChange={(e) => set("location", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.targetQuantity)}>
|
||||
<FieldLabel htmlFor="pt-target">
|
||||
{f.target}
|
||||
{unitSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pt-target"
|
||||
inputMode="decimal"
|
||||
value={v.targetQuantity}
|
||||
onChange={(e) => set("targetQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.targetQuantity)}
|
||||
/>
|
||||
{errors.targetQuantity ? <FieldError>{errors.targetQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.thresholdPct)}>
|
||||
<FieldLabel htmlFor="pt-threshold">{f.thresholdPct}</FieldLabel>
|
||||
<Input
|
||||
id="pt-threshold"
|
||||
inputMode="numeric"
|
||||
value={v.thresholdPct}
|
||||
onChange={(e) => set("thresholdPct", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.thresholdPct)}
|
||||
/>
|
||||
{errors.thresholdPct ? <FieldError>{errors.thresholdPct}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.targetHint} {f.thresholdHint}
|
||||
</FieldDescription>
|
||||
{!item ? (
|
||||
<Field data-invalid={Boolean(errors.initialQuantity)}>
|
||||
<FieldLabel htmlFor="pt-initial">
|
||||
{f.initialQuantity}
|
||||
{unitSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pt-initial"
|
||||
inputMode="decimal"
|
||||
value={v.initialQuantity}
|
||||
onChange={(e) => set("initialQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.initialQuantity)}
|
||||
/>
|
||||
<FieldDescription>{f.initialHint}</FieldDescription>
|
||||
{errors.initialQuantity ? <FieldError>{errors.initialQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.step)}>
|
||||
<FieldLabel htmlFor="pt-step">
|
||||
{f.step}
|
||||
{unitSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pt-step"
|
||||
inputMode="decimal"
|
||||
value={v.step}
|
||||
onChange={(e) => set("step", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.step)}
|
||||
/>
|
||||
{errors.step ? <FieldError>{errors.step}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.countEveryDays)}>
|
||||
<FieldLabel htmlFor="pt-count">{f.countEveryDays}</FieldLabel>
|
||||
<Input
|
||||
id="pt-count"
|
||||
inputMode="numeric"
|
||||
value={v.countEveryDays}
|
||||
onChange={(e) => set("countEveryDays", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.countEveryDays)}
|
||||
/>
|
||||
{errors.countEveryDays ? <FieldError>{errors.countEveryDays}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.stepHint} {f.countEveryDaysHint}
|
||||
</FieldDescription>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pt-note">{f.note}</FieldLabel>
|
||||
<Input id="pt-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export type MovementKindInput = "purchase" | "consume" | "waste";
|
||||
|
||||
/** Alım, tüketim veya fire. */
|
||||
export function StockMovementDialog({
|
||||
item,
|
||||
initialKind,
|
||||
onClose,
|
||||
}: {
|
||||
item: ProductTypeView;
|
||||
initialKind: MovementKindInput;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [kind, setKind] = useState<MovementKindInput>(initialKind);
|
||||
const [v, setV] = useState({
|
||||
date: todayIstanbul(),
|
||||
quantity:
|
||||
initialKind === "purchase" && item.suggestedQuantity
|
||||
? quantityInput(item.suggestedQuantity)
|
||||
: "",
|
||||
wasteReason: "" as WasteReason | "",
|
||||
expiryDate: "",
|
||||
note: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await addStockMovementAction(item.id, { kind, ...v });
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.toast.movementSaved);
|
||||
onClose();
|
||||
} else setErrors(errorsOf(r));
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.movementTitles[kind]} · ${item.name}`}
|
||||
description={`${t.detail.current}: ${formatStock(item.current, item.unit)}`}
|
||||
footer={<Footer busy={busy} formId="stock-movement-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="stock-movement-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<Tabs
|
||||
value={kind}
|
||||
onValueChange={(x) => {
|
||||
setKind(x as MovementKindInput);
|
||||
setErrors({});
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
{(["purchase", "consume", "waste"] as const).map((k) => (
|
||||
<TabsTrigger key={k} value={k}>
|
||||
{t.kinds[k]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.quantity)}>
|
||||
<FieldLabel htmlFor="sm-qty">
|
||||
{f.quantity} ({item.unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="sm-qty"
|
||||
inputMode="decimal"
|
||||
value={v.quantity}
|
||||
onChange={(e) => set("quantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.quantity)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="sm-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="sm-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
{kind === "purchase" ? (
|
||||
<Field data-invalid={Boolean(errors.expiryDate)}>
|
||||
<FieldLabel htmlFor="sm-expiry">{f.expiryDate}</FieldLabel>
|
||||
<Input
|
||||
id="sm-expiry"
|
||||
type="date"
|
||||
value={v.expiryDate}
|
||||
min={v.date}
|
||||
onChange={(e) => set("expiryDate", e.target.value)}
|
||||
/>
|
||||
<FieldDescription>{f.expiryHint}</FieldDescription>
|
||||
{errors.expiryDate ? <FieldError>{errors.expiryDate}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
{kind === "waste" ? (
|
||||
<Field data-invalid={Boolean(errors.wasteReason)}>
|
||||
<FieldLabel htmlFor="sm-reason">{f.wasteReason}</FieldLabel>
|
||||
<Select
|
||||
value={v.wasteReason}
|
||||
onValueChange={(x) => set("wasteReason", x as WasteReason)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="sm-reason"
|
||||
className="w-full"
|
||||
aria-invalid={Boolean(errors.wasteReason)}
|
||||
>
|
||||
<SelectValue placeholder={t.validation.wasteReason}>
|
||||
{v.wasteReason ? t.wasteReasons[v.wasteReason] : null}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WASTE_REASONS.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{t.wasteReasons[r]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.wasteReason ? <FieldError>{errors.wasteReason}</FieldError> : null}
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sm-note">{f.note}</FieldLabel>
|
||||
<Input id="sm-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tek ürün sayımı: gerçek miktar → fark sayım düzeltmesi. */
|
||||
export function StockCountDialog({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: ProductTypeView;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({ date: todayIstanbul(), actualQuantity: "", note: "" });
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await countStockAction(item.id, v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
const diff = (r.data as { diff: number }).diff;
|
||||
if (diff === 0) toast.success(t.toast.noChange);
|
||||
else
|
||||
toast.success(
|
||||
t.toast.counted(`${diff > 0 ? "+" : "−"}${formatStock(Math.abs(diff), item.unit)}`),
|
||||
);
|
||||
onClose();
|
||||
} else setErrors(errorsOf(r));
|
||||
}
|
||||
|
||||
const f = t.fields;
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={`${t.countTitle} · ${item.name}`}
|
||||
description={t.countDescription}
|
||||
footer={<Footer busy={busy} formId="stock-count-form" onClose={onClose} />}
|
||||
>
|
||||
<form id="stock-count-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.actualQuantity)}>
|
||||
<FieldLabel htmlFor="sc-qty">
|
||||
{f.actualQuantity} ({item.unit})
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="sc-qty"
|
||||
inputMode="decimal"
|
||||
value={v.actualQuantity}
|
||||
onChange={(e) => set("actualQuantity", e.target.value)}
|
||||
className="text-right tabular-nums"
|
||||
aria-invalid={Boolean(errors.actualQuantity)}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.actualQuantity ? <FieldError>{errors.actualQuantity}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errors.date)}>
|
||||
<FieldLabel htmlFor="sc-date">{f.date}</FieldLabel>
|
||||
<Input
|
||||
id="sc-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => set("date", e.target.value)}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">
|
||||
{f.expected(formatStock(Math.max(0, item.current), item.unit))}
|
||||
</FieldDescription>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sc-note">{f.note}</FieldLabel>
|
||||
<Input id="sc-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||||
</Field>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, ChevronRight, Clock, FolderTree, Plus, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate } from "@/shared/format";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import { formatStock, stepLabel, type ExpiringLot } from "@/shared/stock";
|
||||
import { quickConsumeAction } from "../actions";
|
||||
import type { ProductTypeView, StockOption, StockView as Data } from "../service";
|
||||
import { ProductTypeFormDialog } from "./stock-dialogs";
|
||||
|
||||
const t = tr.stock;
|
||||
export const ymdLabel = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
/** Mevcut/hedef çubuğu; eşik çizgiyle işaretli. */
|
||||
export function StockBar({ item }: { item: ProductTypeView }) {
|
||||
const pct = Math.min(100, item.level.pct ?? 0);
|
||||
return (
|
||||
<div
|
||||
className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label={t.list.of(
|
||||
formatStock(Math.max(0, item.current), item.unit),
|
||||
formatStock(item.targetQuantity, item.unit),
|
||||
)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct}
|
||||
>
|
||||
<div
|
||||
className={cn("h-full rounded-full", item.level.low ? "bg-expense" : "bg-budget")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
{item.thresholdPct > 0 && item.thresholdPct < 100 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-0 h-full w-0.5 bg-foreground/50"
|
||||
style={{ left: `${item.thresholdPct}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LevelBadge({ item }: { item: ProductTypeView }) {
|
||||
if (item.current <= 0 && item.targetQuantity > 0)
|
||||
return <Badge variant="destructive">{t.list.outBadge}</Badge>;
|
||||
if (item.level.low)
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 border-expense/50 text-expense">
|
||||
<AlertTriangle className="size-3" />
|
||||
{t.list.lowBadge}
|
||||
</Badge>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ExpiryBadge({ lot }: { lot: Pick<ExpiringLot, "expiryDate" | "status"> }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"gap-1",
|
||||
lot.status === "expired"
|
||||
? "border-expense/50 text-expense"
|
||||
: "border-amber-500/50 text-amber-700 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{t.expiring.on(ymdLabel(lot.expiryDate))}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** "1 azalt" düğmesi. */
|
||||
export function QuickConsumeButton({ item }: { item: ProductTypeView }) {
|
||||
const [pending, start] = useTransition();
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0 tabular-nums"
|
||||
disabled={pending || item.current <= 0}
|
||||
aria-label={t.list.quickConsume(item.name)}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
const r = await quickConsumeAction(item.id);
|
||||
if (r.ok)
|
||||
toast.success(
|
||||
t.list.quickDone(
|
||||
item.name,
|
||||
formatStock((r.data as { remaining: number }).remaining, item.unit),
|
||||
),
|
||||
);
|
||||
else toast.error(r.error);
|
||||
})
|
||||
}
|
||||
>
|
||||
{stepLabel(item.step, item.unit)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-lg font-semibold tabular-nums", value > 0 && tone)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** /stok: özet, öneriler, SKT uyarıları ve kategoriye göre ürün listesi. */
|
||||
export function StockView({ data, options }: { data: Data; options: StockOption[] }) {
|
||||
const router = useRouter();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [onlyLow, setOnlyLow] = useState(false);
|
||||
|
||||
const expired = data.expiring.filter((e) => e.lot.status === "expired").length;
|
||||
const groups = useMemo(() => {
|
||||
const q = foldTr(query.trim());
|
||||
const match = (i: ProductTypeView) =>
|
||||
(!onlyLow || i.level.low) &&
|
||||
(!q || foldTr(i.name).includes(q) || foldTr(i.location).includes(q));
|
||||
return data.categories
|
||||
.map((c) => ({
|
||||
category: c,
|
||||
items: data.items.filter((i) => i.categoryId === c.id && match(i)),
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}, [data, query, onlyLow]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/stok/kategoriler">
|
||||
<FolderTree />
|
||||
{t.categoriesLink}
|
||||
</Link>
|
||||
</Button>
|
||||
{data.canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus />
|
||||
{t.newProduct}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card size="sm">
|
||||
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label={t.summary.products} value={data.items.length} />
|
||||
<Stat label={t.summary.low} value={data.suggestions.length} tone="text-expense" />
|
||||
<Stat
|
||||
label={t.summary.expiring}
|
||||
value={data.expiring.length - expired}
|
||||
tone="text-amber-700 dark:text-amber-400"
|
||||
/>
|
||||
<Stat label={t.summary.expired} value={expired} tone="text-expense" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.suggestions.title}</CardTitle>
|
||||
<CardDescription>{t.suggestions.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.suggestions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.suggestions.empty}</p>
|
||||
) : (
|
||||
<ul className="divide-y text-sm">
|
||||
{data.suggestions.map((i) => (
|
||||
<li key={i.id} className="flex items-center justify-between gap-3 py-2">
|
||||
<Link href={`/stok/${i.id}`} className="min-w-0 hover:underline">
|
||||
<span className="block truncate font-medium">{i.name}</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{t.list.of(
|
||||
formatStock(Math.max(0, i.current), i.unit),
|
||||
formatStock(i.targetQuantity, i.unit),
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="shrink-0 text-right font-medium tabular-nums">
|
||||
{t.suggestions.suggested(formatStock(i.suggestedQuantity, i.unit))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{data.expiring.length ? (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t.expiring.title}</CardTitle>
|
||||
<CardDescription>{t.expiring.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y text-sm">
|
||||
{data.expiring.map(({ item, lot }) => (
|
||||
<li
|
||||
key={`${item.id}-${lot.index}`}
|
||||
className="flex items-center justify-between gap-3 py-2"
|
||||
>
|
||||
<Link href={`/stok/${item.id}`} className="min-w-0 hover:underline">
|
||||
<span className="block truncate font-medium">{item.name}</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{formatStock(lot.remaining, item.unit)}
|
||||
</span>
|
||||
</Link>
|
||||
<ExpiryBadge lot={lot} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{data.items.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t.list.empty}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-48 flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t.list.search}
|
||||
aria-label={t.list.search}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={onlyLow} onCheckedChange={(x) => setOnlyLow(x === true)} />
|
||||
{t.list.onlyLow}
|
||||
</label>
|
||||
</div>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t.list.noMatch}</p>
|
||||
) : null}
|
||||
{groups.map(({ category, items }) => (
|
||||
<section key={category.id} className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">{category.path}</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<StockRow key={item.id} item={item} canWrite={data.canWrite} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{creating ? (
|
||||
<ProductTypeFormDialog
|
||||
item={null}
|
||||
categories={options}
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={(id) => router.push(`/stok/${id}`)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StockRow({ item, canWrite }: { item: ProductTypeView; canWrite: boolean }) {
|
||||
const lot = item.expiring[0];
|
||||
return (
|
||||
<li className="flex items-center gap-2 rounded-xl border bg-card p-3">
|
||||
<Link href={`/stok/${item.id}`} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-1.5 font-medium">
|
||||
<span className="truncate">{item.name}</span>
|
||||
<LevelBadge item={item} />
|
||||
{lot ? <ExpiryBadge lot={lot} /> : null}
|
||||
</p>
|
||||
<span className="shrink-0 text-sm tabular-nums">
|
||||
<span className="font-semibold">
|
||||
{formatStock(Math.max(0, item.current), item.unit)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
/ {formatStock(item.targetQuantity, item.unit)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<StockBar item={item} />
|
||||
{item.location ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{item.location}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
{canWrite ? <QuickConsumeButton item={item} /> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Varsayılan stok kategori seti (Bölüm 6.10). `key` sabittir (tekrar yüklemede kopya olmaz).
|
||||
* `expenseKey`: eşlenen gider kategorisinin varsayılan anahtarı (Faz 13'te alışveriş giderleri).
|
||||
* Parantez içindeki örnekler (Kırmızı Mercimek, Toz Deterjan …) ürün tipidir; birim ve hedef
|
||||
* haneye göre değiştiği için otomatik eklenmez (seed örnek verisinde vardır).
|
||||
*/
|
||||
export type DefaultStockCategory = {
|
||||
key: string;
|
||||
name: string;
|
||||
expenseKey?: string;
|
||||
children?: Omit<DefaultStockCategory, "children" | "expenseKey">[];
|
||||
};
|
||||
|
||||
export const DEFAULT_STOCK_CATEGORIES: DefaultStockCategory[] = [
|
||||
{
|
||||
key: "mutfak",
|
||||
name: "Mutfak",
|
||||
expenseKey: "market.gida",
|
||||
children: [
|
||||
{ key: "mutfak.manav", name: "Manav" },
|
||||
{ key: "mutfak.sarkuteri", name: "Şarküteri" },
|
||||
{ key: "mutfak.bakliyat", name: "Bakliyat" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "genel-temizlik",
|
||||
name: "Genel Temizlik",
|
||||
expenseKey: "market.temizlik",
|
||||
children: [
|
||||
{ key: "genel-temizlik.bulasik", name: "Bulaşık" },
|
||||
{ key: "genel-temizlik.camasir", name: "Çamaşır" },
|
||||
{ key: "genel-temizlik.genel", name: "Genel" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "kisisel-temizlik",
|
||||
name: "Kişisel Temizlik",
|
||||
expenseKey: "market.kisisel-bakim",
|
||||
children: [
|
||||
{ key: "kisisel-temizlik.sampuan", name: "Şampuan" },
|
||||
{ key: "kisisel-temizlik.tiras", name: "Tıraş" },
|
||||
{ key: "kisisel-temizlik.kisisel-bakim", name: "Kişisel Bakım" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,307 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import {
|
||||
productTypeFormSchema,
|
||||
stockCategoryFormSchema,
|
||||
stockCountFormSchema,
|
||||
stockMovementFormSchema,
|
||||
type ProductTypeFormInput,
|
||||
} from "@/shared/schemas/stock";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
addStockMovement,
|
||||
countStock,
|
||||
createProductType,
|
||||
createStockCategory,
|
||||
deleteStockMovement,
|
||||
getProductType,
|
||||
getStock,
|
||||
getStockOptions,
|
||||
listStockCategories,
|
||||
quickConsume,
|
||||
setProductTypeArchived,
|
||||
setStockCategoryArchived,
|
||||
updateStockCategory,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
const today = todayIstanbul();
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
async function categoryId(path: string) {
|
||||
const o = (await getStockOptions(ayse, cakir))!;
|
||||
return o.categories.find((c) => c.path === path)!.id;
|
||||
}
|
||||
|
||||
function product(input: Partial<ProductTypeFormInput> & { categoryId: string }) {
|
||||
return productTypeFormSchema.parse({
|
||||
name: "Test Ürün",
|
||||
unit: "kg",
|
||||
targetQuantity: "4",
|
||||
thresholdPct: "20",
|
||||
step: "0,5",
|
||||
...input,
|
||||
});
|
||||
}
|
||||
|
||||
describe("seed ve görünüm", () => {
|
||||
it("varsayılan ağaç, gider eşlemesi devralınır; azalanlar ve SKT", async () => {
|
||||
const s = (await getStock(ayse, cakir))!;
|
||||
expect(s.items).toHaveLength(19);
|
||||
const bakliyat = s.categories.find((c) => c.path === "Mutfak › Bakliyat")!;
|
||||
expect(bakliyat.depth).toBe(1);
|
||||
expect(bakliyat.expense).toEqual(
|
||||
expect.objectContaining({ path: "Market › Gıda", inherited: true }),
|
||||
);
|
||||
expect(s.categories.find((c) => c.path === "Mutfak")!.expense?.inherited).toBe(false);
|
||||
// Bir süredir alınmayanlar eşiğin altında.
|
||||
const low = s.suggestions.map((i) => i.name);
|
||||
expect(low).toEqual(expect.arrayContaining(["Kırmızı Mercimek", "Tablet Deterjan"]));
|
||||
for (const i of s.suggestions) {
|
||||
expect(i.level.low).toBe(true);
|
||||
expect(i.suggestedQuantity).toBe(i.targetQuantity - Math.max(0, i.current));
|
||||
}
|
||||
// Hiçbir stok eksiye düşmez; süt partilerinin SKT'si yakın.
|
||||
expect(s.items.every((i) => i.current >= 0)).toBe(true);
|
||||
expect(s.expiring.some((e) => e.item.name === "Süt")).toBe(true);
|
||||
// Kategori sırası: Mutfak ürünleri temizlikten önce.
|
||||
expect(s.items.findIndex((i) => i.name === "Domates")).toBeLessThan(
|
||||
s.items.findIndex((i) => i.name === "Toz Deterjan"),
|
||||
);
|
||||
|
||||
const annemler = (await getStock(fatma, ids.household("Annemler")))!;
|
||||
expect(annemler.items).toHaveLength(0);
|
||||
expect(annemler.categories.length).toBe(12);
|
||||
});
|
||||
|
||||
it("izolasyon: üye olmayan göremez, izleyici yazamaz", async () => {
|
||||
expect(await getStock(fatma, cakir)).toBeNull();
|
||||
const s = (await getStock(zeynep, cakir))!;
|
||||
expect(s.canWrite).toBe(false);
|
||||
const any = s.items[0]!;
|
||||
expect((await quickConsume(zeynep, cakir, any.id)).ok).toBe(false);
|
||||
expect(await getProductType(fatma, cakir, any.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ürün tipi ve hareketler", () => {
|
||||
it("başlangıç miktarı, tüketim sınırı, 1 azalt, fire, SKT FIFO, sayım, silme", async () => {
|
||||
const cat = await categoryId("Mutfak › Bakliyat");
|
||||
const created = await createProductType(
|
||||
ayse,
|
||||
cakir,
|
||||
product({ categoryId: cat, name: "Bulgur", initialQuantity: "1,5" }),
|
||||
);
|
||||
expect(created.ok).toBe(true);
|
||||
const id = created.ok ? created.data.id : "";
|
||||
let p = (await getProductType(ayse, cakir, id))!;
|
||||
expect(p.current).toBe(1_500);
|
||||
expect(p.movements[0]).toMatchObject({ kind: "count_adjust", initial: true, delta: 1_500 });
|
||||
// 1,5 < %20 × 4 = 0,8? hayır → azalmadı.
|
||||
expect(p.level.low).toBe(false);
|
||||
|
||||
const dup = await createProductType(ayse, cakir, product({ categoryId: cat, name: "bulgur" }));
|
||||
expect(dup.ok).toBe(false);
|
||||
|
||||
const tooMuch = await addStockMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
stockMovementFormSchema.parse({ kind: "consume", date: today, quantity: "2" }),
|
||||
);
|
||||
expect(tooMuch).toMatchObject({ ok: false, fieldErrors: { quantity: expect.any(String) } });
|
||||
|
||||
const q = await quickConsume(ayse, cakir, id);
|
||||
expect(q).toEqual({ ok: true, data: { remaining: 1_000 } });
|
||||
|
||||
// Alım (SKT'li) sonra fire: fire en eski partiden (başlangıç) düşer.
|
||||
await addStockMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
stockMovementFormSchema.parse({
|
||||
kind: "purchase",
|
||||
date: today,
|
||||
quantity: "1",
|
||||
expiryDate: addDays(today, 3),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
stockMovementFormSchema.safeParse({ kind: "waste", date: today, quantity: "1" }).success,
|
||||
).toBe(false);
|
||||
await addStockMovement(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
stockMovementFormSchema.parse({
|
||||
kind: "waste",
|
||||
date: today,
|
||||
quantity: "0,8",
|
||||
wasteReason: "spoiled",
|
||||
}),
|
||||
);
|
||||
p = (await getProductType(ayse, cakir, id))!;
|
||||
expect(p.current).toBe(1_200);
|
||||
expect(p.lots).toEqual([
|
||||
expect.objectContaining({ remaining: 200, expiryDate: null }),
|
||||
expect.objectContaining({ remaining: 1_000, expiryDate: addDays(today, 3), status: "soon" }),
|
||||
]);
|
||||
expect(p.expiring).toHaveLength(1);
|
||||
|
||||
// Sayım: 0,5 sayıldı → −0,7 düzeltme; aynı miktar tekrar → fark yok.
|
||||
const c = await countStock(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }),
|
||||
);
|
||||
expect(c).toEqual({ ok: true, data: { diff: -700 } });
|
||||
const same = await countStock(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
stockCountFormSchema.parse({ date: today, actualQuantity: "0,5" }),
|
||||
);
|
||||
expect(same).toEqual({ ok: true, data: { diff: 0 } });
|
||||
p = (await getProductType(ayse, cakir, id))!;
|
||||
expect(p.current).toBe(500);
|
||||
expect(p.level.low).toBe(true);
|
||||
expect(p.suggestedQuantity).toBe(3_500);
|
||||
|
||||
// Hareket silme: yeniden hesap + denetim kaydı.
|
||||
const adj = p.movements[0]!;
|
||||
expect(adj.kind).toBe("count_adjust");
|
||||
expect((await deleteStockMovement(ayse, cakir, adj.id)).ok).toBe(true);
|
||||
expect((await getProductType(ayse, cakir, id))!.current).toBe(1_200);
|
||||
expect(
|
||||
await AuditLogModel.exists({ action: "stock.movement.delete", targetId: adj.id }),
|
||||
).toBeTruthy();
|
||||
expect(await StockMovementModel.exists({ _id: adj.id, deletedAt: null })).toBeNull();
|
||||
|
||||
// Arşivli üründe hareket yok; arşivden çıkınca devam.
|
||||
expect((await setProductTypeArchived(ayse, cakir, id, true)).ok).toBe(true);
|
||||
expect((await quickConsume(ayse, cakir, id)).ok).toBe(false);
|
||||
expect((await getStock(ayse, cakir))!.items.some((i) => i.id === id)).toBe(false);
|
||||
expect((await setProductTypeArchived(ayse, cakir, id, false)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("boş stokta 1 azalt reddedilir; özel birim", async () => {
|
||||
const cat = await categoryId("Genel Temizlik › Çamaşır");
|
||||
const r = await createProductType(
|
||||
ayse,
|
||||
cakir,
|
||||
product({ categoryId: cat, name: "Leke Çıkarıcı", unit: "yıkama", step: "1" }),
|
||||
);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect(await quickConsume(ayse, cakir, id)).toEqual({
|
||||
ok: false,
|
||||
error: tr.stock.validation.emptyStock,
|
||||
});
|
||||
expect((await getProductType(ayse, cakir, id))!.unit).toBe("yıkama");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stok kategorileri", () => {
|
||||
it("oluştur, gider eşle, taşıma kuralları, arşiv koşulu", async () => {
|
||||
const opts = (await getStockOptions(ayse, cakir))!;
|
||||
const market = opts.expenseCategories.find((c) => c.path === "Market")!;
|
||||
const parent = await createStockCategory(
|
||||
ayse,
|
||||
cakir,
|
||||
stockCategoryFormSchema.parse({
|
||||
name: "Bebek",
|
||||
parentId: "",
|
||||
expenseCategoryId: market.id,
|
||||
countEveryDays: "30",
|
||||
}),
|
||||
);
|
||||
const parentId = parent.ok ? parent.data.id : "";
|
||||
const child = await createStockCategory(
|
||||
ayse,
|
||||
cakir,
|
||||
stockCategoryFormSchema.parse({
|
||||
name: "Bez",
|
||||
parentId,
|
||||
expenseCategoryId: "",
|
||||
countEveryDays: "",
|
||||
}),
|
||||
);
|
||||
const childId = child.ok ? child.data.id : "";
|
||||
let list = (await listStockCategories(ayse, cakir))!.categories;
|
||||
const bez = list.find((c) => c.id === childId)!;
|
||||
expect(bez).toMatchObject({
|
||||
path: "Bebek › Bez",
|
||||
effectiveCountDays: 30,
|
||||
expense: expect.objectContaining({ path: "Market", inherited: true }),
|
||||
});
|
||||
|
||||
// Kendi altına taşınamaz; aynı adlı kardeş olamaz.
|
||||
const loop = await updateStockCategory(
|
||||
ayse,
|
||||
cakir,
|
||||
parentId,
|
||||
stockCategoryFormSchema.parse({
|
||||
name: "Bebek",
|
||||
parentId: childId,
|
||||
expenseCategoryId: "",
|
||||
countEveryDays: "",
|
||||
}),
|
||||
);
|
||||
expect(loop.ok).toBe(false);
|
||||
const dup = await createStockCategory(
|
||||
ayse,
|
||||
cakir,
|
||||
stockCategoryFormSchema.parse({
|
||||
name: "mutfak",
|
||||
parentId: "",
|
||||
expenseCategoryId: "",
|
||||
countEveryDays: "",
|
||||
}),
|
||||
);
|
||||
expect(dup.ok).toBe(false);
|
||||
|
||||
// Gelir kategorisi eşlenemez.
|
||||
const income = await createStockCategory(ayse, cakir, {
|
||||
name: "Yanlış",
|
||||
parentId: null,
|
||||
expenseCategoryId: "0".repeat(24),
|
||||
countEveryDays: null,
|
||||
});
|
||||
expect(income.ok).toBe(false);
|
||||
|
||||
// Ürün varken arşivlenemez; ürün yokken alt ağaçla arşivlenir.
|
||||
const r = await createProductType(
|
||||
ayse,
|
||||
cakir,
|
||||
product({ categoryId: childId, name: "Islak Mendil" }),
|
||||
);
|
||||
expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(false);
|
||||
await setProductTypeArchived(ayse, cakir, r.ok ? r.data.id : "", true);
|
||||
expect((await setStockCategoryArchived(ayse, cakir, parentId, true)).ok).toBe(true);
|
||||
list = (await listStockCategories(ayse, cakir))!.categories;
|
||||
expect(list.find((c) => c.id === childId)!.archived).toBe(true);
|
||||
expect((await getStockOptions(ayse, cakir))!.categories.some((c) => c.id === childId)).toBe(
|
||||
false,
|
||||
);
|
||||
// Arşivli kategoriye ürün eklenemez.
|
||||
expect(
|
||||
(await createProductType(ayse, cakir, product({ categoryId: childId, name: "Pudra" }))).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,938 @@
|
||||
import "server-only";
|
||||
import { Types, type ClientSession } from "mongoose";
|
||||
import {
|
||||
ancestorIds,
|
||||
buildTree,
|
||||
checkCreate,
|
||||
checkMove,
|
||||
descendantIds,
|
||||
toOptions,
|
||||
type FlatCategory,
|
||||
type MoveViolation,
|
||||
} from "@/features/categories/tree";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { connectMongoose } from "@/lib/db/mongoose";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { CategoryModel } from "@/models/category";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StockCategoryModel } from "@/models/stock-category";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import type {
|
||||
ProductTypeForm,
|
||||
StockCategoryForm,
|
||||
StockCountForm,
|
||||
StockMovementForm,
|
||||
} from "@/shared/schemas/stock";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
import {
|
||||
currentQuantity,
|
||||
expiringLots,
|
||||
expiryStatus,
|
||||
fifoLots,
|
||||
formatStock,
|
||||
refillQuantity,
|
||||
signedDelta,
|
||||
stockLevel,
|
||||
type ExpiringLot,
|
||||
type ExpiryStatus,
|
||||
type StockLevel,
|
||||
type StockMovementKind,
|
||||
type WasteReason,
|
||||
} from "@/shared/stock";
|
||||
import { DEFAULT_STOCK_CATEGORIES } from "./defaults";
|
||||
|
||||
/**
|
||||
* Ev stoğu (Bölüm 6.10). Okuma: viewer; yazma: member. Stok hane ortaktır (gizlilik yok).
|
||||
* Mevcut miktar hareketlerden türetilir; SKT uyarıları FIFO partilerinden.
|
||||
*/
|
||||
|
||||
const t = tr.stock;
|
||||
|
||||
type CatLean = {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
parentId: Types.ObjectId | null;
|
||||
ancestors: Types.ObjectId[];
|
||||
order: number;
|
||||
archivedAt: Date | null;
|
||||
expenseCategoryId: Types.ObjectId | null;
|
||||
countEveryDays: number | null;
|
||||
defaultKey: string | null;
|
||||
};
|
||||
|
||||
type ProductLean = {
|
||||
_id: Types.ObjectId;
|
||||
categoryId: Types.ObjectId;
|
||||
name: string;
|
||||
unit: string;
|
||||
targetQuantity: number;
|
||||
thresholdPct: number;
|
||||
location: string;
|
||||
step: number;
|
||||
countEveryDays: number | null;
|
||||
note: string;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
|
||||
type MoveLean = {
|
||||
_id: Types.ObjectId;
|
||||
productTypeId: Types.ObjectId;
|
||||
kind: StockMovementKind;
|
||||
date: string;
|
||||
quantity: number;
|
||||
wasteReason: WasteReason | null;
|
||||
expiryDate: string | null;
|
||||
note: string;
|
||||
initial: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type StockCategoryView = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
depth: number;
|
||||
path: string;
|
||||
archived: boolean;
|
||||
isDefault: boolean;
|
||||
expenseCategoryId: string | null;
|
||||
/** Etkin gider kategorisi (kendisi veya üstten devralınan). */
|
||||
expense: { id: string; path: string; inherited: boolean } | null;
|
||||
countEveryDays: number | null;
|
||||
effectiveCountDays: number | null;
|
||||
productCount: number;
|
||||
};
|
||||
|
||||
export type ProductTypeView = {
|
||||
id: string;
|
||||
name: string;
|
||||
categoryId: string;
|
||||
categoryPath: string;
|
||||
unit: string;
|
||||
targetQuantity: number;
|
||||
thresholdPct: number;
|
||||
location: string;
|
||||
step: number;
|
||||
countEveryDays: number | null;
|
||||
note: string;
|
||||
archived: boolean;
|
||||
current: number;
|
||||
level: StockLevel;
|
||||
suggestedQuantity: number;
|
||||
expiring: ExpiringLot[];
|
||||
lastMovementDate: string | null;
|
||||
movementCount: number;
|
||||
};
|
||||
|
||||
export type StockView = {
|
||||
categories: StockCategoryView[];
|
||||
items: ProductTypeView[];
|
||||
suggestions: ProductTypeView[];
|
||||
expiring: { item: ProductTypeView; lot: ExpiringLot }[];
|
||||
canWrite: boolean;
|
||||
today: string;
|
||||
};
|
||||
|
||||
export type StockMovementRow = {
|
||||
id: string;
|
||||
kind: StockMovementKind;
|
||||
date: string;
|
||||
/** İşaretli etki. */
|
||||
delta: number;
|
||||
wasteReason: WasteReason | null;
|
||||
expiryDate: string | null;
|
||||
note: string;
|
||||
initial: boolean;
|
||||
quantityAfter: number;
|
||||
};
|
||||
|
||||
export type LotRow = {
|
||||
date: string;
|
||||
expiryDate: string | null;
|
||||
remaining: number;
|
||||
status: ExpiryStatus | null;
|
||||
};
|
||||
|
||||
export type ProductTypeDetail = ProductTypeView & {
|
||||
movements: StockMovementRow[];
|
||||
lots: LotRow[];
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
export type StockOption = { id: string; label: string; path: string; depth: number };
|
||||
|
||||
// --- Yükleme yardımcıları ---
|
||||
|
||||
/** Ağaç fonksiyonları tür alanı ister; stok ağacı tek türlüdür. */
|
||||
const flat = (c: CatLean): FlatCategory => ({
|
||||
id: String(c._id),
|
||||
kind: "expense",
|
||||
name: c.name,
|
||||
parentId: c.parentId ? String(c.parentId) : null,
|
||||
order: c.order,
|
||||
archived: Boolean(c.archivedAt),
|
||||
});
|
||||
|
||||
const sortMoves = (a: MoveLean, b: MoveLean) =>
|
||||
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
|
||||
|
||||
async function loadCategories(householdId: Types.ObjectId, session?: ClientSession) {
|
||||
return StockCategoryModel.find({ householdId })
|
||||
.session(session ?? null)
|
||||
.lean<CatLean[]>();
|
||||
}
|
||||
|
||||
async function loadMoves(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||
const docs = await StockMovementModel.find({
|
||||
householdId,
|
||||
productTypeId: { $in: productIds },
|
||||
deletedAt: null,
|
||||
}).lean<MoveLean[]>();
|
||||
const by = new Map<string, MoveLean[]>();
|
||||
for (const m of docs) {
|
||||
const list = by.get(String(m.productTypeId)) ?? [];
|
||||
list.push(m);
|
||||
by.set(String(m.productTypeId), list);
|
||||
}
|
||||
for (const list of by.values()) list.sort(sortMoves);
|
||||
return by;
|
||||
}
|
||||
|
||||
/** Gider kategorisi yolları ("Market › Gıda"), arşivliler dahil. */
|
||||
async function expensePaths(householdId: Types.ObjectId) {
|
||||
const rows = await CategoryModel.find({ householdId, kind: "expense" }).lean<
|
||||
{
|
||||
_id: Types.ObjectId;
|
||||
kind: "expense";
|
||||
name: string;
|
||||
parentId: Types.ObjectId | null;
|
||||
order: number;
|
||||
archivedAt: Date | null;
|
||||
}[]
|
||||
>();
|
||||
const items = rows.map((c) => ({
|
||||
id: String(c._id),
|
||||
kind: c.kind,
|
||||
name: c.name,
|
||||
parentId: c.parentId ? String(c.parentId) : null,
|
||||
order: c.order,
|
||||
archived: Boolean(c.archivedAt),
|
||||
}));
|
||||
return new Map(toOptions(buildTree(items), { includeArchived: true }).map((o) => [o.id, o.path]));
|
||||
}
|
||||
|
||||
function categoryViews(
|
||||
cats: CatLean[],
|
||||
expense: Map<string, string>,
|
||||
productCounts: Map<string, number>,
|
||||
{ includeArchived = false } = {},
|
||||
): StockCategoryView[] {
|
||||
const items = cats.map(flat);
|
||||
const byId = new Map(cats.map((c) => [String(c._id), c]));
|
||||
const options = toOptions(buildTree(items), { includeArchived });
|
||||
return options.map((o) => {
|
||||
const c = byId.get(o.id)!;
|
||||
const chain = [...ancestorIds(items, o.id), o.id].map((id) => byId.get(id)!).reverse();
|
||||
const mapped = chain.find((x) => x.expenseCategoryId);
|
||||
const days = chain.find((x) => x.countEveryDays)?.countEveryDays ?? null;
|
||||
const expenseId = mapped?.expenseCategoryId ? String(mapped.expenseCategoryId) : null;
|
||||
return {
|
||||
id: o.id,
|
||||
name: c.name,
|
||||
parentId: c.parentId ? String(c.parentId) : null,
|
||||
depth: o.depth,
|
||||
path: o.path,
|
||||
archived: Boolean(c.archivedAt),
|
||||
isDefault: Boolean(c.defaultKey),
|
||||
expenseCategoryId: c.expenseCategoryId ? String(c.expenseCategoryId) : null,
|
||||
expense:
|
||||
expenseId && expense.has(expenseId)
|
||||
? { id: expenseId, path: expense.get(expenseId)!, inherited: mapped !== c }
|
||||
: null,
|
||||
countEveryDays: c.countEveryDays,
|
||||
effectiveCountDays: days,
|
||||
productCount: productCounts.get(o.id) ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toView(
|
||||
p: ProductLean,
|
||||
moves: MoveLean[],
|
||||
categoryPath: string,
|
||||
today: string,
|
||||
): ProductTypeView {
|
||||
const current = currentQuantity(moves);
|
||||
const level = stockLevel(current, p.targetQuantity, p.thresholdPct);
|
||||
return {
|
||||
id: String(p._id),
|
||||
name: p.name,
|
||||
categoryId: String(p.categoryId),
|
||||
categoryPath,
|
||||
unit: p.unit,
|
||||
targetQuantity: p.targetQuantity,
|
||||
thresholdPct: p.thresholdPct,
|
||||
location: p.location,
|
||||
step: p.step,
|
||||
countEveryDays: p.countEveryDays,
|
||||
note: p.note,
|
||||
archived: Boolean(p.archivedAt),
|
||||
current,
|
||||
level,
|
||||
suggestedQuantity: refillQuantity(current, p.targetQuantity),
|
||||
expiring: expiringLots(fifoLots(moves), today),
|
||||
lastMovementDate: moves.at(-1)?.date ?? null,
|
||||
movementCount: moves.length,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Okuma ---
|
||||
|
||||
export async function getStock(userId: string, householdId: string): Promise<StockView | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const today = todayIstanbul();
|
||||
const [cats, products] = await Promise.all([
|
||||
loadCategories(m.householdId),
|
||||
ProductTypeModel.find({ householdId: m.householdId, archivedAt: null }).lean<ProductLean[]>(),
|
||||
]);
|
||||
const counts = new Map<string, number>();
|
||||
for (const p of products)
|
||||
counts.set(String(p.categoryId), (counts.get(String(p.categoryId)) ?? 0) + 1);
|
||||
const categories = categoryViews(cats, await expensePaths(m.householdId), counts, {
|
||||
includeArchived: true,
|
||||
});
|
||||
const order = new Map(categories.map((c, i) => [c.id, i]));
|
||||
const pathOf = new Map(categories.map((c) => [c.id, c.path]));
|
||||
const moves = await loadMoves(
|
||||
m.householdId,
|
||||
products.map((p) => p._id),
|
||||
);
|
||||
const items = products
|
||||
.map((p) =>
|
||||
toView(p, moves.get(String(p._id)) ?? [], pathOf.get(String(p.categoryId)) ?? "", today),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) ||
|
||||
compareTr(a.name, b.name),
|
||||
);
|
||||
const suggestions = items
|
||||
.filter((i) => i.level.low)
|
||||
.sort((a, b) => (a.level.pct ?? 0) - (b.level.pct ?? 0) || compareTr(a.name, b.name));
|
||||
const expiring = items
|
||||
.flatMap((item) => item.expiring.map((lot) => ({ item, lot })))
|
||||
.sort((a, b) => (a.lot.expiryDate < b.lot.expiryDate ? -1 : 1));
|
||||
return {
|
||||
categories: categories.filter((c) => !c.archived),
|
||||
items,
|
||||
suggestions,
|
||||
expiring,
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
today,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadProduct(m: Membership, productTypeId: string, session?: ClientSession) {
|
||||
const id = toObjectId(productTypeId);
|
||||
if (!id) return null;
|
||||
return ProductTypeModel.findOne({ _id: id, householdId: m.householdId })
|
||||
.session(session ?? null)
|
||||
.lean<ProductLean>();
|
||||
}
|
||||
|
||||
export async function getProductType(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
productTypeId: string,
|
||||
): Promise<ProductTypeDetail | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const p = await loadProduct(m, productTypeId);
|
||||
if (!p) return null;
|
||||
const today = todayIstanbul();
|
||||
const cats = await loadCategories(m.householdId);
|
||||
const path =
|
||||
categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find(
|
||||
(c) => c.id === String(p.categoryId),
|
||||
)?.path ?? "";
|
||||
const moves = (await loadMoves(m.householdId, [p._id])).get(String(p._id)) ?? [];
|
||||
let running = 0;
|
||||
const rows = moves.map((mv): StockMovementRow => {
|
||||
const delta = signedDelta(mv);
|
||||
running += delta;
|
||||
return {
|
||||
id: String(mv._id),
|
||||
kind: mv.kind,
|
||||
date: mv.date,
|
||||
delta,
|
||||
wasteReason: mv.wasteReason,
|
||||
expiryDate: mv.expiryDate,
|
||||
note: mv.note,
|
||||
initial: mv.initial,
|
||||
quantityAfter: running,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...toView(p, moves, path, today),
|
||||
movements: rows.reverse(),
|
||||
lots: fifoLots(moves).map((l) => ({
|
||||
date: l.date,
|
||||
expiryDate: l.expiryDate,
|
||||
remaining: l.remaining,
|
||||
status: l.expiryDate ? expiryStatus(l.expiryDate, today) : null,
|
||||
})),
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Form seçenekleri: aktif stok kategorileri ve gider kategorileri. */
|
||||
export async function getStockOptions(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<{ categories: StockOption[]; expenseCategories: StockOption[] } | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const cats = await loadCategories(m.householdId);
|
||||
const expense = await CategoryModel.find({
|
||||
householdId: m.householdId,
|
||||
kind: "expense",
|
||||
archivedAt: null,
|
||||
}).lean<
|
||||
{ _id: Types.ObjectId; name: string; parentId: Types.ObjectId | null; order: number }[]
|
||||
>();
|
||||
const expenseOptions = toOptions(
|
||||
buildTree(
|
||||
expense.map((c) => ({
|
||||
id: String(c._id),
|
||||
kind: "expense" as const,
|
||||
name: c.name,
|
||||
parentId: c.parentId ? String(c.parentId) : null,
|
||||
order: c.order,
|
||||
archived: false,
|
||||
})),
|
||||
),
|
||||
);
|
||||
const pick = (o: { id: string; label: string; path: string; depth: number }) => ({
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
path: o.path,
|
||||
depth: o.depth,
|
||||
});
|
||||
return {
|
||||
categories: toOptions(buildTree(cats.map(flat))).map(pick),
|
||||
expenseCategories: expenseOptions.map(pick),
|
||||
};
|
||||
}
|
||||
|
||||
/** /stok/kategoriler: arşivliler dahil tüm stok kategorileri. */
|
||||
export async function listStockCategories(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<{ categories: StockCategoryView[]; canWrite: boolean } | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const [cats, products] = await Promise.all([
|
||||
loadCategories(m.householdId),
|
||||
ProductTypeModel.find({ householdId: m.householdId, archivedAt: null })
|
||||
.select({ categoryId: 1 })
|
||||
.lean<{ categoryId: Types.ObjectId }[]>(),
|
||||
]);
|
||||
const counts = new Map<string, number>();
|
||||
for (const p of products)
|
||||
counts.set(String(p.categoryId), (counts.get(String(p.categoryId)) ?? 0) + 1);
|
||||
return {
|
||||
categories: categoryViews(cats, await expensePaths(m.householdId), counts, {
|
||||
includeArchived: true,
|
||||
}),
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Varsayılan kategoriler ---
|
||||
|
||||
/**
|
||||
* Varsayılan stok kategorilerini yükler (idempotent, defaultKey'e göre). Gider eşlemesi
|
||||
* hanenin varsayılan gider kategorilerinden (varsa). Eklenen kategori sayısını döner.
|
||||
*/
|
||||
export async function installDefaultStockCategories(
|
||||
householdId: Types.ObjectId,
|
||||
actor: Types.ObjectId,
|
||||
session?: ClientSession,
|
||||
): Promise<number> {
|
||||
await connectMongoose();
|
||||
const expense = await CategoryModel.find({
|
||||
householdId,
|
||||
kind: "expense",
|
||||
defaultKey: { $type: "string" },
|
||||
})
|
||||
.session(session ?? null)
|
||||
.select({ defaultKey: 1 })
|
||||
.lean<{ _id: Types.ObjectId; defaultKey: string }[]>();
|
||||
const expenseByKey = new Map(expense.map((c) => [c.defaultKey, c._id]));
|
||||
let inserted = 0;
|
||||
const upsert = async (
|
||||
def: { key: string; name: string; expenseKey?: string },
|
||||
order: number,
|
||||
parent: { _id: Types.ObjectId; ancestors: Types.ObjectId[] } | null,
|
||||
) => {
|
||||
const res = await StockCategoryModel.findOneAndUpdate(
|
||||
{ householdId, defaultKey: def.key },
|
||||
{
|
||||
$setOnInsert: {
|
||||
householdId,
|
||||
name: def.name,
|
||||
nameKey: foldTr(def.name),
|
||||
parentId: parent?._id ?? null,
|
||||
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
||||
order,
|
||||
expenseCategoryId: def.expenseKey ? (expenseByKey.get(def.expenseKey) ?? null) : null,
|
||||
defaultKey: def.key,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
},
|
||||
},
|
||||
{ upsert: true, returnDocument: "after", includeResultMetadata: true, session },
|
||||
);
|
||||
if (!res.lastErrorObject?.updatedExisting) inserted++;
|
||||
return res.value!;
|
||||
};
|
||||
for (const [i, def] of DEFAULT_STOCK_CATEGORIES.entries()) {
|
||||
const parent = await upsert(def, i, null);
|
||||
for (const [j, child] of (def.children ?? []).entries())
|
||||
await upsert(child, j, { _id: parent._id, ancestors: parent.ancestors });
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
// --- Stok kategorisi yazma ---
|
||||
|
||||
const violation = (v: MoveViolation) => tr.categories.violations[v];
|
||||
|
||||
function hasSiblingNamed(all: CatLean[], parentId: string | null, name: string, except?: string) {
|
||||
const key = foldTr(name);
|
||||
return all.some(
|
||||
(c) =>
|
||||
!c.archivedAt &&
|
||||
String(c._id) !== except &&
|
||||
(c.parentId ? String(c.parentId) : null) === parentId &&
|
||||
foldTr(c.name) === key,
|
||||
);
|
||||
}
|
||||
|
||||
async function checkExpenseCategory(m: Membership, id: string | null) {
|
||||
if (!id) return true;
|
||||
return Boolean(
|
||||
await CategoryModel.exists({
|
||||
_id: new Types.ObjectId(id),
|
||||
householdId: m.householdId,
|
||||
kind: "expense",
|
||||
archivedAt: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createStockCategory(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: StockCategoryForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const all = await loadCategories(m.householdId);
|
||||
const v = checkCreate(all.map(flat), "expense", input.parentId);
|
||||
if (v) return fail(violation(v), { parentId: violation(v) });
|
||||
if (hasSiblingNamed(all, input.parentId, input.name))
|
||||
return fail(t.categories.duplicate, { name: t.categories.duplicate });
|
||||
if (!(await checkExpenseCategory(m, input.expenseCategoryId)))
|
||||
return fail(t.categories.expenseInvalid, { expenseCategoryId: t.categories.expenseInvalid });
|
||||
const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId)! : null;
|
||||
const siblings = all.filter((c) => (c.parentId ? String(c.parentId) : null) === input.parentId);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await StockCategoryModel.create({
|
||||
householdId: m.householdId,
|
||||
name: input.name,
|
||||
nameKey: foldTr(input.name),
|
||||
parentId: parent?._id ?? null,
|
||||
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
||||
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
||||
expenseCategoryId: input.expenseCategoryId ? new Types.ObjectId(input.expenseCategoryId) : null,
|
||||
countEveryDays: input.countEveryDays,
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateStockCategory(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
categoryId: string,
|
||||
input: StockCategoryForm,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const all = await loadCategories(m.householdId);
|
||||
const current = all.find((c) => String(c._id) === categoryId);
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
const currentParent = current.parentId ? String(current.parentId) : null;
|
||||
const moving = currentParent !== input.parentId;
|
||||
if (moving) {
|
||||
const v = checkMove(all.map(flat), categoryId, input.parentId);
|
||||
if (v) return fail(violation(v), { parentId: violation(v) });
|
||||
}
|
||||
if (hasSiblingNamed(all, input.parentId, input.name, categoryId))
|
||||
return fail(t.categories.duplicate, { name: t.categories.duplicate });
|
||||
if (!(await checkExpenseCategory(m, input.expenseCategoryId)))
|
||||
return fail(t.categories.expenseInvalid, { expenseCategoryId: t.categories.expenseInvalid });
|
||||
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const mongoose = await connectMongoose();
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
const set: Record<string, unknown> = {
|
||||
name: input.name,
|
||||
nameKey: foldTr(input.name),
|
||||
expenseCategoryId: input.expenseCategoryId
|
||||
? new Types.ObjectId(input.expenseCategoryId)
|
||||
: null,
|
||||
countEveryDays: input.countEveryDays,
|
||||
updatedBy: actor,
|
||||
};
|
||||
if (moving) {
|
||||
const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId)! : null;
|
||||
const newAncestors = parent ? [...parent.ancestors, parent._id] : [];
|
||||
const siblings = all.filter(
|
||||
(c) => (c.parentId ? String(c.parentId) : null) === input.parentId,
|
||||
);
|
||||
Object.assign(set, {
|
||||
parentId: parent?._id ?? null,
|
||||
ancestors: newAncestors,
|
||||
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
||||
});
|
||||
for (const d of all.filter((c) => c.ancestors.some((a) => a.equals(current._id)))) {
|
||||
const idx = d.ancestors.findIndex((a) => a.equals(current._id));
|
||||
await StockCategoryModel.updateOne(
|
||||
{ _id: d._id, householdId: m.householdId },
|
||||
{ $set: { ancestors: [...newAncestors, current._id, ...d.ancestors.slice(idx + 1)] } },
|
||||
{ session },
|
||||
);
|
||||
}
|
||||
}
|
||||
await StockCategoryModel.updateOne(
|
||||
{ _id: current._id, householdId: m.householdId },
|
||||
{ $set: set },
|
||||
{ session },
|
||||
);
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Arşivler (alt ağaçla; içinde aktif ürün tipi yoksa) veya arşivden çıkarır (üstü aktifse). */
|
||||
export async function setStockCategoryArchived(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
categoryId: string,
|
||||
archived: boolean,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const all = await loadCategories(m.householdId);
|
||||
const current = all.find((c) => String(c._id) === categoryId);
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
const ids = [
|
||||
current._id,
|
||||
...descendantIds(all.map(flat), categoryId).map((id) => new Types.ObjectId(id)),
|
||||
];
|
||||
if (archived) {
|
||||
if (
|
||||
await ProductTypeModel.exists({
|
||||
householdId: m.householdId,
|
||||
categoryId: { $in: ids },
|
||||
archivedAt: null,
|
||||
})
|
||||
)
|
||||
return fail(t.categories.hasProducts);
|
||||
} else {
|
||||
const parent = current.parentId ? all.find((c) => c._id.equals(current.parentId!)) : null;
|
||||
if (parent?.archivedAt) return fail(tr.categories.violations.parentArchived);
|
||||
const parentId = current.parentId ? String(current.parentId) : null;
|
||||
if (hasSiblingNamed(all, parentId, current.name, categoryId))
|
||||
return fail(t.categories.duplicate);
|
||||
}
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await StockCategoryModel.updateMany(
|
||||
{ _id: { $in: archived ? ids : [current._id] }, householdId: m.householdId },
|
||||
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: archived ? "stock.category.archive" : "stock.category.unarchive",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "stockCategory",
|
||||
targetId: categoryId,
|
||||
diff: { name: current.name, affected: archived ? ids.length : 1 },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Ürün tipi yazma ---
|
||||
|
||||
async function checkProductInput(
|
||||
m: Membership,
|
||||
input: ProductTypeForm,
|
||||
exceptId?: Types.ObjectId,
|
||||
): Promise<ActionResult | null> {
|
||||
const cat = await StockCategoryModel.exists({
|
||||
_id: new Types.ObjectId(input.categoryId),
|
||||
householdId: m.householdId,
|
||||
archivedAt: null,
|
||||
});
|
||||
if (!cat)
|
||||
return fail(t.validation.categoryArchived, { categoryId: t.validation.categoryArchived });
|
||||
const dup = await ProductTypeModel.exists({
|
||||
householdId: m.householdId,
|
||||
nameKey: foldTr(input.name),
|
||||
archivedAt: null,
|
||||
...(exceptId ? { _id: { $ne: exceptId } } : {}),
|
||||
});
|
||||
if (dup) return fail(t.validation.duplicate, { name: t.validation.duplicate });
|
||||
return null;
|
||||
}
|
||||
|
||||
const productFields = (input: ProductTypeForm) => ({
|
||||
categoryId: new Types.ObjectId(input.categoryId),
|
||||
name: input.name,
|
||||
nameKey: foldTr(input.name),
|
||||
unit: input.unit,
|
||||
targetQuantity: input.targetQuantity,
|
||||
thresholdPct: input.thresholdPct,
|
||||
location: input.location,
|
||||
step: input.step,
|
||||
countEveryDays: input.countEveryDays,
|
||||
note: input.note,
|
||||
});
|
||||
|
||||
export async function createProductType(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: ProductTypeForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const bad = await checkProductInput(m, input);
|
||||
if (bad) return bad as ActionResult<{ id: string }>;
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const mongoose = await connectMongoose();
|
||||
let id = "";
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
const [doc] = await ProductTypeModel.create(
|
||||
[{ householdId: m.householdId, ...productFields(input), createdBy: actor, updatedBy: actor }],
|
||||
{ session },
|
||||
);
|
||||
id = String(doc!._id);
|
||||
if (input.initialQuantity)
|
||||
await StockMovementModel.create(
|
||||
[
|
||||
{
|
||||
householdId: m.householdId,
|
||||
productTypeId: doc!._id,
|
||||
kind: "count_adjust",
|
||||
date: todayIstanbul(),
|
||||
quantity: input.initialQuantity,
|
||||
initial: true,
|
||||
createdBy: actor,
|
||||
},
|
||||
],
|
||||
{ session },
|
||||
);
|
||||
});
|
||||
return ok({ id });
|
||||
}
|
||||
|
||||
export async function updateProductType(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
productTypeId: string,
|
||||
input: ProductTypeForm,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const p = await loadProduct(m, productTypeId);
|
||||
if (!p) return fail(tr.common.notFound);
|
||||
const bad = await checkProductInput(m, input, p._id);
|
||||
if (bad) return bad;
|
||||
await ProductTypeModel.updateOne(
|
||||
{ _id: p._id, householdId: m.householdId },
|
||||
{ $set: { ...productFields(input), updatedBy: new Types.ObjectId(actorId) } },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export async function setProductTypeArchived(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
productTypeId: string,
|
||||
archived: boolean,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const p = await loadProduct(m, productTypeId);
|
||||
if (!p) return fail(tr.common.notFound);
|
||||
if (!archived) {
|
||||
const dup = await ProductTypeModel.exists({
|
||||
householdId: m.householdId,
|
||||
nameKey: foldTr(p.name),
|
||||
archivedAt: null,
|
||||
_id: { $ne: p._id },
|
||||
});
|
||||
if (dup) return fail(t.validation.duplicate);
|
||||
const cat = await StockCategoryModel.exists({ _id: p.categoryId, archivedAt: null });
|
||||
if (!cat) return fail(t.validation.categoryArchived);
|
||||
}
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await ProductTypeModel.updateOne(
|
||||
{ _id: p._id, householdId: m.householdId },
|
||||
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: archived ? "stock.product.archive" : "stock.product.unarchive",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "productType",
|
||||
targetId: productTypeId,
|
||||
diff: { name: p.name },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Hareketler ---
|
||||
|
||||
async function activeProductWithStock(
|
||||
m: Membership,
|
||||
productTypeId: string,
|
||||
upTo?: string,
|
||||
): Promise<{ error: string } | { error: null; p: ProductLean; current: number }> {
|
||||
const p = await loadProduct(m, productTypeId);
|
||||
if (!p) return { error: tr.common.notFound };
|
||||
if (p.archivedAt) return { error: t.validation.archived };
|
||||
const moves = (await loadMoves(m.householdId, [p._id])).get(String(p._id)) ?? [];
|
||||
const current = currentQuantity(upTo ? moves.filter((x) => x.date <= upTo) : moves);
|
||||
return { error: null, p, current };
|
||||
}
|
||||
|
||||
export async function addStockMovement(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
productTypeId: string,
|
||||
input: StockMovementForm,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const r = await activeProductWithStock(m, productTypeId);
|
||||
if (r.error !== null) return fail(r.error);
|
||||
if (input.kind !== "purchase" && input.quantity > Math.max(0, r.current)) {
|
||||
const msg = t.validation.notEnough(formatStock(Math.max(0, r.current), r.p.unit));
|
||||
return fail(msg, { quantity: msg });
|
||||
}
|
||||
await StockMovementModel.create({
|
||||
householdId: m.householdId,
|
||||
productTypeId: r.p._id,
|
||||
kind: input.kind,
|
||||
date: input.date,
|
||||
quantity: input.quantity,
|
||||
wasteReason: input.wasteReason,
|
||||
expiryDate: input.expiryDate,
|
||||
note: input.note,
|
||||
createdBy: new Types.ObjectId(actorId),
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** "1 azalt": adım kadar (stok daha azsa kalanı) bugün tüketim. Kalan miktarı döner. */
|
||||
export async function quickConsume(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
productTypeId: string,
|
||||
): Promise<ActionResult<{ remaining: number }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const r = await activeProductWithStock(m, productTypeId);
|
||||
if (r.error !== null) return fail(r.error);
|
||||
if (r.current <= 0) return fail(t.validation.emptyStock);
|
||||
const quantity = Math.min(r.p.step, r.current);
|
||||
await StockMovementModel.create({
|
||||
householdId: m.householdId,
|
||||
productTypeId: r.p._id,
|
||||
kind: "consume",
|
||||
date: todayIstanbul(),
|
||||
quantity,
|
||||
createdBy: new Types.ObjectId(actorId),
|
||||
});
|
||||
return ok({ remaining: r.current - quantity });
|
||||
}
|
||||
|
||||
/**
|
||||
* Tek ürün sayımı: o tarihe kadarki kayıtlara göre beklenen ile sayılan arasındaki fark sayım
|
||||
* düzeltmesi olur. Fark yoksa kayıt oluşmaz. Farkı (işaretli) döner.
|
||||
*/
|
||||
export async function countStock(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
productTypeId: string,
|
||||
input: StockCountForm,
|
||||
): Promise<ActionResult<{ diff: number }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const r = await activeProductWithStock(m, productTypeId, input.date);
|
||||
if (r.error !== null) return fail(r.error);
|
||||
const diff = input.actualQuantity - r.current;
|
||||
if (diff !== 0)
|
||||
await StockMovementModel.create({
|
||||
householdId: m.householdId,
|
||||
productTypeId: r.p._id,
|
||||
kind: "count_adjust",
|
||||
date: input.date,
|
||||
quantity: diff,
|
||||
note: input.note,
|
||||
createdBy: new Types.ObjectId(actorId),
|
||||
});
|
||||
return ok({ diff });
|
||||
}
|
||||
|
||||
export async function deleteStockMovement(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
movementId: string,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(movementId);
|
||||
if (!id) return fail(tr.common.notFound);
|
||||
const mv = await StockMovementModel.findOne({
|
||||
_id: id,
|
||||
householdId: m.householdId,
|
||||
deletedAt: null,
|
||||
}).lean<MoveLean>();
|
||||
if (!mv) return fail(tr.common.notFound);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await StockMovementModel.updateOne({ _id: mv._id }, { $set: { deletedAt: new Date() } });
|
||||
await writeAudit({
|
||||
action: "stock.movement.delete",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "stockMovement",
|
||||
targetId: movementId,
|
||||
diff: {
|
||||
productTypeId: String(mv.productTypeId),
|
||||
kind: mv.kind,
|
||||
date: mv.date,
|
||||
quantity: mv.quantity,
|
||||
},
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
+192
@@ -929,6 +929,198 @@ export const tr = {
|
||||
category: "Kategori seçin",
|
||||
},
|
||||
},
|
||||
stock: {
|
||||
title: "Ev stoğu",
|
||||
description:
|
||||
"Evdeki ürünlerin miktarı, kritik eşik ve son kullanma tarihleri. Stok ürün tipi düzeyinde tutulur.",
|
||||
newProduct: "Yeni ürün tipi",
|
||||
categoriesLink: "Stok kategorileri",
|
||||
readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.",
|
||||
summary: {
|
||||
products: "Ürün tipi",
|
||||
low: "Azalan",
|
||||
expiring: "SKT yaklaşan",
|
||||
expired: "SKT geçmiş",
|
||||
},
|
||||
suggestions: {
|
||||
title: "Alınması önerilenler",
|
||||
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
|
||||
empty: "Eşiğin altına düşen ürün yok.",
|
||||
suggested: (q: string) => `Öneri: ${q}`,
|
||||
},
|
||||
expiring: {
|
||||
title: "Son kullanma tarihi",
|
||||
description: "Evde kalan miktarı olan ve SKT'si 7 gün içinde dolan veya geçmiş partiler.",
|
||||
expired: "Geçti",
|
||||
soon: "Yaklaşıyor",
|
||||
on: (date: string) => `SKT ${date}`,
|
||||
},
|
||||
list: {
|
||||
empty: "Henüz ürün tipi yok. “Yeni ürün tipi” ile evdeki ürünleri ekleyin.",
|
||||
emptyCategory: "Bu kategoride ürün yok.",
|
||||
search: "Ürün ara",
|
||||
onlyLow: "Yalnızca azalanlar",
|
||||
noMatch: "Aramaya uyan ürün yok.",
|
||||
lowBadge: "Azaldı",
|
||||
outBadge: "Bitti",
|
||||
of: (current: string, target: string) => `${current} / ${target}`,
|
||||
quickConsume: (name: string) => `${name}: 1 azalt`,
|
||||
quickDone: (name: string, rest: string) => `${name} azaltıldı, kalan ${rest}.`,
|
||||
},
|
||||
fields: {
|
||||
name: "Ad",
|
||||
namePlaceholder: "ör. Toz Deterjan, Kırmızı Mercimek",
|
||||
category: "Stok kategorisi",
|
||||
unit: "Temel ölçü birimi",
|
||||
customUnit: "Özel birim",
|
||||
customUnitOption: "Özel birim…",
|
||||
customUnitPlaceholder: "ör. yıkama, tablet",
|
||||
target: "Hedef (tam) stok",
|
||||
targetHint: "Evde bulunmasını istediğiniz tam miktar.",
|
||||
thresholdPct: "Kritik eşik (%)",
|
||||
thresholdHint: "Mevcut miktar hedefin bu yüzdesinin altına düşerse ürün önerilir.",
|
||||
location: "Saklama yeri",
|
||||
locationPlaceholder: "Kiler, banyo dolabı…",
|
||||
step: "“1 azalt” miktarı",
|
||||
stepHint: "Hızlı azaltma düğmesinin düşeceği miktar.",
|
||||
countEveryDays: "Sayım sıklığı (gün)",
|
||||
countEveryDaysHint: "Boş bırakılırsa kategoriden devralınır.",
|
||||
note: "Not",
|
||||
initialQuantity: "Evdeki mevcut miktar",
|
||||
initialHint: "Tahmini veya ambalaj üzerinden. Boş bırakılırsa 0 kabul edilir.",
|
||||
date: "Tarih",
|
||||
quantity: "Miktar",
|
||||
wasteReason: "Fire nedeni",
|
||||
expiryDate: "Son kullanma tarihi",
|
||||
expiryHint: "İsteğe bağlı. Yaklaşınca uyarı verilir.",
|
||||
actualQuantity: "Sayılan (gerçek) miktar",
|
||||
expected: (q: string) => `Kayıtlara göre beklenen: ${q}`,
|
||||
},
|
||||
createTitle: "Yeni ürün tipi",
|
||||
editTitle: "Ürün tipini düzenle",
|
||||
kinds: {
|
||||
purchase: "Alım",
|
||||
consume: "Tüketim",
|
||||
waste: "Fire / zarar",
|
||||
count_adjust: "Sayım düzeltmesi",
|
||||
},
|
||||
actions: {
|
||||
purchase: "Alım ekle",
|
||||
consume: "Tüketim gir",
|
||||
waste: "Fire gir",
|
||||
count: "Sayım yap",
|
||||
},
|
||||
movementTitles: {
|
||||
purchase: "Alım ekle",
|
||||
consume: "Tüketim gir",
|
||||
waste: "Fire / zarar gir",
|
||||
},
|
||||
countTitle: "Sayım yap",
|
||||
countDescription:
|
||||
"Evdeki gerçek miktarı girin; kayıtlarla arasındaki fark sayım düzeltmesi olarak işlenir.",
|
||||
wasteReasons: {
|
||||
spoiled: "Bozuldu",
|
||||
expired: "Son kullanma tarihi geçti",
|
||||
damaged: "Hasar / döküldü",
|
||||
other: "Diğer",
|
||||
},
|
||||
initial: "Başlangıç miktarı",
|
||||
detail: {
|
||||
back: "Ev stoğuna dön",
|
||||
current: "Mevcut",
|
||||
target: "Hedef",
|
||||
threshold: "Eşik",
|
||||
suggested: "Önerilen alım",
|
||||
location: "Saklama yeri",
|
||||
lots: "Evdeki partiler",
|
||||
lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).",
|
||||
noLots: "Evde bu üründen yok.",
|
||||
noExpiry: "SKT yok",
|
||||
movements: "Hareketler",
|
||||
noMovements: "Henüz hareket yok.",
|
||||
after: (q: string) => `Sonra: ${q}`,
|
||||
archive: "Arşivle",
|
||||
unarchive: "Arşivden çıkar",
|
||||
archived: "Arşivli",
|
||||
archiveTitle: "Ürün tipi arşivlensin mi?",
|
||||
archiveDescription: (name: string) =>
|
||||
`“${name}” stok listesinde ve önerilerde görünmez. Hareketleri silinmez; istediğinizde arşivden çıkarabilirsiniz.`,
|
||||
deleteMovement: "Hareketi sil",
|
||||
deleteMovementTitle: "Hareket silinsin mi?",
|
||||
deleteMovementDescription: "Mevcut miktar bu hareket olmadan yeniden hesaplanır.",
|
||||
},
|
||||
categories: {
|
||||
title: "Stok kategorileri",
|
||||
description:
|
||||
"Stok kategorileri gider kategorilerinden ayrıdır. Her kategori bir gider kategorisine eşlenebilir; alışveriş tamamlanınca ürünler o gidere yazılır.",
|
||||
back: "Ev stoğuna dön",
|
||||
new: "Yeni kategori",
|
||||
addChild: "Alt kategori ekle",
|
||||
edit: "Düzenle",
|
||||
archive: "Arşivle",
|
||||
unarchive: "Arşivden çıkar",
|
||||
showArchived: "Arşivi göster",
|
||||
archived: "Arşivli",
|
||||
defaultBadge: "Varsayılan",
|
||||
empty: "Stok kategorisi yok.",
|
||||
createTitle: "Yeni stok kategorisi",
|
||||
editTitle: "Stok kategorisini düzenle",
|
||||
name: "Ad",
|
||||
parent: "Üst kategori",
|
||||
noParent: "— Ana kategori —",
|
||||
expenseCategory: "Gider kategorisi",
|
||||
expenseInherit: "— Üstten devral —",
|
||||
expenseHint: "Alışveriş tamamlanınca bu kategorideki ürünlerin yazılacağı gider kategorisi.",
|
||||
inherited: (name: string) => `${name} (üstten)`,
|
||||
noExpense: "Eşlenmedi",
|
||||
countEveryDays: "Sayım sıklığı (gün)",
|
||||
countEveryDaysHint: "Boş bırakılırsa üst kategoriden devralınır.",
|
||||
everyDays: (n: number) => `${n} günde bir sayım`,
|
||||
productCount: (n: number) => `${n} ürün`,
|
||||
archiveTitle: "Stok kategorisi arşivlensin mi?",
|
||||
archiveDescription: (name: string) =>
|
||||
`“${name}” ve alt kategorileri yeni ürünlerde seçilemez.`,
|
||||
hasProducts:
|
||||
"Bu kategoride veya altında aktif ürün tipi var; önce onları başka kategoriye taşıyın veya arşivleyin.",
|
||||
duplicate: "Bu düzeyde aynı adlı bir kategori zaten var.",
|
||||
expenseInvalid: "Geçerli bir gider kategorisi seçin.",
|
||||
},
|
||||
toast: {
|
||||
saved: "Kaydedildi.",
|
||||
movementSaved: "Hareket kaydedildi.",
|
||||
deleted: "Silindi.",
|
||||
archived: "Arşivlendi.",
|
||||
unarchived: "Arşivden çıkarıldı.",
|
||||
counted: (diff: string) => `Sayım kaydedildi; fark ${diff}.`,
|
||||
noChange: "Sayım kayıtlarla aynı; düzeltme gerekmedi.",
|
||||
},
|
||||
validation: {
|
||||
selectCategory: "Bir stok kategorisi seçin",
|
||||
quantity: "Geçerli bir miktar girin (ör. 1,5)",
|
||||
countDays: "1 ile 365 arasında gün sayısı girin",
|
||||
unit: "Birim 1–20 karakter olmalı",
|
||||
thresholdPct: "0 ile 100 arasında tam sayı girin",
|
||||
wasteReason: "Fire nedenini seçin",
|
||||
expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz",
|
||||
notEnough: (q: string) => `Stokta yalnızca ${q} var.`,
|
||||
emptyStock: "Stok zaten boş.",
|
||||
duplicate: "Bu adla bir ürün tipi zaten var.",
|
||||
categoryArchived: "Seçilen stok kategorisi arşivli veya bulunamadı.",
|
||||
archived: "Ürün tipi arşivli.",
|
||||
},
|
||||
dashboard: {
|
||||
title: "Azalan stoklar",
|
||||
description: (low: number, expiring: number) =>
|
||||
[
|
||||
low ? `${low} ürün eşiğin altında` : null,
|
||||
expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
more: (n: number) => `+${n} ürün daha`,
|
||||
open: "Ev stoğuna git",
|
||||
},
|
||||
},
|
||||
prices: {
|
||||
title: "Fiyat tablosu",
|
||||
description:
|
||||
|
||||
@@ -35,6 +35,11 @@ export const AUDIT_ACTIONS = [
|
||||
"inkind.delete",
|
||||
"asset.delete",
|
||||
"asset.movement.delete",
|
||||
"stock.category.archive",
|
||||
"stock.category.unarchive",
|
||||
"stock.product.archive",
|
||||
"stock.product.unarchive",
|
||||
"stock.movement.delete",
|
||||
] as const;
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
|
||||
/**
|
||||
* Ürün tipi (genel ürün, markadan bağımsız ihtiyaç: "Toz Deterjan"). Stok bu seviyede tutulur.
|
||||
* Miktarlar binde bir (QUANTITY_SCALE). Birim hazır listeden ya da özel ("yıkama").
|
||||
*/
|
||||
const productTypeSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
categoryId: { type: Schema.Types.ObjectId, required: true },
|
||||
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
nameKey: { type: String, required: true },
|
||||
unit: { type: String, required: true, trim: true, maxlength: 20 },
|
||||
/** Hedef (tam) stok miktarı. */
|
||||
targetQuantity: { type: Number, required: true, min: 0 },
|
||||
/** Kritik eşik, hedefin yüzdesi (varsayılan 20). */
|
||||
thresholdPct: { type: Number, default: 20, min: 0, max: 100 },
|
||||
/** Saklama yeri (kiler, banyo dolabı …). */
|
||||
location: { type: String, default: "", maxlength: 80 },
|
||||
/** "1 azalt" düğmesinin düşeceği miktar. */
|
||||
step: { type: Number, default: 1000, min: 1 },
|
||||
/** Sayım sıklığı (gün); null ise kategoriden devralınır (Faz 11b). */
|
||||
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
archivedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "productTypes" },
|
||||
);
|
||||
|
||||
productTypeSchema.index({ householdId: 1, archivedAt: 1, categoryId: 1 });
|
||||
productTypeSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type ProductType = InferSchemaType<typeof productTypeSchema>;
|
||||
|
||||
export const ProductTypeModel: Model<ProductType> =
|
||||
(models.ProductType as Model<ProductType> | undefined) ??
|
||||
model<ProductType>("ProductType", productTypeSchema);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
|
||||
/**
|
||||
* Stok kategori ağacı (Bölüm 6.10), gelir/gider kategorilerinden ayrı. Silme yok; arşivleme.
|
||||
* `expenseCategoryId`: alışveriş tamamlanınca (Faz 13) kalemlerin yazılacağı gider kategorisi;
|
||||
* null ise üst kategoriden devralınır. `countEveryDays`: periyodik sayım sıklığı (Faz 11b);
|
||||
* null ise üstten devralınır.
|
||||
*/
|
||||
const stockCategorySchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||
nameKey: { type: String, required: true },
|
||||
parentId: { type: Schema.Types.ObjectId, default: null },
|
||||
ancestors: { type: [Schema.Types.ObjectId], default: [] },
|
||||
order: { type: Number, default: 0 },
|
||||
expenseCategoryId: { type: Schema.Types.ObjectId, default: null },
|
||||
countEveryDays: { type: Number, default: null, min: 1, max: 365 },
|
||||
archivedAt: { type: Date, default: null },
|
||||
defaultKey: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "stockCategories" },
|
||||
);
|
||||
|
||||
stockCategorySchema.index({ householdId: 1, parentId: 1, order: 1 });
|
||||
stockCategorySchema.index(
|
||||
{ householdId: 1, defaultKey: 1 },
|
||||
{ unique: true, partialFilterExpression: { defaultKey: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type StockCategory = InferSchemaType<typeof stockCategorySchema>;
|
||||
|
||||
export const StockCategoryModel: Model<StockCategory> =
|
||||
(models.StockCategory as Model<StockCategory> | undefined) ??
|
||||
model<StockCategory>("StockCategory", stockCategorySchema);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||
import { STOCK_MOVEMENT_KINDS, WASTE_REASONS } from "@/shared/stock";
|
||||
|
||||
/**
|
||||
* Stok hareketi: alım, tüketim, fire (neden ile), sayım düzeltmesi. Mevcut miktar hareketlerden
|
||||
* türetilir. Miktar binde bir; yalnızca sayım düzeltmesi işaretlidir. Sıra: tarih, oluşturulma.
|
||||
*/
|
||||
const stockMovementSchema = new Schema(
|
||||
{
|
||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||
productTypeId: { type: Schema.Types.ObjectId, required: true },
|
||||
kind: { type: String, enum: STOCK_MOVEMENT_KINDS, required: true },
|
||||
/** "YYYY-MM-DD" (İstanbul). */
|
||||
date: { type: String, required: true },
|
||||
quantity: { type: Number, required: true },
|
||||
wasteReason: { type: String, enum: [...WASTE_REASONS, null], default: null },
|
||||
/** Son kullanma tarihi (yalnızca alımda, isteğe bağlı). */
|
||||
expiryDate: { type: String, default: null },
|
||||
note: { type: String, default: "", maxlength: 300 },
|
||||
/** İlk kurulumda girilen başlangıç miktarı (sayım düzeltmesi olarak). */
|
||||
initial: { type: Boolean, default: false },
|
||||
/** Sayım oturumu (Faz 11b). */
|
||||
countId: { type: Schema.Types.ObjectId, default: null },
|
||||
deletedAt: { type: Date, default: null },
|
||||
seedRef: { type: String, default: null },
|
||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||
},
|
||||
{ timestamps: true, collection: "stockMovements" },
|
||||
);
|
||||
|
||||
stockMovementSchema.index({
|
||||
householdId: 1,
|
||||
productTypeId: 1,
|
||||
deletedAt: 1,
|
||||
date: 1,
|
||||
createdAt: 1,
|
||||
});
|
||||
stockMovementSchema.index({ householdId: 1, kind: 1, date: -1 });
|
||||
stockMovementSchema.index(
|
||||
{ householdId: 1, seedRef: 1 },
|
||||
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||
);
|
||||
|
||||
export type StockMovement = InferSchemaType<typeof stockMovementSchema>;
|
||||
|
||||
export const StockMovementModel: Model<StockMovement> =
|
||||
(models.StockMovement as Model<StockMovement> | undefined) ??
|
||||
model<StockMovement>("StockMovement", stockMovementSchema);
|
||||
@@ -0,0 +1,120 @@
|
||||
import { z } from "zod";
|
||||
import { tr } from "../../i18n/tr";
|
||||
import { parseQuantity } from "../quantity";
|
||||
import { WASTE_REASONS } from "../stock";
|
||||
|
||||
const v = tr.validation;
|
||||
const s = tr.stock.validation;
|
||||
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
|
||||
const optionalId = z
|
||||
.string()
|
||||
.regex(/^([a-f0-9]{24})?$/i)
|
||||
.transform((x) => (x === "" ? null : x));
|
||||
const requiredId = z.string().regex(/^[a-f0-9]{24}$/i, s.selectCategory);
|
||||
|
||||
/** Miktar metni → binde bir tamsayı. `allowZero` sayım ve başlangıç için. */
|
||||
const quantity = (allowZero = false) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x, ctx) => {
|
||||
const q = parseQuantity(x);
|
||||
if (q === null || q < 0 || (!allowZero && q === 0)) {
|
||||
ctx.addIssue({ code: "custom", message: s.quantity });
|
||||
return z.NEVER;
|
||||
}
|
||||
return q;
|
||||
});
|
||||
|
||||
const optionalQuantity = z.preprocess(
|
||||
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
|
||||
quantity(true).nullable(),
|
||||
);
|
||||
|
||||
/** Boş → null; dolu → 1..365 gün. */
|
||||
const countEveryDays = z.preprocess(
|
||||
(x) => (x === undefined || x === null || String(x).trim() === "" ? null : x),
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x, ctx) => {
|
||||
const n = Number(x);
|
||||
if (!/^\d+$/.test(x) || n < 1 || n > 365) {
|
||||
ctx.addIssue({ code: "custom", message: s.countDays });
|
||||
return z.NEVER;
|
||||
}
|
||||
return n;
|
||||
})
|
||||
.nullable(),
|
||||
);
|
||||
|
||||
export const stockCategoryFormSchema = z.object({
|
||||
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
|
||||
parentId: optionalId,
|
||||
expenseCategoryId: optionalId,
|
||||
countEveryDays,
|
||||
});
|
||||
export type StockCategoryFormInput = z.input<typeof stockCategoryFormSchema>;
|
||||
export type StockCategoryForm = z.output<typeof stockCategoryFormSchema>;
|
||||
|
||||
export const productTypeFormSchema = z.object({
|
||||
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
|
||||
categoryId: requiredId,
|
||||
unit: z.string().trim().min(1, s.unit).max(20, s.unit),
|
||||
targetQuantity: quantity(),
|
||||
thresholdPct: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((x, ctx) => {
|
||||
const n = Number(x);
|
||||
if (!/^\d+$/.test(x) || n > 100) {
|
||||
ctx.addIssue({ code: "custom", message: s.thresholdPct });
|
||||
return z.NEVER;
|
||||
}
|
||||
return n;
|
||||
}),
|
||||
location: z.string().trim().max(80).default(""),
|
||||
step: quantity(),
|
||||
countEveryDays,
|
||||
note: z.string().trim().max(300).default(""),
|
||||
/** Yalnızca yeni ürün tipinde: evdeki başlangıç miktarı. */
|
||||
initialQuantity: optionalQuantity.default(null),
|
||||
});
|
||||
export type ProductTypeFormInput = z.input<typeof productTypeFormSchema>;
|
||||
export type ProductTypeForm = z.output<typeof productTypeFormSchema>;
|
||||
|
||||
/** Alım, tüketim veya fire. Sayım düzeltmesi ayrı formdan (gerçek miktar). */
|
||||
export const stockMovementFormSchema = z
|
||||
.object({
|
||||
kind: z.enum(["purchase", "consume", "waste"]),
|
||||
date: ymd,
|
||||
quantity: quantity(),
|
||||
wasteReason: z.preprocess(
|
||||
(x) => (x === "" || x === undefined ? null : x),
|
||||
z.enum(WASTE_REASONS).nullable(),
|
||||
),
|
||||
expiryDate: z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable()),
|
||||
note: z.string().trim().max(300).default(""),
|
||||
})
|
||||
.superRefine((x, ctx) => {
|
||||
if (x.kind === "waste" && !x.wasteReason)
|
||||
ctx.addIssue({ code: "custom", path: ["wasteReason"], message: s.wasteReason });
|
||||
if (x.expiryDate && x.expiryDate < x.date)
|
||||
ctx.addIssue({ code: "custom", path: ["expiryDate"], message: s.expiryBeforeDate });
|
||||
})
|
||||
.transform((x) => ({
|
||||
...x,
|
||||
wasteReason: x.kind === "waste" ? x.wasteReason : null,
|
||||
expiryDate: x.kind === "purchase" ? x.expiryDate : null,
|
||||
}));
|
||||
export type StockMovementFormInput = z.input<typeof stockMovementFormSchema>;
|
||||
export type StockMovementForm = z.output<typeof stockMovementFormSchema>;
|
||||
|
||||
/** Tek ürün sayımı: gerçek miktar → fark sayım düzeltmesi olur. */
|
||||
export const stockCountFormSchema = z.object({
|
||||
date: ymd,
|
||||
actualQuantity: quantity(true),
|
||||
note: z.string().trim().max(300).default(""),
|
||||
});
|
||||
export type StockCountFormInput = z.input<typeof stockCountFormSchema>;
|
||||
export type StockCountForm = z.output<typeof stockCountFormSchema>;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
currentQuantity,
|
||||
expiringLots,
|
||||
expiryStatus,
|
||||
fifoLots,
|
||||
quantityInput,
|
||||
refillQuantity,
|
||||
stepLabel,
|
||||
stockLevel,
|
||||
type StockMove,
|
||||
} from "./stock";
|
||||
|
||||
const m = (
|
||||
kind: StockMove["kind"],
|
||||
date: string,
|
||||
quantity: number,
|
||||
expiryDate: string | null = null,
|
||||
): StockMove => ({ kind, date, quantity, expiryDate });
|
||||
|
||||
describe("mevcut miktar", () => {
|
||||
it("alım − tüketim − fire ± sayım", () => {
|
||||
expect(
|
||||
currentQuantity([
|
||||
m("purchase", "2026-10-01", 5_000),
|
||||
m("consume", "2026-10-05", 1_500),
|
||||
m("waste", "2026-10-06", 500),
|
||||
m("count_adjust", "2026-10-10", -250),
|
||||
m("count_adjust", "2026-10-11", 100),
|
||||
]),
|
||||
).toBe(2_850);
|
||||
expect(currentQuantity([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIFO partileri ve SKT", () => {
|
||||
it("çıkışlar en eski partiden düşer", () => {
|
||||
const lots = fifoLots([
|
||||
m("purchase", "2026-10-01", 2_000, "2026-11-10"),
|
||||
m("purchase", "2026-10-20", 3_000, "2026-12-01"),
|
||||
m("consume", "2026-10-25", 2_500),
|
||||
m("count_adjust", "2026-10-26", 400),
|
||||
]);
|
||||
expect(lots).toEqual([
|
||||
{ index: 1, date: "2026-10-20", expiryDate: "2026-12-01", quantity: 3_000, remaining: 2_500 },
|
||||
{ index: 3, date: "2026-10-26", expiryDate: null, quantity: 400, remaining: 400 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("geçici eksi stok sonraki girişten kapatılır", () => {
|
||||
const lots = fifoLots([
|
||||
m("consume", "2026-10-01", 1_000),
|
||||
m("purchase", "2026-10-02", 3_000, "2026-10-30"),
|
||||
]);
|
||||
expect(lots).toEqual([expect.objectContaining({ quantity: 3_000, remaining: 2_000 })]);
|
||||
expect(fifoLots([m("purchase", "2026-10-01", 1_000), m("waste", "2026-10-02", 1_000)])).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("SKT durumu: geçmiş, 7 gün içinde, sorunsuz", () => {
|
||||
expect(expiryStatus("2026-11-05", "2026-11-06")).toBe("expired");
|
||||
expect(expiryStatus("2026-11-06", "2026-11-06")).toBe("soon");
|
||||
expect(expiryStatus("2026-11-13", "2026-11-06")).toBe("soon");
|
||||
expect(expiryStatus("2026-11-14", "2026-11-06")).toBe("ok");
|
||||
const lots = fifoLots([
|
||||
m("purchase", "2026-10-01", 1_000, "2026-11-20"),
|
||||
m("purchase", "2026-10-02", 1_000, "2026-11-08"),
|
||||
m("purchase", "2026-10-03", 1_000, "2026-11-01"),
|
||||
m("purchase", "2026-10-04", 1_000),
|
||||
]);
|
||||
expect(expiringLots(lots, "2026-11-06").map((l) => [l.expiryDate, l.status])).toEqual([
|
||||
["2026-11-01", "expired"],
|
||||
["2026-11-08", "soon"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eşik ve öneri", () => {
|
||||
it("%20 eşiğinin altı azalan sayılır", () => {
|
||||
expect(stockLevel(1_000, 5_000, 20)).toEqual({ thresholdQuantity: 1_000, pct: 20, low: false });
|
||||
expect(stockLevel(999, 5_000, 20).low).toBe(true);
|
||||
expect(stockLevel(-200, 5_000, 20)).toMatchObject({ pct: 0, low: true });
|
||||
expect(stockLevel(3_000, 10_000, 35)).toMatchObject({ thresholdQuantity: 3_500, low: true });
|
||||
expect(stockLevel(0, 0, 20)).toEqual({ thresholdQuantity: 0, pct: null, low: false });
|
||||
expect(refillQuantity(800, 5_000)).toBe(4_200);
|
||||
expect(refillQuantity(-300, 5_000)).toBe(5_000);
|
||||
expect(refillQuantity(6_000, 5_000)).toBe(0);
|
||||
});
|
||||
|
||||
it("form metni gruplamasız ve geri okunabilir", () => {
|
||||
expect(quantityInput(1_000_000)).toBe("1000");
|
||||
expect(quantityInput(1_500)).toBe("1,5");
|
||||
expect(quantityInput(250)).toBe("0,25");
|
||||
});
|
||||
|
||||
it("azaltma adımı etiketi", () => {
|
||||
expect(stepLabel(1_000, "adet")).toBe("−1 adet");
|
||||
expect(stepLabel(500, "kg")).toBe("−0,5 kg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { daysBetween } from "./budget";
|
||||
import { formatQuantity, QUANTITY_SCALE } from "./quantity";
|
||||
|
||||
/**
|
||||
* Ev stoğu motoru (Bölüm 6.10). Saf fonksiyonlar, framework'ten bağımsız.
|
||||
*
|
||||
* Miktarlar binde bir tamsayı (QUANTITY_SCALE). Mevcut miktar hareketlerden türetilir:
|
||||
* alım (+) − tüketim − fire ± sayım düzeltmesi.
|
||||
* SKT: alım hareketinde (isteğe bağlı). Çıkışlar (tüketim, fire, eksi sayım farkı) FIFO ile en
|
||||
* eski girişten düşülür; evde kalan miktarın SKT'si uyarı için kullanılır.
|
||||
*/
|
||||
|
||||
export const STOCK_MOVEMENT_KINDS = ["purchase", "consume", "waste", "count_adjust"] as const;
|
||||
export type StockMovementKind = (typeof STOCK_MOVEMENT_KINDS)[number];
|
||||
|
||||
export const WASTE_REASONS = ["spoiled", "expired", "damaged", "other"] as const;
|
||||
export type WasteReason = (typeof WASTE_REASONS)[number];
|
||||
|
||||
/** Hazır birimler; bunların dışındaki her değer özel birimdir ("yıkama", "tablet"). */
|
||||
export const STOCK_UNITS = ["adet", "kg", "g", "lt", "ml", "paket"] as const;
|
||||
|
||||
export const DEFAULT_THRESHOLD_PCT = 20;
|
||||
/** SKT'ye bu kadar gün (dahil) kala uyarı. */
|
||||
export const EXPIRY_WARN_DAYS = 7;
|
||||
|
||||
export type StockMove = {
|
||||
kind: StockMovementKind;
|
||||
/** "YYYY-MM-DD". */
|
||||
date: string;
|
||||
/** Pozitif; yalnızca sayım düzeltmesi işaretli (+ fazla çıktı, − eksik çıktı). */
|
||||
quantity: number;
|
||||
expiryDate?: string | null;
|
||||
};
|
||||
|
||||
/** Hareketin stoka etkisi (işaretli). */
|
||||
export function signedDelta(m: Pick<StockMove, "kind" | "quantity">): number {
|
||||
switch (m.kind) {
|
||||
case "purchase":
|
||||
return m.quantity;
|
||||
case "consume":
|
||||
case "waste":
|
||||
return -m.quantity;
|
||||
case "count_adjust":
|
||||
return m.quantity;
|
||||
}
|
||||
}
|
||||
|
||||
export function currentQuantity(moves: readonly Pick<StockMove, "kind" | "quantity">[]): number {
|
||||
return moves.reduce((sum, m) => sum + signedDelta(m), 0);
|
||||
}
|
||||
|
||||
export type Lot = {
|
||||
/** Girişin hareket listesindeki sırası. */
|
||||
index: number;
|
||||
date: string;
|
||||
expiryDate: string | null;
|
||||
quantity: number;
|
||||
remaining: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* FIFO partileri. `moves` tarih (ve oluşturulma) sırasında olmalı. Girişler (alım, artı sayım
|
||||
* farkı) parti açar; çıkışlar en eski partiden düşer. Stok geçici olarak eksiye düşerse (geriye
|
||||
* tarihli tüketim) açık, sonraki girişten kapatılır. Yalnızca kalanı olan partiler döner.
|
||||
*/
|
||||
export function fifoLots(moves: readonly StockMove[]): Lot[] {
|
||||
const lots: Lot[] = [];
|
||||
let deficit = 0;
|
||||
moves.forEach((m, index) => {
|
||||
const delta = signedDelta(m);
|
||||
if (delta > 0) {
|
||||
const covered = Math.min(deficit, delta);
|
||||
deficit -= covered;
|
||||
lots.push({
|
||||
index,
|
||||
date: m.date,
|
||||
expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null,
|
||||
quantity: delta,
|
||||
remaining: delta - covered,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let out = -delta;
|
||||
for (const lot of lots) {
|
||||
if (out === 0) break;
|
||||
const take = Math.min(lot.remaining, out);
|
||||
lot.remaining -= take;
|
||||
out -= take;
|
||||
}
|
||||
deficit += out;
|
||||
});
|
||||
return lots.filter((l) => l.remaining > 0);
|
||||
}
|
||||
|
||||
export type ExpiryStatus = "expired" | "soon" | "ok";
|
||||
|
||||
export function expiryStatus(
|
||||
expiryDate: string,
|
||||
today: string,
|
||||
warnDays = EXPIRY_WARN_DAYS,
|
||||
): ExpiryStatus {
|
||||
if (expiryDate < today) return "expired";
|
||||
// daysBetween dahil sayar: bugün = 1.
|
||||
return daysBetween(today, expiryDate) - 1 <= warnDays ? "soon" : "ok";
|
||||
}
|
||||
|
||||
export type ExpiringLot = Lot & { expiryDate: string; status: Exclude<ExpiryStatus, "ok"> };
|
||||
|
||||
/** Evde kalan ve SKT'si geçmiş/yaklaşan partiler (en erken SKT önce). */
|
||||
export function expiringLots(
|
||||
lots: readonly Lot[],
|
||||
today: string,
|
||||
warnDays = EXPIRY_WARN_DAYS,
|
||||
): ExpiringLot[] {
|
||||
const out: ExpiringLot[] = [];
|
||||
for (const lot of lots) {
|
||||
if (!lot.expiryDate) continue;
|
||||
const status = expiryStatus(lot.expiryDate, today, warnDays);
|
||||
if (status !== "ok") out.push({ ...lot, expiryDate: lot.expiryDate, status });
|
||||
}
|
||||
return out.sort((a, b) =>
|
||||
a.expiryDate < b.expiryDate ? -1 : a.expiryDate > b.expiryDate ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
export type StockLevel = {
|
||||
/** Eşik miktarı: hedef × eşik yüzdesi. */
|
||||
thresholdQuantity: number;
|
||||
/** Mevcut / hedef (yüzde, tamsayı; hedef yoksa null). */
|
||||
pct: number | null;
|
||||
/** Eşiğin altında mı ("altına düşerse": kesin küçük). */
|
||||
low: boolean;
|
||||
};
|
||||
|
||||
export function stockLevel(current: number, target: number, thresholdPct: number): StockLevel {
|
||||
if (target <= 0) return { thresholdQuantity: 0, pct: null, low: false };
|
||||
const thresholdQuantity = Math.round((target * thresholdPct) / 100);
|
||||
return {
|
||||
thresholdQuantity,
|
||||
pct: Math.round((Math.max(0, current) * 100) / target),
|
||||
low: current < thresholdQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
/** Basit öneri: hedefe tamamla (Faz 11b'de tüketim hızı ve fireye göre ayarlanır). */
|
||||
export function refillQuantity(current: number, target: number): number {
|
||||
return Math.max(0, target - Math.max(0, current));
|
||||
}
|
||||
|
||||
/** Miktar + birim: 1500, "kg" → "1,5 kg". */
|
||||
export function formatStock(quantity: number, unit: string): string {
|
||||
return formatQuantity(quantity, unit);
|
||||
}
|
||||
|
||||
/** "1 azalt" düğmesi etiketi: adım 1 birim ise "−1 adet", değilse "−0,5 kg". */
|
||||
export function stepLabel(step: number, unit: string): string {
|
||||
return `−${formatQuantity(step || QUANTITY_SCALE, unit)}`;
|
||||
}
|
||||
|
||||
/** Form alanı için gruplamasız metin: 1500 → "1,5"; 1_000_000 → "1000" (parseQuantity ile uyumlu). */
|
||||
export function quantityInput(quantity: number): string {
|
||||
const int = Math.trunc(quantity / QUANTITY_SCALE);
|
||||
const frac = (quantity % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, "");
|
||||
return frac ? `${int},${frac}` : String(int);
|
||||
}
|
||||
Reference in New Issue
Block a user