apskel-pos-backend/internal/processor/purchase_order_processor.go

509 lines
18 KiB
Go

package processor
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"context"
"fmt"
"github.com/google/uuid"
)
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)
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)
GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error)
GetOverduePurchaseOrders(ctx context.Context, organizationID uuid.UUID) ([]*models.PurchaseOrderResponse, error)
UpdatePurchaseOrderStatus(ctx context.Context, id, organizationID, userID, outletID uuid.UUID, status string) (*models.PurchaseOrderResponse, error)
}
type PurchaseOrderProcessorImpl struct {
purchaseOrderRepo PurchaseOrderRepository
vendorRepo VendorRepository
ingredientRepo IngredientRepository
purchaseCategoryRepo PurchaseCategoryRepository
unitRepo UnitRepository
fileRepo FileRepository
inventoryMovementService InventoryMovementService
unitConverterRepo IngredientUnitConverterRepository
}
func NewPurchaseOrderProcessorImpl(
purchaseOrderRepo PurchaseOrderRepository,
vendorRepo VendorRepository,
ingredientRepo IngredientRepository,
purchaseCategoryRepo PurchaseCategoryRepository,
unitRepo UnitRepository,
fileRepo FileRepository,
inventoryMovementService InventoryMovementService,
unitConverterRepo IngredientUnitConverterRepository,
) *PurchaseOrderProcessorImpl {
return &PurchaseOrderProcessorImpl{
purchaseOrderRepo: purchaseOrderRepo,
vendorRepo: vendorRepo,
ingredientRepo: ingredientRepo,
purchaseCategoryRepo: purchaseCategoryRepo,
unitRepo: unitRepo,
fileRepo: fileRepo,
inventoryMovementService: inventoryMovementService,
unitConverterRepo: unitConverterRepo,
}
}
func (p *PurchaseOrderProcessorImpl) CreatePurchaseOrder(ctx context.Context, organizationID uuid.UUID, req *models.CreatePurchaseOrderRequest) (*models.PurchaseOrderResponse, error) {
// Check if vendor exists and belongs to organization
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, req.VendorID, organizationID)
if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err)
}
// Check if PO number already exists in organization
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, req.PONumber, organizationID)
if err == nil && existingPO != nil {
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", req.PONumber)
}
// Validate categories and inventory fields per item type.
for i, item := range req.Items {
category, err := p.validatePurchaseCategory(ctx, item.PurchaseCategoryID, organizationID, i)
if err != nil {
return nil, err
}
switch category.Type {
case entities.PurchaseCategoryTypeRawMaterial:
if item.IngredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if item.Quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if item.UnitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found for item %d: %w", i, err)
}
_, err = p.unitRepo.GetByID(ctx, *item.UnitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found for item %d: %w", i, err)
}
case entities.PurchaseCategoryTypeExpense:
if item.IngredientID != nil || item.Quantity != nil || item.UnitID != nil {
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
}
default:
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
}
}
// Calculate total amount
totalAmount := 0.0
for _, item := range req.Items {
totalAmount += item.Amount
}
// Create purchase order entity
poEntity := &entities.PurchaseOrder{
OrganizationID: organizationID,
VendorID: req.VendorID,
PONumber: req.PONumber,
TransactionDate: req.TransactionDate,
DueDate: req.DueDate,
Reference: req.Reference,
Status: "draft", // Default status
Message: req.Message,
TotalAmount: totalAmount,
}
if req.Status != nil {
poEntity.Status = *req.Status
}
// Create purchase order
err = p.purchaseOrderRepo.Create(ctx, poEntity)
if err != nil {
return nil, fmt.Errorf("failed to create purchase order: %w", err)
}
// Create purchase order items
for _, itemReq := range req.Items {
itemEntity := &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
IngredientID: itemReq.IngredientID,
PurchaseCategoryID: itemReq.PurchaseCategoryID,
Description: itemReq.Description,
Quantity: itemReq.Quantity,
UnitID: itemReq.UnitID,
Amount: itemReq.Amount,
}
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
if err != nil {
return nil, fmt.Errorf("failed to create purchase order item: %w", err)
}
}
// Create attachments if provided
for _, fileID := range req.AttachmentFileIDs {
attachmentEntity := &entities.PurchaseOrderAttachment{
PurchaseOrderID: poEntity.ID,
FileID: fileID,
}
err = p.purchaseOrderRepo.CreateAttachment(ctx, attachmentEntity)
if err != nil {
return nil, fmt.Errorf("failed to create purchase order attachment: %w", err)
}
}
// Get the created purchase order with all relations
createdPO, err := p.purchaseOrderRepo.GetByID(ctx, poEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to get created purchase order: %w", err)
}
return mappers.PurchaseOrderEntityToResponse(createdPO), nil
}
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrder(ctx context.Context, id, organizationID 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)
}
// Check if vendor exists and belongs to organization (if vendor is being updated)
if req.VendorID != nil {
_, err := p.vendorRepo.GetByIDAndOrganizationID(ctx, *req.VendorID, organizationID)
if err != nil {
return nil, fmt.Errorf("vendor not found: %w", err)
}
poEntity.VendorID = *req.VendorID
}
// Check if PO number already exists (if PO number is being updated)
if req.PONumber != nil && *req.PONumber != poEntity.PONumber {
existingPO, err := p.purchaseOrderRepo.GetByPONumber(ctx, *req.PONumber, organizationID)
if err == nil && existingPO != nil {
return nil, fmt.Errorf("purchase order with PO number %s already exists in this organization", *req.PONumber)
}
poEntity.PONumber = *req.PONumber
}
// Update other fields
if req.TransactionDate != nil {
poEntity.TransactionDate = *req.TransactionDate
}
if req.DueDate != nil {
poEntity.DueDate = req.DueDate
}
if req.Reference != nil {
poEntity.Reference = req.Reference
}
if req.Status != nil {
poEntity.Status = *req.Status
}
if req.Message != nil {
poEntity.Message = req.Message
}
// Update items if provided
if req.Items != nil {
totalAmount := 0.0
items := make([]*entities.PurchaseOrderItem, len(req.Items))
for i, itemReq := range req.Items {
if itemReq.PurchaseCategoryID == nil {
return nil, fmt.Errorf("purchase_category_id is required for item %d", i)
}
ingredientID := itemReq.IngredientID
purchaseCategoryID := *itemReq.PurchaseCategoryID
unitID := itemReq.UnitID
quantity := itemReq.Quantity
amount := 0.0
if itemReq.Amount != nil {
amount = *itemReq.Amount
}
description := itemReq.Description
category, err := p.validatePurchaseCategory(ctx, purchaseCategoryID, organizationID, i)
if err != nil {
return nil, err
}
switch category.Type {
case entities.PurchaseCategoryTypeRawMaterial:
if ingredientID == nil {
return nil, fmt.Errorf("ingredient_id is required for raw_material item %d", i)
}
if quantity == nil {
return nil, fmt.Errorf("quantity is required for raw_material item %d", i)
}
if unitID == nil {
return nil, fmt.Errorf("unit_id is required for raw_material item %d", i)
}
_, err := p.ingredientRepo.GetByID(ctx, *ingredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("ingredient not found: %w", err)
}
_, err = p.unitRepo.GetByID(ctx, *unitID, organizationID)
if err != nil {
return nil, fmt.Errorf("unit not found: %w", err)
}
case entities.PurchaseCategoryTypeExpense:
if ingredientID != nil || quantity != nil || unitID != nil {
return nil, fmt.Errorf("ingredient_id, quantity, and unit_id must be empty for expense item %d", i)
}
default:
return nil, fmt.Errorf("purchase category for item %d has unsupported type %s", i, category.Type)
}
items[i] = &entities.PurchaseOrderItem{
PurchaseOrderID: poEntity.ID,
IngredientID: ingredientID,
PurchaseCategoryID: purchaseCategoryID,
Description: description,
Quantity: quantity,
UnitID: unitID,
Amount: amount,
}
totalAmount += amount
}
// Delete and recreate only after all replacement items are valid.
err = p.purchaseOrderRepo.DeleteItemsByPurchaseOrderID(ctx, poEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to delete existing items: %w", err)
}
for _, itemEntity := range items {
err = p.purchaseOrderRepo.CreateItem(ctx, itemEntity)
if err != nil {
return nil, fmt.Errorf("failed to create purchase order item: %w", err)
}
}
poEntity.TotalAmount = totalAmount
}
// Update attachments if provided
if req.AttachmentFileIDs != nil {
// Delete existing attachments
err = p.purchaseOrderRepo.DeleteAttachmentsByPurchaseOrderID(ctx, poEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to delete existing attachments: %w", err)
}
// Create new attachments
for _, fileID := range req.AttachmentFileIDs {
attachmentEntity := &entities.PurchaseOrderAttachment{
PurchaseOrderID: poEntity.ID,
FileID: fileID,
}
err = p.purchaseOrderRepo.CreateAttachment(ctx, attachmentEntity)
if err != nil {
return nil, fmt.Errorf("failed to create purchase order attachment: %w", err)
}
}
}
// Update purchase order
err = p.purchaseOrderRepo.Update(ctx, poEntity)
if err != nil {
return nil, fmt.Errorf("failed to update purchase order: %w", err)
}
// Get the updated purchase order with all relations
updatedPO, err := p.purchaseOrderRepo.GetByID(ctx, poEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to get updated purchase order: %w", err)
}
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
}
func (p *PurchaseOrderProcessorImpl) DeletePurchaseOrder(ctx context.Context, id, organizationID uuid.UUID) error {
_, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return fmt.Errorf("purchase order not found: %w", err)
}
err = p.purchaseOrderRepo.Delete(ctx, id)
if err != nil {
return fmt.Errorf("failed to delete purchase order: %w", err)
}
return nil
}
func (p *PurchaseOrderProcessorImpl) GetPurchaseOrderByID(ctx context.Context, id, organizationID uuid.UUID) (*models.PurchaseOrderResponse, error) {
poEntity, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase order not found: %w", err)
}
return mappers.PurchaseOrderEntityToResponse(poEntity), nil
}
func (p *PurchaseOrderProcessorImpl) ListPurchaseOrders(ctx context.Context, organizationID uuid.UUID, filters map[string]interface{}, page, limit int) ([]*models.PurchaseOrderResponse, int, error) {
offset := (page - 1) * limit
poEntities, total, err := p.purchaseOrderRepo.List(ctx, organizationID, filters, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to list purchase orders: %w", err)
}
poResponses := make([]*models.PurchaseOrderResponse, len(poEntities))
for i, poEntity := range poEntities {
poResponses[i] = mappers.PurchaseOrderEntityToResponse(poEntity)
}
totalPages := int((total + int64(limit) - 1) / int64(limit))
return poResponses, totalPages, nil
}
func (p *PurchaseOrderProcessorImpl) GetPurchaseOrdersByStatus(ctx context.Context, organizationID uuid.UUID, status string) ([]*models.PurchaseOrderResponse, error) {
poEntities, err := p.purchaseOrderRepo.GetByStatus(ctx, organizationID, status)
if err != nil {
return nil, fmt.Errorf("failed to get purchase orders by status: %w", err)
}
poResponses := make([]*models.PurchaseOrderResponse, len(poEntities))
for i, poEntity := range poEntities {
poResponses[i] = mappers.PurchaseOrderEntityToResponse(poEntity)
}
return poResponses, nil
}
func (p *PurchaseOrderProcessorImpl) GetOverduePurchaseOrders(ctx context.Context, organizationID uuid.UUID) ([]*models.PurchaseOrderResponse, error) {
poEntities, err := p.purchaseOrderRepo.GetOverdue(ctx, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to get overdue purchase orders: %w", err)
}
poResponses := make([]*models.PurchaseOrderResponse, len(poEntities))
for i, poEntity := range poEntities {
poResponses[i] = mappers.PurchaseOrderEntityToResponse(poEntity)
}
return poResponses, nil
}
func (p *PurchaseOrderProcessorImpl) UpdatePurchaseOrderStatus(ctx context.Context, id, organizationID, userID, outletID uuid.UUID, status string) (*models.PurchaseOrderResponse, error) {
// Get the purchase order with items to check current status
po, err := p.purchaseOrderRepo.GetByIDAndOrganizationID(ctx, id, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase order not found: %w", err)
}
fmt.Println("status:", po.Status)
// Check if status is changing to "received" and current status is not "received"
if status == "received" && po.Status != "received" {
// Get purchase order with items for inventory update
poWithItems, err := p.purchaseOrderRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get purchase order with items: %w", err)
}
// Update inventory for each item
for _, item := range poWithItems.Items {
if item.PurchaseCategory != nil && item.PurchaseCategory.Type == entities.PurchaseCategoryTypeExpense {
continue
}
if item.IngredientID == nil || item.UnitID == nil || item.Quantity == nil {
return nil, fmt.Errorf("purchase order item %s is missing raw material inventory fields", item.ID)
}
// Get ingredient to find its base unit
ingredient, err := p.ingredientRepo.GetByID(ctx, *item.IngredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to get ingredient %s: %w", *item.IngredientID, err)
}
// Convert quantity to ingredient's base unit if needed
quantityToAdd := *item.Quantity
if *item.UnitID != ingredient.UnitID {
// Convert from purchase unit to ingredient's base unit
convertedQuantity, err := p.unitConverterRepo.ConvertQuantity(ctx, *item.IngredientID, *item.UnitID, ingredient.UnitID, organizationID, *item.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to convert quantity for ingredient %s from unit %s to %s: %w", *item.IngredientID, *item.UnitID, ingredient.UnitID, err)
}
quantityToAdd = convertedQuantity
}
// Calculate unit cost in ingredient's base unit
unitCost := 0.0
if quantityToAdd > 0 {
unitCost = 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
err = p.purchaseOrderRepo.UpdateStatus(ctx, id, status)
if err != nil {
return nil, fmt.Errorf("failed to update purchase order status: %w", err)
}
// Get the updated purchase order
updatedPO, err := p.purchaseOrderRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get updated purchase order: %w", err)
}
return mappers.PurchaseOrderEntityToResponse(updatedPO), nil
}
func (p *PurchaseOrderProcessorImpl) validatePurchaseCategory(ctx context.Context, categoryID, organizationID uuid.UUID, itemIndex int) (*entities.PurchaseCategory, error) {
category, err := p.purchaseCategoryRepo.GetByIDAndOrganizationID(ctx, categoryID, organizationID)
if err != nil {
return nil, fmt.Errorf("purchase category not found for item %d: %w", itemIndex, err)
}
if !category.IsActive {
return nil, fmt.Errorf("purchase category for item %d is inactive", itemIndex)
}
if category.Type != entities.PurchaseCategoryTypeRawMaterial && category.Type != entities.PurchaseCategoryTypeExpense {
return nil, fmt.Errorf("purchase category for item %d must be raw_material or expense", itemIndex)
}
return category, nil
}