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..e066381 --- /dev/null +++ b/internal/constants/purchase_team.go @@ -0,0 +1,19 @@ +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" + + // PurchaseTeamNone is the value the list filter takes to ask for purchases + // that have not been charged to any team yet. + PurchaseTeamNone = "none" + + // PurchaseTeamNoneName labels those purchases in the reports. + PurchaseTeamNoneName = "Tanpa Team" +) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index b49fd58..918be35 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -88,15 +88,19 @@ type SalesAnalyticsData struct { type PurchasingAnalyticsRequest struct { OrganizationID uuid.UUID OutletID *string `form:"outlet_id,omitempty"` - DateFrom string `form:"date_from" validate:"required"` - DateTo string `form:"date_to" validate:"required"` - GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` + // Team narrows the report to one team: a parent category id, "central" for + // Pusat, or "none" for purchases charged to no team. Empty covers all teams. + Team string `form:"team,omitempty"` + DateFrom string `form:"date_from" validate:"required"` + DateTo string `form:"date_to" validate:"required"` + GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"` } type PurchasingAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletName *string `json:"outlet_name,omitempty"` + Team string `json:"team,omitempty"` DateFrom time.Time `json:"date_from"` DateTo time.Time `json:"date_to"` GroupBy string `json:"group_by"` @@ -104,6 +108,21 @@ type PurchasingAnalyticsResponse struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData is one team's share of the purchases. Scope and CategoryID +// are exactly what the team filter takes, so a row doubles as a drill-down link. +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } type PurchasingSummary struct { @@ -117,6 +136,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } type PurchasingAnalyticsData struct { diff --git a/internal/contract/ingredient_unit_converter_contract.go b/internal/contract/ingredient_unit_converter_contract.go index 9741814..9c42d6e 100644 --- a/internal/contract/ingredient_unit_converter_contract.go +++ b/internal/contract/ingredient_unit_converter_contract.go @@ -77,7 +77,7 @@ type ListIngredientUnitConvertersResponse struct { type IngredientUnitsResponse struct { IngredientID uuid.UUID `json:"ingredient_id"` IngredientName string `json:"ingredient_name"` - BaseUnitID uuid.UUID `json:"base_unit_id"` + BaseUnitID *uuid.UUID `json:"base_unit_id"` BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } diff --git a/internal/contract/product_recipe_contract.go b/internal/contract/product_recipe_contract.go index cc73de2..8806601 100644 --- a/internal/contract/product_recipe_contract.go +++ b/internal/contract/product_recipe_contract.go @@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` diff --git a/internal/contract/purchase_order_contract.go b/internal/contract/purchase_order_contract.go index 7038b4b..26bbd52 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,20 @@ 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"` + // Team is the single-value form of the two filters below, so the team picker + // can send back what it was given: a parent category id, "central" for Pusat, + // or "none" for purchases with no team yet. It replaces them rather than + // narrowing alongside them. + Team string `json:"team,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/analytics.go b/internal/entities/analytics.go index 4ea0933..fc38018 100644 --- a/internal/entities/analytics.go +++ b/internal/entities/analytics.go @@ -27,6 +27,14 @@ type SalesAnalytics struct { NetSales float64 `json:"net_sales"` } +// PurchaseTeamFilter narrows purchasing figures to a single team: a parent +// category, Pusat, or the purchases that carry no team at all. A nil filter +// leaves the figures spanning every team. +type PurchaseTeamFilter struct { + Scope string + CategoryID *uuid.UUID +} + // PurchasingAnalytics represents purchasing analytics data type PurchasingAnalytics struct { OutletName *string `json:"outlet_name,omitempty"` @@ -34,6 +42,22 @@ type PurchasingAnalytics struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData is one team's share of the purchases: a parent category, +// Pusat, or the purchases charged to no team at all. Scope and CategoryID are +// what the team filter takes back, so a row can be clicked straight through. +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } type PurchasingSummary struct { @@ -47,6 +71,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } type PurchasingAnalyticsData struct { diff --git a/internal/entities/ingredient.go b/internal/entities/ingredient.go index d8e7f91..4db67ca 100644 --- a/internal/entities/ingredient.go +++ b/internal/entities/ingredient.go @@ -11,7 +11,7 @@ type Ingredient struct { OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"` OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"` Name string `gorm:"not null;size:255" json:"name"` - UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"` + UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"` Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"` Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"` IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"` 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..f1d1012 100644 --- a/internal/handler/purchase_order_handler.go +++ b/internal/handler/purchase_order_handler.go @@ -176,6 +176,20 @@ func (h *PurchaseOrderHandler) ListPurchaseOrders(c *gin.Context) { } } + if team := c.Query("team"); team != "" { + req.Team = team + } + + 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 +238,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/analytics.go b/internal/models/analytics.go index 60b511d..d3aaa07 100644 --- a/internal/models/analytics.go +++ b/internal/models/analytics.go @@ -1,8 +1,12 @@ package models import ( + "fmt" "time" + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "github.com/google/uuid" ) @@ -93,9 +97,34 @@ type SalesAnalyticsData struct { type PurchasingAnalyticsRequest struct { OrganizationID uuid.UUID `validate:"required"` OutletID *uuid.UUID `validate:"omitempty"` - DateFrom time.Time `validate:"required"` - DateTo time.Time `validate:"required"` - GroupBy string `validate:"omitempty,oneof=day hour week month"` + // Team is the raw value the team picker sends: a parent category id, + // "central" for Pusat, "none" for purchases with no team, or empty for all. + Team string + DateFrom time.Time `validate:"required"` + DateTo time.Time `validate:"required"` + GroupBy string `validate:"omitempty,oneof=day hour week month"` +} + +// ParsePurchaseTeamFilter turns the team value the picker sends into the scope and +// category the purchasing queries filter on. An empty value spans every team; an +// unknown one is an error rather than a report that quietly ignores the filter. +func ParsePurchaseTeamFilter(team string) (*entities.PurchaseTeamFilter, error) { + switch team { + case "": + return nil, nil + case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone: + return &entities.PurchaseTeamFilter{Scope: team}, nil + } + + categoryID, err := uuid.Parse(team) + if err != nil || categoryID == uuid.Nil { + return nil, fmt.Errorf("team must be one of: central, none, or a category id") + } + + return &entities.PurchaseTeamFilter{ + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + }, nil } // PurchasingAnalyticsResponse represents the response for purchasing analytics @@ -103,6 +132,7 @@ type PurchasingAnalyticsResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletName *string `json:"outlet_name,omitempty"` + Team string `json:"team,omitempty"` DateFrom time.Time `json:"date_from"` DateTo time.Time `json:"date_to"` GroupBy string `json:"group_by"` @@ -110,6 +140,20 @@ type PurchasingAnalyticsResponse struct { Data []PurchasingAnalyticsData `json:"data"` IngredientData []PurchasingIngredientData `json:"ingredient_data"` VendorData []PurchasingVendorData `json:"vendor_data"` + TeamData []PurchasingTeamData `json:"team_data"` +} + +// PurchasingTeamData represents purchasing analytics for a single team +type PurchasingTeamData struct { + Scope string `json:"scope"` + CategoryID *uuid.UUID `json:"category_id"` + Name string `json:"name"` + TotalPurchases float64 `json:"total_purchases"` + RawMaterialPurchases float64 `json:"raw_material_purchases"` + ExpensePurchases float64 `json:"expense_purchases"` + PurchaseOrderCount int64 `json:"purchase_order_count"` + Quantity float64 `json:"quantity"` + Percentage float64 `json:"percentage"` } // PurchasingSummary represents the summary of purchasing analytics @@ -124,6 +168,7 @@ type PurchasingSummary struct { AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"` TotalIngredients int64 `json:"total_ingredients"` TotalVendors int64 `json:"total_vendors"` + TotalTeams int64 `json:"total_teams"` } // PurchasingAnalyticsData represents purchasing analytics by time period diff --git a/internal/models/ingredient.go b/internal/models/ingredient.go index 7a3ac3d..e4293e6 100644 --- a/internal/models/ingredient.go +++ b/internal/models/ingredient.go @@ -12,7 +12,7 @@ type Ingredient struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` @@ -29,7 +29,7 @@ type CreateIngredientRequest struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name" validate:"required,min=1,max=255"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` + UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"` Cost float64 `json:"cost" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"` IsSemiFinished bool `json:"is_semi_finished"` @@ -48,7 +48,7 @@ type CompositionItemRequest struct { type UpdateIngredientRequest struct { OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name" validate:"required,min=1,max=255"` - UnitID uuid.UUID `json:"unit_id" validate:"required"` + UnitID *uuid.UUID `json:"unit_id" validate:"omitempty"` Cost float64 `json:"cost" validate:"min=0"` Stock float64 `json:"stock" validate:"min=0"` IsSemiFinished bool `json:"is_semi_finished"` @@ -61,7 +61,7 @@ type IngredientResponse struct { OrganizationID uuid.UUID `json:"organization_id"` OutletID *uuid.UUID `json:"outlet_id"` Name string `json:"name"` - UnitID uuid.UUID `json:"unit_id"` + UnitID *uuid.UUID `json:"unit_id"` Cost float64 `json:"cost"` Stock float64 `json:"stock"` IsSemiFinished bool `json:"is_semi_finished"` diff --git a/internal/models/ingredient_unit_converter.go b/internal/models/ingredient_unit_converter.go index af14c0c..5290284 100644 --- a/internal/models/ingredient_unit_converter.go +++ b/internal/models/ingredient_unit_converter.go @@ -97,7 +97,7 @@ type ListIngredientUnitConvertersResponse struct { type IngredientUnitsResponse struct { IngredientID uuid.UUID `json:"ingredient_id"` IngredientName string `json:"ingredient_name"` - BaseUnitID uuid.UUID `json:"base_unit_id"` + BaseUnitID *uuid.UUID `json:"base_unit_id"` BaseUnitName string `json:"base_unit_name"` Units []*UnitResponse `json:"units"` } diff --git a/internal/models/purchase_order.go b/internal/models/purchase_order.go index 7122ed8..6ac202e 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,20 @@ 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"` + Team string `json:"team,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/analytics_processor.go b/internal/processor/analytics_processor.go index 2560124..37ad289 100644 --- a/internal/processor/analytics_processor.go +++ b/internal/processor/analytics_processor.go @@ -200,7 +200,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req req.GroupBy = "day" } - result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy) + teamFilter, err := models.ParsePurchaseTeamFilter(req.Team) + if err != nil { + return nil, err + } + + result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, teamFilter, req.DateFrom, req.DateTo, req.GroupBy) if err != nil { return nil, fmt.Errorf("failed to get purchasing analytics: %w", err) } @@ -245,10 +250,26 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req } } + teamData := make([]models.PurchasingTeamData, len(result.TeamData)) + for i, item := range result.TeamData { + teamData[i] = models.PurchasingTeamData{ + Scope: item.Scope, + CategoryID: item.CategoryID, + Name: item.Name, + TotalPurchases: item.TotalPurchases, + RawMaterialPurchases: item.RawMaterialPurchases, + ExpensePurchases: item.ExpensePurchases, + PurchaseOrderCount: item.PurchaseOrderCount, + Quantity: item.Quantity, + Percentage: item.Percentage, + } + } + return &models.PurchasingAnalyticsResponse{ OrganizationID: req.OrganizationID, OutletID: req.OutletID, OutletName: result.OutletName, + Team: req.Team, DateFrom: req.DateFrom, DateTo: req.DateTo, GroupBy: req.GroupBy, @@ -263,10 +284,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue, TotalIngredients: result.Summary.TotalIngredients, TotalVendors: result.Summary.TotalVendors, + TotalTeams: result.Summary.TotalTeams, }, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, }, nil } diff --git a/internal/processor/analytics_processor_test.go b/internal/processor/analytics_processor_test.go index 6cb4722..7ae2d0e 100644 --- a/internal/processor/analytics_processor_test.go +++ b/internal/processor/analytics_processor_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" @@ -14,6 +15,7 @@ import ( type analyticsRepositoryStub struct { purchasingResult *entities.PurchasingAnalytics + purchasingTeam *entities.PurchaseTeamFilter budgetCutOffWeeks []*entities.BudgetCutOffWeek profitLossResult *entities.ProfitLossAnalytics exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics @@ -32,7 +34,8 @@ func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uu return nil, nil } -func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) { +func (s *analyticsRepositoryStub) GetPurchasingAnalytics(_ context.Context, _ uuid.UUID, _ *uuid.UUID, team *entities.PurchaseTeamFilter, _, _ time.Time, _ string) (*entities.PurchasingAnalytics, error) { + s.purchasingTeam = team return s.purchasingResult, nil } @@ -158,6 +161,110 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } +func TestAnalyticsProcessorGetPurchasingAnalyticsPassesTeamFilter(t *testing.T) { + categoryID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + team string + want *entities.PurchaseTeamFilter + }{ + {name: "all teams", team: "", want: nil}, + {name: "pusat", team: constants.PurchaseTeamScopeCentral, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCentral}}, + {name: "no team", team: constants.PurchaseTeamNone, want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamNone}}, + { + name: "category team", + team: categoryID.String(), + want: &entities.PurchaseTeamFilter{Scope: constants.PurchaseTeamScopeCategory, CategoryID: &categoryID}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}} + processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + Team: tt.team, + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.Equal(t, tt.team, result.Team) + require.Equal(t, tt.want, repo.purchasingTeam) + }) + } +} + +func TestAnalyticsProcessorGetPurchasingAnalyticsMapsTeamBreakdown(t *testing.T) { + categoryID := uuid.New() + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{ + purchasingResult: &entities.PurchasingAnalytics{ + Summary: entities.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2}, + TeamData: []entities.PurchasingTeamData{ + { + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: "Kitchen", + TotalPurchases: 200, + RawMaterialPurchases: 150, + ExpensePurchases: 50, + PurchaseOrderCount: 2, + Quantity: 12, + Percentage: 66.67, + }, + { + Scope: constants.PurchaseTeamNone, + Name: constants.PurchaseTeamNoneName, + TotalPurchases: 100, + PurchaseOrderCount: 1, + Percentage: 33.33, + }, + }, + }, + }, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + }) + + require.NoError(t, err) + require.Equal(t, int64(2), result.Summary.TotalTeams) + require.Len(t, result.TeamData, 2) + require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope) + require.Equal(t, &categoryID, result.TeamData[0].CategoryID) + require.Equal(t, "Kitchen", result.TeamData[0].Name) + require.Equal(t, float64(200), result.TeamData[0].TotalPurchases) + require.Equal(t, float64(150), result.TeamData[0].RawMaterialPurchases) + require.Equal(t, 66.67, result.TeamData[0].Percentage) + require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope) + require.Nil(t, result.TeamData[1].CategoryID) + require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name) +} + +func TestAnalyticsProcessorGetPurchasingAnalyticsRejectsUnknownTeam(t *testing.T) { + now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) + repo := &analyticsRepositoryStub{purchasingResult: &entities.PurchasingAnalytics{}} + processor := NewAnalyticsProcessorImpl(repo, expenseRepositoryStub{}) + + result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + Team: "marketing", + DateFrom: now, + DateTo: now, + }) + + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "team must be one of") +} + func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) { productID := uuid.New() categoryID := uuid.New() 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/ingredient_processor.go b/internal/processor/ingredient_processor.go index 6a3ff3d..f804422 100644 --- a/internal/processor/ingredient_processor.go +++ b/internal/processor/ingredient_processor.go @@ -27,8 +27,11 @@ func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRe } func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) { - if _, err := p.unitRepo.GetByID(ctx, req.UnitID, req.OrganizationID); err != nil { - return nil, err + // The unit is optional, so it is only validated when one is supplied. + if req.UnitID != nil { + if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, req.OrganizationID); err != nil { + return nil, err + } } ingredient := &entities.Ingredient{ @@ -107,8 +110,8 @@ func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid. return nil, err } - if req.UnitID != existing.UnitID { - if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil { + if req.UnitID != nil && (existing.UnitID == nil || *req.UnitID != *existing.UnitID) { + if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, organizationID); err != nil { return nil, err } } diff --git a/internal/processor/ingredient_unit_converter_processor.go b/internal/processor/ingredient_unit_converter_processor.go index 9998fe7..dd91983 100644 --- a/internal/processor/ingredient_unit_converter_processor.go +++ b/internal/processor/ingredient_unit_converter_processor.go @@ -266,15 +266,27 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex return nil, fmt.Errorf("failed to get ingredient: %w", err) } - // Get the base unit details - baseUnit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID) - if err != nil { - return nil, fmt.Errorf("failed to get base unit: %w", err) + response := &models.IngredientUnitsResponse{ + IngredientID: ingredientID, + IngredientName: ingredient.Name, } - // Start with the base unit - units := []*models.UnitResponse{ - mappers.MapUnitEntityToResponse(baseUnit), + units := make([]*models.UnitResponse, 0) + unitMap := make(map[uuid.UUID]bool) + + // An ingredient does not necessarily have a unit assigned yet. When it has + // none there is no base unit to start from, so the only units on offer are + // the ones its converters mention. + if ingredient.UnitID != nil { + baseUnit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID) + if err != nil { + return nil, fmt.Errorf("failed to get base unit: %w", err) + } + + units = append(units, mappers.MapUnitEntityToResponse(baseUnit)) + unitMap[baseUnit.ID] = true + response.BaseUnitID = &baseUnit.ID + response.BaseUnitName = baseUnit.Name } // Get all converters for this ingredient @@ -283,10 +295,6 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex return nil, fmt.Errorf("failed to get converters: %w", err) } - // Add unique units from converters - unitMap := make(map[uuid.UUID]bool) - unitMap[baseUnit.ID] = true - for _, converter := range converters { if converter.IsActive { // Add FromUnit if not already added @@ -309,13 +317,7 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex } } - response := &models.IngredientUnitsResponse{ - IngredientID: ingredientID, - IngredientName: ingredient.Name, - BaseUnitID: baseUnit.ID, - BaseUnitName: baseUnit.Name, - Units: units, - } + response.Units = units return response, nil } diff --git a/internal/processor/order_ingredient_transaction_processor.go b/internal/processor/order_ingredient_transaction_processor.go index ba84237..0729b5f 100644 --- a/internal/processor/order_ingredient_transaction_processor.go +++ b/internal/processor/order_ingredient_transaction_processor.go @@ -371,8 +371,8 @@ func (p *OrderIngredientTransactionProcessorImpl) CalculateWasteQuantities(ctx c // Get unit name unitName := "unit" // default - if ingredient.UnitID != uuid.Nil { - unit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID) + if ingredient.UnitID != nil { + unit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID) if err == nil { unitName = unit.Name } diff --git a/internal/processor/purchase_order_processor.go b/internal/processor/purchase_order_processor.go index fe8de6e..867f833 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,8 +29,12 @@ type PurchaseOrderProcessorImpl struct { vendorRepo VendorRepository ingredientRepo IngredientRepository purchaseCategoryRepo PurchaseCategoryRepository + categoryRepo CategoryRepository unitRepo UnitRepository fileRepo FileRepository + // Kept wired but currently unused: purchase orders are a record of spending + // only, so nothing here moves stock or converts units. These stay so that + // tying purchases back to inventory is a change in one place. inventoryMovementService InventoryMovementService unitConverterRepo IngredientUnitConverterRepository } @@ -37,6 +44,7 @@ func NewPurchaseOrderProcessorImpl( vendorRepo VendorRepository, ingredientRepo IngredientRepository, purchaseCategoryRepo PurchaseCategoryRepository, + categoryRepo CategoryRepository, unitRepo UnitRepository, fileRepo FileRepository, inventoryMovementService InventoryMovementService, @@ -47,6 +55,7 @@ func NewPurchaseOrderProcessorImpl( vendorRepo: vendorRepo, ingredientRepo: ingredientRepo, purchaseCategoryRepo: purchaseCategoryRepo, + categoryRepo: categoryRepo, unitRepo: unitRepo, fileRepo: fileRepo, inventoryMovementService: inventoryMovementService, @@ -63,6 +72,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 +138,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 +237,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 @@ -415,71 +441,11 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte fmt.Println("status:", po.Status) - // Check if status is changing to "received" and current status is not "received" - if status == "received" && po.Status != "received" { - // Get purchase order with items for inventory update - poWithItems, err := p.purchaseOrderRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to get purchase order with items: %w", err) - } - - // Update inventory for each item - for _, item := range poWithItems.Items { - if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense { - continue - } - - if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil { - return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID) - } - - // Get ingredient to find its base unit - ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID) - if err != nil { - return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err) - } - - // Convert quantity to ingredient's base unit if needed - quantityToAdd := *item.Quantity - if *item.UnitID != ingredient.UnitID { - // Convert from purchase unit to ingredient's base unit - convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity) - if err != nil { - return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err) - } - quantityToAdd = convertedQuantity - } - - // Calculate unit cost in ingredient's base unit - unitCost := 0.0 - if quantityToAdd > 0 { - unitCost = calculatePurchaseOrderItemTotal(item.Quantity, item.Amount) / quantityToAdd - } - - // Create inventory movement for ingredient purchase - reason := fmt.Sprintf("Purchase order %s received", po.PONumber) - referenceType := entities.InventoryMovementReferenceTypePurchaseOrder - referenceID := &id - - err = p.inventoryMovementService.CreateIngredientMovement( - ctx, - *item.IngredientID, - organizationID, - outletID, - userID, - entities.InventoryMovementTypePurchase, - quantityToAdd, - unitCost, - reason, - &referenceType, - referenceID, - &item.ID, - ) - if err != nil { - return nil, fmt.Errorf("failed to create inventory movement for ingredient %s: %w", *item.IngredientID, err) - } - } - } + // A purchase order is a record of spending only. Receiving one does not move + // ingredient stock, does not recalculate ingredient cost, and never converts + // units: the quantity and unit on an item are kept exactly as the user + // entered them. Raw material items are therefore treated the same way expense + // items already were, and the ingredient on an item is just a reference. // Update the purchase order status statusOutletID := po.OutletID @@ -501,6 +467,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/analytics_repository.go b/internal/repository/analytics_repository.go index a1d6a86..9d83d16 100644 --- a/internal/repository/analytics_repository.go +++ b/internal/repository/analytics_repository.go @@ -6,6 +6,7 @@ import ( "sort" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "github.com/google/uuid" @@ -15,7 +16,7 @@ import ( type AnalyticsRepository interface { GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) GetSalesAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.SalesAnalytics, error) - GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) + GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error) GetProductAnalyticsPerParentCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) @@ -159,7 +160,7 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz return results, err } -func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { +func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { var outletName *string if outletID != nil { @@ -179,10 +180,10 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or outletName = &outlet.Name } } - return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, outletName, dateFrom, dateTo, groupBy) + return r.getPurchaseOrderPurchasingAnalytics(ctx, organizationID, outletID, team, outletName, dateFrom, dateTo, groupBy) } -func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { +func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, outletName *string, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) { var summary entities.PurchasingSummary summaryQuery := r.db.WithContext(ctx). Table("purchase_orders po"). @@ -210,6 +211,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Where("po.status != ?", "cancelled"). Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo) summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID) + summaryQuery = r.applyPurchaseOrderTeamFilter(summaryQuery, team) if err := summaryQuery.Scan(&summary).Error; err != nil { return nil, err @@ -253,6 +255,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group(dateFormat). Order(dateFormat) dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID) + dataQuery = r.applyPurchaseOrderTeamFilter(dataQuery, team) if err := dataQuery.Scan(&data).Error; err != nil { return nil, err @@ -283,6 +286,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group("i.id, i.name"). Order("total_cost DESC") ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID) + ingredientQuery = r.applyPurchaseOrderTeamFilter(ingredientQuery, team) if err := ingredientQuery.Scan(&ingredientData).Error; err != nil { return nil, err @@ -310,20 +314,105 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex Group("v.id, COALESCE(v.name, 'No Vendor')"). Order("total_cost DESC") vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID) + vendorQuery = r.applyPurchaseOrderTeamFilter(vendorQuery, team) if err := vendorQuery.Scan(&vendorData).Error; err != nil { return nil, err } + teamData, err := r.getPurchaseOrderTeamBreakdown(ctx, organizationID, outletID, team, dateFrom, dateTo, summary.TotalPurchases) + if err != nil { + return nil, err + } + summary.TotalTeams = int64(len(teamData)) + return &entities.PurchasingAnalytics{ OutletName: outletName, Summary: summary, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, }, nil } +// getPurchaseOrderTeamBreakdown splits the purchases over the teams they were +// charged to. Purchases with no team are kept as their own row rather than +// dropped, so the rows still add up to the summary total. +func (r *AnalyticsRepositoryImpl) getPurchaseOrderTeamBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, team *entities.PurchaseTeamFilter, dateFrom, dateTo time.Time, totalPurchases float64) ([]entities.PurchasingTeamData, error) { + var rows []struct { + Scope *string + CategoryID *uuid.UUID + CategoryName *string + TotalPurchases float64 + RawMaterialPurchases float64 + ExpensePurchases float64 + PurchaseOrderCount int64 + Quantity float64 + } + + query := r.db.WithContext(ctx). + Table("purchase_orders po"). + Select(` + po.team_scope as scope, + po.team_category_id as category_id, + c.name as category_name, + COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases, + COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material_purchases, + COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense_purchases, + COUNT(DISTINCT po.id) as purchase_order_count, + COALESCE(SUM(poi.quantity), 0) as quantity + `). + Joins("LEFT JOIN purchase_order_items poi ON poi.purchase_order_id = po.id"). + Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id"). + Joins("LEFT JOIN categories c ON po.team_category_id = c.id"). + Where("po.organization_id = ?", organizationID). + Where("po.status != ?", "cancelled"). + Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo). + Group("po.team_scope, po.team_category_id, c.name"). + Order("total_purchases DESC") + query = r.applyPurchaseOrderItemOutletFilter(query, outletID) + query = r.applyPurchaseOrderTeamFilter(query, team) + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + teamData := make([]entities.PurchasingTeamData, len(rows)) + for i, row := range rows { + entry := entities.PurchasingTeamData{ + CategoryID: row.CategoryID, + TotalPurchases: row.TotalPurchases, + RawMaterialPurchases: row.RawMaterialPurchases, + ExpensePurchases: row.ExpensePurchases, + PurchaseOrderCount: row.PurchaseOrderCount, + Quantity: row.Quantity, + } + + switch { + case row.Scope == nil: + entry.Scope = constants.PurchaseTeamNone + entry.Name = constants.PurchaseTeamNoneName + case *row.Scope == constants.PurchaseTeamScopeCentral: + entry.Scope = constants.PurchaseTeamScopeCentral + entry.Name = constants.PurchaseTeamCentralName + default: + entry.Scope = *row.Scope + if row.CategoryName != nil { + entry.Name = *row.CategoryName + } + } + + if totalPurchases != 0 { + entry.Percentage = row.TotalPurchases / totalPurchases * 100 + } + + teamData[i] = entry + } + + return teamData, nil +} + func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB { if outletID == nil { return query @@ -331,6 +420,28 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm return query.Where("po.outlet_id = ?", *outletID) } +// applyPurchaseOrderTeamFilter narrows a purchase order query to the team the +// report asked for. A nil filter, or a category team without a category, leaves +// the query spanning every team. +func (r *AnalyticsRepositoryImpl) applyPurchaseOrderTeamFilter(query *gorm.DB, team *entities.PurchaseTeamFilter) *gorm.DB { + if team == nil { + return query + } + + switch team.Scope { + case constants.PurchaseTeamNone: + return query.Where("po.team_scope IS NULL") + case constants.PurchaseTeamScopeCentral: + return query.Where("po.team_scope = ?", constants.PurchaseTeamScopeCentral) + case constants.PurchaseTeamScopeCategory: + if team.CategoryID != nil { + return query.Where("po.team_scope = ? AND po.team_category_id = ?", constants.PurchaseTeamScopeCategory, *team.CategoryID) + } + } + + return query +} + func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) { var results []*entities.ProductAnalytics 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..d88694f 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,18 @@ 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 "team_unassigned": + if unassigned, ok := value.(bool); ok && unassigned { + query = query.Where("team_scope IS NULL") + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) @@ -106,6 +124,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 +156,18 @@ 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 "team_unassigned": + if unassigned, ok := value.(bool); ok && unassigned { + query = query.Where("team_scope IS NULL") + } case "start_date": if startDate, ok := value.(time.Time); ok { query = query.Where("transaction_date >= ?", startDate) @@ -170,6 +201,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 +214,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/analytics_service.go b/internal/service/analytics_service.go index 889b766..3ddb1a3 100644 --- a/internal/service/analytics_service.go +++ b/internal/service/analytics_service.go @@ -238,6 +238,10 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu } } + if _, err := models.ParsePurchaseTeamFilter(req.Team); err != nil { + return err + } + return nil } diff --git a/internal/service/analytics_service_test.go b/internal/service/analytics_service_test.go index e300d77..c67a864 100644 --- a/internal/service/analytics_service_test.go +++ b/internal/service/analytics_service_test.go @@ -113,6 +113,16 @@ func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) { }, wantErr: "invalid group_by value: quarter", }, + { + name: "unknown team", + req: &models.PurchasingAnalyticsRequest{ + OrganizationID: uuid.New(), + DateFrom: now, + DateTo: now, + Team: "marketing", + }, + wantErr: "team must be one of", + }, } for _, tt := range tests { diff --git a/internal/service/purchase_order_service.go b/internal/service/purchase_order_service.go index 27fbcb7..b10c8d8 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,26 @@ 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 + } + // team spells out the same two filters in one value; the validator has already + // ruled out sending it together with them. + switch modelReq.Team { + case "": + case constants.PurchaseTeamNone: + filters["team_unassigned"] = true + case constants.PurchaseTeamScopeCentral: + filters["team_scope"] = constants.PurchaseTeamScopeCentral + default: + if teamCategoryID, err := uuid.Parse(modelReq.Team); err == nil { + filters["team_scope"] = constants.PurchaseTeamScopeCategory + filters["team_category_id"] = teamCategoryID + } + } if modelReq.StartDate != nil { filters["start_date"] = *modelReq.StartDate } @@ -145,6 +166,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/analytics_transformer.go b/internal/transformer/analytics_transformer.go index 14d008f..d9214eb 100644 --- a/internal/transformer/analytics_transformer.go +++ b/internal/transformer/analytics_transformer.go @@ -156,6 +156,7 @@ func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest return &models.PurchasingAnalyticsRequest{ OrganizationID: req.OrganizationID, OutletID: parseOutletID(req.OutletID), + Team: req.Team, DateFrom: dateFrom, DateTo: dateTo, GroupBy: req.GroupBy, @@ -208,10 +209,26 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse } } + teamData := make([]contract.PurchasingTeamData, len(resp.TeamData)) + for i, item := range resp.TeamData { + teamData[i] = contract.PurchasingTeamData{ + Scope: item.Scope, + CategoryID: item.CategoryID, + Name: item.Name, + TotalPurchases: item.TotalPurchases, + RawMaterialPurchases: item.RawMaterialPurchases, + ExpensePurchases: item.ExpensePurchases, + PurchaseOrderCount: item.PurchaseOrderCount, + Quantity: item.Quantity, + Percentage: item.Percentage, + } + } + return &contract.PurchasingAnalyticsResponse{ OrganizationID: resp.OrganizationID, OutletID: resp.OutletID, OutletName: resp.OutletName, + Team: resp.Team, DateFrom: resp.DateFrom, DateTo: resp.DateTo, GroupBy: resp.GroupBy, @@ -226,10 +243,12 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue, TotalIngredients: resp.Summary.TotalIngredients, TotalVendors: resp.Summary.TotalVendors, + TotalTeams: resp.Summary.TotalTeams, }, Data: data, IngredientData: ingredientData, VendorData: vendorData, + TeamData: teamData, } } diff --git a/internal/transformer/analytics_transformer_test.go b/internal/transformer/analytics_transformer_test.go index 4d1327e..300decc 100644 --- a/internal/transformer/analytics_transformer_test.go +++ b/internal/transformer/analytics_transformer_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/models" @@ -95,6 +96,49 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) { require.Equal(t, float64(175), result.Data[0].ExpensePurchases) } +func TestPurchasingAnalyticsModelToContractCopiesTeamData(t *testing.T) { + categoryID := uuid.New() + + result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ + OrganizationID: uuid.New(), + Team: categoryID.String(), + Summary: models.PurchasingSummary{TotalPurchases: 300, TotalTeams: 2}, + TeamData: []models.PurchasingTeamData{ + { + Scope: constants.PurchaseTeamScopeCategory, + CategoryID: &categoryID, + Name: "Kitchen", + TotalPurchases: 200, + RawMaterialPurchases: 150, + ExpensePurchases: 50, + PurchaseOrderCount: 2, + Quantity: 12, + Percentage: 66.67, + }, + { + Scope: constants.PurchaseTeamNone, + Name: constants.PurchaseTeamNoneName, + TotalPurchases: 100, + PurchaseOrderCount: 1, + Percentage: 33.33, + }, + }, + }) + + require.NotNil(t, result) + require.Equal(t, categoryID.String(), result.Team) + require.Equal(t, int64(2), result.Summary.TotalTeams) + require.Len(t, result.TeamData, 2) + require.Equal(t, constants.PurchaseTeamScopeCategory, result.TeamData[0].Scope) + require.Equal(t, &categoryID, result.TeamData[0].CategoryID) + require.Equal(t, "Kitchen", result.TeamData[0].Name) + require.Equal(t, float64(200), result.TeamData[0].TotalPurchases) + require.Equal(t, 66.67, result.TeamData[0].Percentage) + require.Equal(t, constants.PurchaseTeamNone, result.TeamData[1].Scope) + require.Nil(t, result.TeamData[1].CategoryID) + require.Equal(t, constants.PurchaseTeamNoneName, result.TeamData[1].Name) +} + func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) { result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{ OrganizationID: uuid.New(), diff --git a/internal/transformer/purchase_order_transformer.go b/internal/transformer/purchase_order_transformer.go index 814b8c4..962ec19 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,40 @@ 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, + Team: req.Team, + 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 +157,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..1de3855 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,55 @@ 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, "" +} + +// validatePurchaseTeamFilter accepts the values the team picker hands back: Pusat, +// no team at all, or the id of the parent category a purchase is charged to. +func validatePurchaseTeamFilter(team string) (error, string) { + switch team { + case constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone: + return nil, "" + } + + if categoryID, err := uuid.Parse(team); err != nil || categoryID == uuid.Nil { + return errors.New("team must be one of: central, none, or a category id"), 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 +228,31 @@ func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *cont } } + if req.Team != "" { + if req.TeamScope != "" || req.TeamCategoryID != nil { + return errors.New("team cannot be combined with team_scope or team_category_id"), constants.MalformedFieldErrorCode + } + + if err, code := validatePurchaseTeamFilter(req.Team); err != nil { + return err, code + } + } + + 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..f7a7914 100644 --- a/internal/validator/purchase_order_validator_test.go +++ b/internal/validator/purchase_order_validator_test.go @@ -90,3 +90,164 @@ 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 TestPurchaseOrderValidatorListAcceptsTeamFilter(t *testing.T) { + validator := NewPurchaseOrderValidator() + + for _, team := range []string{constants.PurchaseTeamScopeCentral, constants.PurchaseTeamNone, uuid.New().String()} { + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: team, + }) + + require.NoError(t, err, team) + require.Empty(t, code, team) + } +} + +func TestPurchaseOrderValidatorListRejectsUnknownTeamFilter(t *testing.T) { + validator := NewPurchaseOrderValidator() + + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: "marketing", + }) + + require.Error(t, err) + require.Equal(t, constants.MalformedFieldErrorCode, code) +} + +func TestPurchaseOrderValidatorListRejectsTeamWithScope(t *testing.T) { + validator := NewPurchaseOrderValidator() + + err, code := validator.ValidateListPurchaseOrdersRequest(&contract.ListPurchaseOrdersRequest{ + Page: 1, + Limit: 10, + Team: constants.PurchaseTeamNone, + TeamScope: constants.PurchaseTeamScopeCentral, + }) + + 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); diff --git a/migrations/000086_make_ingredients_unit_id_nullable.down.sql b/migrations/000086_make_ingredients_unit_id_nullable.down.sql new file mode 100644 index 0000000..a0de2d3 --- /dev/null +++ b/migrations/000086_make_ingredients_unit_id_nullable.down.sql @@ -0,0 +1,6 @@ +-- Restoring NOT NULL fails if any ingredient still has a NULL unit_id. Assign a +-- unit to those rows first: +-- SELECT id, name FROM ingredients WHERE unit_id IS NULL; +COMMENT ON COLUMN ingredients.unit_id IS NULL; + +ALTER TABLE ingredients ALTER COLUMN unit_id SET NOT NULL; diff --git a/migrations/000086_make_ingredients_unit_id_nullable.up.sql b/migrations/000086_make_ingredients_unit_id_nullable.up.sql new file mode 100644 index 0000000..ba15a45 --- /dev/null +++ b/migrations/000086_make_ingredients_unit_id_nullable.up.sql @@ -0,0 +1,5 @@ +-- An ingredient can be registered before its unit has been decided, so unit_id +-- is optional. Existing rows are untouched: they already have a unit. +ALTER TABLE ingredients ALTER COLUMN unit_id DROP NOT NULL; + +COMMENT ON COLUMN ingredients.unit_id IS 'Base unit of the ingredient. NULL means no unit has been assigned yet.';