feat(prices): fiyat kayıtları, birim fiyat motoru ve analizde aylık maliyet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { productPriceFormSchema } from "@/shared/schemas/product-price";
|
||||
import { addProductPrice, deleteProductPrice } from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function run<T, R>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||
): Promise<ActionResult<R>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function addProductPriceAction(input: unknown) {
|
||||
return run(productPriceFormSchema, input, (u, h, d) => addProductPrice(u, h, d));
|
||||
}
|
||||
|
||||
export async function deleteProductPriceAction(priceId: unknown) {
|
||||
const id = idSchema.safeParse(priceId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => deleteProductPrice(u, h, id.data));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ProductView } from "@/features/products/service";
|
||||
|
||||
/** Fiyat formu ürün seçeneği (birim fiyat önizlemesi için dönüşümle). İstemcide de kullanılır. */
|
||||
export type PriceProductOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
packageLabel: string;
|
||||
inTypeUnit: number | null;
|
||||
typeUnit: string;
|
||||
};
|
||||
|
||||
export const priceProductOption = (p: ProductView): PriceProductOption => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
packageLabel: p.packageLabel,
|
||||
inTypeUnit: p.inTypeUnit,
|
||||
typeUnit: p.typeUnit,
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getStockAnalysis } from "@/features/stock/service";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { addDays } from "@/shared/recurrence";
|
||||
import { productPriceFormSchema } from "@/shared/schemas/product-price";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
addProductPrice,
|
||||
deleteProductPrice,
|
||||
getProductPrices,
|
||||
getTypePriceComparison,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
const today = todayIstanbul();
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const ref = (
|
||||
model: typeof ProductModel | typeof StoreModel | typeof ProductTypeModel,
|
||||
seedRef: string,
|
||||
) =>
|
||||
(model as typeof ProductModel)
|
||||
.findOne({ seedRef })
|
||||
.lean()
|
||||
.then((x) => String(x!._id));
|
||||
|
||||
describe("birim fiyat karşılaştırması", () => {
|
||||
it("toz deterjan: birim fiyata göre sıralı, en uygun işaretli, süren kampanya", async () => {
|
||||
const c = (await getTypePriceComparison(
|
||||
ayse,
|
||||
cakir,
|
||||
await ref(ProductTypeModel, "toz-deterjan"),
|
||||
))!;
|
||||
expect(c.typeUnit).toBe("yıkama");
|
||||
const units = c.offers.map((o) => o.unitPriceMinor!);
|
||||
expect(units).toEqual([...units].sort((a, b) => a - b));
|
||||
expect(c.offers[0]!.cheapest).toBe(true);
|
||||
expect(c.offers.filter((o) => o.cheapest)).toHaveLength(1);
|
||||
const ariel = c.offers.find(
|
||||
(o) => o.productLabel.startsWith("Ariel") && o.store.label.startsWith("Migros"),
|
||||
)!;
|
||||
expect(ariel.price).toMatchObject({ isPromo: true, promoUntil: addDays(today, 6) });
|
||||
expect(ariel.promoEnded).toBe(false);
|
||||
// Birim fiyat = paket / 33 yıkama.
|
||||
expect(ariel.unitPriceMinor!).toBeCloseTo((ariel.price.priceMinor * 1000) / 33_000, 6);
|
||||
expect(c.products).toHaveLength(3);
|
||||
expect(
|
||||
await getTypePriceComparison(fatma, cakir, await ref(ProductTypeModel, "toz-deterjan")),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("süresi biten kampanyada normal fiyata dönülür; eski fiyat işaretlenir", async () => {
|
||||
const sut = (await getProductPrices(ayse, cakir, await ref(ProductModel, "pinar-sut")))!;
|
||||
const a101 = sut.offers.find((o) => o.store.label.startsWith("A101"))!;
|
||||
expect(a101).toMatchObject({ fellBack: true, promoEnded: false });
|
||||
expect(a101.price.isPromo).toBe(false);
|
||||
expect(a101.price.date).toBe(addDays(today, -30));
|
||||
expect(sut.history.some((h) => h.isPromo && h.promoEnded)).toBe(true);
|
||||
expect(sut.series.length).toBeGreaterThanOrEqual(3);
|
||||
expect(sut.points.length).toBeGreaterThan(5);
|
||||
|
||||
const reis = (await getProductPrices(ayse, cakir, await ref(ProductModel, "reis-mercimek")))!;
|
||||
expect(reis.offers.find((o) => o.store.label.startsWith("Metro"))!.stale).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fiyat girişi", () => {
|
||||
it("ekle, gelecek tarih ve arşivli mağaza reddi, izleyici yazamaz, sil + denetim", async () => {
|
||||
const productId = await ref(ProductModel, "omo-6kg");
|
||||
const storeId = await ref(StoreModel, "sok");
|
||||
const form = (over: Record<string, unknown> = {}) =>
|
||||
productPriceFormSchema.parse({
|
||||
productId,
|
||||
storeId,
|
||||
date: today,
|
||||
price: "449,90",
|
||||
isPromo: false,
|
||||
promoUntil: "",
|
||||
note: "",
|
||||
...over,
|
||||
});
|
||||
expect(
|
||||
productPriceFormSchema.safeParse({ ...form(), price: "0", isPromo: false }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
productPriceFormSchema.safeParse({
|
||||
productId,
|
||||
storeId,
|
||||
date: today,
|
||||
price: "10",
|
||||
isPromo: true,
|
||||
promoUntil: addDays(today, -1),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
|
||||
expect((await addProductPrice(ayse, cakir, form({ date: addDays(today, 1) }))).ok).toBe(false);
|
||||
expect((await addProductPrice(zeynep, cakir, form())).ok).toBe(false);
|
||||
|
||||
const r = await addProductPrice(ayse, cakir, form());
|
||||
expect(r.ok).toBe(true);
|
||||
let d = (await getProductPrices(ayse, cakir, productId))!;
|
||||
const sok = d.offers.find((o) => o.store.id === storeId)!;
|
||||
expect(sok.price).toMatchObject({ priceMinor: 449_90, date: today, source: "manual" });
|
||||
// 6 kg = 40 yıkama → 11,2475 TL/yıkama.
|
||||
expect(sok.unitPriceMinor).toBeCloseTo(1124.75, 6);
|
||||
|
||||
const priceId = r.ok ? r.data.id : "";
|
||||
expect((await deleteProductPrice(fatma, cakir, priceId)).ok).toBe(false);
|
||||
expect((await deleteProductPrice(ayse, cakir, priceId)).ok).toBe(true);
|
||||
expect(await AuditLogModel.exists({ action: "price.delete", targetId: priceId })).toBeTruthy();
|
||||
d = (await getProductPrices(ayse, cakir, productId))!;
|
||||
expect(d.history.some((h) => h.id === priceId)).toBe(false);
|
||||
|
||||
await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: new Date() } });
|
||||
expect(await addProductPrice(ayse, cakir, form())).toMatchObject({
|
||||
ok: false,
|
||||
fieldErrors: { storeId: expect.any(String) },
|
||||
});
|
||||
await StoreModel.updateOne({ _id: storeId }, { $set: { archivedAt: null } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tüketim analizinde maliyet", () => {
|
||||
it("aylık ortalama tüketim × son birim fiyat", async () => {
|
||||
const a = (await getStockAnalysis(ayse, cakir))!;
|
||||
const deterjan = a.rows.find((r) => r.name === "Toz Deterjan")!;
|
||||
expect(deterjan.unitPrice).not.toBeNull();
|
||||
// Maliyet yuvarlanmamış ortalamadan; gösterilen ortalama yuvarlıdır (≤ %1 fark).
|
||||
const expected = (deterjan.avgMonthlyUsed! * deterjan.unitPrice!.minor) / 1000;
|
||||
expect(Math.abs(deterjan.avgMonthlyCostMinor! - expected) / expected).toBeLessThan(0.01);
|
||||
// Ürünü (marka/paket) olmayan tipte maliyet yok.
|
||||
expect(a.rows.find((r) => r.name === "Domates")!.avgMonthlyCostMinor).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||
import { listProducts, type ProductView } from "@/features/products/service";
|
||||
import { storeLabel } from "@/features/stores/service";
|
||||
import { priceProductOption, type PriceProductOption } from "./options";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductPriceModel } from "@/models/product-price";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import {
|
||||
effectivePrice,
|
||||
promoEnded,
|
||||
rankOffers,
|
||||
seriesSlot,
|
||||
unitPrice,
|
||||
type PriceSource,
|
||||
type RankedOffer,
|
||||
} from "@/shared/prices";
|
||||
import { packageInTypeUnit } from "@/shared/products";
|
||||
import type { ProductPriceForm } from "@/shared/schemas/product-price";
|
||||
import { compareTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Ürün fiyatları (Bölüm 6.11). Hane bazlı; okuma viewer, yazma member. Birim fiyat ürünün
|
||||
* dönüşümüyle okuma sırasında hesaplanır (saf motor: shared/prices).
|
||||
*/
|
||||
|
||||
const t = tr.productPrices;
|
||||
|
||||
type PriceLean = {
|
||||
_id: Types.ObjectId;
|
||||
productId: Types.ObjectId;
|
||||
storeId: Types.ObjectId;
|
||||
date: string;
|
||||
priceMinor: number;
|
||||
isPromo: boolean;
|
||||
promoUntil: string | null;
|
||||
source: PriceSource;
|
||||
note: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type StoreLean = {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
branch: string;
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type StoreRef = { id: string; label: string; slot: number; archived: boolean };
|
||||
|
||||
export type PriceRow = {
|
||||
id: string;
|
||||
productId: string;
|
||||
store: StoreRef;
|
||||
date: string;
|
||||
priceMinor: number;
|
||||
isPromo: boolean;
|
||||
promoUntil: string | null;
|
||||
promoEnded: boolean;
|
||||
source: PriceSource;
|
||||
note: string;
|
||||
/** Ürün tipi biriminde 1 birimin fiyatı (kesirli kuruş; dönüşüm yoksa null). */
|
||||
unitPriceMinor: number | null;
|
||||
};
|
||||
|
||||
export type StoreOffer = {
|
||||
/** Teklif anahtarı: ürün + mağaza. */
|
||||
key: string;
|
||||
productId: string;
|
||||
productLabel: string;
|
||||
packageLabel: string;
|
||||
store: StoreRef;
|
||||
price: PriceRow;
|
||||
/** Son kayıt süresi biten kampanyaydı; önceki normal fiyat gösteriliyor. */
|
||||
fellBack: boolean;
|
||||
promoEnded: boolean;
|
||||
ageDays: number;
|
||||
stale: boolean;
|
||||
unitPriceMinor: number | null;
|
||||
};
|
||||
|
||||
export type ProductPriceDetail = {
|
||||
product: ProductView;
|
||||
offers: RankedOffer<StoreOffer>[];
|
||||
/** Tüm kayıtlar, yeniden eskiye. */
|
||||
history: PriceRow[];
|
||||
/** Grafik: mağaza serileri ve tarih noktaları (paket fiyatı, kuruş). */
|
||||
series: StoreRef[];
|
||||
points: Record<string, number | string>[];
|
||||
stores: StoreRef[];
|
||||
canWrite: boolean;
|
||||
today: string;
|
||||
};
|
||||
|
||||
export type TypePriceComparison = {
|
||||
typeUnit: string;
|
||||
offers: RankedOffer<StoreOffer>[];
|
||||
/** Fiyat girişi seçenekleri. */
|
||||
products: PriceProductOption[];
|
||||
stores: StoreRef[];
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
/** Hanenin tüm mağazaları; renk yuvası ekleniş sırasına göre sabit. */
|
||||
async function loadStores(householdId: Types.ObjectId) {
|
||||
const rows = await StoreModel.find({ householdId })
|
||||
.select({ name: 1, branch: 1, archivedAt: 1, createdAt: 1 })
|
||||
.sort({ createdAt: 1, _id: 1 })
|
||||
.lean<StoreLean[]>();
|
||||
return new Map(
|
||||
rows.map((s, i) => [
|
||||
String(s._id),
|
||||
{
|
||||
id: String(s._id),
|
||||
label: storeLabel(s),
|
||||
slot: seriesSlot(i),
|
||||
archived: Boolean(s.archivedAt),
|
||||
} satisfies StoreRef,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const sortRecords = (a: PriceLean, b: PriceLean) =>
|
||||
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
|
||||
|
||||
async function loadPrices(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||
const rows = await ProductPriceModel.find({
|
||||
householdId,
|
||||
productId: { $in: productIds },
|
||||
deletedAt: null,
|
||||
}).lean<PriceLean[]>();
|
||||
return rows.sort(sortRecords);
|
||||
}
|
||||
|
||||
function toRow(r: PriceLean, store: StoreRef, inTypeUnit: number | null, today: string): PriceRow {
|
||||
return {
|
||||
id: String(r._id),
|
||||
productId: String(r.productId),
|
||||
store,
|
||||
date: r.date,
|
||||
priceMinor: r.priceMinor,
|
||||
isPromo: r.isPromo,
|
||||
promoUntil: r.promoUntil,
|
||||
promoEnded: promoEnded(r, today),
|
||||
source: r.source,
|
||||
note: r.note,
|
||||
unitPriceMinor: inTypeUnit ? unitPrice(r.priceMinor, inTypeUnit) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ürün × mağaza başına geçerli fiyat teklifleri (sıralanmamış). */
|
||||
function offersFor(
|
||||
products: ProductView[],
|
||||
prices: PriceLean[],
|
||||
stores: Map<string, StoreRef>,
|
||||
today: string,
|
||||
): StoreOffer[] {
|
||||
const groups = new Map<string, PriceLean[]>();
|
||||
for (const r of prices) {
|
||||
const key = `${r.productId}:${r.storeId}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||
}
|
||||
const byId = new Map(products.map((p) => [p.id, p]));
|
||||
const out: StoreOffer[] = [];
|
||||
for (const [key, records] of groups) {
|
||||
const p = byId.get(String(records[0]!.productId));
|
||||
const store = stores.get(String(records[0]!.storeId));
|
||||
if (!p || !store) continue;
|
||||
const eff = effectivePrice(records, today)!;
|
||||
const row = toRow(eff.record, store, p.inTypeUnit, today);
|
||||
out.push({
|
||||
key,
|
||||
productId: p.id,
|
||||
productLabel: p.label,
|
||||
packageLabel: p.packageLabel,
|
||||
store,
|
||||
price: row,
|
||||
fellBack: eff.fellBack,
|
||||
promoEnded: eff.promoEnded,
|
||||
ageDays: eff.ageDays,
|
||||
stale: eff.stale,
|
||||
unitPriceMinor: row.unitPriceMinor,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const activeStores = (stores: Map<string, StoreRef>) =>
|
||||
[...stores.values()].filter((s) => !s.archived).sort((a, b) => compareTr(a.label, b.label));
|
||||
|
||||
/** /stok/urunler/:id — ürünün mağaza bazında fiyatları, geçmişi ve grafiği. */
|
||||
export async function getProductPrices(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
productId: string,
|
||||
): Promise<ProductPriceDetail | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const id = toObjectId(productId);
|
||||
if (!id) return null;
|
||||
const doc = await ProductModel.findOne({ _id: id, householdId: m.householdId })
|
||||
.select({ productTypeId: 1 })
|
||||
.lean<{ productTypeId: Types.ObjectId }>();
|
||||
if (!doc) return null;
|
||||
const list = await listProducts(userId, householdId, {
|
||||
productTypeId: String(doc.productTypeId),
|
||||
includeArchived: true,
|
||||
});
|
||||
const product = list?.products.find((p) => p.id === productId);
|
||||
if (!product) return null;
|
||||
const today = todayIstanbul();
|
||||
const [stores, prices] = await Promise.all([
|
||||
loadStores(m.householdId),
|
||||
loadPrices(m.householdId, [id]),
|
||||
]);
|
||||
const history = prices
|
||||
.map((r) => toRow(r, stores.get(String(r.storeId))!, product.inTypeUnit, today))
|
||||
.filter((r) => r.store)
|
||||
.reverse();
|
||||
const seriesIds = [...new Set(prices.map((r) => String(r.storeId)))];
|
||||
const series = seriesIds
|
||||
.map((sid) => stores.get(sid)!)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.slot - b.slot);
|
||||
// Aynı gün aynı mağazada birden çok kayıt: son giriş geçerli.
|
||||
const byDate = new Map<string, Record<string, number | string>>();
|
||||
for (const r of prices) {
|
||||
const point = byDate.get(r.date) ?? { date: r.date };
|
||||
point[`s${String(r.storeId)}`] = r.priceMinor;
|
||||
byDate.set(r.date, point);
|
||||
}
|
||||
// Son nokta bugün: her mağazanın geçerli fiyatı (çizgi bugüne uzar; biten kampanyada normal fiyat).
|
||||
const offers = offersFor([product], prices, stores, today);
|
||||
if (offers.length) {
|
||||
const point = byDate.get(today) ?? { date: today };
|
||||
for (const o of offers) point[`s${o.store.id}`] ??= o.price.priceMinor;
|
||||
byDate.set(today, point);
|
||||
}
|
||||
return {
|
||||
product,
|
||||
offers: rankOffers(offers),
|
||||
history,
|
||||
series,
|
||||
points: [...byDate.values()].sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1)),
|
||||
stores: activeStores(stores),
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
today,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ürün tipi detayı: tüm marka/paket ve mağazalar birim fiyata göre. */
|
||||
export async function getTypePriceComparison(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
productTypeId: string,
|
||||
): Promise<TypePriceComparison | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const list = await listProducts(userId, householdId, { productTypeId });
|
||||
if (!list) return null;
|
||||
const type = await ProductTypeModel.findOne({
|
||||
_id: new Types.ObjectId(productTypeId),
|
||||
householdId: m.householdId,
|
||||
})
|
||||
.select({ unit: 1 })
|
||||
.lean<{ unit: string }>();
|
||||
if (!type) return null;
|
||||
const today = todayIstanbul();
|
||||
const [stores, prices] = await Promise.all([
|
||||
loadStores(m.householdId),
|
||||
loadPrices(
|
||||
m.householdId,
|
||||
list.products.map((p) => new Types.ObjectId(p.id)),
|
||||
),
|
||||
]);
|
||||
return {
|
||||
typeUnit: type.unit,
|
||||
offers: rankOffers(
|
||||
offersFor(list.products, prices, stores, today).filter((o) => !o.store.archived),
|
||||
),
|
||||
products: list.products.map(priceProductOption),
|
||||
stores: activeStores(stores),
|
||||
canWrite: list.canWrite,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ürün tipi başına son bilinen birim fiyat (en yeni geçerli fiyat kaydı; tüm marka/mağazalar).
|
||||
* Tüketim analizinde aylık maliyet tahmini için. Dönüşümü eksik ürünler atlanır.
|
||||
*/
|
||||
export async function latestTypeUnitPrices(
|
||||
householdId: Types.ObjectId,
|
||||
productTypeIds: Types.ObjectId[],
|
||||
today = todayIstanbul(),
|
||||
): Promise<Map<string, { unitPriceMinor: number; date: string }>> {
|
||||
const [products, types] = await Promise.all([
|
||||
ProductModel.find({ householdId, productTypeId: { $in: productTypeIds } })
|
||||
.select({ productTypeId: 1, packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||||
.lean<
|
||||
{
|
||||
_id: Types.ObjectId;
|
||||
productTypeId: Types.ObjectId;
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
typeQuantity: number | null;
|
||||
}[]
|
||||
>(),
|
||||
ProductTypeModel.find({ householdId, _id: { $in: productTypeIds } })
|
||||
.select({ unit: 1 })
|
||||
.lean<{ _id: Types.ObjectId; unit: string }[]>(),
|
||||
]);
|
||||
const unitOf = new Map(types.map((x) => [String(x._id), x.unit]));
|
||||
const prices = await loadPrices(
|
||||
householdId,
|
||||
products.map((p) => p._id),
|
||||
);
|
||||
const groups = new Map<string, PriceLean[]>();
|
||||
for (const r of prices) {
|
||||
const key = `${r.productId}:${r.storeId}`;
|
||||
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||
}
|
||||
const productById = new Map(products.map((p) => [String(p._id), p]));
|
||||
const out = new Map<string, { unitPriceMinor: number; date: string }>();
|
||||
for (const records of groups.values()) {
|
||||
const p = productById.get(String(records[0]!.productId))!;
|
||||
const typeId = String(p.productTypeId);
|
||||
const inType = packageInTypeUnit({
|
||||
packageQuantity: p.packageQuantity,
|
||||
packageUnit: p.packageUnit,
|
||||
typeUnit: unitOf.get(typeId) ?? "",
|
||||
typeQuantity: p.typeQuantity,
|
||||
});
|
||||
if (!inType) continue;
|
||||
const eff = effectivePrice(records, today)!;
|
||||
const prev = out.get(typeId);
|
||||
if (!prev || eff.record.date > prev.date)
|
||||
out.set(typeId, {
|
||||
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
|
||||
date: eff.record.date,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Yazma ---
|
||||
|
||||
async function activeRefs(m: Membership, input: Pick<ProductPriceForm, "productId" | "storeId">) {
|
||||
const [product, store] = await Promise.all([
|
||||
ProductModel.exists({
|
||||
_id: new Types.ObjectId(input.productId),
|
||||
householdId: m.householdId,
|
||||
archivedAt: null,
|
||||
}),
|
||||
StoreModel.exists({
|
||||
_id: new Types.ObjectId(input.storeId),
|
||||
householdId: m.householdId,
|
||||
archivedAt: null,
|
||||
}),
|
||||
]);
|
||||
if (!product) return fail(t.validation.product, { productId: t.validation.product });
|
||||
if (!store) return fail(t.validation.store, { storeId: t.validation.store });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fiyat kaydı ekler. Gelecek tarihli fiyat girilemez. */
|
||||
export async function addProductPrice(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: ProductPriceForm,
|
||||
source: PriceSource = "manual",
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
if (input.date > todayIstanbul())
|
||||
return fail(t.validation.futureDate, { date: t.validation.futureDate });
|
||||
const bad = await activeRefs(m, input);
|
||||
if (bad) return bad;
|
||||
const doc = await ProductPriceModel.create({
|
||||
householdId: m.householdId,
|
||||
productId: new Types.ObjectId(input.productId),
|
||||
storeId: new Types.ObjectId(input.storeId),
|
||||
date: input.date,
|
||||
priceMinor: input.priceMinor,
|
||||
isPromo: input.isPromo,
|
||||
promoUntil: input.promoUntil,
|
||||
source,
|
||||
note: input.note,
|
||||
createdBy: new Types.ObjectId(actorId),
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function deleteProductPrice(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
priceId: string,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(priceId);
|
||||
const row = id
|
||||
? await ProductPriceModel.findOne({
|
||||
_id: id,
|
||||
householdId: m.householdId,
|
||||
deletedAt: null,
|
||||
}).lean<PriceLean>()
|
||||
: null;
|
||||
if (!row) return fail(tr.common.notFound);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await ProductPriceModel.updateOne({ _id: row._id }, { $set: { deletedAt: new Date() } });
|
||||
await writeAudit({
|
||||
action: "price.delete",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "productPrice",
|
||||
targetId: priceId,
|
||||
diff: {
|
||||
productId: String(row.productId),
|
||||
storeId: String(row.storeId),
|
||||
date: row.date,
|
||||
priceMinor: row.priceMinor,
|
||||
},
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export type { PriceProductOption };
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/features/categories/tree";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||
import { latestTypeUnitPrices } from "@/features/prices/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
@@ -24,6 +25,7 @@ import { StockCountModel } from "@/models/stock-count";
|
||||
import { StockMovementModel } from "@/models/stock-movement";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { shiftMonth } from "@/shared/budget";
|
||||
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||
import type {
|
||||
ProductTypeForm,
|
||||
StockCategoryForm,
|
||||
@@ -1242,6 +1244,10 @@ export type StockAnalysisRow = {
|
||||
avgMonthlyWasted: number | null;
|
||||
/** Fire / (kullanım + fire), %. */
|
||||
wastePct: number | null;
|
||||
/** Son bilinen birim fiyat (ürün tipi biriminde, kesirli kuruş) ve tarihi. */
|
||||
unitPrice: { minor: number; date: string } | null;
|
||||
/** Aylık ortalama maliyet tahmini: ortalama tüketim × son birim fiyat (kuruş). */
|
||||
avgMonthlyCostMinor: number | null;
|
||||
/** Bu ay (bugüne kadar). */
|
||||
thisMonth: MonthUsage;
|
||||
};
|
||||
@@ -1264,10 +1270,11 @@ export async function getStockAnalysis(
|
||||
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 ids = data.items.map((i) => new Types.ObjectId(i.id));
|
||||
const [moves, prices] = await Promise.all([
|
||||
loadMoves(m.householdId, ids),
|
||||
latestTypeUnitPrices(m.householdId, ids, data.today),
|
||||
]);
|
||||
const rows = data.items.map((item): StockAnalysisRow => {
|
||||
const list = moves.get(item.id) ?? [];
|
||||
const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!];
|
||||
@@ -1277,6 +1284,8 @@ export async function getStockAnalysis(
|
||||
const sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0);
|
||||
const used = sum("used");
|
||||
const wasted = sum("wasted");
|
||||
const price = prices.get(item.id) ?? null;
|
||||
const avgUsed = full.length ? used / full.length : null;
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
@@ -1287,6 +1296,11 @@ export async function getStockAnalysis(
|
||||
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,
|
||||
unitPrice: price ? { minor: price.unitPriceMinor, date: price.date } : null,
|
||||
avgMonthlyCostMinor:
|
||||
price && avgUsed !== null
|
||||
? Math.round((avgUsed * price.unitPriceMinor) / QUANTITY_SCALE)
|
||||
: null,
|
||||
thisMonth: current,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user