feat(stock): sayım oturumları, fire ayarlı öneri motoru ve tüketim analizi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { Types } from "mongoose";
|
||||
import { StockCountModel } from "@/models/stock-count";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { signedDelta, type StockMovementKind } from "@/shared/stock";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
type Move = {
|
||||
_id: Types.ObjectId;
|
||||
householdId: Types.ObjectId;
|
||||
productTypeId: Types.ObjectId;
|
||||
kind: StockMovementKind;
|
||||
date: string;
|
||||
quantity: number;
|
||||
initial: boolean;
|
||||
countId: Types.ObjectId | null;
|
||||
createdBy: Types.ObjectId;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Faz 11b: sayım oturumları. Faz 11a'da tek ürün sayımıyla oluşan (oturumsuz) sayım
|
||||
* düzeltmeleri, o ana kadarki kayıtlardan beklenen miktar hesaplanarak hane + tarih + kişi
|
||||
* başına bir sayım oturumuna bağlanır. Başlangıç miktarları sayım sayılmaz.
|
||||
*/
|
||||
export const migration: Migration = {
|
||||
name: "012-stock-counts",
|
||||
description: "stockCounts index'leri; oturumsuz sayım düzeltmelerini sayım oturumuna bağla",
|
||||
async up() {
|
||||
await StockCountModel.createIndexes();
|
||||
const orphans = await StockMovementModel.find({
|
||||
kind: "count_adjust",
|
||||
initial: { $ne: true },
|
||||
countId: null,
|
||||
deletedAt: null,
|
||||
}).lean<Move[]>();
|
||||
if (orphans.length === 0) return;
|
||||
const products = [...new Set(orphans.map((o) => String(o.productTypeId)))];
|
||||
const all = await StockMovementModel.find({
|
||||
productTypeId: { $in: products.map((id) => new Types.ObjectId(id)) },
|
||||
deletedAt: null,
|
||||
}).lean<Move[]>();
|
||||
const byProduct = new Map<string, Move[]>();
|
||||
for (const m of all) {
|
||||
const list = byProduct.get(String(m.productTypeId)) ?? [];
|
||||
list.push(m);
|
||||
byProduct.set(String(m.productTypeId), list);
|
||||
}
|
||||
// Her düzeltme için beklenen = önceki hareketlerin toplamı (tarih, oluşturulma sırası).
|
||||
const expectedOf = new Map<string, number>();
|
||||
for (const list of byProduct.values()) {
|
||||
list.sort((a, b) =>
|
||||
a.date === b.date
|
||||
? a.createdAt.getTime() - b.createdAt.getTime()
|
||||
: a.date < b.date
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
let running = 0;
|
||||
for (const m of list) {
|
||||
expectedOf.set(String(m._id), running);
|
||||
running += signedDelta(m);
|
||||
}
|
||||
}
|
||||
const sessions = new Map<string, Move[]>();
|
||||
for (const o of orphans) {
|
||||
const key = `${o.householdId}:${o.date}:${o.createdBy}`;
|
||||
sessions.set(key, [...(sessions.get(key) ?? []), o]);
|
||||
}
|
||||
for (const moves of sessions.values()) {
|
||||
const first = moves[0]!;
|
||||
const count = await StockCountModel.create({
|
||||
householdId: first.householdId,
|
||||
date: first.date,
|
||||
categoryId: null,
|
||||
items: moves.map((m) => {
|
||||
const expected = expectedOf.get(String(m._id)) ?? 0;
|
||||
return {
|
||||
productTypeId: m.productTypeId,
|
||||
expected,
|
||||
actual: expected + m.quantity,
|
||||
wasted: 0,
|
||||
};
|
||||
}),
|
||||
createdBy: first.createdBy,
|
||||
});
|
||||
await StockMovementModel.updateMany(
|
||||
{ _id: { $in: moves.map((m) => m._id) } },
|
||||
{ $set: { countId: count._id } },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -11,6 +11,7 @@ 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 { migration as m012 } from "./012-stock-counts";
|
||||
import type { Migration } from "./types";
|
||||
|
||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||
@@ -26,6 +27,7 @@ export const MIGRATIONS: Migration[] = [
|
||||
m009,
|
||||
m010,
|
||||
m011,
|
||||
m012,
|
||||
];
|
||||
|
||||
const COLLECTION = "migrations";
|
||||
|
||||
@@ -83,6 +83,7 @@ const DOMAIN_COLLECTIONS = [
|
||||
"stockCategories",
|
||||
"productTypes",
|
||||
"stockMovements",
|
||||
"stockCounts",
|
||||
];
|
||||
|
||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||
|
||||
+72
-17
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -9,8 +10,10 @@ 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.
|
||||
* 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 = {
|
||||
@@ -30,8 +33,8 @@ type SeedProduct = {
|
||||
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 };
|
||||
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
||||
countDrift?: { everyCounts: number; pct: number };
|
||||
};
|
||||
|
||||
const K = 1000;
|
||||
@@ -188,7 +191,7 @@ export const PRODUCTS: SeedProduct[] = [
|
||||
pack: 50 * K,
|
||||
perDay: 700,
|
||||
location: "Banyo dolabı",
|
||||
countDrift: { everyWeeks: 8, pct: 4 },
|
||||
countDrift: { everyCounts: 2, pct: 4 },
|
||||
},
|
||||
{
|
||||
ref: "yumusatici",
|
||||
@@ -221,7 +224,7 @@ export const PRODUCTS: SeedProduct[] = [
|
||||
pack: 16 * K,
|
||||
perDay: 600,
|
||||
location: "Banyo dolabı",
|
||||
countDrift: { everyWeeks: 10, pct: 6 },
|
||||
countDrift: { everyCounts: 3, pct: 6 },
|
||||
},
|
||||
// Kişisel Temizlik
|
||||
{
|
||||
@@ -259,6 +262,12 @@ export const PRODUCTS: SeedProduct[] = [
|
||||
|
||||
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;
|
||||
@@ -267,17 +276,24 @@ type Row = {
|
||||
wasteReason?: string | null;
|
||||
note?: string;
|
||||
initial?: boolean;
|
||||
/** Bu satır hangi sayıma ait (gün önce). */
|
||||
countDaysAgo?: number;
|
||||
};
|
||||
|
||||
/** 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[] {
|
||||
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 });
|
||||
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));
|
||||
@@ -285,12 +301,21 @@ export function simulate(p: SeedProduct, today: string): Row[] {
|
||||
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" });
|
||||
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) {
|
||||
@@ -311,7 +336,7 @@ export function simulate(p: SeedProduct, today: string): Row[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
return { rows, counts };
|
||||
}
|
||||
|
||||
export async function seedStock(
|
||||
@@ -321,6 +346,12 @@ export async function seedStock(
|
||||
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 }[]>();
|
||||
@@ -344,9 +375,33 @@ export async function seedStock(
|
||||
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(
|
||||
simulate(p, today).map((r, i) => {
|
||||
rows.map((r, i) => {
|
||||
const at = new Date(base + i * 1000);
|
||||
return {
|
||||
householdId,
|
||||
@@ -358,7 +413,7 @@ export async function seedStock(
|
||||
expiryDate: r.expiryDate ?? null,
|
||||
note: r.note ?? "",
|
||||
initial: r.initial ?? false,
|
||||
countId: null,
|
||||
countId: r.countDaysAgo === undefined ? null : (countIds.get(r.countDaysAgo) ?? null),
|
||||
deletedAt: null,
|
||||
seedRef: `${p.ref}:${i}`,
|
||||
createdBy: actor,
|
||||
|
||||
Reference in New Issue
Block a user