Compare commits

...

2 Commits

Author SHA1 Message Date
87540fa1b7 Update exclusive summary 2026-06-18 15:44:15 +07:00
66d4c9f0af Update purchase order with outlet id 2026-06-18 15:27:20 +07:00
12 changed files with 146 additions and 23 deletions

View File

@ -52,6 +52,7 @@ type UpdatePurchaseOrderItemRequest struct {
type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`

View File

@ -11,6 +11,7 @@ import (
type PurchaseOrder struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id" validate:"required"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"`
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"`
@ -23,6 +24,7 @@ type PurchaseOrder struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`

View File

@ -13,6 +13,7 @@ func PurchaseOrderEntityToModel(entity *entities.PurchaseOrder) *models.Purchase
return &models.PurchaseOrder{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
VendorID: entity.VendorID,
PONumber: entity.PONumber,
TransactionDate: entity.TransactionDate,
@ -34,6 +35,7 @@ func PurchaseOrderModelToEntity(model *models.PurchaseOrder) *entities.PurchaseO
return &entities.PurchaseOrder{
ID: model.ID,
OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
VendorID: model.VendorID,
PONumber: model.PONumber,
TransactionDate: model.TransactionDate,
@ -55,6 +57,7 @@ func PurchaseOrderEntityToResponse(entity *entities.PurchaseOrder) *models.Purch
response := &models.PurchaseOrderResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
VendorID: entity.VendorID,
PONumber: entity.PONumber,
TransactionDate: entity.TransactionDate,

View File

@ -9,6 +9,7 @@ import (
type PurchaseOrder struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
@ -44,6 +45,7 @@ type PurchaseOrderAttachment struct {
type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
@ -85,6 +87,7 @@ type PurchaseOrderAttachmentResponse struct {
type CreatePurchaseOrderRequest struct {
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date,omitempty"`

View File

@ -11,8 +11,8 @@ import (
)
type PurchaseOrderProcessor interface {
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error)
DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error
GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error)
ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error)
@ -54,7 +54,7 @@ func NewPurchaseOrderProcessorImpl(
}
}
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Check if vendor exists and belongs to organization when provided.
if req.VendorID != nil {
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID)
@ -115,6 +115,7 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
// Create purchase order entity
poEntity := &entities.PurchaseOrder{
OrganizationID: organizationID,
OutletID: outletID,
VendorID: req.VendorID,
PONumber: req.PONumber,
TransactionDate: req.TransactionDate,
@ -175,12 +176,15 @@ func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, or
return mappers.PurchaseOrderEntityToResponse(createdPO), nil
}
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID, outletID *uuid.UUID, req *models.UpdatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Get existing purchase order
poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase order not found: %w", err)
}
if poEntity.OutletID == nil && outletID != nil {
poEntity.OutletID = outletID
}
// Check if vendor exists and belongs to organization (if vendor is being updated)
if req.VendorID != nil {
@ -478,7 +482,12 @@ func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Conte
}
// Update the purchase order status
err = p.purchaseOrderRepo.UpdateStatus(ctx, id, status)
statusOutletID := po.OutletID
if statusOutletID == nil && outletID != uuid.Nil {
statusOutletID = &outletID
}
err = p.purchaseOrderRepo.UpdateStatusAndOutlet(ctx, id, status, statusOutletID)
if err != nil {
return nil, fmt.Errorf("failed to update purchase order status: %w", err)
}

View File

@ -19,6 +19,7 @@ type PurchaseOrderRepository interface {
GetByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*entities.PurchaseOrder, error)
GetOverdue(ctx context.Context, organizationID uuid.UUID) ([]*entities.PurchaseOrder, error)
UpdateStatus(ctx context.Context, id uuid.UUID, status string) error
UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error
UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error
CreateItem(ctx context.Context, item *entities.PurchaseOrderItem) error
UpdateItem(ctx context.Context, item *entities.PurchaseOrderItem) error

View File

