8623c5a02e
- mağaza adı eşleşmesi ve aylık birim fiyat özeti (saf, birim testli) - tamamlanan listelerin plan/gerçekleşen özeti, mağaza bazlı harcama - ürün tipi fiyat geçmişi yükleyicisi; fire oranı ve tahmini maliyet; testler Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
536 lines
17 KiB
TypeScript
536 lines
17 KiB
TypeScript
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 birimine çevrilebilen ürünlerin mağaza başına geçerli fiyatı (planlayıcı girdisi). */
|
||
export type TypeOffer = {
|
||
productId: string;
|
||
productTypeId: string;
|
||
storeId: string;
|
||
priceMinor: number;
|
||
inTypeUnit: number;
|
||
unitPriceMinor: number;
|
||
date: string;
|
||
stale: boolean;
|
||
isPromo: boolean;
|
||
};
|
||
|
||
/**
|
||
* Ürün tiplerinin tüm (arşivsiz) ürünlerinin mağaza başına geçerli fiyatları. Dönüşümü eksik
|
||
* ürünler atlanır. `activeOnly`: arşivli ürün ve mağazaları dışarıda bırak.
|
||
*/
|
||
export async function loadTypeOffers(
|
||
householdId: Types.ObjectId,
|
||
productTypeIds: Types.ObjectId[],
|
||
{ today = todayIstanbul(), activeOnly = false } = {},
|
||
): Promise<TypeOffer[]> {
|
||
if (!productTypeIds.length) return [];
|
||
const [products, types, archivedStores] = await Promise.all([
|
||
ProductModel.find({
|
||
householdId,
|
||
productTypeId: { $in: productTypeIds },
|
||
...(activeOnly ? { archivedAt: null } : {}),
|
||
})
|
||
.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 }[]>(),
|
||
activeOnly
|
||
? StoreModel.find({ householdId, archivedAt: { $ne: null } })
|
||
.select({ _id: 1 })
|
||
.lean<{ _id: Types.ObjectId }[]>()
|
||
: Promise.resolve([]),
|
||
]);
|
||
const skipStore = new Set(archivedStores.map((x) => String(x._id)));
|
||
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) {
|
||
if (skipStore.has(String(r.storeId))) continue;
|
||
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: TypeOffer[] = [];
|
||
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)!;
|
||
out.push({
|
||
productId: String(p._id),
|
||
productTypeId: typeId,
|
||
storeId: String(records[0]!.storeId),
|
||
priceMinor: eff.record.priceMinor,
|
||
inTypeUnit: inType,
|
||
unitPriceMinor: unitPrice(eff.record.priceMinor, inType),
|
||
date: eff.record.date,
|
||
stale: eff.stale,
|
||
isPromo: eff.record.isPromo && !eff.promoEnded,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Bir ürün tipinin tüm fiyat kayıtları, ürün tipi biriminde birim fiyatla (dönüşümsüzler hariç). */
|
||
export async function loadTypePriceHistory(
|
||
householdId: Types.ObjectId,
|
||
productTypeId: Types.ObjectId,
|
||
): Promise<{ date: string; productId: string; storeId: string; unitPriceMinor: number }[]> {
|
||
const [products, type] = await Promise.all([
|
||
ProductModel.find({ householdId, productTypeId })
|
||
.select({ packageQuantity: 1, packageUnit: 1, typeQuantity: 1 })
|
||
.lean<
|
||
{
|
||
_id: Types.ObjectId;
|
||
packageQuantity: number;
|
||
packageUnit: string;
|
||
typeQuantity: number | null;
|
||
}[]
|
||
>(),
|
||
ProductTypeModel.findOne({ householdId, _id: productTypeId })
|
||
.select({ unit: 1 })
|
||
.lean<{ unit: string }>(),
|
||
]);
|
||
if (!type) return [];
|
||
const inType = new Map(
|
||
products.map((p) => [
|
||
String(p._id),
|
||
packageInTypeUnit({
|
||
packageQuantity: p.packageQuantity,
|
||
packageUnit: p.packageUnit,
|
||
typeUnit: type.unit,
|
||
typeQuantity: p.typeQuantity,
|
||
}),
|
||
]),
|
||
);
|
||
const prices = await loadPrices(
|
||
householdId,
|
||
products.map((p) => p._id),
|
||
);
|
||
return prices.flatMap((r) => {
|
||
const q = inType.get(String(r.productId));
|
||
return q
|
||
? [
|
||
{
|
||
date: r.date,
|
||
productId: String(r.productId),
|
||
storeId: String(r.storeId),
|
||
unitPriceMinor: unitPrice(r.priceMinor, q),
|
||
},
|
||
]
|
||
: [];
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ü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 out = new Map<string, { unitPriceMinor: number; date: string }>();
|
||
for (const o of await loadTypeOffers(householdId, productTypeIds, { today })) {
|
||
const prev = out.get(o.productTypeId);
|
||
if (!prev || o.date > prev.date)
|
||
out.set(o.productTypeId, { unitPriceMinor: o.unitPriceMinor, date: o.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 };
|