Dev #27

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