feat: cash advance
This commit is contained in:
@@ -1,13 +1,11 @@
|
||||
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"
|
||||
)
|
||||
@@ -25,13 +23,14 @@ type PurchaseOrderProcessor interface {
|
||||
}
|
||||
|
||||
type PurchaseOrderProcessorImpl struct {
|
||||
purchaseOrderRepo PurchaseOrderRepository
|
||||
vendorRepo VendorRepository
|
||||
ingredientRepo IngredientRepository
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
categoryRepo CategoryRepository
|
||||
unitRepo UnitRepository
|
||||
fileRepo FileRepository
|
||||
purchaseOrderRepo PurchaseOrderRepository
|
||||
vendorRepo VendorRepository
|
||||
ingredientRepo IngredientRepository
|
||||
purchaseCategoryRepo PurchaseCategoryRepository
|
||||
categoryRepo CategoryRepository
|
||||
cashAdvanceRepo CashAdvanceRepository
|
||||
unitRepo UnitRepository
|
||||
fileRepo FileRepository
|
||||
// Kept wired but currently unused: purchase orders are a record of spending
|
||||
// only, so nothing here moves stock or converts units. These stay so that
|
||||
// tying purchases back to inventory is a change in one place.
|
||||
@@ -45,6 +44,7 @@ func NewPurchaseOrderProcessorImpl(
|
||||
ingredientRepo IngredientRepository,
|
||||
purchaseCategoryRepo PurchaseCategoryRepository,
|
||||
categoryRepo CategoryRepository,
|
||||
cashAdvanceRepo CashAdvanceRepository,
|
||||
unitRepo UnitRepository,
|
||||
fileRepo FileRepository,
|
||||
inventoryMovementService InventoryMovementService,
|
||||
@@ -56,6 +56,7 @@ func NewPurchaseOrderProcessorImpl(
|
||||
ingredientRepo: ingredientRepo,
|
||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||
categoryRepo: categoryRepo,
|
||||
cashAdvanceRepo: cashAdvanceRepo,
|
||||
unitRepo: unitRepo,
|
||||
fileRepo: fileRepo,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
@@ -77,6 +78,11 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
return nil, err
|
||||
}
|
||||
|
||||
teamScope, teamCategoryID, err = p.applyCashAdvance(ctx, organizationID, outletID, req.CashAdvanceID, teamScope, 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 {
|
||||
@@ -140,6 +146,7 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
||||
TotalAmount: totalAmount,
|
||||
TeamScope: teamScope,
|
||||
TeamCategoryID: teamCategoryID,
|
||||
CashAdvanceID: req.CashAdvanceID,
|
||||
}
|
||||
|
||||
if req.Status != nil {
|
||||
@@ -247,6 +254,26 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
||||
poEntity.TeamCategoryID = teamCategoryID
|
||||
}
|
||||
|
||||
// An all-zero cash advance id unlinks the purchase; omitting the field leaves it alone.
|
||||
if req.CashAdvanceID != nil {
|
||||
if *req.CashAdvanceID == uuid.Nil {
|
||||
poEntity.CashAdvanceID = nil
|
||||
} else {
|
||||
poEntity.CashAdvanceID = req.CashAdvanceID
|
||||
}
|
||||
}
|
||||
|
||||
// Recheck the pairing whenever either side moved: a purchase can end up on a
|
||||
// cash advance belonging to another team otherwise.
|
||||
if poEntity.CashAdvanceID != nil && (req.CashAdvanceID != nil || req.TeamScope != nil) {
|
||||
teamScope, teamCategoryID, err := p.applyCashAdvance(ctx, organizationID, poEntity.OutletID, poEntity.CashAdvanceID, poEntity.TeamScope, poEntity.TeamCategoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
poEntity.TeamScope = teamScope
|
||||
poEntity.TeamCategoryID = teamCategoryID
|
||||
}
|
||||
|
||||
// Update items if provided
|
||||
if req.Items != nil {
|
||||
totalAmount := 0.0
|
||||
@@ -467,77 +494,49 @@ 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.
|
||||
// ListPurchaseTeams returns the teams a purchase can be charged to. Cash advances are
|
||||
// charged to the same teams, so the list itself is built in one shared place.
|
||||
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
|
||||
return listTeams(ctx, p.categoryRepo, organizationID, outletID)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// the purchase order. A nil or empty scope leaves the purchase without a team, which
|
||||
// is deliberately different from Pusat.
|
||||
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
|
||||
return resolveTeamSelection(ctx, p.categoryRepo, organizationID, outletID, scope, categoryID)
|
||||
}
|
||||
|
||||
// applyCashAdvance checks a purchase may be charged to the cash advance it names, and returns
|
||||
// the team it should carry. A purchase paid out of a team's cash belongs to that
|
||||
// team, so an unassigned purchase inherits it and an assigned one has to agree.
|
||||
func (p *PurchaseOrderProcessorImpl) applyCashAdvance(ctx context.Context, organizationID uuid.UUID, outletID, cashAdvanceID *uuid.UUID, teamScope *string, teamCategoryID *uuid.UUID) (*string, *uuid.UUID, error) {
|
||||
if cashAdvanceID == nil {
|
||||
return teamScope, teamCategoryID, 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
|
||||
cashAdvance, err := resolveSpendingCashAdvance(ctx, p.cashAdvanceRepo, *cashAdvanceID, organizationID, outletID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("team_scope must be one of: category, central")
|
||||
if teamScope == nil {
|
||||
scope := cashAdvance.TeamScope
|
||||
return &scope, cashAdvance.TeamCategoryID, nil
|
||||
}
|
||||
|
||||
if *teamScope != cashAdvance.TeamScope || !sameUUID(teamCategoryID, cashAdvance.TeamCategoryID) {
|
||||
return nil, nil, fmt.Errorf("purchase order team must match the team cash advance %s was issued to", cashAdvance.CodeNumber)
|
||||
}
|
||||
|
||||
return teamScope, teamCategoryID, nil
|
||||
}
|
||||
|
||||
func sameUUID(a, b *uuid.UUID) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
|
||||
|
||||
Reference in New Issue
Block a user