This commit is contained in:
Aerilyn Weber 2026-05-14 18:57:57 +09:00
parent e396f5088c
commit a1801af63b
36 changed files with 4783 additions and 31 deletions

View file

@ -0,0 +1,145 @@
import type { PricesRepository } from './prices.repository.js';
import type { ProductsRepository } from '../products/products.repository.js';
import type { StoresRepository } from '../stores/stores.repository.js';
import type {
CreatePriceRecordInput,
BulkPriceRecordInput,
PriceHistoryQueryInput,
} from '@meshitrack/shared';
import { NotFoundError } from '../../common/errors.js';
interface Deps {
pricesRepository: PricesRepository;
productsRepository: ProductsRepository;
storesRepository: StoresRepository;
}
export class PricesService {
private readonly pricesRepository: PricesRepository;
private readonly productsRepository: ProductsRepository;
private readonly storesRepository: StoresRepository;
public constructor({ pricesRepository, productsRepository, storesRepository }: Deps) {
this.pricesRepository = pricesRepository;
this.productsRepository = productsRepository;
this.storesRepository = storesRepository;
}
/**
* Validates entity existence, computes pricePerUnit, and persists record
*/
public async recordPrice(
data: CreatePriceRecordInput,
householdId: string,
userId: string
) {
const [product, store] = await Promise.all([
this.productsRepository.findById(data.productId, householdId),
this.storesRepository.findById(data.storeId, householdId),
]);
if (!product) throw new NotFoundError(`Product not found: ${data.productId}`);
if (!store) throw new NotFoundError(`Store not found: ${data.storeId}`);
const pricePerUnit = data.quantity > 0 ? data.price / data.quantity : data.price;
return this.pricesRepository.create({
householdId,
productId: data.productId,
productName: product.name as string,
storeId: data.storeId,
storeName: store.name as string,
price: data.price,
currency: data.currency,
quantity: data.quantity,
unit: data.unit,
pricePerUnit,
date: data.date ? new Date(data.date) : new Date(),
receiptImageUrl: data.receiptImageUrl,
notes: data.notes,
createdBy: userId,
});
}
/**
* Ingests a list of purchased products in a single transaction
*/
public async recordBulkPrices(
data: BulkPriceRecordInput,
householdId: string,
userId: string
) {
const store = await this.storesRepository.findById(data.storeId, householdId);
if (!store) throw new NotFoundError(`Store not found: ${data.storeId}`);
const recordDate = data.date ? new Date(data.date) : new Date();
const productIds = data.items.map((it) => it.productId);
const products = await this.productsRepository.findByIds(householdId, productIds);
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
const creationPayloads = data.items.map((item) => {
const product = productMap.get(item.productId);
if (!product) {
throw new NotFoundError(`Product not found in catalog: ${item.productId}`);
}
const pricePerUnit = item.quantity > 0 ? item.price / item.quantity : item.price;
return {
householdId,
productId: item.productId,
productName: product.name as string,
storeId: data.storeId,
storeName: store.name as string,
price: item.price,
currency: 'USD', // Base fallback or pulled from household settings in future
quantity: item.quantity,
unit: item.unit,
pricePerUnit,
date: recordDate,
notes: item.notes,
createdBy: userId,
};
});
return this.pricesRepository.createMany(creationPayloads);
}
public async getPriceHistory(
productId: string,
householdId: string,
query: PriceHistoryQueryInput
) {
return this.pricesRepository.findByProduct(householdId, productId, query);
}
public async compareStores(productId: string, householdId: string) {
return this.pricesRepository.compareStores(householdId, productId);
}
public async getAnalytics(householdId: string) {
return this.pricesRepository.getAnalytics(householdId);
}
/**
* Retrieves the most recent price recorded for this item (optionally restricted to a store)
* to enable quick population of estimated basket subtotals on fresh lists.
*/
public async estimatePrice(
productId: string,
householdId: string,
storeId?: string
): Promise<number | null> {
const latest = await this.pricesRepository.getLatestForProduct(householdId, productId, storeId);
if (!latest) {
// If a specific store was requested but has no history, fall back to the generic latest across all stores
if (storeId) {
const genericLatest = await this.pricesRepository.getLatestForProduct(householdId, productId);
return genericLatest ? genericLatest.price : null;
}
return null;
}
return latest.price;
}
}