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,10 @@
|
|||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "014-product-prices",
|
||||||
|
description: "ürün fiyat kayıtları (productPrices) index'leri",
|
||||||
|
async up() {
|
||||||
|
await ProductPriceModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ 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 { migration as m012 } from "./012-stock-counts";
|
||||||
import { migration as m013 } from "./013-stores-products";
|
import { migration as m013 } from "./013-stores-products";
|
||||||
|
import { migration as m014 } from "./014-product-prices";
|
||||||
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. */
|
||||||
@@ -30,6 +31,7 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
m011,
|
m011,
|
||||||
m012,
|
m012,
|
||||||
m013,
|
m013,
|
||||||
|
m014,
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe("seed", () => {
|
|||||||
expect(summary.created.stock).toBe(19);
|
expect(summary.created.stock).toBe(19);
|
||||||
expect(summary.created.stores).toBe(8);
|
expect(summary.created.stores).toBe(8);
|
||||||
expect(summary.created.products).toBe(22);
|
expect(summary.created.products).toBe(22);
|
||||||
|
expect(summary.created.prices).toBeGreaterThan(200);
|
||||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||||
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
|
||||||
});
|
});
|
||||||
@@ -59,6 +60,7 @@ describe("seed", () => {
|
|||||||
stock: 0,
|
stock: 0,
|
||||||
stores: 0,
|
stores: 0,
|
||||||
products: 0,
|
products: 0,
|
||||||
|
prices: 0,
|
||||||
});
|
});
|
||||||
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
|
||||||
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
|
||||||
|
|||||||
+6
-1
@@ -13,7 +13,7 @@ import { runMigrations } from "../migrations";
|
|||||||
import { seedContacts } from "./contacts";
|
import { seedContacts } from "./contacts";
|
||||||
import { seedAssets } from "./assets";
|
import { seedAssets } from "./assets";
|
||||||
import { seedBudgets } from "./budgets";
|
import { seedBudgets } from "./budgets";
|
||||||
import { seedShopping } from "./shopping";
|
import { seedProductPrices, seedShopping } from "./shopping";
|
||||||
import { seedStock } from "./stock";
|
import { seedStock } from "./stock";
|
||||||
import { seedInKind } from "./in-kind";
|
import { seedInKind } from "./in-kind";
|
||||||
import { seedInstallments } from "./installments";
|
import { seedInstallments } from "./installments";
|
||||||
@@ -60,6 +60,7 @@ export type SeedSummary = {
|
|||||||
stock: number;
|
stock: number;
|
||||||
stores: number;
|
stores: number;
|
||||||
products: number;
|
products: number;
|
||||||
|
prices: number;
|
||||||
};
|
};
|
||||||
accounts: SeedAccountRow[];
|
accounts: SeedAccountRow[];
|
||||||
};
|
};
|
||||||
@@ -89,6 +90,7 @@ const DOMAIN_COLLECTIONS = [
|
|||||||
"stockCounts",
|
"stockCounts",
|
||||||
"stores",
|
"stores",
|
||||||
"products",
|
"products",
|
||||||
|
"productPrices",
|
||||||
];
|
];
|
||||||
|
|
||||||
type AuthApi = ReturnType<typeof getAuth>["api"];
|
type AuthApi = ReturnType<typeof getAuth>["api"];
|
||||||
@@ -147,6 +149,7 @@ export async function runSeed({
|
|||||||
stock: 0,
|
stock: 0,
|
||||||
stores: 0,
|
stores: 0,
|
||||||
products: 0,
|
products: 0,
|
||||||
|
prices: 0,
|
||||||
},
|
},
|
||||||
accounts: [],
|
accounts: [],
|
||||||
};
|
};
|
||||||
@@ -336,6 +339,8 @@ export async function runSeed({
|
|||||||
summary.created.products = shopping.products;
|
summary.created.products = shopping.products;
|
||||||
if (shopping.stores || shopping.products)
|
if (shopping.stores || shopping.products)
|
||||||
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
|
log(`✓ Örnek mağazalar ve ürünler: ${shopping.stores} / ${shopping.products}`);
|
||||||
|
summary.created.prices = await seedProductPrices(householdIds, userIdMap);
|
||||||
|
if (summary.created.prices) log(`✓ Örnek ürün fiyatları: ${summary.created.prices}`);
|
||||||
|
|
||||||
// 7) Geliştirme hesapları tablosu
|
// 7) Geliştirme hesapları tablosu
|
||||||
const all = [
|
const all = [
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
import { ProductModel } from "@/models/product";
|
import { ProductModel } from "@/models/product";
|
||||||
|
import { ProductPriceModel } from "@/models/product-price";
|
||||||
import { ProductTypeModel } from "@/models/product-type";
|
import { ProductTypeModel } from "@/models/product-type";
|
||||||
import { StoreModel } from "@/models/store";
|
import { StoreModel } from "@/models/store";
|
||||||
import type { MealCardProvider } from "@/shared/accounts";
|
import type { MealCardProvider } from "@/shared/accounts";
|
||||||
import { autoConvertible } from "@/shared/products";
|
import { autoConvertible } from "@/shared/products";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
import { foldTr } from "@/shared/search";
|
import { foldTr } from "@/shared/search";
|
||||||
import type { StoreKind } from "@/shared/stores";
|
import type { StoreKind } from "@/shared/stores";
|
||||||
|
|
||||||
@@ -326,3 +329,166 @@ export async function seedShopping(
|
|||||||
}
|
}
|
||||||
return { stores, products };
|
return { stores, products };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Fiyatlar (Faz 12b) ---
|
||||||
|
|
||||||
|
/** Bugünkü paket fiyatı (kuruş, Kasım 2026 tahmini). */
|
||||||
|
const CURRENT_PRICE: Record<string, number> = {
|
||||||
|
"ariel-5kg": 519_90,
|
||||||
|
"omo-6kg": 559_90,
|
||||||
|
"bingo-4kg": 329_90,
|
||||||
|
"fairy-50": 479_90,
|
||||||
|
"finish-40": 419_90,
|
||||||
|
"yayla-mercimek": 84_90,
|
||||||
|
"reis-mercimek": 79_90,
|
||||||
|
"duru-mercimek": 189_90,
|
||||||
|
"yayla-baldo": 259_90,
|
||||||
|
"reis-baldo": 109_90,
|
||||||
|
"pinar-sut": 41_90,
|
||||||
|
"sutas-sut": 39_90,
|
||||||
|
"icim-sut-500": 23_90,
|
||||||
|
"keskinoglu-30": 174_90,
|
||||||
|
"koy-yumurta-15": 119_90,
|
||||||
|
"pinar-peynir": 209_90,
|
||||||
|
"yumos-1440": 139_90,
|
||||||
|
"selpak-32": 329_90,
|
||||||
|
"solo-24": 229_90,
|
||||||
|
"colgate-75": 84_90,
|
||||||
|
"sensodyne-75": 159_90,
|
||||||
|
"elidor-500": 119_90,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mağaza fiyat çarpanı (indirim marketi ucuz, online pahalı). */
|
||||||
|
const STORE_FACTOR: Record<string, number> = {
|
||||||
|
migros: 1.06,
|
||||||
|
a101: 0.94,
|
||||||
|
bim: 0.91,
|
||||||
|
sok: 0.96,
|
||||||
|
carrefoursa: 1.0,
|
||||||
|
"getir-buyuk": 1.12,
|
||||||
|
metro: 0.88,
|
||||||
|
};
|
||||||
|
const PRICE_STORES = Object.keys(STORE_FACTOR);
|
||||||
|
/** Aylık fiyat artışı. */
|
||||||
|
const MONTHLY_INFLATION = 0.025;
|
||||||
|
|
||||||
|
/** x,90 biçimine yuvarla (ör. 87,34 → 86,90 / 87,90). */
|
||||||
|
const ninety = (minor: number) => Math.max(90, Math.round((minor - 90) / 100) * 100 + 90);
|
||||||
|
|
||||||
|
type SeedPrice = {
|
||||||
|
ref: string;
|
||||||
|
product: string;
|
||||||
|
store: string;
|
||||||
|
daysAgo: number;
|
||||||
|
priceMinor: number;
|
||||||
|
isPromo?: boolean;
|
||||||
|
promoUntilDaysAgo?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Her ürün 3 mağazada (ürün sırasına göre dönüşümlü), son 6 ayda ~45 günde bir fiyat.
|
||||||
|
* Kampanyalar: Ariel @ Migros hâlâ süren (%20), Pınar süt @ A101 süresi bitmiş (önceki normal
|
||||||
|
* fiyata dönülür). Reis mercimek @ Metro yalnızca eski kayıtlarla ("eski fiyat").
|
||||||
|
*/
|
||||||
|
export function seedPrices(): SeedPrice[] {
|
||||||
|
const out: SeedPrice[] = [];
|
||||||
|
PRODUCTS.forEach((p, i) => {
|
||||||
|
const current = CURRENT_PRICE[p.ref]!;
|
||||||
|
const stores = [0, 2, 4].map((k) => PRICE_STORES[(i + k) % PRICE_STORES.length]!);
|
||||||
|
stores.forEach((store, si) => {
|
||||||
|
for (let step = 0; step < 4; step++) {
|
||||||
|
const daysAgo = 170 - step * 45 - si * 7 - (i % 5);
|
||||||
|
if (daysAgo < 0) continue;
|
||||||
|
if (p.ref === "reis-mercimek" && store === "metro" && daysAgo < 100) continue;
|
||||||
|
const months = daysAgo / 30;
|
||||||
|
const price = (current * STORE_FACTOR[store]!) / (1 + MONTHLY_INFLATION) ** months;
|
||||||
|
out.push({
|
||||||
|
ref: `${p.ref}:${store}:${step}`,
|
||||||
|
product: p.ref,
|
||||||
|
store,
|
||||||
|
daysAgo,
|
||||||
|
priceMinor: ninety(price),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
out.push(
|
||||||
|
{
|
||||||
|
ref: "ariel-5kg:migros:promo",
|
||||||
|
product: "ariel-5kg",
|
||||||
|
store: "migros",
|
||||||
|
daysAgo: 4,
|
||||||
|
priceMinor: ninety(CURRENT_PRICE["ariel-5kg"]! * 1.06 * 0.8),
|
||||||
|
isPromo: true,
|
||||||
|
promoUntilDaysAgo: -6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "pinar-sut:a101:normal",
|
||||||
|
product: "pinar-sut",
|
||||||
|
store: "a101",
|
||||||
|
daysAgo: 30,
|
||||||
|
priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.94),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: "pinar-sut:a101:promo",
|
||||||
|
product: "pinar-sut",
|
||||||
|
store: "a101",
|
||||||
|
daysAgo: 12,
|
||||||
|
priceMinor: ninety(CURRENT_PRICE["pinar-sut"]! * 0.75),
|
||||||
|
isPromo: true,
|
||||||
|
promoUntilDaysAgo: 3,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedProductPrices(
|
||||||
|
householdIds: Map<string, Types.ObjectId>,
|
||||||
|
users: Map<string, string>,
|
||||||
|
): Promise<number> {
|
||||||
|
const actor = new Types.ObjectId(users.get("ayse")!);
|
||||||
|
const householdId = householdIds.get("Çakır Ailesi")!;
|
||||||
|
const [products, stores] = await Promise.all([
|
||||||
|
ProductModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||||
|
StoreModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1 })
|
||||||
|
.lean<{ _id: Types.ObjectId; seedRef: string }[]>(),
|
||||||
|
]);
|
||||||
|
const productByRef = new Map(products.map((p) => [p.seedRef, p._id]));
|
||||||
|
const storeByRef = new Map(stores.map((x) => [x.seedRef, x._id]));
|
||||||
|
const existing = new Set(
|
||||||
|
(
|
||||||
|
await ProductPriceModel.find({ householdId, seedRef: { $type: "string" } })
|
||||||
|
.select({ seedRef: 1 })
|
||||||
|
.lean<{ seedRef: string }[]>()
|
||||||
|
).map((x) => x.seedRef),
|
||||||
|
);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const base = Date.now() - 200 * 86_400_000;
|
||||||
|
const docs = seedPrices()
|
||||||
|
.filter((x) => !existing.has(x.ref) && productByRef.has(x.product) && storeByRef.has(x.store))
|
||||||
|
.sort((a, b) => b.daysAgo - a.daysAgo)
|
||||||
|
.map((x, i) => {
|
||||||
|
const at = new Date(base + i * 1000);
|
||||||
|
return {
|
||||||
|
householdId,
|
||||||
|
productId: productByRef.get(x.product)!,
|
||||||
|
storeId: storeByRef.get(x.store)!,
|
||||||
|
date: addDays(today, -x.daysAgo),
|
||||||
|
priceMinor: x.priceMinor,
|
||||||
|
isPromo: x.isPromo ?? false,
|
||||||
|
promoUntil: x.promoUntilDaysAgo === undefined ? null : addDays(today, -x.promoUntilDaysAgo),
|
||||||
|
source: "manual",
|
||||||
|
note: "",
|
||||||
|
deletedAt: null,
|
||||||
|
seedRef: x.ref,
|
||||||
|
createdBy: actor,
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (docs.length) await ProductPriceModel.collection.insertMany(docs);
|
||||||
|
return docs.length;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
} from "@/features/categories/tree";
|
||||||
import { hasHouseholdRole } from "@/features/households/roles";
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
import { resolveMembership, type Membership } from "@/features/households/service";
|
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||||
|
import { latestTypeUnitPrices } from "@/features/prices/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
import { writeAudit } from "@/lib/audit";
|
import { writeAudit } from "@/lib/audit";
|
||||||
@@ -24,6 +25,7 @@ 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 { shiftMonth } from "@/shared/budget";
|
||||||
|
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
import type {
|
import type {
|
||||||
ProductTypeForm,
|
ProductTypeForm,
|
||||||
StockCategoryForm,
|
StockCategoryForm,
|
||||||
@@ -1242,6 +1244,10 @@ export type StockAnalysisRow = {
|
|||||||
avgMonthlyWasted: number | null;
|
avgMonthlyWasted: number | null;
|
||||||
/** Fire / (kullanım + fire), %. */
|
/** Fire / (kullanım + fire), %. */
|
||||||
wastePct: number | null;
|
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). */
|
/** Bu ay (bugüne kadar). */
|
||||||
thisMonth: MonthUsage;
|
thisMonth: MonthUsage;
|
||||||
};
|
};
|
||||||
@@ -1264,10 +1270,11 @@ export async function getStockAnalysis(
|
|||||||
shiftMonth(thisMonth, i - ANALYSIS_MONTHS),
|
shiftMonth(thisMonth, i - ANALYSIS_MONTHS),
|
||||||
);
|
);
|
||||||
const m = (await resolveMembership(userId, householdId, "viewer"))!;
|
const m = (await resolveMembership(userId, householdId, "viewer"))!;
|
||||||
const moves = await loadMoves(
|
const ids = data.items.map((i) => new Types.ObjectId(i.id));
|
||||||
m.householdId,
|
const [moves, prices] = await Promise.all([
|
||||||
data.items.map((i) => new Types.ObjectId(i.id)),
|
loadMoves(m.householdId, ids),
|
||||||
);
|
latestTypeUnitPrices(m.householdId, ids, data.today),
|
||||||
|
]);
|
||||||
const rows = data.items.map((item): StockAnalysisRow => {
|
const rows = data.items.map((item): StockAnalysisRow => {
|
||||||
const list = moves.get(item.id) ?? [];
|
const list = moves.get(item.id) ?? [];
|
||||||
const [usage, current] = [monthlyUsage(list, months), monthlyUsage(list, [thisMonth])[0]!];
|
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 sum = (k: "used" | "wasted") => full.reduce((s, u) => s + u[k], 0);
|
||||||
const used = sum("used");
|
const used = sum("used");
|
||||||
const wasted = sum("wasted");
|
const wasted = sum("wasted");
|
||||||
|
const price = prices.get(item.id) ?? null;
|
||||||
|
const avgUsed = full.length ? used / full.length : null;
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
@@ -1287,6 +1296,11 @@ export async function getStockAnalysis(
|
|||||||
avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null,
|
avgMonthlyUsed: full.length ? roundForDisplay(used / full.length) : null,
|
||||||
avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null,
|
avgMonthlyWasted: full.length ? roundForDisplay(wasted / full.length) : null,
|
||||||
wastePct: used + wasted > 0 ? Math.round((wasted * 100) / (used + wasted)) : 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,
|
thisMonth: current,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+64
-1
@@ -981,7 +981,11 @@ export const tr = {
|
|||||||
analysis: {
|
analysis: {
|
||||||
title: "Tüketim analizi",
|
title: "Tüketim analizi",
|
||||||
description:
|
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.",
|
"Ürün tipi başına aylık tüketim, fire ve tahmini maliyet. Tüketim: girilen tüketimler ve sayımda çıkan eksikler. Maliyet: aylık ortalama tüketim × son bilinen birim fiyat.",
|
||||||
|
avgCost: "Aylık ort. maliyet",
|
||||||
|
costHint: (price: string, date: string) => `Son birim fiyat ${price} (${date})`,
|
||||||
|
noPrice: "Fiyat yok",
|
||||||
|
totalCost: (total: string) => `Fiyatı bilinen ürünlerde aylık tahmini maliyet: ${total}`,
|
||||||
back: "Ev stoğuna dön",
|
back: "Ev stoğuna dön",
|
||||||
product: "Ürün tipi",
|
product: "Ürün tipi",
|
||||||
avgUsed: "Aylık ort. tüketim",
|
avgUsed: "Aylık ort. tüketim",
|
||||||
@@ -1348,6 +1352,65 @@ export const tr = {
|
|||||||
archived: "Ürün arşivli.",
|
archived: "Ürün arşivli.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
productPrices: {
|
||||||
|
add: "Fiyat gir",
|
||||||
|
addTitle: "Fiyat gir",
|
||||||
|
comparisonTitle: "Birim fiyat karşılaştırması",
|
||||||
|
comparisonDescription: (unit: string) =>
|
||||||
|
`Marka ve mağazaların son bilinen fiyatları, 1 ${unit} başına sıralı. Süresi biten kampanyada önceki normal fiyat kullanılır.`,
|
||||||
|
noPrices: "Henüz fiyat girilmedi.",
|
||||||
|
noProducts: "Fiyat girmek için önce bir ürün (marka/paket) ekleyin.",
|
||||||
|
cheapest: "En uygun",
|
||||||
|
diff: (pct: number) => `%${pct} pahalı`,
|
||||||
|
perUnit: "Birim fiyat",
|
||||||
|
packagePrice: "Paket fiyatı",
|
||||||
|
store: "Mağaza",
|
||||||
|
product: "Ürün",
|
||||||
|
date: "Tarih",
|
||||||
|
promo: "Kampanya",
|
||||||
|
promoUntil: (date: string) => `${date} tarihine kadar`,
|
||||||
|
promoEnded: "Kampanya bitti",
|
||||||
|
fellBack: "Kampanya bitti, normal fiyat",
|
||||||
|
stale: (days: number) => `${days} gün önce`,
|
||||||
|
staleHint: "90 günden eski fiyat; güncel olmayabilir.",
|
||||||
|
noConversion: "Dönüşüm yok",
|
||||||
|
sources: { manual: "Elle", receipt: "Fiş", provider: "Sağlayıcı" } as Record<string, string>,
|
||||||
|
detail: {
|
||||||
|
back: "Ürün kataloğu",
|
||||||
|
typeLink: (name: string) => `Ürün tipi: ${name}`,
|
||||||
|
currentTitle: "Mağazalardaki fiyatlar",
|
||||||
|
currentDescription: "Her mağazadaki geçerli (son) fiyat; birim fiyata göre sıralı.",
|
||||||
|
chartTitle: "Fiyat geçmişi",
|
||||||
|
chartDescription: "Mağaza başına paket fiyatı; fiyat yeni kayda kadar geçerli sayılır.",
|
||||||
|
chartLabel: (name: string) => `${name}: mağaza başına fiyat geçmişi`,
|
||||||
|
historyTitle: "Fiyat kayıtları",
|
||||||
|
deletePrice: "Fiyat kaydını sil",
|
||||||
|
deleteTitle: "Fiyat kaydı silinsin mi?",
|
||||||
|
deleteDescription: "Kayıt fiyat geçmişinden ve karşılaştırmadan çıkarılır.",
|
||||||
|
images: "Görseller",
|
||||||
|
barcode: "Barkod",
|
||||||
|
package: "Paket",
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
product: "Ürün",
|
||||||
|
store: "Mağaza",
|
||||||
|
date: "Tarih",
|
||||||
|
price: "Paket fiyatı (₺)",
|
||||||
|
isPromo: "Kampanya / indirim",
|
||||||
|
promoUntil: "Kampanya bitişi",
|
||||||
|
promoUntilHint: "İsteğe bağlı. Bitince karşılaştırmada önceki normal fiyat kullanılır.",
|
||||||
|
note: "Not",
|
||||||
|
unitPreview: (value: string) => `Birim fiyat: ${value}`,
|
||||||
|
},
|
||||||
|
toast: { saved: "Fiyat kaydedildi.", deleted: "Fiyat kaydı silindi." },
|
||||||
|
validation: {
|
||||||
|
product: "Aktif bir ürün seçin",
|
||||||
|
store: "Aktif bir mağaza seçin",
|
||||||
|
price: "Geçerli bir fiyat girin (ör. 89,90)",
|
||||||
|
promoUntil: "Kampanya bitişi fiyat tarihinden önce olamaz",
|
||||||
|
futureDate: "Gelecek tarihli fiyat girilemez",
|
||||||
|
},
|
||||||
|
},
|
||||||
prices: {
|
prices: {
|
||||||
title: "Fiyat tablosu",
|
title: "Fiyat tablosu",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const AUDIT_ACTIONS = [
|
|||||||
"store.unarchive",
|
"store.unarchive",
|
||||||
"product.archive",
|
"product.archive",
|
||||||
"product.unarchive",
|
"product.unarchive",
|
||||||
|
"price.delete",
|
||||||
] as const;
|
] as const;
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün fiyat kaydı (Bölüm 6.11): ürün × mağaza × tarih × paket fiyatı (kuruş). Kampanya işareti
|
||||||
|
* ve geçerlilik sonu; kaynak `manual` | `receipt` | `provider:<ad>`. Hane bazlı. Birim fiyat
|
||||||
|
* okuma sırasında ürünün dönüşümüyle hesaplanır (saklanmaz).
|
||||||
|
*/
|
||||||
|
const productPriceSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
productId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
storeId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
/** "YYYY-MM-DD" (İstanbul). */
|
||||||
|
date: { type: String, required: true },
|
||||||
|
priceMinor: { type: Number, required: true, min: 1 },
|
||||||
|
isPromo: { type: Boolean, default: false },
|
||||||
|
promoUntil: { type: String, default: null },
|
||||||
|
source: { type: String, required: true, match: /^(manual|receipt|provider:[a-z0-9-]{1,40})$/ },
|
||||||
|
note: { type: String, default: "", maxlength: 300 },
|
||||||
|
deletedAt: { type: Date, default: null },
|
||||||
|
seedRef: { type: String, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "productPrices" },
|
||||||
|
);
|
||||||
|
|
||||||
|
productPriceSchema.index({ householdId: 1, productId: 1, deletedAt: 1, date: 1, createdAt: 1 });
|
||||||
|
productPriceSchema.index({ householdId: 1, storeId: 1, date: -1 });
|
||||||
|
productPriceSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ProductPrice = InferSchemaType<typeof productPriceSchema>;
|
||||||
|
|
||||||
|
export const ProductPriceModel: Model<ProductPrice> =
|
||||||
|
(models.ProductPrice as Model<ProductPrice> | undefined) ??
|
||||||
|
model<ProductPrice>("ProductPrice", productPriceSchema);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
effectivePrice,
|
||||||
|
formatUnitPrice,
|
||||||
|
PRICE_SOURCE_PATTERN,
|
||||||
|
rankOffers,
|
||||||
|
seriesSlot,
|
||||||
|
unitPrice,
|
||||||
|
unitPriceBase,
|
||||||
|
} from "./prices";
|
||||||
|
|
||||||
|
const r = (
|
||||||
|
date: string,
|
||||||
|
priceMinor: number,
|
||||||
|
isPromo = false,
|
||||||
|
promoUntil: string | null = null,
|
||||||
|
) => ({
|
||||||
|
date,
|
||||||
|
priceMinor,
|
||||||
|
isPromo,
|
||||||
|
promoUntil,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("birim fiyat", () => {
|
||||||
|
it("paket fiyatı / ürün tipi birimindeki miktar; yuvarlama yalnızca gösterimde", () => {
|
||||||
|
// Ariel 5 kg = 33 yıkama, 519,90 TL → 15,754… TL/yıkama.
|
||||||
|
const u = unitPrice(519_90, 33_000);
|
||||||
|
expect(u).toBeCloseTo(1575.4545, 3);
|
||||||
|
expect(formatUnitPrice(u, "yıkama")).toMatch(/15,75\/yıkama$/);
|
||||||
|
// 500 g beyaz peynir 210 TL, ürün tipi g olsaydı: 0,42 TL/g → 420 TL/kg.
|
||||||
|
expect(formatUnitPrice(unitPrice(210_00, 500_000), "g")).toMatch(/420,00\/kg$/);
|
||||||
|
expect(unitPriceBase("ml")).toEqual({ unit: "lt", factor: 1000 });
|
||||||
|
expect(unitPriceBase("adet")).toEqual({ unit: "adet", factor: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kaynak biçimi", () => {
|
||||||
|
expect(
|
||||||
|
["manual", "receipt", "provider:marketfiyati"].every((s) => PRICE_SOURCE_PATTERN.test(s)),
|
||||||
|
).toBe(true);
|
||||||
|
expect(PRICE_SOURCE_PATTERN.test("provider:")).toBe(false);
|
||||||
|
expect(PRICE_SOURCE_PATTERN.test("scrape")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("geçerli fiyat", () => {
|
||||||
|
const today = "2026-11-06";
|
||||||
|
|
||||||
|
it("son kayıt; süresi biten kampanyada önceki normal fiyata dönülür", () => {
|
||||||
|
expect(effectivePrice([], today)).toBeNull();
|
||||||
|
const normal = effectivePrice([r("2026-10-01", 100_00), r("2026-11-01", 110_00)], today)!;
|
||||||
|
expect(normal).toMatchObject({ record: { priceMinor: 110_00 }, fellBack: false, ageDays: 5 });
|
||||||
|
const ended = effectivePrice(
|
||||||
|
[r("2026-10-01", 100_00), r("2026-10-20", 80_00, true, "2026-10-31")],
|
||||||
|
today,
|
||||||
|
)!;
|
||||||
|
expect(ended).toMatchObject({
|
||||||
|
record: { priceMinor: 100_00 },
|
||||||
|
fellBack: true,
|
||||||
|
promoEnded: false,
|
||||||
|
});
|
||||||
|
const active = effectivePrice(
|
||||||
|
[r("2026-10-01", 100_00), r("2026-11-02", 80_00, true, "2026-11-10")],
|
||||||
|
today,
|
||||||
|
)!;
|
||||||
|
expect(active).toMatchObject({ record: { priceMinor: 80_00 }, fellBack: false });
|
||||||
|
// Dönülecek normal fiyat yoksa kampanya fiyatı işaretle.
|
||||||
|
const only = effectivePrice([r("2026-10-20", 80_00, true, "2026-10-31")], today)!;
|
||||||
|
expect(only).toMatchObject({ fellBack: false, promoEnded: true });
|
||||||
|
expect(effectivePrice([r("2026-07-01", 1)], today)!.stale).toBe(true);
|
||||||
|
expect(effectivePrice([r("2026-09-01", 1)], today)!.stale).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sıralama", () => {
|
||||||
|
it("birim fiyata göre artan, eşitler en uygun, hesaplanamayan sonda", () => {
|
||||||
|
const ranked = rankOffers([
|
||||||
|
{ id: "a", unitPriceMinor: 1_500 },
|
||||||
|
{ id: "b", unitPriceMinor: null },
|
||||||
|
{ id: "c", unitPriceMinor: 1_200 },
|
||||||
|
{ id: "d", unitPriceMinor: 1_200 },
|
||||||
|
{ id: "e", unitPriceMinor: 1_800 },
|
||||||
|
]);
|
||||||
|
expect(ranked.map((o) => [o.id, o.rank, o.cheapest, o.diffPct])).toEqual([
|
||||||
|
["c", 1, true, 0],
|
||||||
|
["d", 2, true, 0],
|
||||||
|
["a", 3, false, 25],
|
||||||
|
["e", 4, false, 50],
|
||||||
|
["b", null, false, null],
|
||||||
|
]);
|
||||||
|
expect([seriesSlot(0), seriesSlot(7), seriesSlot(8)]).toEqual([1, 8, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { daysBetween } from "./budget";
|
||||||
|
import { formatMoney } from "./format";
|
||||||
|
import { QUANTITY_SCALE } from "./quantity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ürün fiyatı ve birim fiyat motoru (Bölüm 6.11). Saf fonksiyonlar.
|
||||||
|
*
|
||||||
|
* Fiyat paket fiyatıdır (kuruş). Birim fiyat, ürün tipinin biriminde 1 birimin fiyatıdır:
|
||||||
|
* birim fiyat = paket fiyatı × 1000 / (paketin ürün tipi birimindeki miktarı, binde bir)
|
||||||
|
* Kesirli kuruş olarak tutulur; yuvarlama yalnızca gösterimde. Gösterimde g → kg, ml → lt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PRICE_SOURCE_PATTERN = /^(manual|receipt|provider:[a-z0-9-]{1,40})$/;
|
||||||
|
export type PriceSource = "manual" | "receipt" | `provider:${string}`;
|
||||||
|
|
||||||
|
/** Bu kadar günden eski fiyat "eski" sayılır. */
|
||||||
|
export const PRICE_STALE_DAYS = 90;
|
||||||
|
|
||||||
|
export function unitPrice(priceMinor: number, packageInTypeUnit: number): number {
|
||||||
|
return (priceMinor * QUANTITY_SCALE) / packageInTypeUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gösterim birimi: küçük birimler büyüğe (g → kg, ml → lt). */
|
||||||
|
export function unitPriceBase(typeUnit: string): { unit: string; factor: number } {
|
||||||
|
if (typeUnit === "g") return { unit: "kg", factor: 1000 };
|
||||||
|
if (typeUnit === "ml") return { unit: "lt", factor: 1000 };
|
||||||
|
return { unit: typeUnit, factor: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4250 kuruş/kg → "₺42,50/kg"; g biriminde 4,25 kuruş/g → "₺42,50/kg". */
|
||||||
|
export function formatUnitPrice(unitPriceMinor: number, typeUnit: string): string {
|
||||||
|
const base = unitPriceBase(typeUnit);
|
||||||
|
return `${formatMoney(Math.round(unitPriceMinor * base.factor))}/${base.unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PriceRecordLike = {
|
||||||
|
date: string;
|
||||||
|
priceMinor: number;
|
||||||
|
isPromo: boolean;
|
||||||
|
/** Kampanya geçerlilik sonu (dahil); yoksa süresiz. */
|
||||||
|
promoUntil: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const promoEnded = (r: PriceRecordLike, today: string) =>
|
||||||
|
r.isPromo && r.promoUntil !== null && r.promoUntil < today;
|
||||||
|
|
||||||
|
export type EffectivePrice<T> = {
|
||||||
|
record: T;
|
||||||
|
/** Son kayıt süresi biten kampanyaydı; önceki normal fiyata dönüldü. */
|
||||||
|
fellBack: boolean;
|
||||||
|
/** Geçerli fiyat süresi biten kampanya (dönülecek normal fiyat yok). */
|
||||||
|
promoEnded: boolean;
|
||||||
|
ageDays: number;
|
||||||
|
stale: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geçerli fiyat: tarih (ve giriş) sırasındaki son kayıt. Son kayıt süresi biten kampanyaysa,
|
||||||
|
* ondan önceki son kampanyasız (veya hâlâ geçerli kampanyalı) kayda dönülür; yoksa kampanya
|
||||||
|
* fiyatı işaretle kullanılır. `records` eskiden yeniye sıralı olmalı.
|
||||||
|
*/
|
||||||
|
export function effectivePrice<T extends PriceRecordLike>(
|
||||||
|
records: readonly T[],
|
||||||
|
today: string,
|
||||||
|
): EffectivePrice<T> | null {
|
||||||
|
const latest = records.at(-1);
|
||||||
|
if (!latest) return null;
|
||||||
|
let record = latest;
|
||||||
|
let fellBack = false;
|
||||||
|
if (promoEnded(latest, today)) {
|
||||||
|
const prev = [...records].reverse().find((r) => !promoEnded(r, today));
|
||||||
|
if (prev) {
|
||||||
|
record = prev;
|
||||||
|
fellBack = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ageDays = record.date <= today ? daysBetween(record.date, today) - 1 : 0;
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
fellBack,
|
||||||
|
promoEnded: promoEnded(record, today),
|
||||||
|
ageDays,
|
||||||
|
stale: ageDays > PRICE_STALE_DAYS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Offer = { unitPriceMinor: number | null };
|
||||||
|
|
||||||
|
export type RankedOffer<T> = T & {
|
||||||
|
rank: number | null;
|
||||||
|
cheapest: boolean;
|
||||||
|
/** En uygundan yüzde fark (tamsayı); en uygunda 0. */
|
||||||
|
diffPct: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Birim fiyata göre sıralar (artan); birim fiyatı hesaplanamayanlar (dönüşüm eksik) sonda.
|
||||||
|
* En düşük birim fiyatlı tüm teklifler (eşitlik dahil) "en uygun".
|
||||||
|
*/
|
||||||
|
export function rankOffers<T extends Offer>(offers: readonly T[]): RankedOffer<T>[] {
|
||||||
|
const priced = offers.filter((o) => o.unitPriceMinor !== null);
|
||||||
|
const min = priced.length ? Math.min(...priced.map((o) => o.unitPriceMinor!)) : null;
|
||||||
|
const sorted = [...offers].sort((a, b) => {
|
||||||
|
if (a.unitPriceMinor === null || b.unitPriceMinor === null)
|
||||||
|
return a.unitPriceMinor === null ? (b.unitPriceMinor === null ? 0 : 1) : -1;
|
||||||
|
return a.unitPriceMinor - b.unitPriceMinor;
|
||||||
|
});
|
||||||
|
let rank = 0;
|
||||||
|
return sorted.map((o) => {
|
||||||
|
if (o.unitPriceMinor === null || min === null)
|
||||||
|
return { ...o, rank: null, cheapest: false, diffPct: null };
|
||||||
|
rank++;
|
||||||
|
return {
|
||||||
|
...o,
|
||||||
|
rank,
|
||||||
|
cheapest: Math.abs(o.unitPriceMinor - min) < 1e-9,
|
||||||
|
diffPct: min > 0 ? Math.round(((o.unitPriceMinor - min) * 100) / min) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grafik/legend için mağaza renk yuvası (1–8): mağazanın hanedeki ekleniş sırasına göre sabit. */
|
||||||
|
export const SERIES_SLOTS = 8;
|
||||||
|
export const seriesSlot = (storeIndex: number) => (storeIndex % SERIES_SLOTS) + 1;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { parseMoney } from "../money";
|
||||||
|
|
||||||
|
const v = tr.validation;
|
||||||
|
const s = tr.productPrices.validation;
|
||||||
|
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
|
||||||
|
const id = (message: string) => z.string().regex(/^[a-f0-9]{24}$/i, message);
|
||||||
|
|
||||||
|
/** Elle fiyat girişi (kaynak: manual). Paket fiyatı TL; kampanyada geçerlilik sonu isteğe bağlı. */
|
||||||
|
export const productPriceFormSchema = z
|
||||||
|
.object({
|
||||||
|
productId: id(s.product),
|
||||||
|
storeId: id(s.store),
|
||||||
|
date: ymd,
|
||||||
|
price: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((x, ctx) => {
|
||||||
|
const minor = parseMoney(x);
|
||||||
|
if (minor === null || minor <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: s.price });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return minor;
|
||||||
|
}),
|
||||||
|
isPromo: z.boolean(),
|
||||||
|
promoUntil: z.preprocess((x) => (x === "" || x === undefined ? null : x), ymd.nullable()),
|
||||||
|
note: z.string().trim().max(300).default(""),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
if (x.isPromo && x.promoUntil && x.promoUntil < x.date)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["promoUntil"], message: s.promoUntil });
|
||||||
|
})
|
||||||
|
.transform(({ price, ...x }) => ({
|
||||||
|
...x,
|
||||||
|
priceMinor: price,
|
||||||
|
promoUntil: x.isPromo ? x.promoUntil : null,
|
||||||
|
}));
|
||||||
|
export type ProductPriceFormInput = z.input<typeof productPriceFormSchema>;
|
||||||
|
export type ProductPriceForm = z.output<typeof productPriceFormSchema>;
|
||||||
Reference in New Issue
Block a user