A product sold by weight with no unit produces order lines with nothing to print: the receipt would read "4,2" with no idea of what. Until now nothing stopped that — the mistake only surfaced at the cashier. Enforce it in two places, because neither alone sees the whole picture. On create, the validator has everything it needs. On update, the request may omit unit_id for a product that already has one, so the check runs in the processor against the merged product: what is rejected is the end state, a product sold by weight with no unit. Also fixes two things this uncovered: The struct tags on the product contracts are decorative — this validator is hand-written and never calls validator.Struct — so `oneof=unit weight` was never enforced, and an unknown sell_by was silently rewritten to "unit" by the mapper. It is now rejected with a message that names the valid values. The update validator's "at least one field" guard did not list unit_id, sell_by or print_to_checker, so an update carrying only one of those was turned away as an empty request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
461 lines
17 KiB
Go
461 lines
17 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"apskel-pos-be/internal/constants"
|
|
"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)
|
|
|
|
// Checked after the merge, not on the request: switching a product to sell_by
|
|
// "weight" is valid when it already carries a unit, and clearing the unit is
|
|
// invalid when it is already sold by weight. Only the merged product shows either.
|
|
if existingProduct.SellBy == constants.SellByWeight && existingProduct.UnitID == nil {
|
|
return nil, fmt.Errorf("product '%s' is sold by weight and requires a unit_id", existingProduct.Name)
|
|
}
|
|
|
|
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
|
|
}
|