From 9ae5be2c33cfe3cad2c7ae7f5026f65bb1ef3677 Mon Sep 17 00:00:00 2001 From: efrilm Date: Tue, 11 Aug 2026 16:39:13 +0700 Subject: [PATCH] feat(purchasae): added team with category parent and central --- internal/app/app.go | 2 +- internal/constants/purchase_team.go | 12 ++ internal/contract/purchase_order_contract.go | 51 +++++--- internal/entities/purchase_order.go | 9 +- internal/handler/purchase_order_handler.go | 25 ++++ internal/mappers/purchase_order_mapper.go | 30 +++++ internal/models/purchase_order.go | 37 ++++-- internal/processor/category_processor.go | 1 + .../processor/purchase_order_processor.go | 96 ++++++++++++++ internal/repository/category_repository.go | 20 +++ .../repository/purchase_order_repository.go | 27 +++- internal/router/router.go | 1 + internal/service/purchase_order_service.go | 22 ++++ .../transformer/purchase_order_transformer.go | 44 +++++-- .../validator/purchase_order_validator.go | 57 +++++++++ .../purchase_order_validator_test.go | 119 ++++++++++++++++++ ...00085_add_team_to_purchase_orders.down.sql | 12 ++ .../000085_add_team_to_purchase_orders.up.sql | 33 +++++ 18 files changed, 566 insertions(+), 32 deletions(-) create mode 100644 internal/constants/purchase_team.go create mode 100644 migrations/000085_add_team_to_purchase_orders.down.sql create mode 100644 migrations/000085_add_team_to_purchase_orders.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 6a0f4d1..42dd639 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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), diff --git a/internal/constants/purchase_team.go b/internal/constants/purchase_team.go new file mode 100644 index 0000000..c3cc147 --- /dev/null +++ b/internal/constants/purchase_team.go @@ -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" +) diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 7038b4b..79b14b2 100644 --- a/internal/contract/purchase_order_contract.go +++ b/internal/contract/purchase_order_contract.go @@ -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 { diff --git a/internal/entities/purchase_order.go b/internal/entities/purchase_order.go index ba5209e..e6f5208 100644 --- a/internal/entities/purchase_order.go +++ b/internal/entities/purchase_order.go @@ -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"` } diff --git a/internal/handler/purchase_order_handler.go b/internal/handler/purchase_order_handler.go index 6be1cff..b210fd0 100644 --- a/internal/handler/purchase_order_handler.go +++ b/internal/handler/purchase_order_handler.go @@ -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) diff --git a/internal/mappers/purchase_order_mapper.go b/internal/mappers/purchase_order_mapper.go index 5be72d2..dddaa11 100644 --- a/internal/mappers/purchase_order_mapper.go +++ b/internal/mappers/purchase_order_mapper.go @@ -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 diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 7122ed8..e5e1287 100644 --- a/internal/models/purchase_order.go +++ b/internal/models/purchase_order.go @@ -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 { diff --git a/internal/processor/category_processor.go b/internal/processor/category_processor.go index 73be6d5..26eba2c 100644 --- a/internal/processor/category_processor.go +++ b/internal/processor/category_processor.go @@ -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 diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index fe8de6e..3d0e150 100644 --- a/internal/processor/purchase_order_processor.go +++ b/internal/processor/purchase_order_processor.go @@ -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 { diff --git a/internal/repository/category_repository.go b/internal/repository/category_repository.go index ee4af10..1f007bf 100644 --- a/internal/repository/category_repository.go +++ b/internal/repository/category_repository.go @@ -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 diff --git a/internal/repository/purchase_order_repository.go b/internal/repository/purchase_order_repository.go index d65cd6b..3dd71a2 100644 --- a/internal/repository/purchase_order_repository.go +++ b/internal/repository/purchase_order_repository.go @@ -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"). diff --git a/internal/router/router.go b/internal/router/router.go index 26c1dae..97d84ac 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/service/purchase_order_service.go b/internal/service/purchase_order_service.go index 27fbcb7..d6d4458 100644 --- a/internal/service/purchase_order_service.go +++ b/internal/service/purchase_order_service.go @@ -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 { diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index 814b8c4..704e7e8 100644 --- a/internal/transformer/purchase_order_transformer.go +++ b/internal/transformer/purchase_order_transformer.go @@ -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 diff --git a/internal/validator/purchase_order_validator.go b/internal/validator/purchase_order_validator.go index b578a94..3d16631 100644 --- a/internal/validator/purchase_order_validator.go +++ b/internal/validator/purchase_order_validator.go @@ -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 diff --git a/internal/validator/purchase_order_validator_test.go b/internal/validator/purchase_order_validator_test.go index d7e146d..32a8eac 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -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) +} diff --git a/migrations/000085_add_team_to_purchase_orders.down.sql b/migrations/000085_add_team_to_purchase_orders.down.sql new file mode 100644 index 0000000..0632ce0 --- /dev/null +++ b/migrations/000085_add_team_to_purchase_orders.down.sql @@ -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; diff --git a/migrations/000085_add_team_to_purchase_orders.up.sql b/migrations/000085_add_team_to_purchase_orders.up.sql new file mode 100644 index 0000000..86f9f7f --- /dev/null +++ b/migrations/000085_add_team_to_purchase_orders.up.sql @@ -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);