Dev #27
+1
-1
@@ -372,7 +372,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
||||
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
||||
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
|
||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
||||
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
|
||||
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
|
||||
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package constants
|
||||
|
||||
// A purchase order is charged to a team. Teams come from the parent product
|
||||
// categories, plus Pusat for spending that belongs to no single team.
|
||||
const (
|
||||
PurchaseTeamScopeCategory = "category"
|
||||
PurchaseTeamScopeCentral = "central"
|
||||
|
||||
// PurchaseTeamCentralName is what Pusat is called in the picker. Pusat has no
|
||||
// row of its own, so the name lives here rather than in the database.
|
||||
PurchaseTeamCentralName = "Pusat"
|
||||
)
|
||||
@@ -14,6 +14,8 @@ type CreatePurchaseOrderRequest struct {
|
||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
@@ -28,13 +30,16 @@ type CreatePurchaseOrderItemRequest struct {
|
||||
}
|
||||
|
||||
type UpdatePurchaseOrderRequest struct {
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"`
|
||||
TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"`
|
||||
TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
// An empty string clears the team; omitting the field leaves it untouched.
|
||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
@@ -61,13 +66,29 @@ type PurchaseOrderResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message *string `json:"message"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TeamScope *string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Team *PurchaseTeamResponse `json:"team,omitempty"`
|
||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
||||
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// PurchaseTeamResponse is one entry of the team picker. Teams come from the parent
|
||||
// product categories; Pusat is the extra entry that has no category behind it, so
|
||||
// its CategoryID is null.
|
||||
type PurchaseTeamResponse struct {
|
||||
Scope string `json:"scope"`
|
||||
CategoryID *uuid.UUID `json:"category_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ListPurchaseTeamsResponse struct {
|
||||
Teams []PurchaseTeamResponse `json:"teams"`
|
||||
}
|
||||
|
||||
type PurchaseOrderItemResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||
@@ -93,13 +114,15 @@ type PurchaseOrderAttachmentResponse struct {
|
||||
}
|
||||
|
||||
type ListPurchaseOrdersRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
}
|
||||
|
||||
type ListPurchaseOrdersResponse struct {
|
||||
|
||||
@@ -20,12 +20,17 @@ type PurchaseOrder struct {
|
||||
Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"`
|
||||
Message *string `gorm:"type:text" json:"message" validate:"omitempty"`
|
||||
TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
// TeamScope is 'category' when the purchase is charged to a parent category, or
|
||||
// 'central' for Pusat. Nil means no team was chosen, which is not the same as Pusat.
|
||||
TeamScope *string `gorm:"size:20;index" json:"team_scope" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id" validate:"omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
|
||||
TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"`
|
||||
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
|
||||
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
@@ -176,6 +176,16 @@ func (h *PurchaseOrderHandler) ListPurchaseOrders(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if teamScope := c.Query("team_scope"); teamScope != "" {
|
||||
req.TeamScope = teamScope
|
||||
}
|
||||
|
||||
if teamCategoryIDStr := c.Query("team_category_id"); teamCategoryIDStr != "" {
|
||||
if teamCategoryID, err := uuid.Parse(teamCategoryIDStr); err == nil {
|
||||
req.TeamCategoryID = &teamCategoryID
|
||||
}
|
||||
}
|
||||
|
||||
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
||||
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
||||
req.StartDate = &startDate
|
||||
@@ -224,6 +234,21 @@ func (h *PurchaseOrderHandler) GetPurchaseOrdersByStatus(c *gin.Context) {
|
||||
util.HandleResponse(c.Writer, c.Request, poResponse, "PurchaseOrderHandler::GetPurchaseOrdersByStatus")
|
||||
}
|
||||
|
||||
// ListPurchaseTeams serves the team picker for the purchase form: the parent
|
||||
// categories of the caller's outlet, plus Pusat.
|
||||
func (h *PurchaseOrderHandler) ListPurchaseTeams(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
teamsResponse := h.purchaseOrderService.ListPurchaseTeams(ctx, contextInfo)
|
||||
if teamsResponse.HasErrors() {
|
||||
errorResp := teamsResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("PurchaseOrderHandler::ListPurchaseTeams -> Failed to list purchase teams from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, teamsResponse, "PurchaseOrderHandler::ListPurchaseTeams")
|
||||
}
|
||||
|
||||
func (h *PurchaseOrderHandler) GetOverduePurchaseOrders(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// purchaseTeamFromEntity renders the team a purchase order is charged to. It returns
|
||||
// nil when no team was chosen, which is distinct from a purchase charged to Pusat.
|
||||
// The category name is only filled in when TeamCategory was preloaded.
|
||||
func purchaseTeamFromEntity(entity *entities.PurchaseOrder) *models.PurchaseTeam {
|
||||
if entity.TeamScope == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
team := &models.PurchaseTeam{Scope: *entity.TeamScope}
|
||||
switch *entity.TeamScope {
|
||||
case constants.PurchaseTeamScopeCentral:
|
||||
team.Name = constants.PurchaseTeamCentralName
|
||||
case constants.PurchaseTeamScopeCategory:
|
||||
team.CategoryID = entity.TeamCategoryID
|
||||
if entity.TeamCategory != nil {
|
||||
team.Name = entity.TeamCategory.Name
|
||||
}
|
||||
}
|
||||
|
||||
return team
|
||||
}
|
||||
|
||||
func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.PurchaseOrder {
|
||||
if entity == nil {
|
||||
return nil
|
||||
@@ -22,6 +45,8 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
|
||||
Status: entity.Status,
|
||||
Message: entity.Message,
|
||||
TotalAmount: entity.TotalAmount,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
@@ -44,6 +69,8 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
|
||||
Status: model.Status,
|
||||
Message: model.Message,
|
||||
TotalAmount: model.TotalAmount,
|
||||
TeamScope: model.TeamScope,
|
||||
TeamCategoryID: model.TeamCategoryID,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
@@ -66,8 +93,11 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
|
||||
Status: entity.Status,
|
||||
Message: entity.Message,
|
||||
TotalAmount: entity.TotalAmount,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
Team: purchaseTeamFromEntity(entity),
|
||||
}
|
||||
|
||||
// Map vendor if present
|
||||
|
||||
@@ -18,10 +18,20 @@ type PurchaseOrder struct {
|
||||
Status string `json:"status"`
|
||||
Message *string `json:"message"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TeamScope *string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PurchaseTeam is one entry of the team picker: either a parent category or Pusat.
|
||||
// Pusat carries no CategoryID because it has no category of its own.
|
||||
type PurchaseTeam struct {
|
||||
Scope string `json:"scope"`
|
||||
CategoryID *uuid.UUID `json:"category_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type PurchaseOrderItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||
@@ -54,8 +64,11 @@ type PurchaseOrderResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message *string `json:"message"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TeamScope *string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Team *PurchaseTeam `json:"team,omitempty"`
|
||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
||||
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
||||
@@ -94,6 +107,8 @@ type CreatePurchaseOrderRequest struct {
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
Items []CreatePurchaseOrderItemRequest `json:"items"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
@@ -115,6 +130,8 @@ type UpdatePurchaseOrderRequest struct {
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
@@ -130,13 +147,19 @@ type UpdatePurchaseOrderItemRequest struct {
|
||||
}
|
||||
|
||||
type ListPurchaseOrdersRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
TeamScope string `json:"team_scope,omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
}
|
||||
|
||||
type ListPurchaseTeamsResponse struct {
|
||||
Teams []PurchaseTeam `json:"teams"`
|
||||
}
|
||||
|
||||
type ListPurchaseOrdersResponse struct {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -48,6 +48,26 @@ func (r *CategoryRepositoryImpl) GetByOrganization(ctx context.Context, organiza
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// ListParentCategories returns the top-level categories of an organization. These are
|
||||
// the buckets the parent category reports roll up to via COALESCE(parent_id, id), so
|
||||
// the list is deliberately every top-level category, not only those with children —
|
||||
// otherwise a team could show up in a report but not be selectable on a purchase.
|
||||
// Categories with no outlet of their own are shared, so they are always included.
|
||||
func (r *CategoryRepositoryImpl) ListParentCategories(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]*entities.Category, error) {
|
||||
var categories []*entities.Category
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Where("organization_id = ?", organizationID).
|
||||
Where("parent_id IS NULL")
|
||||
|
||||
if outletID != nil {
|
||||
query = query.Where("outlet_id = ? OR outlet_id IS NULL", *outletID)
|
||||
}
|
||||
|
||||
err := query.Order("\"order\" ASC, name ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) {
|
||||
var categories []*entities.Category
|
||||
err := r.db.WithContext(ctx).Where("business_type = ?", businessType).Find(&categories).Error
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type PurchaseOrderRepositoryImpl struct {
|
||||
@@ -30,6 +31,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID)
|
||||
var po entities.PurchaseOrder
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Vendor").
|
||||
Preload("TeamCategory").
|
||||
Preload("Items.Ingredient").
|
||||
Preload("Items.PurchaseCategory").
|
||||
Preload("Items.Unit").
|
||||
@@ -45,6 +47,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
|
||||
var po entities.PurchaseOrder
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Vendor").
|
||||
Preload("TeamCategory").
|
||||
Preload("Items.Ingredient").
|
||||
Preload("Items.PurchaseCategory").
|
||||
Preload("Items.Unit").
|
||||
@@ -58,7 +61,10 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
|
||||
}
|
||||
|
||||
func (r *PurchaseOrderRepositoryImpl) Update(ctx context.Context, po *entities.PurchaseOrder) error {
|
||||
return r.db.WithContext(ctx).Save(po).Error
|
||||
// Omit associations so preloaded relations are not upserted back. Items and
|
||||
// attachments are rewritten explicitly by the processor, and without this a
|
||||
// preloaded TeamCategory would be written over the category row itself.
|
||||
return r.db.WithContext(ctx).Omit(clause.Associations).Save(po).Error
|
||||
}
|
||||
|
||||
func (r *PurchaseOrderRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
@@ -87,6 +93,14 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
|
||||
if vendorID, ok := value.(uuid.UUID); ok {
|
||||
query = query.Where("vendor_id = ?", vendorID)
|
||||
}
|
||||
case "team_scope":
|
||||
if teamScope, ok := value.(string); ok && teamScope != "" {
|
||||
query = query.Where("team_scope = ?", teamScope)
|
||||
}
|
||||
case "team_category_id":
|
||||
if teamCategoryID, ok := value.(uuid.UUID); ok {
|
||||
query = query.Where("team_category_id = ?", teamCategoryID)
|
||||
}
|
||||
case "start_date":
|
||||
if startDate, ok := value.(time.Time); ok {
|
||||
query = query.Where("transaction_date >= ?", startDate)
|
||||
@@ -106,6 +120,7 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
|
||||
|
||||
err := query.
|
||||
Preload("Vendor").
|
||||
Preload("TeamCategory").
|
||||
Preload("Items.Ingredient").
|
||||
Preload("Items.PurchaseCategory").
|
||||
Preload("Items.Unit").
|
||||
@@ -137,6 +152,14 @@ func (r *PurchaseOrderRepositoryImpl) Count(ctx context.Context, organizationID
|
||||
if vendorID, ok := value.(uuid.UUID); ok {
|
||||
query = query.Where("vendor_id = ?", vendorID)
|
||||
}
|
||||
case "team_scope":
|
||||
if teamScope, ok := value.(string); ok && teamScope != "" {
|
||||
query = query.Where("team_scope = ?", teamScope)
|
||||
}
|
||||
case "team_category_id":
|
||||
if teamCategoryID, ok := value.(uuid.UUID); ok {
|
||||
query = query.Where("team_category_id = ?", teamCategoryID)
|
||||
}
|
||||
case "start_date":
|
||||
if startDate, ok := value.(time.Time); ok {
|
||||
query = query.Where("transaction_date >= ?", startDate)
|
||||
@@ -170,6 +193,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("organization_id = ? AND status = ?", organizationID, status).
|
||||
Preload("Vendor").
|
||||
Preload("TeamCategory").
|
||||
Preload("Items.Ingredient").
|
||||
Preload("Items.PurchaseCategory").
|
||||
Preload("Items.Unit").
|
||||
@@ -182,6 +206,7 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
|
||||
Preload("Vendor").
|
||||
Preload("TeamCategory").
|
||||
Preload("Items.Ingredient").
|
||||
Preload("Items.PurchaseCategory").
|
||||
Preload("Items.Unit").
|
||||
|
||||
@@ -388,6 +388,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
||||
purchaseOrders.GET("/status/:status", r.purchaseOrderHandler.GetPurchaseOrdersByStatus)
|
||||
purchaseOrders.GET("/overdue", r.purchaseOrderHandler.GetOverduePurchaseOrders)
|
||||
purchaseOrders.GET("/teams", r.purchaseOrderHandler.ListPurchaseTeams)
|
||||
purchaseOrders.GET("/:id", r.purchaseOrderHandler.GetPurchaseOrder)
|
||||
purchaseOrders.PUT("/:id", r.purchaseOrderHandler.UpdatePurchaseOrder)
|
||||
purchaseOrders.PUT("/:id/status/:status", r.purchaseOrderHandler.UpdatePurchaseOrderStatus)
|
||||
|
||||
@@ -21,6 +21,7 @@ type PurchaseOrderService interface {
|
||||
GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response
|
||||
GetOverduePurchaseOrders(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
||||
UpdatePurchaseOrderStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, status string) *contract.Response
|
||||
ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response
|
||||
}
|
||||
|
||||
type PurchaseOrderServiceImpl struct {
|
||||
@@ -113,6 +114,12 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
|
||||
if modelReq.VendorID != nil {
|
||||
filters["vendor_id"] = *modelReq.VendorID
|
||||
}
|
||||
if modelReq.TeamScope != "" {
|
||||
filters["team_scope"] = modelReq.TeamScope
|
||||
}
|
||||
if modelReq.TeamCategoryID != nil {
|
||||
filters["team_category_id"] = *modelReq.TeamCategoryID
|
||||
}
|
||||
if modelReq.StartDate != nil {
|
||||
filters["start_date"] = *modelReq.StartDate
|
||||
}
|
||||
@@ -145,6 +152,21 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
|
||||
return contract.BuildSuccessResponse(response)
|
||||
}
|
||||
|
||||
func (s *PurchaseOrderServiceImpl) ListPurchaseTeams(ctx context.Context, apctx *appcontext.ContextInfo) *contract.Response {
|
||||
var outletID *uuid.UUID
|
||||
if apctx.OutletID != uuid.Nil {
|
||||
outletID = &apctx.OutletID
|
||||
}
|
||||
|
||||
teams, err := s.purchaseOrderProcessor.ListPurchaseTeams(ctx, apctx.OrganizationID, outletID)
|
||||
if err != nil {
|
||||
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
|
||||
}
|
||||
|
||||
return contract.BuildSuccessResponse(transformer.ListPurchaseTeamsModelResponseToResponse(teams))
|
||||
}
|
||||
|
||||
func (s *PurchaseOrderServiceImpl) GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response {
|
||||
poResponses, err := s.purchaseOrderProcessor.GetPurchaseOrdersByStatus(ctx, apctx.OrganizationID, status)
|
||||
if err != nil {
|
||||
|
||||
@@ -44,6 +44,8 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
|
||||
Reference: req.Reference,
|
||||
Status: req.Status,
|
||||
Message: req.Message,
|
||||
TeamScope: req.TeamScope,
|
||||
TeamCategoryID: req.TeamCategoryID,
|
||||
Items: items,
|
||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||
}, nil
|
||||
@@ -94,6 +96,8 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
|
||||
Reference: req.Reference,
|
||||
Status: req.Status,
|
||||
Message: req.Message,
|
||||
TeamScope: req.TeamScope,
|
||||
TeamCategoryID: req.TeamCategoryID,
|
||||
Items: items,
|
||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||
}, nil
|
||||
@@ -101,16 +105,39 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
|
||||
|
||||
func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) *models.ListPurchaseOrdersRequest {
|
||||
return &models.ListPurchaseOrdersRequest{
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
Status: req.Status,
|
||||
VendorID: req.VendorID,
|
||||
StartDate: req.StartDate,
|
||||
EndDate: req.EndDate,
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
Status: req.Status,
|
||||
VendorID: req.VendorID,
|
||||
TeamScope: req.TeamScope,
|
||||
TeamCategoryID: req.TeamCategoryID,
|
||||
StartDate: req.StartDate,
|
||||
EndDate: req.EndDate,
|
||||
}
|
||||
}
|
||||
|
||||
func PurchaseTeamModelToResponse(team *models.PurchaseTeam) *contract.PurchaseTeamResponse {
|
||||
if team == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &contract.PurchaseTeamResponse{
|
||||
Scope: team.Scope,
|
||||
CategoryID: team.CategoryID,
|
||||
Name: team.Name,
|
||||
}
|
||||
}
|
||||
|
||||
func ListPurchaseTeamsModelResponseToResponse(resp *models.ListPurchaseTeamsResponse) *contract.ListPurchaseTeamsResponse {
|
||||
teams := make([]contract.PurchaseTeamResponse, len(resp.Teams))
|
||||
for i, team := range resp.Teams {
|
||||
teams[i] = *PurchaseTeamModelToResponse(&team)
|
||||
}
|
||||
|
||||
return &contract.ListPurchaseTeamsResponse{Teams: teams}
|
||||
}
|
||||
|
||||
// Model to Contract conversions
|
||||
func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *contract.PurchaseOrderResponse {
|
||||
if po == nil {
|
||||
@@ -129,8 +156,11 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
|
||||
Status: po.Status,
|
||||
Message: po.Message,
|
||||
TotalAmount: po.TotalAmount,
|
||||
TeamScope: po.TeamScope,
|
||||
TeamCategoryID: po.TeamCategoryID,
|
||||
CreatedAt: po.CreatedAt,
|
||||
UpdatedAt: po.UpdatedAt,
|
||||
Team: PurchaseTeamModelToResponse(po.Team),
|
||||
}
|
||||
|
||||
// Map vendor if present
|
||||
|
||||
@@ -76,6 +76,10 @@ func (v *PurchaseOrderValidatorImpl) ValidateCreatePurchaseOrderRequest(req *con
|
||||
}
|
||||
}
|
||||
|
||||
if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, false); err != nil {
|
||||
return err, code
|
||||
}
|
||||
|
||||
if len(req.Items) == 0 {
|
||||
return errors.New("at least one item is required"), constants.MissingFieldErrorCode
|
||||
}
|
||||
@@ -139,6 +143,10 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con
|
||||
}
|
||||
}
|
||||
|
||||
if err, code := validatePurchaseTeamSelection(req.TeamScope, req.TeamCategoryID, true); err != nil {
|
||||
return err, code
|
||||
}
|
||||
|
||||
// Validate items if provided
|
||||
if req.Items != nil {
|
||||
for i, item := range req.Items {
|
||||
@@ -151,6 +159,40 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// validatePurchaseTeamSelection keeps team_scope and team_category_id in step with
|
||||
// the database check constraint: a category team needs a category, Pusat must not
|
||||
// carry one. allowClear lets an update send an empty scope to drop the team.
|
||||
func validatePurchaseTeamSelection(scope *string, categoryID *uuid.UUID, allowClear bool) (error, string) {
|
||||
if scope == nil {
|
||||
if categoryID != nil {
|
||||
return errors.New("team_scope is required when team_category_id is provided"), constants.MissingFieldErrorCode
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(*scope) {
|
||||
case "":
|
||||
if !allowClear {
|
||||
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
if categoryID != nil {
|
||||
return errors.New("team_category_id must be empty when clearing the team"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
case constants.PurchaseTeamScopeCategory:
|
||||
if categoryID == nil || *categoryID == uuid.Nil {
|
||||
return errors.New("team_category_id is required when team_scope is category"), constants.MissingFieldErrorCode
|
||||
}
|
||||
case constants.PurchaseTeamScopeCentral:
|
||||
if categoryID != nil {
|
||||
return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
default:
|
||||
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) {
|
||||
if req == nil {
|
||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
||||
@@ -171,6 +213,21 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont
|
||||
}
|
||||
}
|
||||
|
||||
if req.TeamScope != "" {
|
||||
validScopes := []string{constants.PurchaseTeamScopeCategory, constants.PurchaseTeamScopeCentral}
|
||||
if !contains(validScopes, req.TeamScope) {
|
||||
return errors.New("team_scope must be one of: category, central"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if req.TeamScope == constants.PurchaseTeamScopeCentral && req.TeamCategoryID != nil {
|
||||
return errors.New("team_category_id must be empty when team_scope is central"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
if req.TeamCategoryID != nil && *req.TeamCategoryID == uuid.Nil {
|
||||
return errors.New("team_category_id cannot be empty"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if req.StartDate != nil && req.EndDate != nil {
|
||||
if req.EndDate.Before(*req.StartDate) {
|
||||
return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode
|
||||
|
||||
@@ -90,3 +90,122 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "due_date must be after transaction_date")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateAllowsCentralTeam(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
scope := constants.PurchaseTeamScopeCentral
|
||||
req.TeamScope = &scope
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsCentralTeamWithCategory(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
scope := constants.PurchaseTeamScopeCentral
|
||||
categoryID := uuid.New()
|
||||
req.TeamScope = &scope
|
||||
req.TeamCategoryID = &categoryID
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "team_category_id must be empty")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsCategoryTeamWithoutCategory(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
scope := constants.PurchaseTeamScopeCategory
|
||||
req.TeamScope = &scope
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MissingFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "team_category_id is required")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsCategoryWithoutScope(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
categoryID := uuid.New()
|
||||
req.TeamCategoryID = &categoryID
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MissingFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "team_scope is required")
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsUnknownTeamScope(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
scope := "outlet"
|
||||
req.TeamScope = &scope
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
require.Contains(t, err.Error(), "team_scope must be one of")
|
||||
}
|
||||
|
||||
// An update may clear the team with an empty scope; a create may not, because
|
||||
// leaving the field out already means "no team".
|
||||
func TestPurchaseOrderValidatorUpdateAllowsClearingTeam(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
scope := ""
|
||||
|
||||
err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{TeamScope: &scope})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorCreateRejectsEmptyTeamScope(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
req := validCreatePurchaseOrderRequest()
|
||||
scope := ""
|
||||
req.TeamScope = &scope
|
||||
|
||||
err, code := validator.ValidateCreatePurchaseOrderRequest(req)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorUpdateRejectsClearingTeamWithCategory(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
scope := ""
|
||||
categoryID := uuid.New()
|
||||
|
||||
err, code := validator.ValidateUpdatePurchaseOrderRequest(&contract.UpdatePurchaseOrderRequest{
|
||||
TeamScope: &scope,
|
||||
TeamCategoryID: &categoryID,
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
}
|
||||
|
||||
func TestPurchaseOrderValidatorListRejectsCentralScopeWithCategory(t *testing.T) {
|
||||
validator := NewPurchaseOrderValidator()
|
||||
categoryID := uuid.New()
|
||||
|
||||
err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
TeamScope: constants.PurchaseTeamScopeCentral,
|
||||
TeamCategoryID: &categoryID,
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
DROP INDEX IF EXISTS idx_purchase_orders_team_scope;
|
||||
DROP INDEX IF EXISTS idx_purchase_orders_team_category_id;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
DROP CONSTRAINT IF EXISTS chk_purchase_orders_team;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
DROP CONSTRAINT IF EXISTS fk_purchase_orders_team_category;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
DROP COLUMN IF EXISTS team_category_id,
|
||||
DROP COLUMN IF EXISTS team_scope;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- A purchase is charged either to a team (a parent product category) or to Pusat.
|
||||
-- Pusat has no category of its own, so it is stored as a scope rather than a row;
|
||||
-- which outlet's Pusat it is comes from purchase_orders.outlet_id.
|
||||
-- team_scope IS NULL means the team was never chosen, which is deliberately
|
||||
-- distinct from a purchase that belongs to Pusat.
|
||||
ALTER TABLE purchase_orders
|
||||
ADD COLUMN IF NOT EXISTS team_scope VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS team_category_id UUID;
|
||||
|
||||
ALTER TABLE purchase_orders
|
||||
ADD CONSTRAINT fk_purchase_orders_team_category
|
||||
FOREIGN KEY (team_category_id) REFERENCES categories(id) ON DELETE RESTRICT;
|
||||
|
||||
-- Deleting a category that is still charged on a purchase order must fail rather
|
||||
-- than silently drop the attribution, hence RESTRICT above and this pairing check.
|
||||
-- Written as a CASE because an OR chain would evaluate to NULL when team_scope is
|
||||
-- NULL, and a CHECK only rejects FALSE — a stray team_category_id would slip past.
|
||||
ALTER TABLE purchase_orders
|
||||
ADD CONSTRAINT chk_purchase_orders_team
|
||||
CHECK (
|
||||
CASE
|
||||
WHEN team_scope IS NULL THEN team_category_id IS NULL
|
||||
WHEN team_scope = 'category' THEN team_category_id IS NOT NULL
|
||||
WHEN team_scope = 'central' THEN team_category_id IS NULL
|
||||
ELSE false
|
||||
END
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_purchase_orders_team_category_id
|
||||
ON purchase_orders(team_category_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_purchase_orders_team_scope
|
||||
ON purchase_orders(team_scope);
|
||||
Reference in New Issue
Block a user