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:
@@ -9,6 +9,7 @@ import {
|
||||
productTypeFormSchema,
|
||||
stockCategoryFormSchema,
|
||||
stockCountFormSchema,
|
||||
stockCountSheetSchema,
|
||||
stockMovementFormSchema,
|
||||
} from "@/shared/schemas/stock";
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
quickConsume,
|
||||
setProductTypeArchived,
|
||||
setStockCategoryArchived,
|
||||
submitCount,
|
||||
updateProductType,
|
||||
updateStockCategory,
|
||||
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) {
|
||||
return withId(movementId, (id) =>
|
||||
run(z.unknown(), null, (u, h) => deleteStockMovement(u, h, id)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { StockCountModel } from "@/models/stock-count";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
productTypeFormSchema,
|
||||
stockCategoryFormSchema,
|
||||
stockCountFormSchema,
|
||||
stockCountSheetSchema,
|
||||
stockMovementFormSchema,
|
||||
type ProductTypeFormInput,
|
||||
} from "@/shared/schemas/stock";
|
||||
@@ -18,13 +20,16 @@ import {
|
||||
createProductType,
|
||||
createStockCategory,
|
||||
deleteStockMovement,
|
||||
getCountSheet,
|
||||
getProductType,
|
||||
getStock,
|
||||
getStockAnalysis,
|
||||
getStockOptions,
|
||||
listStockCategories,
|
||||
quickConsume,
|
||||
setProductTypeArchived,
|
||||
setStockCategoryArchived,
|
||||
submitCount,
|
||||
updateStockCategory,
|
||||
} from "./service";
|
||||
|
||||
@@ -305,3 +310,137 @@ describe("stok kategorileri", () => {
|
||||
).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 { 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 { shiftMonth } from "@/shared/budget";
|
||||
import type {
|
||||
ProductTypeForm,
|
||||
StockCategoryForm,
|
||||
StockCountForm,
|
||||
StockCountSheet,
|
||||
StockMovementForm,
|
||||
} from "@/shared/schemas/stock";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
import {
|
||||
countSchedule,
|
||||
currentQuantity,
|
||||
expiringLots,
|
||||
expiryStatus,
|
||||
fifoLots,
|
||||
formatStock,
|
||||
refillQuantity,
|
||||
monthlyUsage,
|
||||
roundForDisplay,
|
||||
signedDelta,
|
||||
stockLevel,
|
||||
suggestPurchase,
|
||||
type CountSchedule,
|
||||
type ExpiringLot,
|
||||
type ExpiryStatus,
|
||||
type MonthUsage,
|
||||
type PurchaseSuggestion,
|
||||
type StockLevel,
|
||||
type StockMovementKind,
|
||||
type WasteReason,
|
||||
@@ -77,6 +86,7 @@ type ProductLean = {
|
||||
countEveryDays: number | null;
|
||||
note: string;
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type MoveLean = {
|
||||
@@ -124,16 +134,30 @@ export type ProductTypeView = {
|
||||
current: number;
|
||||
level: StockLevel;
|
||||
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[];
|
||||
lastMovementDate: string | null;
|
||||
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 = {
|
||||
categories: StockCategoryView[];
|
||||
items: ProductTypeView[];
|
||||
suggestions: ProductTypeView[];
|
||||
expiring: { item: ProductTypeView; lot: ExpiringLot }[];
|
||||
countDue: CountDueGroup[];
|
||||
canWrite: boolean;
|
||||
today: string;
|
||||
};
|
||||
@@ -261,19 +285,43 @@ function categoryViews(
|
||||
});
|
||||
}
|
||||
|
||||
function toView(
|
||||
p: ProductLean,
|
||||
moves: MoveLean[],
|
||||
categoryPath: string,
|
||||
today: string,
|
||||
): ProductTypeView {
|
||||
/** Ürün başına son sayım tarihi (sayım oturumlarından). */
|
||||
async function loadLastCounts(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||
const rows = await StockCountModel.aggregate<{ _id: Types.ObjectId; date: string }>([
|
||||
{ $match: { householdId, "items.productTypeId": { $in: productIds } } },
|
||||
{ $unwind: "$items" },
|
||||
{ $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 level = stockLevel(current, p.targetQuantity, p.thresholdPct);
|
||||
const suggestion = suggestPurchase({
|
||||
current,
|
||||
target: p.targetQuantity,
|
||||
step: p.step,
|
||||
moves,
|
||||
today,
|
||||
});
|
||||
return {
|
||||
id: String(p._id),
|
||||
name: p.name,
|
||||
categoryId: String(p.categoryId),
|
||||
categoryPath,
|
||||
categoryPath: ctx.categoryPath,
|
||||
unit: p.unit,
|
||||
targetQuantity: p.targetQuantity,
|
||||
thresholdPct: p.thresholdPct,
|
||||
@@ -284,7 +332,14 @@ function toView(
|
||||
archived: Boolean(p.archivedAt),
|
||||
current,
|
||||
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),
|
||||
lastMovementDate: moves.at(-1)?.date ?? null,
|
||||
movementCount: moves.length,
|
||||
@@ -308,15 +363,22 @@ export async function getStock(userId: string, householdId: string): Promise<Sto
|
||||
includeArchived: true,
|
||||
});
|
||||
const order = new Map(categories.map((c, i) => [c.id, i]));
|
||||
const pathOf = new Map(categories.map((c) => [c.id, c.path]));
|
||||
const moves = await loadMoves(
|
||||
m.householdId,
|
||||
products.map((p) => p._id),
|
||||
);
|
||||
const catOf = new Map(categories.map((c) => [c.id, c]));
|
||||
const ids = products.map((p) => p._id);
|
||||
const [moves, lastCounts] = await Promise.all([
|
||||
loadMoves(m.householdId, ids),
|
||||
loadLastCounts(m.householdId, ids),
|
||||
]);
|
||||
const items = products
|
||||
.map((p) =>
|
||||
toView(p, moves.get(String(p._id)) ?? [], pathOf.get(String(p.categoryId)) ?? "", today),
|
||||
)
|
||||
.map((p) => {
|
||||
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(
|
||||
(a, b) =>
|
||||
(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,
|
||||
suggestions,
|
||||
expiring,
|
||||
countDue: groupCountDue(items),
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
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) {
|
||||
const id = toObjectId(productTypeId);
|
||||
if (!id) return null;
|
||||
@@ -357,11 +439,14 @@ export async function getProductType(
|
||||
if (!p) return null;
|
||||
const today = todayIstanbul();
|
||||
const cats = await loadCategories(m.householdId);
|
||||
const path =
|
||||
categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find(
|
||||
(c) => c.id === String(p.categoryId),
|
||||
)?.path ?? "";
|
||||
const moves = (await loadMoves(m.householdId, [p._id])).get(String(p._id)) ?? [];
|
||||
const cat = categoryViews(cats, new Map(), new Map(), { includeArchived: true }).find(
|
||||
(c) => c.id === String(p.categoryId),
|
||||
);
|
||||
const [movesBy, lastCounts] = await Promise.all([
|
||||
loadMoves(m.householdId, [p._id]),
|
||||
loadLastCounts(m.householdId, [p._id]),
|
||||
]);
|
||||
const moves = movesBy.get(String(p._id)) ?? [];
|
||||
let running = 0;
|
||||
const rows = moves.map((mv): StockMovementRow => {
|
||||
const delta = signedDelta(mv);
|
||||
@@ -379,7 +464,12 @@ export async function getProductType(
|
||||
};
|
||||
});
|
||||
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(),
|
||||
lots: fifoLots(moves).map((l) => ({
|
||||
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
|
||||
* düzeltmesi olur. Fark yoksa kayıt oluşmaz. Farkı (işaretli) döner.
|
||||
* Tek ürün sayımı (ürün detayından): toplu sayımın tek satırlık hâli. Fark yoksa düzeltme
|
||||
* oluşmaz ama sayım kaydı tutulur. Farkı (işaretli) döner.
|
||||
*/
|
||||
export async function countStock(
|
||||
actorId: string,
|
||||
@@ -886,22 +976,21 @@ export async function countStock(
|
||||
productTypeId: string,
|
||||
input: StockCountForm,
|
||||
): Promise<ActionResult<{ diff: number }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const r = await activeProductWithStock(m, productTypeId, input.date);
|
||||
if (r.error !== null) return fail(r.error);
|
||||
const diff = input.actualQuantity - r.current;
|
||||
if (diff !== 0)
|
||||
await StockMovementModel.create({
|
||||
householdId: m.householdId,
|
||||
productTypeId: r.p._id,
|
||||
kind: "count_adjust",
|
||||
date: input.date,
|
||||
quantity: diff,
|
||||
note: input.note,
|
||||
createdBy: new Types.ObjectId(actorId),
|
||||
});
|
||||
return ok({ diff });
|
||||
const r = await submitCount(actorId, householdId, {
|
||||
date: input.date,
|
||||
categoryId: null,
|
||||
note: input.note,
|
||||
rows: [
|
||||
{
|
||||
productTypeId,
|
||||
actualQuantity: input.actualQuantity,
|
||||
wasteQuantity: null,
|
||||
wasteReason: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!r.ok) return r;
|
||||
return ok({ diff: r.data.diffs[0] ?? 0 });
|
||||
}
|
||||
|
||||
export async function deleteStockMovement(
|
||||
@@ -936,3 +1025,270 @@ export async function deleteStockMovement(
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user