814bdbbc2a
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
import { Types } from "mongoose";
|
||
import { installDefaultStockCategories } from "@/features/stock/service";
|
||
import { ProductTypeModel } from "@/models/product-type";
|
||
import { StockCategoryModel } from "@/models/stock-category";
|
||
import { StockCountModel } from "@/models/stock-count";
|
||
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, periyodik sayım, SKT).
|
||
* Sayım: Mutfak 15 günde bir (son sayım 21 gün önce → gecikmiş), Genel Temizlik 30 günde bir
|
||
* (son sayım 14 gün önce). Deterministik; idempotent: ürün tipi `seedRef` ile bir kez
|
||
* oluşturulur, sayım oturumları `seedRef` ile paylaşılır. 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. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
||
countDrift?: { everyCounts: 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: { everyCounts: 2, 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: { everyCounts: 3, 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;
|
||
|
||
/** Sayım sıklığı (gün) ve sayım günü kuralı (gün önce), ana stok kategorisine göre. */
|
||
const COUNT_PLANS: Record<string, { everyDays: number; on: (daysAgo: number) => boolean }> = {
|
||
mutfak: { everyDays: 15, on: (d) => d >= 21 && d % 14 === 7 },
|
||
"genel-temizlik": { everyDays: 30, on: (d) => d % 28 === 14 },
|
||
};
|
||
|
||
type Row = {
|
||
kind: "purchase" | "consume" | "waste" | "count_adjust";
|
||
daysAgo: number;
|
||
quantity: number;
|
||
expiryDate?: string | null;
|
||
wasteReason?: string | null;
|
||
note?: string;
|
||
initial?: boolean;
|
||
/** Bu satır hangi sayıma ait (gün önce). */
|
||
countDaysAgo?: number;
|
||
};
|
||
|
||
export type SeedCount = { daysAgo: number; expected: number; actual: number };
|
||
|
||
/**
|
||
* Haftalık simülasyon: tüketim, periyodik sayım (farkı nadiren), eşikte alım ve fire. Stok asla
|
||
* eksiye düşmez.
|
||
*/
|
||
export function simulate(p: SeedProduct, today: string): { rows: Row[]; counts: SeedCount[] } {
|
||
const rows: Row[] = [];
|
||
const counts: SeedCount[] = [];
|
||
const plan = COUNT_PLANS[p.category.split(".")[0]!];
|
||
let stock = p.target;
|
||
let purchases = 0;
|
||
rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true });
|
||
for (let d = START_DAYS_AGO - 7; d >= 0; d -= 7) {
|
||
// 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 (plan?.on(d)) {
|
||
const expected = stock;
|
||
const nth = counts.length + 1;
|
||
if (p.countDrift && nth % p.countDrift.everyCounts === 0 && stock > 0) {
|
||
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100));
|
||
rows.push({
|
||
kind: "count_adjust",
|
||
daysAgo: d,
|
||
quantity: -drift,
|
||
note: "Sayım",
|
||
countDaysAgo: d,
|
||
});
|
||
stock -= drift;
|
||
}
|
||
counts.push({ daysAgo: d, expected, actual: stock });
|
||
}
|
||
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, counts };
|
||
}
|
||
|
||
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")!;
|
||
// Periyodik sayım sıklığı (kullanıcı ayarladıysa dokunulmaz).
|
||
for (const [key, plan] of Object.entries(COUNT_PLANS))
|
||
await StockCategoryModel.updateOne(
|
||
{ householdId, defaultKey: key, countEveryDays: null },
|
||
{ $set: { countEveryDays: plan.everyDays } },
|
||
);
|
||
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;
|
||
const { rows, counts } = simulate(p, today);
|
||
// Sayım oturumu kategori başına ortak: ürün satırı oturuma eklenir.
|
||
const root = p.category.split(".")[0]!;
|
||
const countIds = new Map<number, Types.ObjectId>();
|
||
for (const c of counts) {
|
||
const session = await StockCountModel.findOneAndUpdate(
|
||
{ householdId, seedRef: `${root}:${c.daysAgo}` },
|
||
{
|
||
$setOnInsert: {
|
||
householdId,
|
||
date: addDays(today, -c.daysAgo),
|
||
categoryId: catByKey.get(root) ?? null,
|
||
note: "",
|
||
seedRef: `${root}:${c.daysAgo}`,
|
||
createdBy: actor,
|
||
},
|
||
$push: {
|
||
items: { productTypeId: doc._id, expected: c.expected, actual: c.actual, wasted: 0 },
|
||
},
|
||
},
|
||
{ upsert: true, returnDocument: "after" },
|
||
).lean<{ _id: Types.ObjectId }>();
|
||
countIds.set(c.daysAgo, session!._id);
|
||
}
|
||
// Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur.
|
||
await StockMovementModel.collection.insertMany(
|
||
rows.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: r.countDaysAgo === undefined ? null : (countIds.get(r.countDaysAgo) ?? null),
|
||
deletedAt: null,
|
||
seedRef: `${p.ref}:${i}`,
|
||
createdBy: actor,
|
||
createdAt: at,
|
||
updatedAt: at,
|
||
};
|
||
}),
|
||
);
|
||
created++;
|
||
}
|
||
return created;
|
||
}
|