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 m009 } from "./009-assets";
|
||||||
import { migration as m010 } from "./010-budgets";
|
import { migration as m010 } from "./010-budgets";
|
||||||
import { migration as m011 } from "./011-stock";
|
import { migration as m011 } from "./011-stock";
|
||||||
|
import { migration as m012 } from "./012-stock-counts";
|
||||||
import type { Migration } from "./types";
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||||
@@ -26,6 +27,7 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
m009,
|
m009,
|
||||||
m010,
|
m010,
|
||||||
m011,
|
m011,
|
||||||
|
m012,
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"stockCategories",
|
"stockCategories",
|
||||||
"productTypes",
|
"productTypes",
|
||||||
"stockMovements",
|
"stockMovements",
|
||||||
|
"stockCounts",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
|
|||||||
+72
-17
@@ -2,6 +2,7 @@ import { Types } from "mongoose";
|
|||||||
import { installDefaultStockCategories } from "@/features/stock/service";
|
import { installDefaultStockCategories } from "@/features/stock/service";
|
||||||
import { ProductTypeModel } from "@/models/product-type";
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
import { StockCategoryModel } from "@/models/stock-category";
|
import { StockCategoryModel } from "@/models/stock-category";
|
||||||
|
import { StockCountModel } from "@/models/stock-count";
|
||||||
import { StockMovementModel } from "@/models/stock-movement";
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
import { todayIstanbul } from "@/shared/dates";
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
import { addDays } from "@/shared/recurrence";
|
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
|
* Ö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;
|
* ve son 6 ayın haftalık tüketim simülasyonu (alım, tüketim, fire, periyodik sayım, SKT).
|
||||||
* idempotent: ürün tipi `seedRef` ile bir kez oluşturulur. Miktarlar binde bir.
|
* 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 = {
|
type SeedProduct = {
|
||||||
@@ -30,8 +33,8 @@ type SeedProduct = {
|
|||||||
waste?: { every: number; pct: number; reason: "spoiled" | "expired" | "damaged" };
|
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. */
|
/** Bu günden (gün önce) sonra yeniden alınmaz: bugün azalmış görünür. */
|
||||||
stopRefillDaysAgo?: number;
|
stopRefillDaysAgo?: number;
|
||||||
/** Her n haftada bir küçük sayım farkı (−%). */
|
/** Her n. sayımda küçük eksik (−%, hedefin yüzdesi). */
|
||||||
countDrift?: { everyWeeks: number; pct: number };
|
countDrift?: { everyCounts: number; pct: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
const K = 1000;
|
const K = 1000;
|
||||||
@@ -188,7 +191,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
pack: 50 * K,
|
pack: 50 * K,
|
||||||
perDay: 700,
|
perDay: 700,
|
||||||
location: "Banyo dolabı",
|
location: "Banyo dolabı",
|
||||||
countDrift: { everyWeeks: 8, pct: 4 },
|
countDrift: { everyCounts: 2, pct: 4 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ref: "yumusatici",
|
ref: "yumusatici",
|
||||||
@@ -221,7 +224,7 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
pack: 16 * K,
|
pack: 16 * K,
|
||||||
perDay: 600,
|
perDay: 600,
|
||||||
location: "Banyo dolabı",
|
location: "Banyo dolabı",
|
||||||
countDrift: { everyWeeks: 10, pct: 6 },
|
countDrift: { everyCounts: 3, pct: 6 },
|
||||||
},
|
},
|
||||||
// Kişisel Temizlik
|
// Kişisel Temizlik
|
||||||
{
|
{
|
||||||
@@ -259,6 +262,12 @@ export const PRODUCTS: SeedProduct[] = [
|
|||||||
|
|
||||||
const START_DAYS_AGO = 182;
|
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 = {
|
type Row = {
|
||||||
kind: "purchase" | "consume" | "waste" | "count_adjust";
|
kind: "purchase" | "consume" | "waste" | "count_adjust";
|
||||||
daysAgo: number;
|
daysAgo: number;
|
||||||
@@ -267,17 +276,24 @@ type Row = {
|
|||||||
wasteReason?: string | null;
|
wasteReason?: string | null;
|
||||||
note?: string;
|
note?: string;
|
||||||
initial?: boolean;
|
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 type SeedCount = { daysAgo: number; expected: number; actual: number };
|
||||||
export function simulate(p: SeedProduct, today: string): Row[] {
|
|
||||||
|
/**
|
||||||
|
* 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 rows: Row[] = [];
|
||||||
|
const counts: SeedCount[] = [];
|
||||||
|
const plan = COUNT_PLANS[p.category.split(".")[0]!];
|
||||||
let stock = p.target;
|
let stock = p.target;
|
||||||
let purchases = 0;
|
let purchases = 0;
|
||||||
rows.push({ kind: "count_adjust", daysAgo: START_DAYS_AGO, quantity: stock, initial: true });
|
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) {
|
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).
|
// Haftalık tüketim azaltma adımının katı (ör. tam tablet, 0,25 kg).
|
||||||
const step = p.step ?? K;
|
const step = p.step ?? K;
|
||||||
const consume = Math.min(stock, Math.max(step, Math.round((p.perDay * 7) / step) * step));
|
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 });
|
rows.push({ kind: "consume", daysAgo: d, quantity: consume });
|
||||||
stock -= consume;
|
stock -= consume;
|
||||||
}
|
}
|
||||||
if (p.countDrift && week % p.countDrift.everyWeeks === 0 && stock > 0) {
|
if (plan?.on(d)) {
|
||||||
const drift = Math.min(stock, Math.round((p.target * p.countDrift.pct) / 100));
|
const expected = stock;
|
||||||
if (drift > 0) {
|
const nth = counts.length + 1;
|
||||||
rows.push({ kind: "count_adjust", daysAgo: d, quantity: -drift, note: "Sayım" });
|
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;
|
stock -= drift;
|
||||||
}
|
}
|
||||||
|
counts.push({ daysAgo: d, expected, actual: stock });
|
||||||
}
|
}
|
||||||
const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo;
|
const canRefill = p.stopRefillDaysAgo === undefined || d > p.stopRefillDaysAgo;
|
||||||
if (canRefill && stock < (p.target * 35) / 100) {
|
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(
|
export async function seedStock(
|
||||||
@@ -321,6 +346,12 @@ export async function seedStock(
|
|||||||
const actor = new Types.ObjectId(users.get("ayse")!);
|
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||||
for (const [, id] of householdIds) await installDefaultStockCategories(id, actor);
|
for (const [, id] of householdIds) await installDefaultStockCategories(id, actor);
|
||||||
const householdId = householdIds.get("Çakır Ailesi")!;
|
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" } })
|
const cats = await StockCategoryModel.find({ householdId, defaultKey: { $type: "string" } })
|
||||||
.select({ defaultKey: 1 })
|
.select({ defaultKey: 1 })
|
||||||
.lean<{ _id: Types.ObjectId; defaultKey: string }[]>();
|
.lean<{ _id: Types.ObjectId; defaultKey: string }[]>();
|
||||||
@@ -344,9 +375,33 @@ export async function seedStock(
|
|||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
});
|
});
|
||||||
const base = Date.now() - START_DAYS_AGO * 86_400_000;
|
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.
|
// Ham ekleme: aynı tarihli hareketlerin sırası (createdAt) korunur.
|
||||||
await StockMovementModel.collection.insertMany(
|
await StockMovementModel.collection.insertMany(
|
||||||
simulate(p, today).map((r, i) => {
|
rows.map((r, i) => {
|
||||||
const at = new Date(base + i * 1000);
|
const at = new Date(base + i * 1000);
|
||||||
return {
|
return {
|
||||||
householdId,
|
householdId,
|
||||||
@@ -358,7 +413,7 @@ export async function seedStock(
|
|||||||
expiryDate: r.expiryDate ?? null,
|
expiryDate: r.expiryDate ?? null,
|
||||||
note: r.note ?? "",
|
note: r.note ?? "",
|
||||||
initial: r.initial ?? false,
|
initial: r.initial ?? false,
|
||||||
countId: null,
|
countId: r.countDaysAgo === undefined ? null : (countIds.get(r.countDaysAgo) ?? null),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
seedRef: `${p.ref}:${i}`,
|
seedRef: `${p.ref}:${i}`,
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
productTypeFormSchema,
|
productTypeFormSchema,
|
||||||
stockCategoryFormSchema,
|
stockCategoryFormSchema,
|
||||||
stockCountFormSchema,
|
stockCountFormSchema,
|
||||||
|
stockCountSheetSchema,
|
||||||
stockMovementFormSchema,
|
stockMovementFormSchema,
|
||||||
} from "@/shared/schemas/stock";
|
} from "@/shared/schemas/stock";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
quickConsume,
|
quickConsume,
|
||||||
setProductTypeArchived,
|
setProductTypeArchived,
|
||||||
setStockCategoryArchived,
|
setStockCategoryArchived,
|
||||||
|
submitCount,
|
||||||
updateProductType,
|
updateProductType,
|
||||||
updateStockCategory,
|
updateStockCategory,
|
||||||
type StockOption,
|
type StockOption,
|
||||||
@@ -106,6 +108,11 @@ export async function countStockAction(productTypeId: unknown, input: unknown) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toplu sayım (/stok/sayim). */
|
||||||
|
export async function submitCountAction(input: unknown) {
|
||||||
|
return run(stockCountSheetSchema, input, submitCount);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteStockMovementAction(movementId: unknown) {
|
export async function deleteStockMovementAction(movementId: unknown) {
|
||||||
return withId(movementId, (id) =>
|
return withId(movementId, (id) =>
|
||||||
run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)),
|
run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { AuditLogModel } from "@/models/audit-log";
|
import { AuditLogModel } from "@/models/audit-log";
|
||||||
|
import { StockCountModel } from "@/models/stock-count";
|
||||||
import { StockMovementModel } from "@/models/stock-movement";
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
import { todayIstanbul } from "@/shared/dates";
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
import { addDays } from "@/shared/recurrence";
|
import { addDays } from "@/shared/recurrence";
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
productTypeFormSchema,
|
productTypeFormSchema,
|
||||||
stockCategoryFormSchema,
|
stockCategoryFormSchema,
|
||||||
stockCountFormSchema,
|
stockCountFormSchema,
|
||||||
|
stockCountSheetSchema,
|
||||||
stockMovementFormSchema,
|
stockMovementFormSchema,
|
||||||
type ProductTypeFormInput,
|
type ProductTypeFormInput,
|
||||||
} from "@/shared/schemas/stock";
|
} from "@/shared/schemas/stock";
|
||||||
@@ -18,13 +20,16 @@ import {
|
|||||||
createProductType,
|
createProductType,
|
||||||
createStockCategory,
|
createStockCategory,
|
||||||
deleteStockMovement,
|
deleteStockMovement,
|
||||||
|
getCountSheet,
|
||||||
getProductType,
|
getProductType,
|
||||||
getStock,
|
getStock,
|
||||||
|
getStockAnalysis,
|
||||||
getStockOptions,
|
getStockOptions,
|
||||||
listStockCategories,
|
listStockCategories,
|
||||||
quickConsume,
|
quickConsume,
|
||||||
setProductTypeArchived,
|
setProductTypeArchived,
|
||||||
setStockCategoryArchived,
|
setStockCategoryArchived,
|
||||||
|
submitCount,
|
||||||
updateStockCategory,
|
updateStockCategory,
|
||||||
} from "./service";
|
} from "./service";
|
||||||
|
|
||||||
@@ -305,3 +310,137 @@ describe("stok kategorileri", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("periyodik sayım, öneri ve analiz (Faz 11b)", () => {
|
||||||
|
it("seed: Mutfak sayımı gecikti, Genel Temizlik değil; fire öneriyi düşürür", async () => {
|
||||||
|
const s = (await getStock(ayse, cakir))!;
|
||||||
|
const paths = s.countDue.map((g) => g.path);
|
||||||
|
expect(paths).toEqual(expect.arrayContaining(["Mutfak › Manav", "Mutfak › Bakliyat"]));
|
||||||
|
expect(paths.some((p) => p.startsWith("Genel Temizlik"))).toBe(false);
|
||||||
|
// Son sayım 21 gün önce, sıklık 15 → 6 gün gecikti.
|
||||||
|
expect(s.countDue.find((g) => g.path === "Mutfak › Manav")!.overdueDays).toBe(6);
|
||||||
|
const domates = s.items.find((i) => i.name === "Domates")!;
|
||||||
|
expect(domates.count).toMatchObject({ everyDays: 15, lastDate: addDays(today, -21) });
|
||||||
|
expect(domates.suggestion.waste?.pct).toBeGreaterThan(0);
|
||||||
|
const deterjan = s.items.find((i) => i.name === "Toz Deterjan")!;
|
||||||
|
expect(deterjan.count).toMatchObject({ everyDays: 30, due: false });
|
||||||
|
expect(s.items.find((i) => i.name === "Şampuan")!.count).toBeNull();
|
||||||
|
// Öneri hedefi aşmaz, tüketim hızı biliniyor.
|
||||||
|
for (const i of s.suggestions) {
|
||||||
|
expect(i.suggestedQuantity).toBeGreaterThan(0);
|
||||||
|
expect(i.suggestedQuantity).toBeLessThanOrEqual(
|
||||||
|
Math.ceil(i.suggestion.base / i.step) * i.step,
|
||||||
|
);
|
||||||
|
// Bugün eklenen test ürünlerinin geçmişi kısa: kullanım hızı yok.
|
||||||
|
if (i.movementCount > 5) expect(i.suggestion.usage).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sayım listesi kategori alt ağacıyla; toplu sayım fire + düzeltme, oturum kaydı", async () => {
|
||||||
|
const opts = (await getStockOptions(ayse, cakir))!;
|
||||||
|
const manav = opts.categories.find((c) => c.path === "Mutfak › Manav")!.id;
|
||||||
|
const mutfak = opts.categories.find((c) => c.path === "Mutfak")!.id;
|
||||||
|
const sheet = (await getCountSheet(ayse, cakir, mutfak))!;
|
||||||
|
expect(sheet.rows.every((r) => r.categoryPath.startsWith("Mutfak"))).toBe(true);
|
||||||
|
expect(sheet.categories.find((c) => c.id === manav)!.due).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const patates = sheet.rows.find((r) => r.name === "Patates")!;
|
||||||
|
const sogan = sheet.rows.find((r) => r.name === "Kuru Soğan")!;
|
||||||
|
const bad = await submitCount(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
stockCountSheetSchema.parse({
|
||||||
|
date: today,
|
||||||
|
categoryId: mutfak,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
productTypeId: patates.id,
|
||||||
|
actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","),
|
||||||
|
wasteQuantity: "0,6",
|
||||||
|
wasteReason: "spoiled",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(bad).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
fieldErrors: { "rows.0.wasteQuantity": expect.any(String) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = await submitCount(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
stockCountSheetSchema.parse({
|
||||||
|
date: today,
|
||||||
|
categoryId: mutfak,
|
||||||
|
note: "Ay sonu",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
productTypeId: patates.id,
|
||||||
|
actualQuantity: String((patates.expected - 500) / 1000).replace(".", ","),
|
||||||
|
wasteQuantity: "0,3",
|
||||||
|
wasteReason: "spoiled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productTypeId: sogan.id,
|
||||||
|
actualQuantity: String(sogan.expected / 1000).replace(".", ","),
|
||||||
|
},
|
||||||
|
{ productTypeId: manav, actualQuantity: "" },
|
||||||
|
].map((x) => ({ wasteQuantity: "", wasteReason: "", ...x })),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r).toMatchObject({ ok: true, data: { counted: 2, adjusted: 1, diffs: [-500, 0] } });
|
||||||
|
const countId = r.ok ? r.data.countId : "";
|
||||||
|
const session = await StockCountModel.findById(countId).lean();
|
||||||
|
expect(session!.items).toHaveLength(2);
|
||||||
|
expect(session!.items[0]).toMatchObject({ expected: patates.expected, wasted: 300 });
|
||||||
|
const moves = await StockMovementModel.find({ countId }).sort({ createdAt: 1 }).lean();
|
||||||
|
expect(moves.map((m) => [m.kind, m.quantity, m.wasteReason])).toEqual([
|
||||||
|
["waste", 300, "spoiled"],
|
||||||
|
["count_adjust", -200, null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fark sıfır olan soğan da sayıldı: takvim yenilendi.
|
||||||
|
const p = (await getProductType(ayse, cakir, sogan.id))!;
|
||||||
|
expect(p.count).toMatchObject({ lastDate: today, due: false, nextDate: addDays(today, 15) });
|
||||||
|
expect((await getProductType(ayse, cakir, patates.id))!.current).toBe(patates.expected - 500);
|
||||||
|
|
||||||
|
// Hiçbir satır dolu değilse şema reddeder; izleyici sayamaz.
|
||||||
|
expect(
|
||||||
|
stockCountSheetSchema.safeParse({
|
||||||
|
date: today,
|
||||||
|
categoryId: "",
|
||||||
|
rows: [{ productTypeId: sogan.id, actualQuantity: "", wasteQuantity: "", wasteReason: "" }],
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await submitCount(
|
||||||
|
zeynep,
|
||||||
|
cakir,
|
||||||
|
stockCountSheetSchema.parse({
|
||||||
|
date: today,
|
||||||
|
categoryId: "",
|
||||||
|
rows: [
|
||||||
|
{ productTypeId: sogan.id, actualQuantity: "1", wasteQuantity: "", wasteReason: "" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(await getCountSheet(fatma, cakir, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tüketim analizi: aylık ortalama ve fire oranı", async () => {
|
||||||
|
const a = (await getStockAnalysis(ayse, cakir))!;
|
||||||
|
expect(a.months).toHaveLength(6);
|
||||||
|
expect(a.months.at(-1)! < a.thisMonth).toBe(true);
|
||||||
|
const domates = a.rows.find((r) => r.name === "Domates")!;
|
||||||
|
expect(domates.avgMonthlyUsed).toBeGreaterThan(5_000);
|
||||||
|
expect(domates.wastePct).toBeGreaterThan(0);
|
||||||
|
const sampuan = a.rows.find((r) => r.name === "Şampuan")!;
|
||||||
|
expect(sampuan.avgMonthlyWasted).toBe(0);
|
||||||
|
expect(sampuan.wastePct).toBe(0);
|
||||||
|
expect(await getStockAnalysis(fatma, cakir)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+397
-41
@@ -20,26 +20,35 @@ import { toObjectId } from "@/lib/object-id";
|
|||||||
import { CategoryModel } from "@/models/category";
|
import { CategoryModel } from "@/models/category";
|
||||||
import { ProductTypeModel } from "@/models/product-type";
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
import { StockCategoryModel } from "@/models/stock-category";
|
import { StockCategoryModel } from "@/models/stock-category";
|
||||||
|
import { StockCountModel } from "@/models/stock-count";
|
||||||
import { StockMovementModel } from "@/models/stock-movement";
|
import { StockMovementModel } from "@/models/stock-movement";
|
||||||
import { todayIstanbul } from "@/shared/dates";
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { shiftMonth } from "@/shared/budget";
|
||||||
import type {
|
import type {
|
||||||
ProductTypeForm,
|
ProductTypeForm,
|
||||||
StockCategoryForm,
|
StockCategoryForm,
|
||||||
StockCountForm,
|
StockCountForm,
|
||||||
|
StockCountSheet,
|
||||||
StockMovementForm,
|
StockMovementForm,
|
||||||
} from "@/shared/schemas/stock";
|
} from "@/shared/schemas/stock";
|
||||||
import { compareTr, foldTr } from "@/shared/search";
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
import {
|
import {
|
||||||
|
countSchedule,
|
||||||
currentQuantity,
|
currentQuantity,
|
||||||
expiringLots,
|
expiringLots,
|
||||||
expiryStatus,
|
expiryStatus,
|
||||||
fifoLots,
|
fifoLots,
|
||||||
formatStock,
|
formatStock,
|
||||||
refillQuantity,
|
monthlyUsage,
|
||||||
|
roundForDisplay,
|
||||||
signedDelta,
|
signedDelta,
|
||||||
stockLevel,
|
stockLevel,
|
||||||
|
suggestPurchase,
|
||||||
|
type CountSchedule,
|
||||||
type ExpiringLot,
|
type ExpiringLot,
|
||||||
type ExpiryStatus,
|
type ExpiryStatus,
|
||||||
|
type MonthUsage,
|
||||||
|
type PurchaseSuggestion,
|
||||||
type StockLevel,
|
type StockLevel,
|
||||||
type StockMovementKind,
|
type StockMovementKind,
|
||||||
type WasteReason,
|
type WasteReason,
|
||||||
@@ -77,6 +86,7 @@ type ProductLean = {
|
|||||||
countEveryDays: number | null;
|
countEveryDays: number | null;
|
||||||
note: string;
|
note: string;
|
||||||
archivedAt: Date | null;
|
archivedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MoveLean = {
|
type MoveLean = {
|
||||||
@@ -124,16 +134,30 @@ export type ProductTypeView = {
|
|||||||
current: number;
|
current: number;
|
||||||
level: StockLevel;
|
level: StockLevel;
|
||||||
suggestedQuantity: number;
|
suggestedQuantity: number;
|
||||||
|
/** Öneri ayrıntısı: fire indirimi, tüketim hızı yuvarlaması, kaç gün yeter. */
|
||||||
|
suggestion: PurchaseSuggestion;
|
||||||
|
/** Periyodik sayım (sıklık ürün tipinden, yoksa kategoriden). */
|
||||||
|
count: CountSchedule | null;
|
||||||
expiring: ExpiringLot[];
|
expiring: ExpiringLot[];
|
||||||
lastMovementDate: string | null;
|
lastMovementDate: string | null;
|
||||||
movementCount: number;
|
movementCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Sayımı gelen ürünlerin kategoriye göre özeti. */
|
||||||
|
export type CountDueGroup = {
|
||||||
|
categoryId: string;
|
||||||
|
path: string;
|
||||||
|
products: number;
|
||||||
|
/** En çok geciken ürünün gecikmesi (gün). */
|
||||||
|
overdueDays: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type StockView = {
|
export type StockView = {
|
||||||
categories: StockCategoryView[];
|
categories: StockCategoryView[];
|
||||||
items: ProductTypeView[];
|
items: ProductTypeView[];
|
||||||
suggestions: ProductTypeView[];
|
suggestions: ProductTypeView[];
|
||||||
expiring: { item: ProductTypeView; lot: ExpiringLot }[];
|
expiring: { item: ProductTypeView; lot: ExpiringLot }[];
|
||||||
|
countDue: CountDueGroup[];
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
today: string;
|
today: string;
|
||||||
};
|
};
|
||||||
@@ -261,19 +285,43 @@ function categoryViews(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toView(
|
/** Ürün başına son sayım tarihi (sayım oturumlarından). */
|
||||||
p: ProductLean,
|
async function loadLastCounts(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||||
moves: MoveLean[],
|
const rows = await StockCountModel.aggregate<{ _id: Types.ObjectId; date: string }>([
|
||||||
categoryPath: string,
|
{ $match: { householdId, "items.productTypeId": { $in: productIds } } },
|
||||||
today: string,
|
{ $unwind: "$items" },
|
||||||
): ProductTypeView {
|
{ $match: { "items.productTypeId": { $in: productIds } } },
|
||||||
|
{ $group: { _id: "$items.productTypeId", date: { $max: "$date" } } },
|
||||||
|
]);
|
||||||
|
return new Map(rows.map((r) => [String(r._id), r.date]));
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewContext = {
|
||||||
|
categoryPath: string;
|
||||||
|
/** Kategoriden devralınan sayım sıklığı. */
|
||||||
|
categoryCountDays: number | null;
|
||||||
|
lastCount: string | null;
|
||||||
|
today: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ymdOf = (d: Date) => todayIstanbul(d);
|
||||||
|
|
||||||
|
function toView(p: ProductLean, moves: MoveLean[], ctx: ViewContext): ProductTypeView {
|
||||||
|
const { today } = ctx;
|
||||||
const current = currentQuantity(moves);
|
const current = currentQuantity(moves);
|
||||||
const level = stockLevel(current, p.targetQuantity, p.thresholdPct);
|
const level = stockLevel(current, p.targetQuantity, p.thresholdPct);
|
||||||
|
const suggestion = suggestPurchase({
|
||||||
|
current,
|
||||||
|
target: p.targetQuantity,
|
||||||
|
step: p.step,
|
||||||
|
moves,
|
||||||
|
today,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
id: String(p._id),
|
id: String(p._id),
|
||||||
name: p.name,
|
name: p.name,
|
||||||
categoryId: String(p.categoryId),
|
categoryId: String(p.categoryId),
|
||||||
categoryPath,
|
categoryPath: ctx.categoryPath,
|
||||||
unit: p.unit,
|
unit: p.unit,
|
||||||
targetQuantity: p.targetQuantity,
|
targetQuantity: p.targetQuantity,
|
||||||
thresholdPct: p.thresholdPct,
|
thresholdPct: p.thresholdPct,
|
||||||
@@ -284,7 +332,14 @@ function toView(
|
|||||||
archived: Boolean(p.archivedAt),
|
archived: Boolean(p.archivedAt),
|
||||||
current,
|
current,
|
||||||
level,
|
level,
|
||||||
suggestedQuantity: refillQuantity(current, p.targetQuantity),
|
suggestedQuantity: suggestion.quantity,
|
||||||
|
suggestion,
|
||||||
|
count: countSchedule(
|
||||||
|
p.countEveryDays ?? ctx.categoryCountDays,
|
||||||
|
ctx.lastCount,
|
||||||
|
moves[0]?.date ?? ymdOf(p.createdAt),
|
||||||
|
today,
|
||||||
|
),
|
||||||
expiring: expiringLots(fifoLots(moves), today),
|
expiring: expiringLots(fifoLots(moves), today),
|
||||||
lastMovementDate: moves.at(-1)?.date ?? null,
|
lastMovementDate: moves.at(-1)?.date ?? null,
|
||||||
movementCount: moves.length,
|
movementCount: moves.length,
|
||||||
@@ -308,15 +363,22 @@ export async function getStock(userId: string, householdId: string): Promise<Sto
|
|||||||
includeArchived: true,
|
includeArchived: true,
|
||||||
});
|
});
|
||||||
const order = new Map(categories.map((c, i) => [c.id, i]));
|
const order = new Map(categories.map((c, i) => [c.id, i]));
|
||||||
const pathOf = new Map(categories.map((c) => [c.id, c.path]));
|
const catOf = new Map(categories.map((c) => [c.id, c]));
|
||||||
const moves = await loadMoves(
|
const ids = products.map((p) => p._id);
|
||||||
m.householdId,
|
const [moves, lastCounts] = await Promise.all([
|
||||||
products.map((p) => p._id),
|
loadMoves(m.householdId, ids),
|
||||||
);
|
loadLastCounts(m.householdId, ids),
|
||||||
|
]);
|
||||||
const items = products
|
const items = products
|
||||||
.map((p) =>
|
.map((p) => {
|
||||||
toView(p, moves.get(String(p._id)) ?? [], pathOf.get(String(p.categoryId)) ?? "", today),
|
const cat = catOf.get(String(p.categoryId));
|
||||||
)
|
return toView(p, moves.get(String(p._id)) ?? [], {
|
||||||
|
categoryPath: cat?.path ?? "",
|
||||||
|
categoryCountDays: cat?.effectiveCountDays ?? null,
|
||||||
|
lastCount: lastCounts.get(String(p._id)) ?? null,
|
||||||
|
today,
|
||||||
|
});
|
||||||
|
})
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) ||
|
(order.get(a.categoryId) ?? 1e9) - (order.get(b.categoryId) ?? 1e9) ||
|
||||||
@@ -333,11 +395,31 @@ export async function getStock(userId: string, householdId: string): Promise<Sto
|
|||||||
items,
|
items,
|
||||||
suggestions,
|
suggestions,
|
||||||
expiring,
|
expiring,
|
||||||
|
countDue: groupCountDue(items),
|
||||||
canWrite: hasHouseholdRole(m.role, "member"),
|
canWrite: hasHouseholdRole(m.role, "member"),
|
||||||
today,
|
today,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupCountDue(items: ProductTypeView[]): CountDueGroup[] {
|
||||||
|
const by = new Map<string, CountDueGroup>();
|
||||||
|
for (const i of items) {
|
||||||
|
if (!i.count?.due) continue;
|
||||||
|
const g = by.get(i.categoryId) ?? {
|
||||||
|
categoryId: i.categoryId,
|
||||||
|
path: i.categoryPath,
|
||||||
|
products: 0,
|
||||||
|
overdueDays: 0,
|
||||||
|
};
|
||||||
|
g.products++;
|
||||||
|
g.overdueDays = Math.max(g.overdueDays, i.count.overdueDays);
|
||||||
|
by.set(i.categoryId, g);
|
||||||
|
}
|
||||||
|
return [...by.values()].sort(
|
||||||
|
(a, b) => b.overdueDays - a.overdueDays || compareTr(a.path, b.path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadProduct(m: Membership, productTypeId: string, session?: ClientSession) {
|
async function loadProduct(m: Membership, productTypeId: string, session?: ClientSession) {
|
||||||
const id = toObjectId(productTypeId);
|
const id = toObjectId(productTypeId);
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
@@ -357,11 +439,14 @@ export async function getProductType(
|
|||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
const today = todayIstanbul();
|
const today = todayIstanbul();
|
||||||
const cats = await loadCategories(m.householdId);
|
const cats = await loadCategories(m.householdId);
|
||||||
const path =
|
const cat = categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find(
|
||||||
categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find(
|
(c) => c.id === String(p.categoryId),
|
||||||
(c) => c.id === String(p.categoryId),
|
);
|
||||||
)?.path ?? "";
|
const [movesBy, lastCounts] = await Promise.all([
|
||||||
const moves = (await loadMoves(m.householdId, [p._id])).get(String(p._id)) ?? [];
|
loadMoves(m.householdId, [p._id]),
|
||||||
|
loadLastCounts(m.householdId, [p._id]),
|
||||||
|
]);
|
||||||
|
const moves = movesBy.get(String(p._id)) ?? [];
|
||||||
let running = 0;
|
let running = 0;
|
||||||
const rows = moves.map((mv): StockMovementRow => {
|
const rows = moves.map((mv): StockMovementRow => {
|
||||||
const delta = signedDelta(mv);
|
const delta = signedDelta(mv);
|
||||||
@@ -379,7 +464,12 @@ export async function getProductType(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...toView(p, moves, path, today),
|
...toView(p, moves, {
|
||||||
|
categoryPath: cat?.path ?? "",
|
||||||
|
categoryCountDays: cat?.effectiveCountDays ?? null,
|
||||||
|
lastCount: lastCounts.get(String(p._id)) ?? null,
|
||||||
|
today,
|
||||||
|
}),
|
||||||
movements: rows.reverse(),
|
movements: rows.reverse(),
|
||||||
lots: fifoLots(moves).map((l) => ({
|
lots: fifoLots(moves).map((l) => ({
|
||||||
date: l.date,
|
date: l.date,
|
||||||
@@ -877,8 +967,8 @@ export async function quickConsume(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tek ürün sayımı: o tarihe kadarki kayıtlara göre beklenen ile sayılan arasındaki fark sayım
|
* Tek ürün sayımı (ürün detayından): toplu sayımın tek satırlık hâli. Fark yoksa düzeltme
|
||||||
* düzeltmesi olur. Fark yoksa kayıt oluşmaz. Farkı (işaretli) döner.
|
* oluşmaz ama sayım kaydı tutulur. Farkı (işaretli) döner.
|
||||||
*/
|
*/
|
||||||
export async function countStock(
|
export async function countStock(
|
||||||
actorId: string,
|
actorId: string,
|
||||||
@@ -886,22 +976,21 @@ export async function countStock(
|
|||||||
productTypeId: string,
|
productTypeId: string,
|
||||||
input: StockCountForm,
|
input: StockCountForm,
|
||||||
): Promise<ActionResult<{ diff: number }>> {
|
): Promise<ActionResult<{ diff: number }>> {
|
||||||
const m = await resolveMembership(actorId, householdId, "member");
|
const r = await submitCount(actorId, householdId, {
|
||||||
if (!m) return fail(tr.common.unauthorized);
|
date: input.date,
|
||||||
const r = await activeProductWithStock(m, productTypeId, input.date);
|
categoryId: null,
|
||||||
if (r.error !== null) return fail(r.error);
|
note: input.note,
|
||||||
const diff = input.actualQuantity - r.current;
|
rows: [
|
||||||
if (diff !== 0)
|
{
|
||||||
await StockMovementModel.create({
|
productTypeId,
|
||||||
householdId: m.householdId,
|
actualQuantity: input.actualQuantity,
|
||||||
productTypeId: r.p._id,
|
wasteQuantity: null,
|
||||||
kind: "count_adjust",
|
wasteReason: null,
|
||||||
date: input.date,
|
},
|
||||||
quantity: diff,
|
],
|
||||||
note: input.note,
|
});
|
||||||
createdBy: new Types.ObjectId(actorId),
|
if (!r.ok) return r;
|
||||||
});
|
return ok({ diff: r.data.diffs[0] ?? 0 });
|
||||||
return ok({ diff });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteStockMovement(
|
export async function deleteStockMovement(
|
||||||
@@ -936,3 +1025,270 @@ export async function deleteStockMovement(
|
|||||||
});
|
});
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Periyodik sayım (Faz 11b) ---
|
||||||
|
|
||||||
|
export type CountSheetRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
categoryId: string;
|
||||||
|
categoryPath: string;
|
||||||
|
unit: string;
|
||||||
|
step: number;
|
||||||
|
location: string;
|
||||||
|
/** Kayıtlara göre bugün beklenen miktar. */
|
||||||
|
expected: number;
|
||||||
|
count: CountSchedule | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CountSheet = {
|
||||||
|
today: string;
|
||||||
|
category: { id: string; path: string } | null;
|
||||||
|
/** Filtre seçenekleri: ürünü olan kategoriler ve sayımı gelen ürün sayısı. */
|
||||||
|
categories: { id: string; path: string; depth: number; due: number }[];
|
||||||
|
rows: CountSheetRow[];
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /stok/sayim: beklenen miktarlar (kategori seçiliyse alt ağacıyla). */
|
||||||
|
export async function getCountSheet(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
categoryId: string | null,
|
||||||
|
): Promise<CountSheet | null> {
|
||||||
|
const data = await getStock(userId, householdId);
|
||||||
|
if (!data) return null;
|
||||||
|
const all = data.categories;
|
||||||
|
const category = categoryId ? (all.find((c) => c.id === categoryId) ?? null) : null;
|
||||||
|
const inScope = category
|
||||||
|
? new Set([
|
||||||
|
category.id,
|
||||||
|
...descendantIds(
|
||||||
|
all.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
kind: "expense" as const,
|
||||||
|
name: c.name,
|
||||||
|
parentId: c.parentId,
|
||||||
|
order: 0,
|
||||||
|
archived: false,
|
||||||
|
})),
|
||||||
|
category.id,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
: null;
|
||||||
|
const due = new Map(data.countDue.map((g) => [g.categoryId, g.products]));
|
||||||
|
const withItems = new Set(data.items.map((i) => i.categoryId));
|
||||||
|
// Ürünü olan kategoriler ve ataları seçenek olarak görünür.
|
||||||
|
const visible = new Set<string>();
|
||||||
|
for (const c of all)
|
||||||
|
if (withItems.has(c.id)) {
|
||||||
|
visible.add(c.id);
|
||||||
|
let parent = c.parentId;
|
||||||
|
while (parent) {
|
||||||
|
visible.add(parent);
|
||||||
|
parent = all.find((x) => x.id === parent)?.parentId ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
today: data.today,
|
||||||
|
category: category ? { id: category.id, path: category.path } : null,
|
||||||
|
categories: all
|
||||||
|
.filter((c) => visible.has(c.id))
|
||||||
|
.map((c) => ({ id: c.id, path: c.path, depth: c.depth, due: due.get(c.id) ?? 0 })),
|
||||||
|
rows: data.items
|
||||||
|
.filter((i) => !inScope || inScope.has(i.categoryId))
|
||||||
|
.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
name: i.name,
|
||||||
|
categoryId: i.categoryId,
|
||||||
|
categoryPath: i.categoryPath,
|
||||||
|
unit: i.unit,
|
||||||
|
step: i.step,
|
||||||
|
location: i.location,
|
||||||
|
expected: i.current,
|
||||||
|
count: i.count,
|
||||||
|
})),
|
||||||
|
canWrite: data.canWrite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toplu sayım (tek transaction): her sayılan ürün için o tarihe kadarki kayıtlara göre beklenen
|
||||||
|
* hesaplanır; eksiğin fire olarak işaretlenen kısmı `waste`, kalan fark `count_adjust` olur.
|
||||||
|
* Fark sıfır olsa da sayım oturumuna yazılır. Fire, eksikten fazla olamaz.
|
||||||
|
*/
|
||||||
|
export async function submitCount(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: StockCountSheet,
|
||||||
|
): Promise<ActionResult<{ countId: string; counted: number; adjusted: number; diffs: number[] }>> {
|
||||||
|
const m = await resolveMembership(actorId, householdId, "member");
|
||||||
|
if (!m) return fail(tr.common.unauthorized);
|
||||||
|
const rows = input.rows.filter((r) => r.actualQuantity !== null);
|
||||||
|
if (rows.length === 0) return fail(t.validation.nothingCounted);
|
||||||
|
if (new Set(rows.map((r) => r.productTypeId)).size !== rows.length)
|
||||||
|
return fail(tr.common.notFound);
|
||||||
|
const ids = rows.map((r) => new Types.ObjectId(r.productTypeId));
|
||||||
|
const products = await ProductTypeModel.find({
|
||||||
|
_id: { $in: ids },
|
||||||
|
householdId: m.householdId,
|
||||||
|
}).lean<ProductLean[]>();
|
||||||
|
const byId = new Map(products.map((p) => [String(p._id), p]));
|
||||||
|
const moves = await loadMoves(m.householdId, ids);
|
||||||
|
|
||||||
|
const fieldErrors: Record<string, string> = {};
|
||||||
|
const plan = rows.map((r) => {
|
||||||
|
const p = byId.get(r.productTypeId);
|
||||||
|
const index = input.rows.indexOf(r);
|
||||||
|
if (!p) {
|
||||||
|
fieldErrors[`rows.${index}.actualQuantity`] = tr.common.notFound;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (p.archivedAt) {
|
||||||
|
fieldErrors[`rows.${index}.actualQuantity`] = t.validation.archived;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const expected = currentQuantity(
|
||||||
|
(moves.get(r.productTypeId) ?? []).filter((x) => x.date <= input.date),
|
||||||
|
);
|
||||||
|
const diff = r.actualQuantity! - expected;
|
||||||
|
const wasted = r.wasteQuantity ?? 0;
|
||||||
|
if (wasted > Math.max(0, -diff)) {
|
||||||
|
fieldErrors[`rows.${index}.wasteQuantity`] = t.validation.wasteOverShortage(
|
||||||
|
formatStock(Math.max(0, -diff), p.unit),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { p, row: r, expected, diff, wasted };
|
||||||
|
});
|
||||||
|
if (Object.keys(fieldErrors).length) return fail(tr.adminUsers.errors.checkFields, fieldErrors);
|
||||||
|
const items = plan.filter((x) => x !== null);
|
||||||
|
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const mongoose = await connectMongoose();
|
||||||
|
let countId = "";
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
const [count] = await StockCountModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
householdId: m.householdId,
|
||||||
|
date: input.date,
|
||||||
|
categoryId: input.categoryId ? new Types.ObjectId(input.categoryId) : null,
|
||||||
|
note: input.note,
|
||||||
|
items: items.map((x) => ({
|
||||||
|
productTypeId: x.p._id,
|
||||||
|
expected: x.expected,
|
||||||
|
actual: x.row.actualQuantity!,
|
||||||
|
wasted: x.wasted,
|
||||||
|
})),
|
||||||
|
createdBy: actor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
countId = String(count!._id);
|
||||||
|
const docs = items.flatMap((x) => {
|
||||||
|
const base = {
|
||||||
|
householdId: m.householdId,
|
||||||
|
productTypeId: x.p._id,
|
||||||
|
date: input.date,
|
||||||
|
countId: count!._id,
|
||||||
|
createdBy: actor,
|
||||||
|
};
|
||||||
|
const out: Record<string, unknown>[] = [];
|
||||||
|
if (x.wasted > 0)
|
||||||
|
out.push({
|
||||||
|
...base,
|
||||||
|
kind: "waste",
|
||||||
|
quantity: x.wasted,
|
||||||
|
wasteReason: x.row.wasteReason,
|
||||||
|
note: t.countNote,
|
||||||
|
});
|
||||||
|
const rest = x.diff + x.wasted;
|
||||||
|
if (rest !== 0)
|
||||||
|
out.push({
|
||||||
|
...base,
|
||||||
|
kind: "count_adjust",
|
||||||
|
quantity: rest,
|
||||||
|
note: input.note || t.countNote,
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
// Sıralı ekleme: aynı tarihte fire, düzeltmeden önce gelir.
|
||||||
|
for (const d of docs) await StockMovementModel.create([d], { session });
|
||||||
|
});
|
||||||
|
return ok({
|
||||||
|
countId,
|
||||||
|
counted: items.length,
|
||||||
|
adjusted: items.filter((x) => x.diff !== 0).length,
|
||||||
|
diffs: items.map((x) => x.diff),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tüketim analizi (Faz 11b) ---
|
||||||
|
|
||||||
|
/** Analizde gösterilen tam ay sayısı (içinde bulunulan ay ayrıca). */
|
||||||
|
export const ANALYSIS_MONTHS = 6;
|
||||||
|
|
||||||
|
export type StockAnalysisRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
categoryId: string;
|
||||||
|
categoryPath: string;
|
||||||
|
unit: string;
|
||||||
|
months: MonthUsage[];
|
||||||
|
/** Ürünün var olduğu tam aylarda aylık ortalama kullanım (yoksa null). */
|
||||||
|
avgMonthlyUsed: number | null;
|
||||||
|
avgMonthlyWasted: number | null;
|
||||||
|
/** Fire / (kullanım + fire), %. */
|
||||||
|
wastePct: number | null;
|
||||||
|
/** Bu ay (bugüne kadar). */
|
||||||
|
thisMonth: MonthUsage;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StockAnalysis = {
|
||||||
|
months: string[];
|
||||||
|
thisMonth: string;
|
||||||
|
rows: StockAnalysisRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /stok/analiz: ürün tipi başına aylık ortalama tüketim ve fire. */
|
||||||
|
export async function getStockAnalysis(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<StockAnalysis | null> {
|
||||||
|
const data = await getStock(userId, householdId);
|
||||||
|
if (!data) return null;
|
||||||
|
const thisMonth = data.today.slice(0, 7);
|
||||||
|
const months = Array.from({ length: ANALYSIS_MONTHS }, (_, i) =>
|
||||||
|
shiftMonth(thisMonth, i - ANALYSIS_MONTHS),
|
||||||
|
);
|
||||||
|
const m = (await resolveMembership(userId, householdId, "viewer"))!;
|
||||||
|
const moves = await loadMoves(
|
||||||
|
m.householdId,
|
||||||
|
data.items.map((i) => new Types.ObjectId(i.id)),
|
||||||
|
);
|
||||||
|
const rows = data.items.map((item): StockAnalysisRow => {
|
||||||
|
const list = moves.get(item.id) ?? [];
|
||||||
|
const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!];
|
||||||
|
// Ortalama: ilk hareketin ayından sonraki tam aylar (ilk ay eksik olabilir).
|
||||||
|
const first = list[0]?.date.slice(0, 7);
|
||||||
|
const full = first ? usage.filter((u) => u.month > first) : [];
|
||||||
|
const sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0);
|
||||||
|
const used = sum("used");
|
||||||
|
const wasted = sum("wasted");
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
categoryId: item.categoryId,
|
||||||
|
categoryPath: item.categoryPath,
|
||||||
|
unit: item.unit,
|
||||||
|
months: usage,
|
||||||
|
avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null,
|
||||||
|
avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null,
|
||||||
|
wastePct: used + wasted > 0 ? Math.round((wasted * 100) / (used + wasted)) : null,
|
||||||
|
thisMonth: current,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { months, thisMonth, rows };
|
||||||
|
}
|
||||||
|
|||||||
+77
-2
@@ -935,6 +935,67 @@ export const tr = {
|
|||||||
"Evdeki ürünlerin miktarı, kritik eşik ve son kullanma tarihleri. Stok ürün tipi düzeyinde tutulur.",
|
"Evdeki ürünlerin miktarı, kritik eşik ve son kullanma tarihleri. Stok ürün tipi düzeyinde tutulur.",
|
||||||
newProduct: "Yeni ürün tipi",
|
newProduct: "Yeni ürün tipi",
|
||||||
categoriesLink: "Stok kategorileri",
|
categoriesLink: "Stok kategorileri",
|
||||||
|
countLink: "Sayım",
|
||||||
|
analysisLink: "Tüketim analizi",
|
||||||
|
countNote: "Sayım",
|
||||||
|
countDue: {
|
||||||
|
title: "Sayımı gelenler",
|
||||||
|
description:
|
||||||
|
"Sayım sıklığı dolan kategoriler. Sıklık ürün tipinde veya stok kategorisinde ayarlanır.",
|
||||||
|
products: (n: number) => `${n} ürün`,
|
||||||
|
overdue: (days: number) => (days > 0 ? `${days} gün gecikti` : "Bugün"),
|
||||||
|
start: "Sayıma başla",
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
title: "Sayım",
|
||||||
|
description:
|
||||||
|
"Evdeki gerçek miktarları girin. Kayıtlarla arasındaki fark sayım düzeltmesi olur; eksiğin bir kısmını fire olarak işaretleyebilirsiniz. Boş bıraktığınız ürünler sayılmaz.",
|
||||||
|
back: "Ev stoğuna dön",
|
||||||
|
allCategories: "Tüm stok",
|
||||||
|
category: "Kategori",
|
||||||
|
dueCount: (n: number) => `${n} sayımı gelen`,
|
||||||
|
onlyDue: "Yalnızca sayımı gelenler",
|
||||||
|
expected: "Beklenen",
|
||||||
|
actual: "Sayılan",
|
||||||
|
diff: "Fark",
|
||||||
|
same: "Aynı",
|
||||||
|
markWaste: "Eksiğin bir kısmı fire",
|
||||||
|
wasteQuantity: "Fire miktarı",
|
||||||
|
wasteMax: (q: string) => `En çok ${q}`,
|
||||||
|
dueBadge: (days: number) => (days > 0 ? `Sayım ${days} gün gecikti` : "Sayım bugün"),
|
||||||
|
lastCount: (date: string) => `Son sayım ${date}`,
|
||||||
|
neverCounted: "Hiç sayılmadı",
|
||||||
|
fillExpected: "Beklenenle doldur",
|
||||||
|
fillExpectedHint: "Sayılmayan satırlara beklenen miktarı yazar; değişenleri düzeltin.",
|
||||||
|
clear: "Temizle",
|
||||||
|
note: "Not",
|
||||||
|
counted: (n: number) => `${n} ürün sayılacak`,
|
||||||
|
submit: "Sayımı kaydet",
|
||||||
|
empty: "Bu kategoride sayılacak ürün yok.",
|
||||||
|
done: (counted: number, adjusted: number) =>
|
||||||
|
adjusted
|
||||||
|
? `${counted} ürün sayıldı; ${adjusted} üründe fark düzeltildi.`
|
||||||
|
: `${counted} ürün sayıldı; kayıtlarla aynı.`,
|
||||||
|
},
|
||||||
|
analysis: {
|
||||||
|
title: "Tüketim analizi",
|
||||||
|
description:
|
||||||
|
"Ürün tipi başına aylık tüketim ve fire. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet, ürün fiyatları (Faz 12) gelince eklenecek.",
|
||||||
|
back: "Ev stoğuna dön",
|
||||||
|
product: "Ürün tipi",
|
||||||
|
avgUsed: "Aylık ort. tüketim",
|
||||||
|
avgWasted: "Aylık ort. fire",
|
||||||
|
wastePct: "Fire oranı",
|
||||||
|
thisMonth: "Bu ay",
|
||||||
|
trend: (n: number) => `Son ${n} ay`,
|
||||||
|
noData: "—",
|
||||||
|
empty: "Analiz için ürün tipi yok.",
|
||||||
|
chartLabel: (name: string) => `${name}: aylık tüketim`,
|
||||||
|
monthValue: (month: string, used: string, wasted: string) =>
|
||||||
|
`${month}: tüketim ${used}, fire ${wasted}`,
|
||||||
|
highWaste: "Fire oranı yüksek",
|
||||||
|
table: "Tablo",
|
||||||
|
},
|
||||||
readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.",
|
readOnly: "İzleyici rolündesiniz; stoğu değiştiremezsiniz.",
|
||||||
summary: {
|
summary: {
|
||||||
products: "Ürün tipi",
|
products: "Ürün tipi",
|
||||||
@@ -947,6 +1008,10 @@ export const tr = {
|
|||||||
description: "Kritik eşiğin altına düşen ürünler ve hedefe tamamlamak için gereken miktar.",
|
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.",
|
empty: "Eşiğin altına düşen ürün yok.",
|
||||||
suggested: (q: string) => `Öneri: ${q}`,
|
suggested: (q: string) => `Öneri: ${q}`,
|
||||||
|
wasteReduced: (purchases: number, pct: number) =>
|
||||||
|
`Son ${purchases} alımda %${pct} fire, miktar azaltıldı.`,
|
||||||
|
roundedByUsage: (week: string) => `Haftalık tüketime (${week}) göre yuvarlandı.`,
|
||||||
|
covers: (days: number) => `Mevcutla birlikte yaklaşık ${days} gün yeter.`,
|
||||||
},
|
},
|
||||||
expiring: {
|
expiring: {
|
||||||
title: "Son kullanma tarihi",
|
title: "Son kullanma tarihi",
|
||||||
@@ -1031,6 +1096,13 @@ export const tr = {
|
|||||||
target: "Hedef",
|
target: "Hedef",
|
||||||
threshold: "Eşik",
|
threshold: "Eşik",
|
||||||
suggested: "Önerilen alım",
|
suggested: "Önerilen alım",
|
||||||
|
usage: "Tüketim hızı",
|
||||||
|
perWeek: (q: string) => `Haftada ~${q}`,
|
||||||
|
noUsage: "Henüz yeterli geçmiş yok",
|
||||||
|
countSchedule: "Periyodik sayım",
|
||||||
|
countEvery: (n: number) => `${n} günde bir`,
|
||||||
|
nextCount: (date: string) => `Sonraki sayım ${date}`,
|
||||||
|
noCountSchedule: "Sayım sıklığı ayarlanmadı",
|
||||||
location: "Saklama yeri",
|
location: "Saklama yeri",
|
||||||
lots: "Evdeki partiler",
|
lots: "Evdeki partiler",
|
||||||
lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).",
|
lotsHint: "Çıkışlar en eski alımdan düşülür (FIFO).",
|
||||||
@@ -1103,17 +1175,20 @@ export const tr = {
|
|||||||
wasteReason: "Fire nedenini seçin",
|
wasteReason: "Fire nedenini seçin",
|
||||||
expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz",
|
expiryBeforeDate: "Son kullanma tarihi alım tarihinden önce olamaz",
|
||||||
notEnough: (q: string) => `Stokta yalnızca ${q} var.`,
|
notEnough: (q: string) => `Stokta yalnızca ${q} var.`,
|
||||||
|
nothingCounted: "En az bir ürünün sayılan miktarını girin.",
|
||||||
|
wasteOverShortage: (q: string) => `Fire, sayımda çıkan eksikten (${q}) fazla olamaz.`,
|
||||||
emptyStock: "Stok zaten boş.",
|
emptyStock: "Stok zaten boş.",
|
||||||
duplicate: "Bu adla bir ürün tipi zaten var.",
|
duplicate: "Bu adla bir ürün tipi zaten var.",
|
||||||
categoryArchived: "Seçilen stok kategorisi arşivli veya bulunamadı.",
|
categoryArchived: "Seçilen stok kategorisi arşivli veya bulunamadı.",
|
||||||
archived: "Ürün tipi arşivli.",
|
archived: "Ürün tipi arşivli.",
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
title: "Azalan stoklar",
|
title: "Stok uyarıları",
|
||||||
description: (low: number, expiring: number) =>
|
description: (low: number, expiring: number, countDue = 0) =>
|
||||||
[
|
[
|
||||||
low ? `${low} ürün eşiğin altında` : null,
|
low ? `${low} ürün eşiğin altında` : null,
|
||||||
expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null,
|
expiring ? `${expiring} partinin SKT'si yaklaşıyor` : null,
|
||||||
|
countDue ? `${countDue} kategoride sayım zamanı` : null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · "),
|
.join(" · "),
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sayım oturumu (Bölüm 6.10, periyodik sayım). Her sayılan ürün için beklenen ve sayılan miktar,
|
||||||
|
* fireye ayrılan kısım. Fark sıfır olsa da kayıt tutulur: "son sayım" ve sayım takvimi buradan
|
||||||
|
* türetilir. Oluşan hareketler `countId` ile bağlanır.
|
||||||
|
*/
|
||||||
|
const stockCountItemSchema = new Schema(
|
||||||
|
{
|
||||||
|
productTypeId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
expected: { type: Number, required: true },
|
||||||
|
actual: { type: Number, required: true },
|
||||||
|
/** Eksiğin fire olarak işaretlenen kısmı (≥ 0). */
|
||||||
|
wasted: { type: Number, default: 0 },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const stockCountSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
/** "YYYY-MM-DD" (İstanbul). */
|
||||||
|
date: { type: String, required: true },
|
||||||
|
/** Sayılan stok kategorisi (alt ağacıyla); null: tüm stok veya tek ürün. */
|
||||||
|
categoryId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
items: { type: [stockCountItemSchema], default: [] },
|
||||||
|
note: { type: String, default: "", maxlength: 300 },
|
||||||
|
seedRef: { type: String, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "stockCounts" },
|
||||||
|
);
|
||||||
|
|
||||||
|
stockCountSchema.index({ householdId: 1, "items.productTypeId": 1, date: -1 });
|
||||||
|
stockCountSchema.index({ householdId: 1, date: -1 });
|
||||||
|
stockCountSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type StockCount = InferSchemaType<typeof stockCountSchema>;
|
||||||
|
|
||||||
|
export const StockCountModel: Model<StockCount> =
|
||||||
|
(models.StockCount as Model<StockCount> | undefined) ??
|
||||||
|
model<StockCount>("StockCount", stockCountSchema);
|
||||||
@@ -118,3 +118,43 @@ export const stockCountFormSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type StockCountFormInput = z.input<typeof stockCountFormSchema>;
|
export type StockCountFormInput = z.input<typeof stockCountFormSchema>;
|
||||||
export type StockCountForm = z.output<typeof stockCountFormSchema>;
|
export type StockCountForm = z.output<typeof stockCountFormSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toplu sayım satırı: sayılan miktar boşsa ürün sayılmamış sayılır (atlanır). Eksiğin bir kısmı
|
||||||
|
* fire olarak işaretlenebilir (neden zorunlu).
|
||||||
|
*/
|
||||||
|
const stockCountRowSchema = z
|
||||||
|
.object({
|
||||||
|
productTypeId: z.string().regex(/^[a-f0-9]{24}$/i),
|
||||||
|
actualQuantity: optionalQuantity,
|
||||||
|
wasteQuantity: optionalQuantity.default(null),
|
||||||
|
wasteReason: z.preprocess(
|
||||||
|
(x) => (x === "" || x === undefined ? null : x),
|
||||||
|
z.enum(WASTE_REASONS).nullable(),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
if (x.wasteQuantity && !x.wasteReason)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["wasteReason"], message: s.wasteReason });
|
||||||
|
if (x.wasteQuantity && x.actualQuantity === null)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["actualQuantity"], message: s.quantity });
|
||||||
|
})
|
||||||
|
.transform((x) => ({
|
||||||
|
...x,
|
||||||
|
wasteQuantity: x.wasteQuantity || null,
|
||||||
|
wasteReason: x.wasteQuantity ? x.wasteReason : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const stockCountSheetSchema = z
|
||||||
|
.object({
|
||||||
|
date: ymd,
|
||||||
|
categoryId: optionalId,
|
||||||
|
note: z.string().trim().max(300).default(""),
|
||||||
|
rows: z.array(stockCountRowSchema).max(500),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
if (!x.rows.some((r) => r.actualQuantity !== null))
|
||||||
|
ctx.addIssue({ code: "custom", path: ["rows"], message: s.nothingCounted });
|
||||||
|
});
|
||||||
|
export type StockCountSheetInput = z.input<typeof stockCountSheetSchema>;
|
||||||
|
export type StockCountSheet = z.output<typeof stockCountSheetSchema>;
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
countSchedule,
|
||||||
currentQuantity,
|
currentQuantity,
|
||||||
expiringLots,
|
expiringLots,
|
||||||
expiryStatus,
|
expiryStatus,
|
||||||
fifoLots,
|
fifoLots,
|
||||||
|
monthlyUsage,
|
||||||
quantityInput,
|
quantityInput,
|
||||||
|
recentWaste,
|
||||||
|
roundForDisplay,
|
||||||
refillQuantity,
|
refillQuantity,
|
||||||
stepLabel,
|
stepLabel,
|
||||||
stockLevel,
|
stockLevel,
|
||||||
|
suggestPurchase,
|
||||||
|
usageRate,
|
||||||
type StockMove,
|
type StockMove,
|
||||||
} from "./stock";
|
} from "./stock";
|
||||||
|
|
||||||
@@ -94,8 +100,130 @@ describe("eşik ve öneri", () => {
|
|||||||
expect(quantityInput(250)).toBe("0,25");
|
expect(quantityInput(250)).toBe("0,25");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gösterim yuvarlaması", () => {
|
||||||
|
expect(roundForDisplay(18_368)).toBe(18_400);
|
||||||
|
expect(roundForDisplay(1_672)).toBe(1_670);
|
||||||
|
expect(roundForDisplay(441.4)).toBe(440);
|
||||||
|
});
|
||||||
|
|
||||||
it("azaltma adımı etiketi", () => {
|
it("azaltma adımı etiketi", () => {
|
||||||
expect(stepLabel(1_000, "adet")).toBe("−1 adet");
|
expect(stepLabel(1_000, "adet")).toBe("−1 adet");
|
||||||
expect(stepLabel(500, "kg")).toBe("−0,5 kg");
|
expect(stepLabel(500, "kg")).toBe("−0,5 kg");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fire oranı ve önerilen alım", () => {
|
||||||
|
const wasteful = [
|
||||||
|
m("purchase", "2026-11-01", 3_000),
|
||||||
|
m("waste", "2026-11-02", 900),
|
||||||
|
m("purchase", "2026-11-03", 3_000),
|
||||||
|
m("waste", "2026-11-04", 900),
|
||||||
|
m("consume", "2026-11-05", 4_200),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("fire FIFO ile alımlara yazılır; son alımların oranı", () => {
|
||||||
|
expect(recentWaste(wasteful)).toEqual({
|
||||||
|
purchases: 2,
|
||||||
|
purchased: 6_000,
|
||||||
|
wasted: 1_800,
|
||||||
|
pct: 30,
|
||||||
|
});
|
||||||
|
// Başlangıç partisinden düşen fire alım oranına girmez.
|
||||||
|
expect(
|
||||||
|
recentWaste([
|
||||||
|
{ ...m("count_adjust", "2026-10-01", 1_000), initial: true } as StockMove,
|
||||||
|
m("waste", "2026-10-02", 500),
|
||||||
|
m("purchase", "2026-10-03", 2_000),
|
||||||
|
]),
|
||||||
|
).toMatchObject({ purchases: 1, wasted: 0, pct: 0 });
|
||||||
|
expect(recentWaste([m("consume", "2026-10-01", 1)])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("%30 fire öneriyi düşürür, adıma yuvarlanır", () => {
|
||||||
|
const s = suggestPurchase({
|
||||||
|
current: 0,
|
||||||
|
target: 3_000,
|
||||||
|
step: 500,
|
||||||
|
moves: wasteful,
|
||||||
|
today: "2026-11-06",
|
||||||
|
});
|
||||||
|
// 3 kg × %70 = 2,1 kg → 0,5'in katı 2 kg. Geçmiş 14 günden kısa: kullanım yok.
|
||||||
|
expect(s).toMatchObject({ base: 3_000, quantity: 2_000, roundTo: 500, usage: null });
|
||||||
|
expect(s.waste?.pct).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tüketim hızına (haftalık kullanımın katına) yuvarlanır, hedefi aşmaz", () => {
|
||||||
|
const moves = [
|
||||||
|
{ ...m("count_adjust", "2026-10-10", 10_000), initial: true },
|
||||||
|
m("consume", "2026-11-01", 8_400),
|
||||||
|
];
|
||||||
|
expect(usageRate(moves, "2026-11-06")).toEqual({ perDay: 300, days: 28 });
|
||||||
|
const s = suggestPurchase({
|
||||||
|
current: 1_600,
|
||||||
|
target: 6_000,
|
||||||
|
step: 500,
|
||||||
|
moves,
|
||||||
|
today: "2026-11-06",
|
||||||
|
});
|
||||||
|
// Haftalık 2,1 → 2 kg; taban 4,4 → 2 hafta = 4 kg; (1,6 + 4) / 0,3 = 18 gün.
|
||||||
|
expect(s).toMatchObject({
|
||||||
|
base: 4_400,
|
||||||
|
quantity: 4_000,
|
||||||
|
roundTo: 2_000,
|
||||||
|
roundedByUsage: true,
|
||||||
|
waste: null,
|
||||||
|
coverDays: 18,
|
||||||
|
});
|
||||||
|
// Taban küçükse en az bir adım; hedef doluysa öneri yok.
|
||||||
|
expect(
|
||||||
|
suggestPurchase({ current: 950, target: 1_000, step: 100, moves: [], today: "2026-11-06" })
|
||||||
|
.quantity,
|
||||||
|
).toBe(100);
|
||||||
|
expect(
|
||||||
|
suggestPurchase({ current: 1_000, target: 1_000, step: 100, moves: [], today: "2026-11-06" })
|
||||||
|
.quantity,
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sayım takvimi ve aylık kullanım", () => {
|
||||||
|
it("son sayımdan n gün sonra sayım gelir", () => {
|
||||||
|
expect(countSchedule(15, "2026-10-20", "2026-01-01", "2026-11-06")).toEqual({
|
||||||
|
everyDays: 15,
|
||||||
|
lastDate: "2026-10-20",
|
||||||
|
nextDate: "2026-11-04",
|
||||||
|
due: true,
|
||||||
|
overdueDays: 2,
|
||||||
|
});
|
||||||
|
expect(countSchedule(15, null, "2026-10-25", "2026-11-06")).toMatchObject({
|
||||||
|
nextDate: "2026-11-09",
|
||||||
|
due: false,
|
||||||
|
overdueDays: 0,
|
||||||
|
});
|
||||||
|
expect(countSchedule(7, "2026-10-30", "2026-10-01", "2026-11-06")).toMatchObject({
|
||||||
|
due: true,
|
||||||
|
overdueDays: 0,
|
||||||
|
});
|
||||||
|
expect(countSchedule(null, null, "2026-10-01", "2026-11-06")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kullanım = tüketim + sayım eksiği; fire ayrı", () => {
|
||||||
|
expect(
|
||||||
|
monthlyUsage(
|
||||||
|
[
|
||||||
|
{ ...m("count_adjust", "2026-10-01", 5_000), initial: true },
|
||||||
|
m("consume", "2026-10-05", 1_000),
|
||||||
|
m("count_adjust", "2026-10-10", -200),
|
||||||
|
m("waste", "2026-10-12", 300),
|
||||||
|
m("count_adjust", "2026-10-15", 100),
|
||||||
|
m("consume", "2026-11-02", 500),
|
||||||
|
],
|
||||||
|
["2026-09", "2026-10", "2026-11"],
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{ month: "2026-09", used: 0, wasted: 0 },
|
||||||
|
{ month: "2026-10", used: 1_200, wasted: 300 },
|
||||||
|
{ month: "2026-11", used: 500, wasted: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+219
-9
@@ -1,4 +1,5 @@
|
|||||||
import { daysBetween } from "./budget";
|
import { daysBetween } from "./budget";
|
||||||
|
import { addDays } from "./recurrence";
|
||||||
import { formatQuantity, QUANTITY_SCALE } from "./quantity";
|
import { formatQuantity, QUANTITY_SCALE } from "./quantity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,13 +59,11 @@ export type Lot = {
|
|||||||
remaining: number;
|
remaining: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
type TrackedLot = Lot & { kind: StockMovementKind; wasted: 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
|
/** FIFO çalıştırır; her girişin kalanını ve fireyle düşülen kısmını izler. */
|
||||||
* tarihli tüketim) açık, sonraki girişten kapatılır. Yalnızca kalanı olan partiler döner.
|
function runFifo(moves: readonly StockMove[]): TrackedLot[] {
|
||||||
*/
|
const lots: TrackedLot[] = [];
|
||||||
export function fifoLots(moves: readonly StockMove[]): Lot[] {
|
|
||||||
const lots: Lot[] = [];
|
|
||||||
let deficit = 0;
|
let deficit = 0;
|
||||||
moves.forEach((m, index) => {
|
moves.forEach((m, index) => {
|
||||||
const delta = signedDelta(m);
|
const delta = signedDelta(m);
|
||||||
@@ -73,10 +72,12 @@ export function fifoLots(moves: readonly StockMove[]): Lot[] {
|
|||||||
deficit -= covered;
|
deficit -= covered;
|
||||||
lots.push({
|
lots.push({
|
||||||
index,
|
index,
|
||||||
|
kind: m.kind,
|
||||||
date: m.date,
|
date: m.date,
|
||||||
expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null,
|
expiryDate: m.kind === "purchase" ? (m.expiryDate ?? null) : null,
|
||||||
quantity: delta,
|
quantity: delta,
|
||||||
remaining: delta - covered,
|
remaining: delta - covered,
|
||||||
|
wasted: 0,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -85,11 +86,29 @@ export function fifoLots(moves: readonly StockMove[]): Lot[] {
|
|||||||
if (out === 0) break;
|
if (out === 0) break;
|
||||||
const take = Math.min(lot.remaining, out);
|
const take = Math.min(lot.remaining, out);
|
||||||
lot.remaining -= take;
|
lot.remaining -= take;
|
||||||
|
if (m.kind === "waste") lot.wasted += take;
|
||||||
out -= take;
|
out -= take;
|
||||||
}
|
}
|
||||||
deficit += out;
|
deficit += out;
|
||||||
});
|
});
|
||||||
return lots.filter((l) => l.remaining > 0);
|
return lots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[] {
|
||||||
|
return runFifo(moves)
|
||||||
|
.filter((l) => l.remaining > 0)
|
||||||
|
.map(({ index, date, expiryDate, quantity, remaining }) => ({
|
||||||
|
index,
|
||||||
|
date,
|
||||||
|
expiryDate,
|
||||||
|
quantity,
|
||||||
|
remaining,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExpiryStatus = "expired" | "soon" | "ok";
|
export type ExpiryStatus = "expired" | "soon" | "ok";
|
||||||
@@ -142,7 +161,7 @@ export function stockLevel(current: number, target: number, thresholdPct: number
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Basit öneri: hedefe tamamla (Faz 11b'de tüketim hızı ve fireye göre ayarlanır). */
|
/** Hedefe tamamlayan miktar (öneri motorunun tabanı). */
|
||||||
export function refillQuantity(current: number, target: number): number {
|
export function refillQuantity(current: number, target: number): number {
|
||||||
return Math.max(0, target - Math.max(0, current));
|
return Math.max(0, target - Math.max(0, current));
|
||||||
}
|
}
|
||||||
@@ -157,9 +176,200 @@ export function stepLabel(step: number, unit: string): string {
|
|||||||
return `−${formatQuantity(step || QUANTITY_SCALE, unit)}`;
|
return `−${formatQuantity(step || QUANTITY_SCALE, unit)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ortalama/hız gösterimi: 10 birimden büyükse bir, değilse iki ondalık (18,368 → 18,4). */
|
||||||
|
export function roundForDisplay(quantity: number): number {
|
||||||
|
const unit = Math.abs(quantity) >= 10 * QUANTITY_SCALE ? 100 : 10;
|
||||||
|
return Math.round(quantity / unit) * unit;
|
||||||
|
}
|
||||||
|
|
||||||
/** Form alanı için gruplamasız metin: 1500 → "1,5"; 1_000_000 → "1000" (parseQuantity ile uyumlu). */
|
/** Form alanı için gruplamasız metin: 1500 → "1,5"; 1_000_000 → "1000" (parseQuantity ile uyumlu). */
|
||||||
export function quantityInput(quantity: number): string {
|
export function quantityInput(quantity: number): string {
|
||||||
const int = Math.trunc(quantity / QUANTITY_SCALE);
|
const int = Math.trunc(quantity / QUANTITY_SCALE);
|
||||||
const frac = (quantity % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, "");
|
const frac = (quantity % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, "");
|
||||||
return frac ? `${int},${frac}` : String(int);
|
return frac ? `${int},${frac}` : String(int);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Öneri, tüketim hızı, sayım ve analiz (Faz 11b) ---
|
||||||
|
|
||||||
|
/** Fire oranı son bu kadar alımdan hesaplanır. */
|
||||||
|
export const WASTE_LOOKBACK_PURCHASES = 3;
|
||||||
|
/** Bu oranın (%) altındaki fire öneriyi değiştirmez. */
|
||||||
|
export const WASTE_MIN_PCT = 5;
|
||||||
|
/** Tüketim hızı son bu kadar günden hesaplanır. */
|
||||||
|
export const USAGE_WINDOW_DAYS = 90;
|
||||||
|
/** Bundan kısa geçmişte tüketim hızı hesaplanmaz. */
|
||||||
|
export const USAGE_MIN_DAYS = 14;
|
||||||
|
|
||||||
|
export type WasteStat = {
|
||||||
|
/** Hesaba katılan alım sayısı. */
|
||||||
|
purchases: number;
|
||||||
|
purchased: number;
|
||||||
|
wasted: number;
|
||||||
|
/** Fire oranı (%, tamsayı). */
|
||||||
|
pct: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Son alımların fire oranı: fireler FIFO ile hangi alımdan düşüldüyse ona yazılır
|
||||||
|
* (ör. iki alımın 6 kg'ından 1,8 kg'ı bozuldu → %30). Alım yoksa null.
|
||||||
|
*/
|
||||||
|
export function recentWaste(
|
||||||
|
moves: readonly StockMove[],
|
||||||
|
lookback = WASTE_LOOKBACK_PURCHASES,
|
||||||
|
): WasteStat | null {
|
||||||
|
const purchases = runFifo(moves)
|
||||||
|
.filter((l) => l.kind === "purchase")
|
||||||
|
.slice(-lookback);
|
||||||
|
if (purchases.length === 0) return null;
|
||||||
|
const purchased = purchases.reduce((s, l) => s + l.quantity, 0);
|
||||||
|
const wasted = purchases.reduce((s, l) => s + l.wasted, 0);
|
||||||
|
return {
|
||||||
|
purchases: purchases.length,
|
||||||
|
purchased,
|
||||||
|
wasted,
|
||||||
|
pct: purchased > 0 ? Math.round((wasted * 100) / purchased) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kullanım: tüketim + sayımda çıkan eksik (başlangıç hariç). Fire ayrı tutulur. */
|
||||||
|
function usageOf(m: StockMove & { initial?: boolean }): number {
|
||||||
|
if (m.kind === "consume") return m.quantity;
|
||||||
|
if (m.kind === "count_adjust" && !m.initial && m.quantity < 0) return -m.quantity;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsageRate = {
|
||||||
|
/** Günlük ortalama kullanım (binde bir, kesirli olabilir). */
|
||||||
|
perDay: number;
|
||||||
|
/** Hesaba katılan gün sayısı. */
|
||||||
|
days: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Son `window` günün günlük ortalama kullanımı. Pencere, ilk hareketten önce başlıyorsa ilk
|
||||||
|
* hareketten sayılır. Geçmiş `USAGE_MIN_DAYS`'ten kısaysa null.
|
||||||
|
*/
|
||||||
|
export function usageRate(
|
||||||
|
moves: readonly (StockMove & { initial?: boolean })[],
|
||||||
|
today: string,
|
||||||
|
window = USAGE_WINDOW_DAYS,
|
||||||
|
): UsageRate | null {
|
||||||
|
const first = moves[0]?.date;
|
||||||
|
if (!first) return null;
|
||||||
|
const windowStart = addDays(today, -(window - 1));
|
||||||
|
const start = first > windowStart ? first : windowStart;
|
||||||
|
if (start > today) return null;
|
||||||
|
const days = daysBetween(start, today);
|
||||||
|
if (days < USAGE_MIN_DAYS) return null;
|
||||||
|
const used = moves
|
||||||
|
.filter((m) => m.date >= start && m.date <= today)
|
||||||
|
.reduce((s, m) => s + usageOf(m), 0);
|
||||||
|
return { perDay: used / days, days };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PurchaseSuggestion = {
|
||||||
|
/** Önerilen alım (binde bir). */
|
||||||
|
quantity: number;
|
||||||
|
/** Hedefe tamamlayan ham miktar. */
|
||||||
|
base: number;
|
||||||
|
/** Öneriyi düşüren fire (oran WASTE_MIN_PCT'nin altındaysa null). */
|
||||||
|
waste: WasteStat | null;
|
||||||
|
/** Yuvarlama birimi: haftalık kullanım (adımın katı) veya adım. */
|
||||||
|
roundTo: number;
|
||||||
|
roundedByUsage: boolean;
|
||||||
|
usage: UsageRate | null;
|
||||||
|
/** Önerilen alım + mevcut kaç gün yeter (kullanım bilinmiyorsa null). */
|
||||||
|
coverDays: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundUp = (x: number, unit: number) => Math.ceil(x / unit) * unit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Önerilen alım miktarı (Bölüm 6.10):
|
||||||
|
* 1. Taban = hedef − mevcut.
|
||||||
|
* 2. Son alımlarda fire varsa (≥ %5) taban fire oranı kadar düşürülür.
|
||||||
|
* 3. Tüketim hızına göre yuvarlanır: haftalık kullanım adımdan büyükse haftalık kullanımın
|
||||||
|
* (adıma yuvarlanmış) katına, değilse adıma en yakın kata. Sonuç tabanı (adıma yukarı
|
||||||
|
* yuvarlanmış) aşmaz; taban > 0 iken en az bir adımdır.
|
||||||
|
*/
|
||||||
|
export function suggestPurchase(input: {
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
step: number;
|
||||||
|
moves: readonly (StockMove & { initial?: boolean })[];
|
||||||
|
today: string;
|
||||||
|
}): PurchaseSuggestion {
|
||||||
|
const step = Math.max(1, input.step);
|
||||||
|
const base = refillQuantity(input.current, input.target);
|
||||||
|
const stat = recentWaste(input.moves);
|
||||||
|
const waste = stat && stat.pct >= WASTE_MIN_PCT && stat.wasted > 0 ? stat : null;
|
||||||
|
const usage = usageRate(input.moves, input.today);
|
||||||
|
const weekly = usage ? Math.round((usage.perDay * 7) / step) * step : 0;
|
||||||
|
const roundTo = weekly > step ? weekly : step;
|
||||||
|
let quantity = 0;
|
||||||
|
if (base > 0) {
|
||||||
|
const reduced = waste ? (base * (100 - waste.pct)) / 100 : base;
|
||||||
|
quantity = Math.round(reduced / roundTo) * roundTo;
|
||||||
|
if (quantity === 0) quantity = roundUp(reduced, step);
|
||||||
|
quantity = Math.max(step, Math.min(quantity, roundUp(base, step)));
|
||||||
|
}
|
||||||
|
const after = Math.max(0, input.current) + quantity;
|
||||||
|
return {
|
||||||
|
quantity,
|
||||||
|
base,
|
||||||
|
waste,
|
||||||
|
roundTo,
|
||||||
|
roundedByUsage: roundTo !== step,
|
||||||
|
usage,
|
||||||
|
coverDays: usage && usage.perDay > 0 ? Math.floor(after / usage.perDay) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CountSchedule = {
|
||||||
|
everyDays: number;
|
||||||
|
/** Son sayım (yoksa null). */
|
||||||
|
lastDate: string | null;
|
||||||
|
nextDate: string;
|
||||||
|
due: boolean;
|
||||||
|
/** Sayım günü geçtiyse kaç gün geçti (bugün sayım günüyse 0). */
|
||||||
|
overdueDays: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periyodik sayım takvimi: son sayımdan (hiç sayılmadıysa `since`'ten, ör. ilk hareket)
|
||||||
|
* `everyDays` gün sonra sayım gelir. Sıklık yoksa null.
|
||||||
|
*/
|
||||||
|
export function countSchedule(
|
||||||
|
everyDays: number | null,
|
||||||
|
lastDate: string | null,
|
||||||
|
since: string,
|
||||||
|
today: string,
|
||||||
|
): CountSchedule | null {
|
||||||
|
if (!everyDays) return null;
|
||||||
|
const nextDate = addDays(lastDate ?? since, everyDays);
|
||||||
|
const due = nextDate <= today;
|
||||||
|
return {
|
||||||
|
everyDays,
|
||||||
|
lastDate,
|
||||||
|
nextDate,
|
||||||
|
due,
|
||||||
|
overdueDays: due ? daysBetween(nextDate, today) - 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonthUsage = { month: string; used: number; wasted: number };
|
||||||
|
|
||||||
|
/** Ay ay ("YYYY-MM") kullanım ve fire. */
|
||||||
|
export function monthlyUsage(
|
||||||
|
moves: readonly (StockMove & { initial?: boolean })[],
|
||||||
|
months: readonly string[],
|
||||||
|
): MonthUsage[] {
|
||||||
|
const by = new Map(months.map((m) => [m, { month: m, used: 0, wasted: 0 }]));
|
||||||
|
for (const mv of moves) {
|
||||||
|
const row = by.get(mv.date.slice(0, 7));
|
||||||
|
if (!row) continue;
|
||||||
|
row.used += usageOf(mv);
|
||||||
|
if (mv.kind === "waste") row.wasted += mv.quantity;
|
||||||
|
}
|
||||||
|
return months.map((m) => by.get(m)!);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user