Merge pull request 'Dev' (#27) from dev into main
Reviewed-on: #27
This commit was merged in pull request #27.
This commit is contained in:
+1
-1
@@ -372,7 +372,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
||||||
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
||||||
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
|
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),
|
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
|
||||||
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
|
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
|
||||||
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
|
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -88,6 +88,9 @@ type SalesAnalyticsData struct {
|
|||||||
type PurchasingAnalyticsRequest struct {
|
type PurchasingAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID
|
||||||
OutletID *string `form:"outlet_id,omitempty"`
|
OutletID *string `form:"outlet_id,omitempty"`
|
||||||
|
// 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"`
|
DateFrom string `form:"date_from" validate:"required"`
|
||||||
DateTo string `form:"date_to" validate:"required"`
|
DateTo string `form:"date_to" validate:"required"`
|
||||||
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
||||||
@@ -97,6 +100,7 @@ type PurchasingAnalyticsResponse struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
OutletName *string `json:"outlet_name,omitempty"`
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
Team string `json:"team,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@@ -104,6 +108,21 @@ type PurchasingAnalyticsResponse struct {
|
|||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_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 {
|
type PurchasingSummary struct {
|
||||||
@@ -117,6 +136,7 @@ type PurchasingSummary struct {
|
|||||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
TotalIngredients int64 `json:"total_ingredients"`
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
TotalVendors int64 `json:"total_vendors"`
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
|
TotalTeams int64 `json:"total_teams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingAnalyticsData struct {
|
type PurchasingAnalyticsData struct {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ type ListIngredientUnitConvertersResponse struct {
|
|||||||
type IngredientUnitsResponse struct {
|
type IngredientUnitsResponse struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
IngredientName string `json:"ingredient_name"`
|
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"`
|
BaseUnitName string `json:"base_unit_name"`
|
||||||
Units []*UnitResponse `json:"units"`
|
Units []*UnitResponse `json:"units"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UnitID uuid.UUID `json:"unit_id"`
|
UnitID *uuid.UUID `json:"unit_id"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
Stock float64 `json:"stock"`
|
Stock float64 `json:"stock"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type CreatePurchaseOrderRequest struct {
|
|||||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
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"`
|
Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -35,6 +37,9 @@ type UpdatePurchaseOrderRequest struct {
|
|||||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
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"`
|
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -61,13 +66,29 @@ type PurchaseOrderResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message *string `json:"message"`
|
Message *string `json:"message"`
|
||||||
TotalAmount float64 `json:"total_amount"`
|
TotalAmount float64 `json:"total_amount"`
|
||||||
|
TeamScope *string `json:"team_scope"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Team *PurchaseTeamResponse `json:"team,omitempty"`
|
||||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||||
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
||||||
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,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 {
|
type PurchaseOrderItemResponse struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||||
@@ -98,6 +119,13 @@ type ListPurchaseOrdersRequest struct {
|
|||||||
Search string `json:"search,omitempty"`
|
Search string `json:"search,omitempty"`
|
||||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
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"`
|
StartDate *time.Time `json:"start_date,omitempty"`
|
||||||
EndDate *time.Time `json:"end_date,omitempty"`
|
EndDate *time.Time `json:"end_date,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ type SalesAnalytics struct {
|
|||||||
NetSales float64 `json:"net_sales"`
|
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
|
// PurchasingAnalytics represents purchasing analytics data
|
||||||
type PurchasingAnalytics struct {
|
type PurchasingAnalytics struct {
|
||||||
OutletName *string `json:"outlet_name,omitempty"`
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
@@ -34,6 +42,22 @@ type PurchasingAnalytics struct {
|
|||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_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 {
|
type PurchasingSummary struct {
|
||||||
@@ -47,6 +71,7 @@ type PurchasingSummary struct {
|
|||||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
TotalIngredients int64 `json:"total_ingredients"`
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
TotalVendors int64 `json:"total_vendors"`
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
|
TotalTeams int64 `json:"total_teams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasingAnalyticsData struct {
|
type PurchasingAnalyticsData struct {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type Ingredient struct {
|
|||||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
||||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
||||||
Name string `gorm:"not null;size:255" json:"name"`
|
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"`
|
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"`
|
||||||
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
|
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
|
||||||
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
|
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
|
||||||
|
|||||||
@@ -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"`
|
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"`
|
Message *string `gorm:"type:text" json:"message" validate:"omitempty"`
|
||||||
TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"`
|
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"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|
||||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||||
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,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"`
|
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
|
||||||
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
|
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 startDateStr := c.Query("start_date"); startDateStr != "" {
|
||||||
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
||||||
req.StartDate = &startDate
|
req.StartDate = &startDate
|
||||||
@@ -224,6 +238,21 @@ func (h *PurchaseOrderHandler) GetPurchaseOrdersByStatus(c *gin.Context) {
|
|||||||
util.HandleResponse(c.Writer, c.Request, poResponse, "PurchaseOrderHandler::GetPurchaseOrdersByStatus")
|
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) {
|
func (h *PurchaseOrderHandler) GetOverduePurchaseOrders(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
contextInfo := appcontext.FromGinContext(ctx)
|
contextInfo := appcontext.FromGinContext(ctx)
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
package mappers
|
package mappers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"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 {
|
func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.PurchaseOrder {
|
||||||
if entity == nil {
|
if entity == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -22,6 +45,8 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
|
|||||||
Status: entity.Status,
|
Status: entity.Status,
|
||||||
Message: entity.Message,
|
Message: entity.Message,
|
||||||
TotalAmount: entity.TotalAmount,
|
TotalAmount: entity.TotalAmount,
|
||||||
|
TeamScope: entity.TeamScope,
|
||||||
|
TeamCategoryID: entity.TeamCategoryID,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -44,6 +69,8 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
|
|||||||
Status: model.Status,
|
Status: model.Status,
|
||||||
Message: model.Message,
|
Message: model.Message,
|
||||||
TotalAmount: model.TotalAmount,
|
TotalAmount: model.TotalAmount,
|
||||||
|
TeamScope: model.TeamScope,
|
||||||
|
TeamCategoryID: model.TeamCategoryID,
|
||||||
CreatedAt: model.CreatedAt,
|
CreatedAt: model.CreatedAt,
|
||||||
UpdatedAt: model.UpdatedAt,
|
UpdatedAt: model.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -66,8 +93,11 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
|
|||||||
Status: entity.Status,
|
Status: entity.Status,
|
||||||
Message: entity.Message,
|
Message: entity.Message,
|
||||||
TotalAmount: entity.TotalAmount,
|
TotalAmount: entity.TotalAmount,
|
||||||
|
TeamScope: entity.TeamScope,
|
||||||
|
TeamCategoryID: entity.TeamCategoryID,
|
||||||
CreatedAt: entity.CreatedAt,
|
CreatedAt: entity.CreatedAt,
|
||||||
UpdatedAt: entity.UpdatedAt,
|
UpdatedAt: entity.UpdatedAt,
|
||||||
|
Team: purchaseTeamFromEntity(entity),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map vendor if present
|
// Map vendor if present
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,16 +97,42 @@ type SalesAnalyticsData struct {
|
|||||||
type PurchasingAnalyticsRequest struct {
|
type PurchasingAnalyticsRequest struct {
|
||||||
OrganizationID uuid.UUID `validate:"required"`
|
OrganizationID uuid.UUID `validate:"required"`
|
||||||
OutletID *uuid.UUID `validate:"omitempty"`
|
OutletID *uuid.UUID `validate:"omitempty"`
|
||||||
|
// 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"`
|
DateFrom time.Time `validate:"required"`
|
||||||
DateTo time.Time `validate:"required"`
|
DateTo time.Time `validate:"required"`
|
||||||
GroupBy string `validate:"omitempty,oneof=day hour week month"`
|
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
|
// PurchasingAnalyticsResponse represents the response for purchasing analytics
|
||||||
type PurchasingAnalyticsResponse struct {
|
type PurchasingAnalyticsResponse struct {
|
||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||||
OutletName *string `json:"outlet_name,omitempty"`
|
OutletName *string `json:"outlet_name,omitempty"`
|
||||||
|
Team string `json:"team,omitempty"`
|
||||||
DateFrom time.Time `json:"date_from"`
|
DateFrom time.Time `json:"date_from"`
|
||||||
DateTo time.Time `json:"date_to"`
|
DateTo time.Time `json:"date_to"`
|
||||||
GroupBy string `json:"group_by"`
|
GroupBy string `json:"group_by"`
|
||||||
@@ -110,6 +140,20 @@ type PurchasingAnalyticsResponse struct {
|
|||||||
Data []PurchasingAnalyticsData `json:"data"`
|
Data []PurchasingAnalyticsData `json:"data"`
|
||||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||||
VendorData []PurchasingVendorData `json:"vendor_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
|
// PurchasingSummary represents the summary of purchasing analytics
|
||||||
@@ -124,6 +168,7 @@ type PurchasingSummary struct {
|
|||||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||||
TotalIngredients int64 `json:"total_ingredients"`
|
TotalIngredients int64 `json:"total_ingredients"`
|
||||||
TotalVendors int64 `json:"total_vendors"`
|
TotalVendors int64 `json:"total_vendors"`
|
||||||
|
TotalTeams int64 `json:"total_teams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PurchasingAnalyticsData represents purchasing analytics by time period
|
// PurchasingAnalyticsData represents purchasing analytics by time period
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type Ingredient struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UnitID uuid.UUID `json:"unit_id"`
|
UnitID *uuid.UUID `json:"unit_id"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
Stock float64 `json:"stock"`
|
Stock float64 `json:"stock"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
@@ -29,7 +29,7 @@ type CreateIngredientRequest struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
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"`
|
Cost float64 `json:"cost" validate:"min=0"`
|
||||||
Stock float64 `json:"stock" validate:"min=0"`
|
Stock float64 `json:"stock" validate:"min=0"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
@@ -48,7 +48,7 @@ type CompositionItemRequest struct {
|
|||||||
type UpdateIngredientRequest struct {
|
type UpdateIngredientRequest struct {
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
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"`
|
Cost float64 `json:"cost" validate:"min=0"`
|
||||||
Stock float64 `json:"stock" validate:"min=0"`
|
Stock float64 `json:"stock" validate:"min=0"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
@@ -61,7 +61,7 @@ type IngredientResponse struct {
|
|||||||
OrganizationID uuid.UUID `json:"organization_id"`
|
OrganizationID uuid.UUID `json:"organization_id"`
|
||||||
OutletID *uuid.UUID `json:"outlet_id"`
|
OutletID *uuid.UUID `json:"outlet_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
UnitID uuid.UUID `json:"unit_id"`
|
UnitID *uuid.UUID `json:"unit_id"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
Stock float64 `json:"stock"`
|
Stock float64 `json:"stock"`
|
||||||
IsSemiFinished bool `json:"is_semi_finished"`
|
IsSemiFinished bool `json:"is_semi_finished"`
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ type ListIngredientUnitConvertersResponse struct {
|
|||||||
type IngredientUnitsResponse struct {
|
type IngredientUnitsResponse struct {
|
||||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||||
IngredientName string `json:"ingredient_name"`
|
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"`
|
BaseUnitName string `json:"base_unit_name"`
|
||||||
Units []*UnitResponse `json:"units"`
|
Units []*UnitResponse `json:"units"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,20 @@ type PurchaseOrder struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message *string `json:"message"`
|
Message *string `json:"message"`
|
||||||
TotalAmount float64 `json:"total_amount"`
|
TotalAmount float64 `json:"total_amount"`
|
||||||
|
TeamScope *string `json:"team_scope"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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 {
|
type PurchaseOrderItem struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||||
@@ -54,8 +64,11 @@ type PurchaseOrderResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message *string `json:"message"`
|
Message *string `json:"message"`
|
||||||
TotalAmount float64 `json:"total_amount"`
|
TotalAmount float64 `json:"total_amount"`
|
||||||
|
TeamScope *string `json:"team_scope"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Team *PurchaseTeam `json:"team,omitempty"`
|
||||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||||
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
||||||
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
||||||
@@ -94,6 +107,8 @@ type CreatePurchaseOrderRequest struct {
|
|||||||
Reference *string `json:"reference,omitempty"`
|
Reference *string `json:"reference,omitempty"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
Message *string `json:"message,omitempty"`
|
Message *string `json:"message,omitempty"`
|
||||||
|
TeamScope *string `json:"team_scope,omitempty"`
|
||||||
|
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||||
Items []CreatePurchaseOrderItemRequest `json:"items"`
|
Items []CreatePurchaseOrderItemRequest `json:"items"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -115,6 +130,8 @@ type UpdatePurchaseOrderRequest struct {
|
|||||||
Reference *string `json:"reference,omitempty"`
|
Reference *string `json:"reference,omitempty"`
|
||||||
Status *string `json:"status,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
Message *string `json:"message,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"`
|
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty"`
|
||||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -135,10 +152,17 @@ type ListPurchaseOrdersRequest struct {
|
|||||||
Search string `json:"search,omitempty"`
|
Search string `json:"search,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
VendorID *uuid.UUID `json:"vendor_id,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"`
|
StartDate *time.Time `json:"start_date,omitempty"`
|
||||||
EndDate *time.Time `json:"end_date,omitempty"`
|
EndDate *time.Time `json:"end_date,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ListPurchaseTeamsResponse struct {
|
||||||
|
Teams []PurchaseTeam `json:"teams"`
|
||||||
|
}
|
||||||
|
|
||||||
type ListPurchaseOrdersResponse struct {
|
type ListPurchaseOrdersResponse struct {
|
||||||
PurchaseOrders []PurchaseOrderResponse `json:"purchase_orders"`
|
PurchaseOrders []PurchaseOrderResponse `json:"purchase_orders"`
|
||||||
TotalCount int `json:"total_count"`
|
TotalCount int `json:"total_count"`
|
||||||
|
|||||||
@@ -200,7 +200,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
req.GroupBy = "day"
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
|
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{
|
return &models.PurchasingAnalyticsResponse{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: req.OutletID,
|
OutletID: req.OutletID,
|
||||||
OutletName: result.OutletName,
|
OutletName: result.OutletName,
|
||||||
|
Team: req.Team,
|
||||||
DateFrom: req.DateFrom,
|
DateFrom: req.DateFrom,
|
||||||
DateTo: req.DateTo,
|
DateTo: req.DateTo,
|
||||||
GroupBy: req.GroupBy,
|
GroupBy: req.GroupBy,
|
||||||
@@ -263,10 +284,12 @@ func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req
|
|||||||
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
|
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
|
||||||
TotalIngredients: result.Summary.TotalIngredients,
|
TotalIngredients: result.Summary.TotalIngredients,
|
||||||
TotalVendors: result.Summary.TotalVendors,
|
TotalVendors: result.Summary.TotalVendors,
|
||||||
|
TotalTeams: result.Summary.TotalTeams,
|
||||||
},
|
},
|
||||||
Data: data,
|
Data: data,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
|
TeamData: teamData,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
|
|
||||||
type analyticsRepositoryStub struct {
|
type analyticsRepositoryStub struct {
|
||||||
purchasingResult *entities.PurchasingAnalytics
|
purchasingResult *entities.PurchasingAnalytics
|
||||||
|
purchasingTeam *entities.PurchaseTeamFilter
|
||||||
budgetCutOffWeeks []*entities.BudgetCutOffWeek
|
budgetCutOffWeeks []*entities.BudgetCutOffWeek
|
||||||
profitLossResult *entities.ProfitLossAnalytics
|
profitLossResult *entities.ProfitLossAnalytics
|
||||||
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
||||||
@@ -32,7 +34,8 @@ func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uu
|
|||||||
return nil, nil
|
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
|
return s.purchasingResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +161,110 @@ func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T)
|
|||||||
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
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) {
|
func TestAnalyticsProcessorGetProfitLossAnalyticsMapsOverviewAndReportFields(t *testing.T) {
|
||||||
productID := uuid.New()
|
productID := uuid.New()
|
||||||
categoryID := uuid.New()
|
categoryID := uuid.New()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type CategoryRepository interface {
|
|||||||
GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error)
|
GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error)
|
||||||
GetWithProducts(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)
|
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)
|
GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error)
|
||||||
Update(ctx context.Context, category *entities.Category) error
|
Update(ctx context.Context, category *entities.Category) error
|
||||||
Delete(ctx context.Context, id uuid.UUID) error
|
Delete(ctx context.Context, id uuid.UUID) error
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ func NewIngredientProcessor(ingredientRepo IngredientRepository, unitRepo UnitRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *IngredientProcessorImpl) CreateIngredient(ctx context.Context, req *models.CreateIngredientRequest) (*models.IngredientResponse, error) {
|
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 {
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ingredient := &entities.Ingredient{
|
ingredient := &entities.Ingredient{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
@@ -107,8 +110,8 @@ func (p *IngredientProcessorImpl) UpdateIngredient(ctx context.Context, id uuid.
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.UnitID != existing.UnitID {
|
if req.UnitID != nil && (existing.UnitID == nil || *req.UnitID != *existing.UnitID) {
|
||||||
if _, err := p.unitRepo.GetByID(ctx, req.UnitID, organizationID); err != nil {
|
if _, err := p.unitRepo.GetByID(ctx, *req.UnitID, organizationID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,15 +266,27 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
|||||||
return nil, fmt.Errorf("failed to get ingredient: %w", err)
|
return nil, fmt.Errorf("failed to get ingredient: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the base unit details
|
response := &models.IngredientUnitsResponse{
|
||||||
baseUnit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID)
|
IngredientID: ingredientID,
|
||||||
|
IngredientName: ingredient.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get base unit: %w", err)
|
return nil, fmt.Errorf("failed to get base unit: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start with the base unit
|
units = append(units, mappers.MapUnitEntityToResponse(baseUnit))
|
||||||
units := []*models.UnitResponse{
|
unitMap[baseUnit.ID] = true
|
||||||
mappers.MapUnitEntityToResponse(baseUnit),
|
response.BaseUnitID = &baseUnit.ID
|
||||||
|
response.BaseUnitName = baseUnit.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all converters for this ingredient
|
// 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)
|
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 {
|
for _, converter := range converters {
|
||||||
if converter.IsActive {
|
if converter.IsActive {
|
||||||
// Add FromUnit if not already added
|
// Add FromUnit if not already added
|
||||||
@@ -309,13 +317,7 @@ func (p *IngredientUnitConverterProcessorImpl) GetUnitsByIngredientID(ctx contex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response := &models.IngredientUnitsResponse{
|
response.Units = units
|
||||||
IngredientID: ingredientID,
|
|
||||||
IngredientName: ingredient.Name,
|
|
||||||
BaseUnitID: baseUnit.ID,
|
|
||||||
BaseUnitName: baseUnit.Name,
|
|
||||||
Units: units,
|
|
||||||
}
|
|
||||||
|
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,8 +371,8 @@ func (p *OrderIngredientTransactionProcessorImpl) CalculateWasteQuantities(ctx c
|
|||||||
|
|
||||||
// Get unit name
|
// Get unit name
|
||||||
unitName := "unit" // default
|
unitName := "unit" // default
|
||||||
if ingredient.UnitID != uuid.Nil {
|
if ingredient.UnitID != nil {
|
||||||
unit, err := p.unitRepo.GetByID(ctx, ingredient.UnitID, organizationID)
|
unit, err := p.unitRepo.GetByID(ctx, *ingredient.UnitID, organizationID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
unitName = unit.Name
|
unitName = unit.Name
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package processor
|
package processor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
"apskel-pos-be/internal/mappers"
|
"apskel-pos-be/internal/mappers"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -19,6 +21,7 @@ type PurchaseOrderProcessor interface {
|
|||||||
GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error)
|
GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error)
|
||||||
GetOverduePurchaseOrders(ctx context.Context, organizationID uuid.UUID) ([]*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)
|
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 {
|
type PurchaseOrderProcessorImpl struct {
|
||||||
@@ -26,8 +29,12 @@ type PurchaseOrderProcessorImpl struct {
|
|||||||
vendorRepo VendorRepository
|
vendorRepo VendorRepository
|
||||||
ingredientRepo IngredientRepository
|
ingredientRepo IngredientRepository
|
||||||
purchaseCategoryRepo PurchaseCategoryRepository
|
purchaseCategoryRepo PurchaseCategoryRepository
|
||||||
|
categoryRepo CategoryRepository
|
||||||
unitRepo UnitRepository
|
unitRepo UnitRepository
|
||||||
fileRepo FileRepository
|
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
|
inventoryMovementService InventoryMovementService
|
||||||
unitConverterRepo IngredientUnitConverterRepository
|
unitConverterRepo IngredientUnitConverterRepository
|
||||||
}
|
}
|
||||||
@@ -37,6 +44,7 @@ func NewPurchaseOrderProcessorImpl(
|
|||||||
vendorRepo VendorRepository,
|
vendorRepo VendorRepository,
|
||||||
ingredientRepo IngredientRepository,
|
ingredientRepo IngredientRepository,
|
||||||
purchaseCategoryRepo PurchaseCategoryRepository,
|
purchaseCategoryRepo PurchaseCategoryRepository,
|
||||||
|
categoryRepo CategoryRepository,
|
||||||
unitRepo UnitRepository,
|
unitRepo UnitRepository,
|
||||||
fileRepo FileRepository,
|
fileRepo FileRepository,
|
||||||
inventoryMovementService InventoryMovementService,
|
inventoryMovementService InventoryMovementService,
|
||||||
@@ -47,6 +55,7 @@ func NewPurchaseOrderProcessorImpl(
|
|||||||
vendorRepo: vendorRepo,
|
vendorRepo: vendorRepo,
|
||||||
ingredientRepo: ingredientRepo,
|
ingredientRepo: ingredientRepo,
|
||||||
purchaseCategoryRepo: purchaseCategoryRepo,
|
purchaseCategoryRepo: purchaseCategoryRepo,
|
||||||
|
categoryRepo: categoryRepo,
|
||||||
unitRepo: unitRepo,
|
unitRepo: unitRepo,
|
||||||
fileRepo: fileRepo,
|
fileRepo: fileRepo,
|
||||||
inventoryMovementService: inventoryMovementService,
|
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
|
// Check if PO number already exists in organization
|
||||||
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
|
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
|
||||||
if err == nil && existingPO != nil {
|
if err == nil && existingPO != nil {
|
||||||
@@ -124,6 +138,8 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
|
|||||||
Status: "draft", // Default status
|
Status: "draft", // Default status
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
TotalAmount: totalAmount,
|
TotalAmount: totalAmount,
|
||||||
|
TeamScope: teamScope,
|
||||||
|
TeamCategoryID: teamCategoryID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
@@ -221,6 +237,16 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id
|
|||||||
poEntity.Message = req.Message
|
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
|
// Update items if provided
|
||||||
if req.Items != nil {
|
if req.Items != nil {
|
||||||
totalAmount := 0.0
|
totalAmount := 0.0
|
||||||
@@ -415,71 +441,11 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
|
|
||||||
fmt.Println("status:", po.Status)
|
fmt.Println("status:", po.Status)
|
||||||
|
|
||||||
// Check if status is changing to "received" and current status is not "received"
|
// A purchase order is a record of spending only. Receiving one does not move
|
||||||
if status == "received" && po.Status != "received" {
|
// ingredient stock, does not recalculate ingredient cost, and never converts
|
||||||
// Get purchase order with items for inventory update
|
// units: the quantity and unit on an item are kept exactly as the user
|
||||||
poWithItems, err := p.purchaseOrderRepo.GetByID(ctx, id)
|
// entered them. Raw material items are therefore treated the same way expense
|
||||||
if err != nil {
|
// items already were, and the ingredient on an item is just a reference.
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the purchase order status
|
// Update the purchase order status
|
||||||
statusOutletID := po.OutletID
|
statusOutletID := po.OutletID
|
||||||
@@ -501,6 +467,79 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
|
|||||||
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
|
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) {
|
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
|
||||||
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -15,7 +16,7 @@ import (
|
|||||||
type AnalyticsRepository interface {
|
type AnalyticsRepository interface {
|
||||||
GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error)
|
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)
|
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)
|
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)
|
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)
|
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
|
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
|
var outletName *string
|
||||||
|
|
||||||
if outletID != nil {
|
if outletID != nil {
|
||||||
@@ -179,10 +180,10 @@ func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, or
|
|||||||
outletName = &outlet.Name
|
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
|
var summary entities.PurchasingSummary
|
||||||
summaryQuery := r.db.WithContext(ctx).
|
summaryQuery := r.db.WithContext(ctx).
|
||||||
Table("purchase_orders po").
|
Table("purchase_orders po").
|
||||||
@@ -210,6 +211,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
Where("po.status != ?", "cancelled").
|
Where("po.status != ?", "cancelled").
|
||||||
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
|
||||||
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
|
summaryQuery = r.applyPurchaseOrderItemOutletFilter(summaryQuery, outletID)
|
||||||
|
summaryQuery = r.applyPurchaseOrderTeamFilter(summaryQuery, team)
|
||||||
|
|
||||||
if err := summaryQuery.Scan(&summary).Error; err != nil {
|
if err := summaryQuery.Scan(&summary).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -253,6 +255,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
Group(dateFormat).
|
Group(dateFormat).
|
||||||
Order(dateFormat)
|
Order(dateFormat)
|
||||||
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
|
dataQuery = r.applyPurchaseOrderItemOutletFilter(dataQuery, outletID)
|
||||||
|
dataQuery = r.applyPurchaseOrderTeamFilter(dataQuery, team)
|
||||||
|
|
||||||
if err := dataQuery.Scan(&data).Error; err != nil {
|
if err := dataQuery.Scan(&data).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -283,6 +286,7 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
Group("i.id, i.name").
|
Group("i.id, i.name").
|
||||||
Order("total_cost DESC")
|
Order("total_cost DESC")
|
||||||
ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID)
|
ingredientQuery = r.applyPurchaseOrderItemOutletFilter(ingredientQuery, outletID)
|
||||||
|
ingredientQuery = r.applyPurchaseOrderTeamFilter(ingredientQuery, team)
|
||||||
|
|
||||||
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
|
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -310,20 +314,105 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
|
|||||||
Group("v.id, COALESCE(v.name, 'No Vendor')").
|
Group("v.id, COALESCE(v.name, 'No Vendor')").
|
||||||
Order("total_cost DESC")
|
Order("total_cost DESC")
|
||||||
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
|
vendorQuery = r.applyPurchaseOrderItemOutletFilter(vendorQuery, outletID)
|
||||||
|
vendorQuery = r.applyPurchaseOrderTeamFilter(vendorQuery, team)
|
||||||
|
|
||||||
if err := vendorQuery.Scan(&vendorData).Error; err != nil {
|
if err := vendorQuery.Scan(&vendorData).Error; err != nil {
|
||||||
return nil, err
|
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{
|
return &entities.PurchasingAnalytics{
|
||||||
OutletName: outletName,
|
OutletName: outletName,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Data: data,
|
Data: data,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
|
TeamData: teamData,
|
||||||
}, nil
|
}, 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 {
|
func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm.DB, outletID *uuid.UUID) *gorm.DB {
|
||||||
if outletID == nil {
|
if outletID == nil {
|
||||||
return query
|
return query
|
||||||
@@ -331,6 +420,28 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm
|
|||||||
return query.Where("po.outlet_id = ?", *outletID)
|
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) {
|
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
|
var results []*entities.ProductAnalytics
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ func (r *CategoryRepositoryImpl) GetByOrganization(ctx context.Context, organiza
|
|||||||
return categories, err
|
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) {
|
func (r *CategoryRepositoryImpl) GetByBusinessType(ctx context.Context, businessType string) ([]*entities.Category, error) {
|
||||||
var categories []*entities.Category
|
var categories []*entities.Category
|
||||||
err := r.db.WithContext(ctx).Where("business_type = ?", businessType).Find(&categories).Error
|
err := r.db.WithContext(ctx).Where("business_type = ?", businessType).Find(&categories).Error
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PurchaseOrderRepositoryImpl struct {
|
type PurchaseOrderRepositoryImpl struct {
|
||||||
@@ -30,6 +31,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID)
|
|||||||
var po entities.PurchaseOrder
|
var po entities.PurchaseOrder
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
|
Preload("TeamCategory").
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -45,6 +47,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByIDAndOrganizationID(ctx context.Conte
|
|||||||
var po entities.PurchaseOrder
|
var po entities.PurchaseOrder
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
|
Preload("TeamCategory").
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
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 {
|
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 {
|
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 {
|
if vendorID, ok := value.(uuid.UUID); ok {
|
||||||
query = query.Where("vendor_id = ?", vendorID)
|
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":
|
case "start_date":
|
||||||
if startDate, ok := value.(time.Time); ok {
|
if startDate, ok := value.(time.Time); ok {
|
||||||
query = query.Where("transaction_date >= ?", startDate)
|
query = query.Where("transaction_date >= ?", startDate)
|
||||||
@@ -106,6 +124,7 @@ func (r *PurchaseOrderRepositoryImpl) List(ctx context.Context, organizationID u
|
|||||||
|
|
||||||
err := query.
|
err := query.
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
|
Preload("TeamCategory").
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -137,6 +156,18 @@ func (r *PurchaseOrderRepositoryImpl) Count(ctx context.Context, organizationID
|
|||||||
if vendorID, ok := value.(uuid.UUID); ok {
|
if vendorID, ok := value.(uuid.UUID); ok {
|
||||||
query = query.Where("vendor_id = ?", vendorID)
|
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":
|
case "start_date":
|
||||||
if startDate, ok := value.(time.Time); ok {
|
if startDate, ok := value.(time.Time); ok {
|
||||||
query = query.Where("transaction_date >= ?", startDate)
|
query = query.Where("transaction_date >= ?", startDate)
|
||||||
@@ -170,6 +201,7 @@ func (r *PurchaseOrderRepositoryImpl) GetByStatus(ctx context.Context, organizat
|
|||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("organization_id = ? AND status = ?", organizationID, status).
|
Where("organization_id = ? AND status = ?", organizationID, status).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
|
Preload("TeamCategory").
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
@@ -182,6 +214,7 @@ func (r *PurchaseOrderRepositoryImpl) GetOverdue(ctx context.Context, organizati
|
|||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
|
Where("organization_id = ? AND due_date < ? AND status IN (?)", organizationID, time.Now(), []string{"draft", "sent", "approved"}).
|
||||||
Preload("Vendor").
|
Preload("Vendor").
|
||||||
|
Preload("TeamCategory").
|
||||||
Preload("Items.Ingredient").
|
Preload("Items.Ingredient").
|
||||||
Preload("Items.PurchaseCategory").
|
Preload("Items.PurchaseCategory").
|
||||||
Preload("Items.Unit").
|
Preload("Items.Unit").
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
|
||||||
purchaseOrders.GET("/status/:status", r.purchaseOrderHandler.GetPurchaseOrdersByStatus)
|
purchaseOrders.GET("/status/:status", r.purchaseOrderHandler.GetPurchaseOrdersByStatus)
|
||||||
purchaseOrders.GET("/overdue", r.purchaseOrderHandler.GetOverduePurchaseOrders)
|
purchaseOrders.GET("/overdue", r.purchaseOrderHandler.GetOverduePurchaseOrders)
|
||||||
|
purchaseOrders.GET("/teams", r.purchaseOrderHandler.ListPurchaseTeams)
|
||||||
purchaseOrders.GET("/:id", r.purchaseOrderHandler.GetPurchaseOrder)
|
purchaseOrders.GET("/:id", r.purchaseOrderHandler.GetPurchaseOrder)
|
||||||
purchaseOrders.PUT("/:id", r.purchaseOrderHandler.UpdatePurchaseOrder)
|
purchaseOrders.PUT("/:id", r.purchaseOrderHandler.UpdatePurchaseOrder)
|
||||||
purchaseOrders.PUT("/:id/status/:status", r.purchaseOrderHandler.UpdatePurchaseOrderStatus)
|
purchaseOrders.PUT("/:id/status/:status", r.purchaseOrderHandler.UpdatePurchaseOrderStatus)
|
||||||
|
|||||||
@@ -238,6 +238,10 @@ func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.Pu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err := models.ParsePurchaseTeamFilter(req.Team); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: "invalid group_by value: quarter",
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type PurchaseOrderService interface {
|
|||||||
GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response
|
GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response
|
||||||
GetOverduePurchaseOrders(ctx context.Context, apctx *appcontext.ContextInfo) *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
|
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 {
|
type PurchaseOrderServiceImpl struct {
|
||||||
@@ -113,6 +114,26 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
|
|||||||
if modelReq.VendorID != nil {
|
if modelReq.VendorID != nil {
|
||||||
filters["vendor_id"] = *modelReq.VendorID
|
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 {
|
if modelReq.StartDate != nil {
|
||||||
filters["start_date"] = *modelReq.StartDate
|
filters["start_date"] = *modelReq.StartDate
|
||||||
}
|
}
|
||||||
@@ -145,6 +166,21 @@ func (s *PurchaseOrderServiceImpl) ListPurchaseOrders(ctx context.Context, apctx
|
|||||||
return contract.BuildSuccessResponse(response)
|
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 {
|
func (s *PurchaseOrderServiceImpl) GetPurchaseOrdersByStatus(ctx context.Context, apctx *appcontext.ContextInfo, status string) *contract.Response {
|
||||||
poResponses, err := s.purchaseOrderProcessor.GetPurchaseOrdersByStatus(ctx, apctx.OrganizationID, status)
|
poResponses, err := s.purchaseOrderProcessor.GetPurchaseOrdersByStatus(ctx, apctx.OrganizationID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest
|
|||||||
return &models.PurchasingAnalyticsRequest{
|
return &models.PurchasingAnalyticsRequest{
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
OutletID: parseOutletID(req.OutletID),
|
OutletID: parseOutletID(req.OutletID),
|
||||||
|
Team: req.Team,
|
||||||
DateFrom: dateFrom,
|
DateFrom: dateFrom,
|
||||||
DateTo: dateTo,
|
DateTo: dateTo,
|
||||||
GroupBy: req.GroupBy,
|
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{
|
return &contract.PurchasingAnalyticsResponse{
|
||||||
OrganizationID: resp.OrganizationID,
|
OrganizationID: resp.OrganizationID,
|
||||||
OutletID: resp.OutletID,
|
OutletID: resp.OutletID,
|
||||||
OutletName: resp.OutletName,
|
OutletName: resp.OutletName,
|
||||||
|
Team: resp.Team,
|
||||||
DateFrom: resp.DateFrom,
|
DateFrom: resp.DateFrom,
|
||||||
DateTo: resp.DateTo,
|
DateTo: resp.DateTo,
|
||||||
GroupBy: resp.GroupBy,
|
GroupBy: resp.GroupBy,
|
||||||
@@ -226,10 +243,12 @@ func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse
|
|||||||
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
|
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
|
||||||
TotalIngredients: resp.Summary.TotalIngredients,
|
TotalIngredients: resp.Summary.TotalIngredients,
|
||||||
TotalVendors: resp.Summary.TotalVendors,
|
TotalVendors: resp.Summary.TotalVendors,
|
||||||
|
TotalTeams: resp.Summary.TotalTeams,
|
||||||
},
|
},
|
||||||
Data: data,
|
Data: data,
|
||||||
IngredientData: ingredientData,
|
IngredientData: ingredientData,
|
||||||
VendorData: vendorData,
|
VendorData: vendorData,
|
||||||
|
TeamData: teamData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
"apskel-pos-be/internal/contract"
|
"apskel-pos-be/internal/contract"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
@@ -95,6 +96,49 @@ func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
|
|||||||
require.Equal(t, float64(175), result.Data[0].ExpensePurchases)
|
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) {
|
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
|
||||||
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
|
||||||
OrganizationID: uuid.New(),
|
OrganizationID: uuid.New(),
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ func CreatePurchaseOrderRequestToModel(req *contract.CreatePurchaseOrderRequest)
|
|||||||
Reference: req.Reference,
|
Reference: req.Reference,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
|
TeamScope: req.TeamScope,
|
||||||
|
TeamCategoryID: req.TeamCategoryID,
|
||||||
Items: items,
|
Items: items,
|
||||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -94,6 +96,8 @@ func UpdatePurchaseOrderRequestToModel(req *contract.UpdatePurchaseOrderRequest)
|
|||||||
Reference: req.Reference,
|
Reference: req.Reference,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
Message: req.Message,
|
Message: req.Message,
|
||||||
|
TeamScope: req.TeamScope,
|
||||||
|
TeamCategoryID: req.TeamCategoryID,
|
||||||
Items: items,
|
Items: items,
|
||||||
AttachmentFileIDs: req.AttachmentFileIDs,
|
AttachmentFileIDs: req.AttachmentFileIDs,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -106,11 +110,35 @@ func ListPurchaseOrdersRequestToModel(req *contract.ListPurchaseOrdersRequest) *
|
|||||||
Search: req.Search,
|
Search: req.Search,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
VendorID: req.VendorID,
|
VendorID: req.VendorID,
|
||||||
|
Team: req.Team,
|
||||||
|
TeamScope: req.TeamScope,
|
||||||
|
TeamCategoryID: req.TeamCategoryID,
|
||||||
StartDate: req.StartDate,
|
StartDate: req.StartDate,
|
||||||
EndDate: req.EndDate,
|
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
|
// Model to Contract conversions
|
||||||
func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *contract.PurchaseOrderResponse {
|
func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *contract.PurchaseOrderResponse {
|
||||||
if po == nil {
|
if po == nil {
|
||||||
@@ -129,8 +157,11 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
|
|||||||
Status: po.Status,
|
Status: po.Status,
|
||||||
Message: po.Message,
|
Message: po.Message,
|
||||||
TotalAmount: po.TotalAmount,
|
TotalAmount: po.TotalAmount,
|
||||||
|
TeamScope: po.TeamScope,
|
||||||
|
TeamCategoryID: po.TeamCategoryID,
|
||||||
CreatedAt: po.CreatedAt,
|
CreatedAt: po.CreatedAt,
|
||||||
UpdatedAt: po.UpdatedAt,
|
UpdatedAt: po.UpdatedAt,
|
||||||
|
Team: PurchaseTeamModelToResponse(po.Team),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map vendor if present
|
// 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 {
|
if len(req.Items) == 0 {
|
||||||
return errors.New("at least one item is required"), constants.MissingFieldErrorCode
|
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
|
// Validate items if provided
|
||||||
if req.Items != nil {
|
if req.Items != nil {
|
||||||
for i, item := range req.Items {
|
for i, item := range req.Items {
|
||||||
@@ -151,6 +159,55 @@ func (v *PurchaseOrderValidatorImpl) ValidateUpdatePurchaseOrderRequest(req *con
|
|||||||
return nil, ""
|
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) {
|
func (v *PurchaseOrderValidatorImpl) ValidateListPurchaseOrdersRequest(req *contract.ListPurchaseOrdersRequest) (error, string) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return errors.New("request body is required"), constants.MissingFieldErrorCode
|
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.StartDate != nil && req.EndDate != nil {
|
||||||
if req.EndDate.Before(*req.StartDate) {
|
if req.EndDate.Before(*req.StartDate) {
|
||||||
return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode
|
return errors.New("end_date must be after start_date"), constants.MalformedFieldErrorCode
|
||||||
|
|||||||
@@ -90,3 +90,164 @@ func TestPurchaseOrderValidatorCreateRejectsDueDateBeforeTransactionDate(t *test
|
|||||||
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
require.Equal(t, constants.MalformedFieldErrorCode, code)
|
||||||
require.Contains(t, err.Error(), "due_date must be after transaction_date")
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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.';
|
||||||
Reference in New Issue
Block a user