apskel-pos-backend/internal/processor/product_processor.go
2026-05-28 13:49:57 +07:00

453 lines
16 KiB
Go

package processor
import (
"context"
"fmt"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
type ProductProcessor interface {
CreateProduct(ctx context.Context, req *models.CreateProductRequest) (*models.ProductResponse, error)
UpdateProduct(ctx context.Context, id uuid.UUID, req *models.UpdateProductRequest) (*models.ProductResponse, error)
DeleteProduct(ctx context.Context, id uuid.UUID) error
GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.ProductResponse, error)
ListProducts(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error)
ListProductsAll(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error)
}
type ProductRepository interface {
Create(ctx context.Context, product *entities.Product) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.Product, error)
GetWithCategory(ctx context.Context, id uuid.UUID) (*entities.Product, error)
GetWithRelations(ctx context.Context, id uuid.UUID) (*entities.Product, error)
GetByOrganization(ctx context.Context, organizationID uuid.UUID) ([]*entities.Product, error)
GetByCategory(ctx context.Context, categoryID uuid.UUID) ([]*entities.Product, error)
GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Product, error)
GetActiveByCategoryID(ctx context.Context, categoryID uuid.UUID) ([]*entities.Product, error)
Update(ctx context.Context, product *entities.Product) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Product, int64, error)
ListWithOutletPrice(ctx context.Context, filters map[string]interface{}, outletID uuid.UUID, limit, offset int) ([]*entities.Product, int64, error)
Count(ctx context.Context, filters map[string]interface{}) (int64, error)
GetBySKU(ctx context.Context, organizationID uuid.UUID, sku string) (*entities.Product, error)
ExistsBySKU(ctx context.Context, organizationID uuid.UUID, sku string, excludeID *uuid.UUID) (bool, error)
GetByName(ctx context.Context, organizationID uuid.UUID, name string) (*entities.Product, error)
ExistsByName(ctx context.Context, organizationID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
ExistsByNameInOutlet(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error
GetLowCostProducts(ctx context.Context, organizationID uuid.UUID, maxCost float64) ([]*entities.Product, error)
}
type ProductProcessorImpl struct {
productRepo ProductRepository
categoryRepo CategoryRepository
productVariantRepo repository.ProductVariantRepository
inventoryRepo repository.InventoryRepository
outletRepo OutletRepository
outletPriceRepo repository.ProductOutletPriceRepository
}
func NewProductProcessorImpl(productRepo ProductRepository, categoryRepo CategoryRepository, productVariantRepo repository.ProductVariantRepository, inventoryRepo repository.InventoryRepository, outletRepo OutletRepository, outletPriceRepo repository.ProductOutletPriceRepository) *ProductProcessorImpl {
return &ProductProcessorImpl{
productRepo: productRepo,
categoryRepo: categoryRepo,
productVariantRepo: productVariantRepo,
inventoryRepo: inventoryRepo,
outletRepo: outletRepo,
outletPriceRepo: outletPriceRepo,
}
}
func (p *ProductProcessorImpl) CreateProduct(ctx context.Context, req *models.CreateProductRequest) (*models.ProductResponse, error) {
_, err := p.categoryRepo.GetByID(ctx, req.CategoryID)
if err != nil {
return nil, fmt.Errorf("invalid category: %w", err)
}
if req.SKU != nil && *req.SKU != "" {
exists, err := p.productRepo.ExistsBySKU(ctx, req.OrganizationID, *req.SKU, nil)
if err != nil {
return nil, fmt.Errorf("failed to check SKU uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("product with SKU '%s' already exists for this organization", *req.SKU)
}
}
exists, err := p.productRepo.ExistsByNameInOutlet(ctx, req.OrganizationID, req.OutletID, req.Name, nil)
if err != nil {
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("product with name '%s' already exists for this outlet", req.Name)
}
productEntity := mappers.CreateProductRequestToEntity(req)
if err := p.productRepo.Create(ctx, productEntity); err != nil {
return nil, fmt.Errorf("failed to create product: %w", err)
}
// Create variants if provided
if req.Variants != nil && len(req.Variants) > 0 {
for _, variantReq := range req.Variants {
// Set the product ID for the variant
variantReq.ProductID = productEntity.ID
// Check variant name uniqueness within the same product
exists, err := p.productVariantRepo.ExistsByName(ctx, productEntity.ID, variantReq.Name, nil)
if err != nil {
return nil, fmt.Errorf("failed to check variant name uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("variant with name '%s' already exists for this product", variantReq.Name)
}
variantEntity := mappers.CreateProductVariantRequestToEntity(&variantReq)
if err := p.productVariantRepo.Create(ctx, variantEntity); err != nil {
return nil, fmt.Errorf("failed to create product variant: %w", err)
}
}
}
// Create inventory records for all outlets if requested
if req.CreateInventory {
if err := p.createInventoryForAllOutlets(ctx, productEntity.ID, req.OrganizationID, req.InitialStock, req.ReorderLevel); err != nil {
return nil, fmt.Errorf("failed to create inventory records: %w", err)
}
}
// Upsert outlet-specific price if outlet context is present
if req.OutletID != uuid.Nil {
printToChecker := true // default
if req.PrintToChecker != nil {
printToChecker = *req.PrintToChecker
}
outletPriceEntity := &entities.ProductOutletPrice{
ProductID: productEntity.ID,
OutletID: req.OutletID,
Price: req.Price,
PrintToChecker: printToChecker,
}
if err := p.outletPriceRepo.Upsert(ctx, outletPriceEntity); err != nil {
return nil, fmt.Errorf("failed to assign outlet price: %w", err)
}
}
productWithCategory, err := p.productRepo.GetWithCategory(ctx, productEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created product: %w", err)
}
response := mappers.ProductEntityToResponse(productWithCategory)
return response, nil
}
func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID, req *models.UpdateProductRequest) (*models.ProductResponse, error) {
existingProduct, err := p.productRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("product not found: %w", err)
}
if req.CategoryID != nil {
_, err := p.categoryRepo.GetByID(ctx, *req.CategoryID)
if err != nil {
return nil, fmt.Errorf("invalid category: %w", err)
}
}
if req.SKU != nil && *req.SKU != "" {
currentSKU := ""
if existingProduct.SKU != nil {
currentSKU = *existingProduct.SKU
}
if *req.SKU != currentSKU {
exists, err := p.productRepo.ExistsBySKU(ctx, existingProduct.OrganizationID, *req.SKU, &id)
if err != nil {
return nil, fmt.Errorf("failed to check SKU uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("product with SKU '%s' already exists for this organization", *req.SKU)
}
}
}
if req.Name != nil && *req.Name != existingProduct.Name {
exists, err := p.productRepo.ExistsByNameInOutlet(ctx, existingProduct.OrganizationID, req.OutletID, *req.Name, &id)
if err != nil {
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("product with name '%s' already exists for this outlet", *req.Name)
}
}
mappers.UpdateProductEntityFromRequest(existingProduct, req)
if err := p.productRepo.Update(ctx, existingProduct); err != nil {
return nil, fmt.Errorf("failed to update product: %w", err)
}
// Update reorder level for all existing inventory records if provided
if req.ReorderLevel != nil {
if err := p.updateReorderLevelForAllOutlets(ctx, id, *req.ReorderLevel); err != nil {
return nil, fmt.Errorf("failed to update reorder levels: %w", err)
}
}
// Upsert outlet-specific price if outlet context is present and price or print_to_checker is provided
if req.OutletID != uuid.Nil && (req.Price != nil || req.PrintToChecker != nil) {
// Fetch existing outlet price to use as fallback for fields not provided
existing, _ := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, req.OutletID)
price := float64(0)
if existing != nil {
price = existing.Price
}
if req.Price != nil {
price = *req.Price
}
printToChecker := true // default
if existing != nil {
printToChecker = existing.PrintToChecker
}
if req.PrintToChecker != nil {
printToChecker = *req.PrintToChecker
}
outletPriceEntity := &entities.ProductOutletPrice{
ProductID: id,
OutletID: req.OutletID,
Price: price,
PrintToChecker: printToChecker,
}
logger.FromContext(ctx).Infof("ProductProcessor::UpdateProduct -> upserting outlet price: productID=%s outletID=%s price=%f printToChecker=%v", id, req.OutletID, price, printToChecker)
if err := p.outletPriceRepo.Upsert(ctx, outletPriceEntity); err != nil {
return nil, fmt.Errorf("failed to assign outlet price: %w", err)
}
} else {
logger.FromContext(ctx).Infof("ProductProcessor::UpdateProduct -> skipping outlet price upsert: outletID=%s price=%v printToChecker=%v", req.OutletID, req.Price, req.PrintToChecker)
}
productWithCategory, err := p.productRepo.GetWithCategory(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to retrieve updated product: %w", err)
}
response := mappers.ProductEntityToResponse(productWithCategory)
return response, nil
}
func (p *ProductProcessorImpl) DeleteProduct(ctx context.Context, id uuid.UUID) error {
_, err := p.productRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("product not found: %w", err)
}
productWithRelations, err := p.productRepo.GetWithRelations(ctx, id)
if err != nil {
return fmt.Errorf("failed to check product relations: %w", err)
}
if len(productWithRelations.Inventory) > 0 {
return fmt.Errorf("cannot delete product: it has inventory records associated with it")
}
if len(productWithRelations.OrderItems) > 0 {
return fmt.Errorf("cannot delete product: it has order items associated with it")
}
if err := p.productRepo.Delete(ctx, id); err != nil {
return fmt.Errorf("failed to delete product: %w", err)
}
return nil
}
func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.ProductResponse, error) {
productEntity, err := p.productRepo.GetWithCategory(ctx, id)
if err != nil {
return nil, fmt.Errorf("product not found: %w", err)
}
response := mappers.ProductEntityToResponse(productEntity)
if outletID != uuid.Nil {
// Attach outlet-specific price
outletPrice, err := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, outletID)
if err == nil {
response.OutletPrice = &outletPrice.Price
response.PrintToChecker = outletPrice.PrintToChecker
}
} else {
// No outlet context — return all outlet prices for this product
outletPrices, err := p.outletPriceRepo.GetByProductWithOutlet(ctx, id)
if err == nil && len(outletPrices) > 0 {
prices := make([]models.OutletPrice, len(outletPrices))
for i, op := range outletPrices {
prices[i] = models.OutletPrice{
OutletID: op.OutletID,
OutletName: op.Outlet.Name,
Price: op.Price,
PrintToChecker: op.PrintToChecker,
}
}
response.OutletPrices = prices
}
}
return response, nil
}
func (p *ProductProcessorImpl) ListProducts(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) {
offset := (page - 1) * limit
// Extract outletID from filters — it's not a products column so remove it before querying
var outletID uuid.UUID
if oid, ok := filters["outlet_id"]; ok {
outletID = oid.(uuid.UUID)
delete(filters, "outlet_id")
}
// Use the JOIN-based query when an outlet is specified so we get outlet-specific
// prices in a single round-trip; fall back to the plain List otherwise.
var (
productEntities []*entities.Product
total int64
err error
)
if outletID != uuid.Nil {
productEntities, total, err = p.productRepo.ListWithOutletPrice(ctx, filters, outletID, limit, offset)
} else {
productEntities, total, err = p.productRepo.List(ctx, filters, limit, offset)
}
if err != nil {
return nil, 0, fmt.Errorf("failed to list products: %w", err)
}
responses := make([]models.ProductResponse, len(productEntities))
if outletID != uuid.Nil && len(productEntities) > 0 {
// Bulk-fetch outlet prices to populate OutletPrice and PrintToChecker per product
productIDs := make([]uuid.UUID, len(productEntities))
for i, e := range productEntities {
productIDs[i] = e.ID
}
outletPrices, opErr := p.outletPriceRepo.GetByProductsAndOutlet(ctx, productIDs, outletID)
priceMap := make(map[uuid.UUID]*entities.ProductOutletPrice)
if opErr == nil {
for _, op := range outletPrices {
priceMap[op.ProductID] = op
}
}
for i, entity := range productEntities {
response := mappers.ProductEntityToResponse(entity)
if response != nil {
if op, ok := priceMap[entity.ID]; ok {
response.OutletPrice = &op.Price
response.PrintToChecker = op.PrintToChecker
}
responses[i] = *response
}
}
} else {
for i, entity := range productEntities {
response := mappers.ProductEntityToResponse(entity)
if response != nil {
responses[i] = *response
}
}
}
return responses, int(total), nil
}
func (p *ProductProcessorImpl) ListProductsAll(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) {
offset := (page - 1) * limit
productEntities, total, err := p.productRepo.List(ctx, filters, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to list products: %w", err)
}
responses := make([]models.ProductResponse, len(productEntities))
for i, entity := range productEntities {
response := mappers.ProductEntityToResponse(entity)
if response != nil {
responses[i] = *response
}
}
return responses, int(total), nil
}
// Helper methods for inventory management
// createInventoryForAllOutlets creates inventory records for all outlets of an organization
func (p *ProductProcessorImpl) createInventoryForAllOutlets(ctx context.Context, productID, organizationID uuid.UUID, initialStock, reorderLevel *int) error {
// Get all outlets for the organization
outlets, err := p.outletRepo.GetByOrganizationID(ctx, organizationID)
if err != nil {
return fmt.Errorf("failed to get outlets for organization: %w", err)
}
if len(outlets) == 0 {
return fmt.Errorf("no outlets found for organization")
}
// Prepare inventory items for bulk creation
var inventoryItems []*entities.Inventory
for _, outlet := range outlets {
quantity := 0
if initialStock != nil {
quantity = *initialStock
}
reorderLevelValue := 0
if reorderLevel != nil {
reorderLevelValue = *reorderLevel
}
inventoryItem := &entities.Inventory{
OutletID: outlet.ID,
ProductID: productID,
Quantity: quantity,
ReorderLevel: reorderLevelValue,
}
inventoryItems = append(inventoryItems, inventoryItem)
}
// Bulk create inventory records
if err := p.inventoryRepo.BulkCreate(ctx, inventoryItems); err != nil {
return fmt.Errorf("failed to bulk create inventory records: %w", err)
}
return nil
}
// updateReorderLevelForAllOutlets updates the reorder level for all inventory records of a product
func (p *ProductProcessorImpl) updateReorderLevelForAllOutlets(ctx context.Context, productID uuid.UUID, reorderLevel int) error {
// Get all inventory records for the product
inventoryRecords, err := p.inventoryRepo.GetByProduct(ctx, productID)
if err != nil {
return fmt.Errorf("failed to get inventory records for product: %w", err)
}
// Update reorder level for each inventory record
for _, inventory := range inventoryRecords {
inventory.ReorderLevel = reorderLevel
if err := p.inventoryRepo.Update(ctx, inventory); err != nil {
return fmt.Errorf("failed to update inventory reorder level: %w", err)
}
}
return nil
}