Files
efrilmandClaude Opus 5 ebf666c004 feat(product): require a unit for weight-based products
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>
2026-09-06 17:29:34 +07:00

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
}