feat(purchasae): added team with category parent and central

This commit is contained in:
efrilm
2026-08-11 21:00:39 +07:00
parent a230199ce0
commit 9ae5be2c33
18 changed files with 566 additions and 32 deletions
+1
View File
@@ -24,6 +24,7 @@ type CategoryRepository interface {
GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error)
GetWithProducts(ctx context.Context, id uuid.UUID) (*entities.Category, error)
GetByOrganization(ctx context.Context, organizationID uuid.UUID) ([]*entities.Category, error)
ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error)
GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error)
Update(ctx context.Context, category *entities.Category) error
Delete(ctx context.Context, id uuid.UUID) error
@@ -1,11 +1,13 @@
package processor
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"context"
"fmt"
"strings"
"github.com/google/uuid"
)
@@ -19,6 +21,7 @@ type PurchaseOrderProcessor interface {
GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error)
GetOverduePurchaseOrders(ctx context.Context, organizationID uuid.UUID) ([]*models.PurchaseOrderResponse, error)
UpdatePurchaseOrderStatus(ctx context.Context, id, organizationID, userID, outletID uuid.UUID, status string) (*models.PurchaseOrderResponse, error)
ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error)
}
type PurchaseOrderProcessorImpl struct {
@@ -26,6 +29,7 @@ type PurchaseOrderProcessorImpl struct {
vendorRepo VendorRepository
ingredientRepo IngredientRepository
purchaseCategoryRepo PurchaseCategoryRepository
categoryRepo CategoryRepository
unitRepo UnitRepository
fileRepo FileRepository
inventoryMovementService InventoryMovementService
@@ -37,6 +41,7 @@ func NewPurchaseOrderProcessorImpl(
vendorRepo VendorRepository,
ingredientRepo IngredientRepository,
purchaseCategoryRepo PurchaseCategoryRepository,
categoryRepo CategoryRepository,
unitRepo UnitRepository,
fileRepo FileRepository,
inventoryMovementService InventoryMovementService,
@@ -47,6 +52,7 @@ func NewPurchaseOrderProcessorImpl(
vendorRepo: vendorRepo,
ingredientRepo: ingredientRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
categoryRepo: categoryRepo,
unitRepo: unitRepo,
fileRepo: fileRepo,
inventoryMovementService: inventoryMovementService,
@@ -63,6 +69,11 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
}
}
teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, outletID, req.TeamScope, req.TeamCategoryID)
if err != nil {
return nil, err
}
// Check if PO number already exists in organization
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
if err == nil && existingPO != nil {
@@ -124,6 +135,8 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
Status: "draft", // Default status
Message: req.Message,
TotalAmount: totalAmount,
TeamScope: teamScope,
TeamCategoryID: teamCategoryID,
}
if req.Status != nil {
@@ -221,6 +234,16 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
poEntity.Message = req.Message
}
// An omitted team_scope leaves the team as it is; an empty one clears it.
if req.TeamScope != nil {
teamScope, teamCategoryID, err := p.resolvePurchaseTeam(ctx, organizationID, poEntity.OutletID, req.TeamScope, req.TeamCategoryID)
if err != nil {
return nil, err
}
poEntity.TeamScope = teamScope
poEntity.TeamCategoryID = teamCategoryID
}
// Update items if provided
if req.Items != nil {
totalAmount := 0.0
@@ -501,6 +524,79 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
}
// ListPurchaseTeams returns the teams a purchase can be charged to: the parent
// categories of the outlet in scope, followed by Pusat. Pusat has no category row,
// so it is appended here rather than read from the database.
func (p *PurchaseOrderProcessorImpl) ListPurchaseTeams(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) (*models.ListPurchaseTeamsResponse, error) {
categories, err := p.categoryRepo.ListParentCategories(ctx, organizationID, outletID)
if err != nil {
return nil, fmt.Errorf("failed to list parent categories: %w", err)
}
teams := make([]models.PurchaseTeam, 0, len(categories)+1)
for _, category := range categories {
categoryID := category.ID
teams = append(teams, models.PurchaseTeam{
Scope: constants.PurchaseTeamScopeCategory,
CategoryID: &categoryID,
Name: category.Name,
})
}
teams = append(teams, models.PurchaseTeam{
Scope: constants.PurchaseTeamScopeCentral,
Name: constants.PurchaseTeamCentralName,
})
return &models.ListPurchaseTeamsResponse{Teams: teams}, nil
}
// resolvePurchaseTeam turns a requested team into the scope/category pair stored on
// the purchase order, mirroring the database check constraint. A nil or empty scope
// leaves the purchase without a team, which is deliberately different from Pusat.
// Which outlet's Pusat a purchase belongs to comes from the purchase order's outlet,
// so 'central' needs nothing stored beyond the scope itself.
func (p *PurchaseOrderProcessorImpl) resolvePurchaseTeam(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, scope *string, categoryID *uuid.UUID) (*string, *uuid.UUID, error) {
if scope == nil {
return nil, nil, nil
}
switch strings.TrimSpace(*scope) {
case "":
return nil, nil, nil
case constants.PurchaseTeamScopeCentral:
resolved := constants.PurchaseTeamScopeCentral
return &resolved, nil, nil
case constants.PurchaseTeamScopeCategory:
if categoryID == nil {
return nil, nil, fmt.Errorf("team_category_id is required when team_scope is category")
}
category, err := p.categoryRepo.GetByID(ctx, *categoryID)
if err != nil {
return nil, nil, fmt.Errorf("team category not found: %w", err)
}
if category.OrganizationID != organizationID {
return nil, nil, fmt.Errorf("team category does not belong to this organization")
}
if category.ParentID != nil {
return nil, nil, fmt.Errorf("team must be a parent category")
}
// Categories without an outlet are shared, so only an outlet-specific
// category has to match the outlet the purchase is booked against.
if category.OutletID != nil && outletID != nil && *category.OutletID != *outletID {
return nil, nil, fmt.Errorf("team category belongs to a different outlet")
}
resolved := constants.PurchaseTeamScopeCategory
return &resolved, &category.ID, nil
}
return nil, nil, fmt.Errorf("team_scope must be one of: category, central")
}
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil {