145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
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;
|
|
}
|
|
}
|