@ -39,6 +39,10 @@ func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid
return query
}
func purchaseOrderItemTotalAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END"
}
func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) {
var results []*entities.PaymentMethodAnalytics
@ -153,18 +157,19 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
summaryQuery := r.db.WithContext(ctx).
Table("purchase_orders po").
Select(`
COALESCE(SUM(poi.amount), 0) as total_purchases,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_purchases,
COUNT(DISTINCT po.id) as total_purchase_orders,
COALESCE(SUM(poi.quantity), 0) as total_quantity,
CASE
WHEN COUNT(DISTINCT po.id) > 0
THEN COALESCE(SUM(poi.amount), 0) / COUNT(DISTINCT po.id)
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / COUNT(DISTINCT po.id)
ELSE 0
END as average_purchase_order_value,
COUNT(DISTINCT i.id) as total_ingredients,
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as total_vendors
`).
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 ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
@ -193,13 +198,14 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Table("purchase_orders po").
Select(`
`+dateFormat+` as date,
COALESCE(SUM(poi.amount), 0) as purchases,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as purchases,
COUNT(DISTINCT po.id) as purchase_orders,
COALESCE(SUM(poi.quantity), 0) as quantity,
COUNT(DISTINCT i.id) as ingredients,
COUNT(DISTINCT COALESCE(po.vendor_id::text, 'no-vendor')) as vendors
`).
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 ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
@ -220,15 +226,16 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
i.id as ingredient_id,
i.name as ingredient_name,
COALESCE(SUM(poi.quantity), 0) as quantity,
COALESCE(SUM(poi.amount), 0) as total_cost,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost,
CASE
WHEN SUM(poi.quantity) > 0
THEN COALESCE(SUM(poi.amount), 0) / SUM(poi.quantity)
THEN COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) / SUM(poi.quantity)
ELSE 0
END as average_unit_cost,
COUNT(DISTINCT po.id) as purchase_order_count
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("JOIN ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
@ -248,13 +255,14 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderPurchasingAnalytics(ctx contex
Select(`
v.id as vendor_id,
COALESCE(v.name, 'No Vendor') as vendor_name,
COALESCE(SUM(poi.amount), 0) as total_cost,
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total_cost,
COUNT(DISTINCT po.id) as purchase_order_count,
COUNT(DISTINCT i.id) as ingredient_count,
COALESCE(SUM(poi.quantity), 0) as quantity
`).
Joins("LEFT JOIN vendors v ON po.vendor_id = v.id").
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 ingredients i ON poi.ingredient_id = i.id").
Joins("LEFT JOIN units u ON poi.unit_id = u.id").
Where("po.organization_id = ?", organizationID).
@ -281,7 +289,7 @@ func (r *AnalyticsRepositoryImpl) applyPurchaseOrderItemOutletFilter(query *gorm
if outletID == nil {
return query
}
return query.Where("(i.outlet_id = ? OR u.outlet_id = ?)", *outletID, *outletID)
return query.Where("po.outlet_id = ?", *outletID)
}
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
@ -717,7 +725,7 @@ func (r *AnalyticsRepositoryImpl) GetExclusiveSummaryAnalytics(ctx context.Conte
return nil, err
}
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, dateFrom, dateTo)
operationalExpenseBreakdown, err := r.getExclusiveSummaryOperationalExpenseBreakdown(ctx, organizationID, outletID, dateFrom, dateTo)
if err != nil {
return nil, err
}
@ -750,7 +758,7 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
Select(`
pc.code as category_code,
pc.name as category_name,
COALESCE(SUM(poi.amount), 0) as amount
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
@ -770,22 +778,25 @@ func (r *AnalyticsRepositoryImpl) getExclusiveSummaryHPPBreakdown(ctx context.Co
return results, err
}
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
func (r *AnalyticsRepositoryImpl) getExclusiveSummaryOperationalExpenseBreakdown(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]entities.ExclusiveSummaryCategoryTotal, error) {
var results []entities.ExclusiveSummaryCategoryTotal
err := r.db.WithContext(ctx).
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
pc.code as category_code,
pc.name as category_name,
COALESCE(SUM(poi.amount), 0) as amount
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as amount
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID).
Where("pc.type = ?", entities.PurchaseCategoryTypeExpense).
Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
query = r.applyPurchaseOrderItemOutletFilter(query, outletID)
err := query.
Group("pc.id, pc.code, pc.name, pc.sort_order").
Order("pc.sort_order ASC, pc.name ASC").
Scan(&results).Error
@ -830,8 +841,8 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organiz
}
if outletID != nil {
outletFilter = "AND (pc.type = ? OR i.outlet_id = ? OR u.outlet_id = ?)"
args = append(args, entities.PurchaseCategoryTypeExpense, *outletID, *outletID)
outletFilter = "AND po.outlet_id = ?"
args = append(args, *outletID)
}
query := `
@ -840,7 +851,7 @@ func (r *AnalyticsRepositoryImpl) exclusiveSummaryPurchaseOrderItemQuery(organiz
pc.code as category_code,
pc.name as category_name,
COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as description,
poi.amount as amount,
` + purchaseOrderItemTotalAmountSQL() + ` as amount,
'purchase_order' as source
FROM purchase_order_items poi
JOIN purchase_orders po ON poi.purchase_order_id = po.id

View File

@ -196,6 +196,18 @@ func (r *PurchaseOrderRepositoryImpl) UpdateStatus(ctx context.Context, id uuid.
Update("status", status).Error
}
func (r *PurchaseOrderRepositoryImpl) UpdateStatusAndOutlet(ctx context.Context, id uuid.UUID, status string, outletID *uuid.UUID) error {
updates := map[string]interface{}{"status": status}
if outletID != nil {
updates["outlet_id"] = *outletID
}
return r.db.WithContext(ctx).
Model(&entities.PurchaseOrder{}).
Where("id = ?", id).
Updates(updates).Error
}
func (r *PurchaseOrderRepositoryImpl) UpdateTotalAmount(ctx context.Context, id uuid.UUID, totalAmount float64) error {
return r.db.WithContext(ctx).
Model(&entities.PurchaseOrder{}).

View File

@ -40,7 +40,12 @@ func (s *PurchaseOrderServiceImpl) CreatePurchaseOrder(ctx context.Context, apct
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, modelReq)
var outletID *uuid.UUID
if apctx.OutletID != uuid.Nil {
outletID = &apctx.OutletID
}
poResponse, err := s.purchaseOrderProcessor.CreatePurchaseOrder(ctx, apctx.OrganizationID, outletID, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
@ -57,7 +62,12 @@ func (s *PurchaseOrderServiceImpl) UpdatePurchaseOrder(ctx context.Context, apct
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, modelReq)
var outletID *uuid.UUID
if apctx.OutletID != uuid.Nil {
outletID = &apctx.OutletID
}
poResponse, err := s.purchaseOrderProcessor.UpdatePurchaseOrder(ctx, id, apctx.OrganizationID, outletID, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.PurchaseOrderServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})

View File

@ -120,6 +120,7 @@ func PurchaseOrderModelResponseToResponse(po *models.PurchaseOrderResponse) *con
response := &contract.PurchaseOrderResponse{
ID: po.ID,
OrganizationID: po.OrganizationID,
OutletID: po.OutletID,
VendorID: po.VendorID,
PONumber: po.PONumber,
TransactionDate: po.TransactionDate,

View File

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_purchase_orders_outlet_id;
ALTER TABLE purchase_orders
DROP COLUMN IF EXISTS outlet_id;

View File

@ -0,0 +1,66 @@
ALTER TABLE purchase_orders
ADD COLUMN IF NOT EXISTS outlet_id UUID REFERENCES outlets(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_purchase_orders_outlet_id
ON purchase_orders(outlet_id);
WITH movement_outlets AS (
SELECT
poi.purchase_order_id,
MIN(im.outlet_id) AS outlet_id
FROM inventory_movements im
JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id
WHERE im.outlet_id IS NOT NULL
AND im.purchase_order_item_id IS NOT NULL
GROUP BY poi.purchase_order_id
HAVING COUNT(DISTINCT im.outlet_id) = 1
)
UPDATE purchase_orders po
SET outlet_id = movement_outlets.outlet_id
FROM movement_outlets
WHERE po.id = movement_outlets.purchase_order_id
AND po.outlet_id IS NULL;
WITH candidate_item_outlets AS (
SELECT
poi.purchase_order_id,
i.outlet_id
FROM purchase_order_items poi
JOIN ingredients i ON poi.ingredient_id = i.id
WHERE i.outlet_id IS NOT NULL
UNION ALL
SELECT
poi.purchase_order_id,
u.outlet_id
FROM purchase_order_items poi
JOIN units u ON poi.unit_id = u.id
WHERE u.outlet_id IS NOT NULL
), item_outlets AS (
SELECT
purchase_order_id,
MIN(outlet_id) AS outlet_id
FROM candidate_item_outlets
GROUP BY purchase_order_id
HAVING COUNT(DISTINCT outlet_id) = 1
)
UPDATE purchase_orders po
SET outlet_id = item_outlets.outlet_id
FROM item_outlets
WHERE po.id = item_outlets.purchase_order_id
AND po.outlet_id IS NULL;
WITH single_outlet_organizations AS (
SELECT
organization_id,
MIN(id) AS outlet_id
FROM outlets
GROUP BY organization_id
HAVING COUNT(*) = 1
)
UPDATE purchase_orders po
SET outlet_id = single_outlet_organizations.outlet_id
FROM single_outlet_organizations
WHERE po.organization_id = single_outlet_organizations.organization_id
AND po.outlet_id IS NULL;