Files
lti-api/internal/modules/purchases/services/purchase.service.go
T

1993 lines
61 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"math"
"mime/multipart"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type PurchaseService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.Purchase, error)
CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error)
ApproveStaffPurchase(ctx *fiber.Ctx, id uint, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error)
ApproveManagerPurchase(ctx *fiber.Ctx, id uint, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error)
ReceiveProducts(ctx *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error)
DeleteItems(ctx *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error)
DeletePurchase(ctx *fiber.Ctx, id uint) error
}
const (
priceTolerance = 0.0001
)
type purchaseService struct {
Log *logrus.Logger
Validate *validator.Validate
PurchaseRepo rPurchase.PurchaseRepository
ProductRepo rProduct.ProductRepository
WarehouseRepo rWarehouse.WarehouseRepository
SupplierRepo rSupplier.SupplierRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
ApprovalSvc commonSvc.ApprovalService
ExpenseBridge PurchaseExpenseBridge
FifoStockV2Svc commonSvc.FifoStockV2Service
DocumentSvc commonSvc.DocumentService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
type staffAdjustmentPayload struct {
PricingUpdates []rPurchase.PurchasePricingUpdate
NewItems []*entity.PurchaseItem
}
func NewPurchaseService(
validate *validator.Validate,
purchaseRepo rPurchase.PurchaseRepository,
productRepo rProduct.ProductRepository,
warehouseRepo rWarehouse.WarehouseRepository,
supplierRepo rSupplier.SupplierRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
approvalSvc commonSvc.ApprovalService,
expenseBridge PurchaseExpenseBridge,
fifoStockV2Svc commonSvc.FifoStockV2Service,
documentSvc commonSvc.DocumentService,
) PurchaseService {
return &purchaseService{
Log: utils.Log,
Validate: validate,
PurchaseRepo: purchaseRepo,
ProductRepo: productRepo,
WarehouseRepo: warehouseRepo,
SupplierRepo: supplierRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
ApprovalSvc: approvalSvc,
ExpenseBridge: expenseBridge,
FifoStockV2Svc: fifoStockV2Svc,
DocumentSvc: documentSvc,
approvalWorkflow: utils.ApprovalWorkflowPurchase,
}
}
func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
return db.
Preload("Supplier").
Preload("CreatedUser").
Preload("Items", func(db *gorm.DB) *gorm.DB {
return db.Order("id ASC")
}).
Preload("Items.Product").
Preload("Items.Product.Uom").
Preload("Items.Product.ProductCategory").
Preload("Items.Warehouse").
Preload("Items.Product.Flags").
Preload("Items.Warehouse.Area").
Preload("Items.Warehouse.Location").
Preload("Items.ProductWarehouse").
Preload("Items.ExpenseNonstock").
Preload("Items.ExpenseNonstock.Expense").
Preload("Items.ExpenseNonstock.Expense.Supplier")
}
func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo)
if err != nil {
return nil, 0, utils.BadRequest(err.Error())
}
purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db = db.Where("purchases.deleted_at IS NULL")
if params.SupplierID > 0 {
db = db.Where("supplier_id = ?", params.SupplierID)
}
if createdFrom != nil {
db = db.Where("created_at >= ?", *createdFrom)
}
if createdTo != nil {
db = db.Where("created_at < ?", *createdTo)
}
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
WHERE pi.purchase_id = purchases.id AND w.location_id IN ?
)`,
scope.IDs,
)
}
if params.AreaID > 0 {
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
WHERE pi.purchase_id = purchases.id AND w.area_id = ?
)`,
params.AreaID,
)
}
if params.LocationID > 0 {
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
WHERE pi.purchase_id = purchases.id AND w.location_id = ?
)`,
params.LocationID,
)
}
if params.ProductCategoryID > 0 {
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
JOIN products p ON p.id = pi.product_id
WHERE pi.purchase_id = purchases.id AND p.product_category_id = ?
)`,
params.ProductCategoryID,
)
}
return db.Order("created_at DESC").Order("purchases.id DESC")
})
if err != nil {
s.Log.Errorf("Failed to get purchases: %+v", err)
return nil, 0, utils.Internal("Failed to get purchases")
}
for i := range purchases {
if err := s.attachLatestApproval(c.Context(), &purchases[i]); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", purchases[i].Id, err)
}
}
return purchases, total, nil
}
func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) {
scope, err := m.ResolveLocationScope(c, s.PurchaseRepo.DB())
if err != nil {
return nil, err
}
purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where(
`EXISTS (
SELECT 1
FROM purchase_items pi
JOIN warehouses w ON w.id = pi.warehouse_id
WHERE pi.purchase_id = purchases.id AND w.location_id IN ?
)`,
scope.IDs,
)
}
return db
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, utils.NotFound("Purchase not found")
}
s.Log.Errorf("Failed to get purchase %d: %+v", id, err)
return nil, utils.Internal("Failed to get purchase")
}
if err := s.attachLatestApproval(c.Context(), purchase); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err)
}
if len(purchase.Items) > 0 {
itemIDs := make([]uint, 0, len(purchase.Items))
for i := range purchase.Items {
if purchase.Items[i].Id == 0 {
continue
}
itemIDs = append(itemIDs, purchase.Items[i].Id)
}
if len(itemIDs) > 0 {
var usedIDs []uint
if err := s.PurchaseRepo.DB().WithContext(c.Context()).
Model(&entity.StockAllocation{}).
Distinct("stockable_id").
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Pluck("stockable_id", &usedIDs).Error; err != nil {
return nil, err
}
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
}
for i := range purchase.Items {
if _, ok := usedSet[purchase.Items[i].Id]; ok {
purchase.Items[i].HasChickin = true
}
}
}
}
s.applyTravelDocumentURLs(c.Context(), purchase)
return purchase, nil
}
func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
if _, err := s.SupplierRepo.GetByID(c.Context(), req.SupplierID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, utils.NotFound("Supplier not found")
}
s.Log.Errorf("Failed to get supplier: %+v", err)
return nil, utils.Internal("Failed to get supplier")
}
type aggregatedItem struct {
productId uint
warehouseId uint
subQty float64
pfkID *uint
}
if len(req.Items) == 0 {
return nil, utils.BadRequest("Items must not be empty")
}
warehouseCache := make(map[uint]*entity.Warehouse)
productSupplierCache := make(map[uint]bool)
getWarehouse := func(id uint) (*entity.Warehouse, *uint, error) {
if warehouse, ok := warehouseCache[id]; ok {
return warehouse, nil, nil
}
warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Preload("Area").Preload("Location")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, utils.NotFound(fmt.Sprintf("Warehouse %d not found", id))
}
s.Log.Errorf("Failed to get warehouse %d: %+v", id, err)
return nil, nil, utils.Internal("Failed to get warehouse")
}
var pfkID *uint
isKandang := strings.EqualFold(strings.TrimSpace(warehouse.Type), "KANDANG")
if isKandang {
if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
return nil, nil, utils.BadRequest(fmt.Sprintf("%s is not linked to a kandang", warehouse.Name))
}
if s.ProjectFlockKandangRepo != nil {
if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil {
if pfk.ClosedAt != nil {
return nil, nil, utils.BadRequest("Project sudah closing")
}
idCopy := uint(pfk.Id)
pfkID = &idCopy
} else if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, utils.BadRequest(fmt.Sprintf("%s has no active project flock", warehouse.Name))
} else if err != nil {
s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err)
return nil, nil, utils.Internal("Failed to validate project flock")
}
}
}
warehouseCache[id] = warehouse
return warehouse, pfkID, nil
}
aggregated := make([]*aggregatedItem, 0, len(req.Items))
indexMap := make(map[string]int)
for _, item := range req.Items {
_, pfkID, err := getWarehouse(item.WarehouseID)
if err != nil {
return nil, err
}
if _, checked := productSupplierCache[item.ProductID]; !checked {
linked, err := s.ProductRepo.IsLinkedToSupplier(c.Context(), item.ProductID, req.SupplierID)
if err != nil {
s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", item.ProductID, req.SupplierID, err)
return nil, utils.Internal("Failed to validate product for supplier")
}
if !linked {
return nil, utils.BadRequest(fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID))
}
productSupplierCache[item.ProductID] = true
}
productId := uint(item.ProductID)
warehouseId := uint(item.WarehouseID)
key := fmt.Sprintf("%d:%d", productId, warehouseId)
if idx, ok := indexMap[key]; ok {
aggregated[idx].subQty += item.Quantity
continue
}
entry := &aggregatedItem{
productId: productId,
warehouseId: warehouseId,
subQty: item.Quantity,
pfkID: pfkID,
}
aggregated = append(aggregated, entry)
indexMap[key] = len(aggregated) - 1
}
var dueDate *time.Time
now := time.Now().UTC()
d := now.AddDate(0, 0, req.CreditTerm)
dueDate = &d
purchase := &entity.Purchase{
SupplierId: uint(req.SupplierID),
CreditTerm: req.CreditTerm,
DueDate: dueDate,
Notes: req.Notes,
CreatedBy: uint(actorID),
}
items := make([]*entity.PurchaseItem, 0, len(aggregated))
emptyVehicle := ""
for _, item := range aggregated {
items = append(items, &entity.PurchaseItem{
ProductId: item.productId,
WarehouseId: item.warehouseId,
ProjectFlockKandangId: item.pfkID,
SubQty: item.subQty,
TotalQty: 0,
TotalUsed: 0,
Price: 0,
TotalPrice: 0,
VehicleNumber: &emptyVehicle,
})
}
transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
code, err := purchaseRepoTx.NextPrNumber(c.Context(), tx)
if err != nil {
return err
}
purchase.PrNumber = code
if err := purchaseRepoTx.CreateWithItems(c.Context(), purchase, items); err != nil {
return err
}
if err := purchaseRepoTx.BackfillProjectFlockKandang(c.Context(), purchase.Id); err != nil {
return err
}
actorID := uint(purchase.CreatedBy)
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, actorID, nil, false); err != nil {
return err
}
return nil
})
if transactionErr != nil {
s.Log.Errorf("Failed to create purchase: %+v", transactionErr)
return nil, utils.Internal("Failed to create purchase")
}
created, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations)
if err != nil {
s.Log.Errorf("Failed to load created purchase: %+v", err)
return nil, utils.Internal("Failed to load purchase")
}
if err := s.attachLatestApproval(c.Context(), created); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err)
}
return created, nil
}
func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
ctx := c.Context()
action, err := parseApprovalActionInput(req.Action)
if err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
purchase, err := s.loadPurchase(ctx, id)
if err != nil {
return nil, err
}
if action == entity.ApprovalActionApproved {
if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil {
return nil, err
}
}
var latestStep uint16
if purchase.LatestApproval != nil {
latestStep = purchase.LatestApproval.StepNumber
}
if action == entity.ApprovalActionRejected {
return s.rejectAndReload(c, utils.PurchaseStepStaffPurchase, purchase.Id, actorID, req.Notes)
}
isInitialApproval := latestStep < uint16(utils.PurchaseStepStaffPurchase)
if isInitialApproval && latestStep != uint16(utils.PurchaseStepPengajuan) {
return nil, utils.BadRequest("Purchase is not ready for staff approval")
}
hasReceivingData := false
for _, item := range purchase.Items {
if item.TotalQty > 0 || item.TotalUsed > 0 {
hasReceivingData = true
break
}
}
syncReceiving := !isInitialApproval && hasReceivingData
if action == entity.ApprovalActionApproved && len(req.Items) == 0 {
return nil, utils.BadRequest("Items must not be empty for staff approval")
}
if action == entity.ApprovalActionApproved {
itemIDs := make([]uint, 0, len(purchase.Items))
itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
if purchase.Items[i].Id == 0 {
continue
}
itemIDs = append(itemIDs, purchase.Items[i].Id)
itemByID[purchase.Items[i].Id] = purchase.Items[i]
}
if len(itemIDs) > 0 {
var usedIDs []uint
if err := s.PurchaseRepo.DB().WithContext(ctx).
Model(&entity.StockAllocation{}).
Distinct("stockable_id").
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Pluck("stockable_id", &usedIDs).Error; err != nil {
return nil, err
}
if len(usedIDs) > 0 {
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
}
for _, payload := range req.Items {
if payload.PurchaseItemID == 0 || payload.Qty == nil {
continue
}
if _, used := usedSet[payload.PurchaseItemID]; !used {
continue
}
item, ok := itemByID[payload.PurchaseItemID]
if !ok {
continue
}
if *payload.Qty != item.SubQty {
return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah")
}
}
}
}
}
payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving)
if err != nil {
return nil, err
}
transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
if len(payload.PricingUpdates) > 0 {
if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates); err != nil {
return err
}
}
if len(payload.NewItems) > 0 {
if err := purchaseRepoTx.CreateItems(c.Context(), purchase.Id, payload.NewItems); err != nil {
return err
}
}
if isInitialApproval {
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil {
return err
}
return nil
}
if len(payload.PricingUpdates) > 0 || len(payload.NewItems) > 0 {
if err := s.createPurchaseApproval(
c.Context(),
tx,
purchase.Id,
utils.PurchaseStepStaffPurchase,
entity.ApprovalActionUpdated,
actorID,
req.Notes,
true, // allowDuplicate = true supaya boleh UPDATED berkali2
); err != nil {
return err
}
}
return nil
})
if transactionErr != nil {
if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
return nil, utils.NotFound("Purchase item not found")
}
if isInitialApproval {
s.Log.Errorf("Failed to approve purchase %d: %+v", purchase.Id, transactionErr)
return nil, utils.Internal("Failed to approve purchase")
}
return nil, utils.Internal("Failed to update purchase pricing")
}
updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations)
if err != nil {
return nil, utils.Internal("Failed to load purchase")
}
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
}
return updated, nil
}
func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
action, err := parseApprovalActionInput(req.Action)
if err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
purchase, err := s.loadPurchase(c.Context(), id)
if err != nil {
return nil, err
}
if action == entity.ApprovalActionApproved {
if err := s.ensureProjectFlockNotClosedForPurchase(c.Context(), purchase); err != nil {
return nil, err
}
}
if purchase.LatestApproval == nil ||
purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepStaffPurchase) {
return nil, utils.BadRequest("Purchase must reach staff purchase step before manager approval")
}
if action == entity.ApprovalActionRejected {
return s.rejectAndReload(c, utils.PurchaseStepManager, purchase.Id, actorID, req.Notes)
}
now := time.Now().UTC()
hasExistingPO := purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != ""
var generatedNumber string
transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
updateData := map[string]any{}
if !hasExistingPO {
repoTx := rPurchase.NewPurchaseRepository(tx)
code, err := repoTx.NextPoNumber(c.Context(), tx)
if err != nil {
return err
}
updateData["po_number"] = code
updateData["po_date"] = now
generatedNumber = code
}
if len(updateData) > 0 {
repoTx := rPurchase.NewPurchaseRepository(tx)
if err := repoTx.PatchOne(c.Context(), uint(purchase.Id), updateData, nil); err != nil {
return err
}
}
forceManagerApproval := false
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
if approvalSvcTx != nil {
filterByStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("step_number = ?", uint16(step))
}
}
latestStaff, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase))
if err != nil {
return err
}
latestManager, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepManager))
if err != nil {
return err
}
if latestStaff != nil && latestManager != nil && latestStaff.ActionAt.After(latestManager.ActionAt) {
forceManagerApproval = true
}
}
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepManager, action, actorID, req.Notes, forceManagerApproval); err != nil {
return err
}
return nil
})
if transactionErr != nil {
s.Log.Errorf("Failed to approve manager purchase %d: %+v", purchase.Id, transactionErr)
return nil, utils.Internal("Failed to generate purchase order")
}
if generatedNumber != "" {
purchase.PoNumber = &generatedNumber
purchase.PoDate = &now
}
updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations)
if err != nil {
s.Log.Errorf("Failed to load purchase after manager approval: %+v", err)
return nil, utils.Internal("Failed to load purchase")
}
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
}
return updated, nil
}
func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
ctx := c.Context()
action, err := parseApprovalActionInput(req.Action)
if err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
purchase, err := s.loadPurchase(ctx, id)
if err != nil {
return nil, err
}
if purchase.PoNumber == nil || strings.TrimSpace(*purchase.PoNumber) == "" {
return nil, utils.BadRequest("Purchase order has not been generated")
}
if purchase.LatestApproval == nil ||
purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepManager) {
return nil, utils.BadRequest("Purchase must be approved by manager before receiving products")
}
if action == entity.ApprovalActionApproved {
if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil {
return nil, err
}
}
if action == entity.ApprovalActionApproved && len(req.Items) == 0 {
return nil, utils.BadRequest("Receiving data must not be empty")
}
if action == entity.ApprovalActionRejected {
if err := s.createPurchaseApproval(ctx, nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil {
return nil, err
}
updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations)
if err != nil {
return nil, utils.Internal("Failed to load purchase")
}
if err := s.attachLatestApproval(ctx, updated); err != nil {
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
}
return updated, nil
}
if action == entity.ApprovalActionApproved && len(req.TravelDocuments) > 0 {
if len(req.TravelDocuments) > len(req.Items) {
return nil, utils.BadRequest("Travel documents exceed total receiving items")
}
for idx, file := range req.TravelDocuments {
if file == nil {
continue
}
if idx >= len(req.Items) {
break
}
itemID := req.Items[idx].PurchaseItemID
if itemID == 0 {
return nil, utils.BadRequest("Purchase item id is required for travel document upload")
}
uploadedURL, err := s.uploadTravelDocument(ctx, actorID, itemID, file)
if err != nil {
s.Log.Errorf("Failed to upload travel document for item %d: %+v", itemID, err)
return nil, utils.Internal("Failed to upload travel document")
}
req.Items[idx].TravelDocumentPath = &uploadedURL
}
}
if action == entity.ApprovalActionApproved {
itemIDs := make([]uint, 0, len(purchase.Items))
itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
if purchase.Items[i].Id == 0 {
continue
}
itemIDs = append(itemIDs, purchase.Items[i].Id)
itemByID[purchase.Items[i].Id] = purchase.Items[i]
}
if len(itemIDs) > 0 {
var usedIDs []uint
if err := s.PurchaseRepo.DB().WithContext(ctx).
Model(&entity.StockAllocation{}).
Distinct("stockable_id").
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Pluck("stockable_id", &usedIDs).Error; err != nil {
return nil, err
}
if len(usedIDs) > 0 {
usedSet := make(map[uint]struct{}, len(usedIDs))
for _, id := range usedIDs {
usedSet[id] = struct{}{}
}
for _, payload := range req.Items {
if _, used := usedSet[payload.PurchaseItemID]; !used {
continue
}
item, ok := itemByID[payload.PurchaseItemID]
if !ok {
continue
}
receivedQty := item.SubQty
if payload.ReceivedQty != nil {
receivedQty = *payload.ReceivedQty
}
if receivedQty != item.TotalQty {
return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah")
}
}
}
}
}
itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
itemMap[purchase.Items[i].Id] = &purchase.Items[i]
}
type preparedReceiving struct {
item *entity.PurchaseItem
payload validation.ReceivePurchaseItemRequest
receivedDate time.Time
warehouseID uint
supplierID uint
transportPerItem *float64
vehicleNumber *string
overrideWarehouse bool
receivedQty float64
}
visitedItems := make(map[uint]struct{}, len(req.Items))
prepared := make([]preparedReceiving, 0, len(req.Items))
var earliestReceived *time.Time
for _, payload := range req.Items {
item, exists := itemMap[payload.PurchaseItemID]
if !exists {
return nil, utils.BadRequest(fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID))
}
receivedDate, err := utils.ParseDateString(payload.ReceivedDate)
if err != nil {
return nil, utils.BadRequest(fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID))
}
receivedDate = receivedDate.UTC()
if earliestReceived == nil || receivedDate.Before(*earliestReceived) {
copy := receivedDate
earliestReceived = &copy
}
warehouseID := uint(item.WarehouseId)
overrideWarehouse := false
if payload.WarehouseID != nil && *payload.WarehouseID != 0 {
warehouseID = *payload.WarehouseID
overrideWarehouse = true
}
if warehouseID == 0 {
return nil, utils.BadRequest(fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID))
}
if payload.WarehouseID != nil && uint(item.WarehouseId) != warehouseID {
return nil, utils.BadRequest("Receiving does not allow changing warehouse")
}
var receivedQty float64
if payload.ReceivedQty != nil {
receivedQty = *payload.ReceivedQty
} else {
receivedQty = item.SubQty
}
if receivedQty < 0 {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID))
}
if receivedQty > item.SubQty {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty))
}
if receivedQty < item.TotalUsed {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed))
}
if _, dup := visitedItems[payload.PurchaseItemID]; dup {
return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID))
}
visitedItems[payload.PurchaseItemID] = struct{}{}
var supplierID uint
if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 {
supplierID = *payload.ExpeditionVendorID
}
var transportPerItem *float64
if payload.TransportPerItem != nil {
if *payload.TransportPerItem < 0 {
return nil, utils.BadRequest(fmt.Sprintf("transport_per_item for item %d cannot be negative", payload.PurchaseItemID))
}
val := *payload.TransportPerItem
transportPerItem = &val
}
var vehicleNumber *string
if payload.VehicleNumber != nil && strings.TrimSpace(*payload.VehicleNumber) != "" {
val := strings.TrimSpace(*payload.VehicleNumber)
vehicleNumber = &val
} else if item.VehicleNumber != nil && strings.TrimSpace(*item.VehicleNumber) != "" {
val := strings.TrimSpace(*item.VehicleNumber)
vehicleNumber = &val
}
prepared = append(prepared, preparedReceiving{
item: item,
payload: payload,
receivedDate: receivedDate,
warehouseID: warehouseID,
supplierID: supplierID,
transportPerItem: transportPerItem,
vehicleNumber: vehicleNumber,
overrideWarehouse: overrideWarehouse,
receivedQty: receivedQty,
})
}
// Require receiving payload to cover all purchase items so that
// receiving cannot be submitted partially item-by-item.
if len(visitedItems) != len(itemMap) {
return nil, utils.BadRequest("Receiving data must be provided for all purchase items")
}
receivingAction := action
completedAction := entity.ApprovalActionApproved
approvalSvc := commonSvc.NewApprovalService(
commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()),
)
if approvalSvc != nil {
filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("step_number = ?", uint16(step))
}
}
latestReceiving, err := approvalSvc.LatestByTarget(
c.Context(),
utils.ApprovalWorkflowPurchase,
uint(purchase.Id),
filterStep(utils.PurchaseStepReceiving),
)
if err != nil {
s.Log.Errorf("Failed to inspect receiving approval for purchase %d: %+v", purchase.Id, err)
return nil, utils.Internal("Failed to record purchase receiving")
}
if latestReceiving != nil {
receivingAction = entity.ApprovalActionUpdated
}
}
noteSuffix := "receive"
if receivingAction == entity.ApprovalActionUpdated {
noteSuffix = "edit-receive"
}
receiveNote := fmt.Sprintf("%s#%s", strings.TrimSpace(*purchase.PoNumber), noteSuffix)
transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
repoTx := rPurchase.NewPurchaseRepository(tx)
pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx)
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
affected := make(map[uint]struct{})
updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared))
priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared))
totalQtyDeltas := make(map[uint]float64)
reflowAsOfByPW := make(map[uint]time.Time)
logEntries := make([]struct {
itemID uint
pwID uint
delta float64
}, 0, len(prepared))
for _, prep := range prepared {
item := prep.item
var newPWID *uint
// Always ensure PW after receiving so linkage stays stable.
pwID, err := pwRepoTx.EnsureProductWarehouse(
c.Context(),
uint(item.ProductId),
prep.warehouseID,
item.ProjectFlockKandangId,
purchase.CreatedBy,
)
if err != nil {
return err
}
// Safety: ensure the PW we got matches the purchase item product.
if pwDetail, err := pwRepoTx.GetDetailByID(c.Context(), pwID); err != nil {
return err
} else if pwDetail.ProductId != uint(item.ProductId) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Product warehouse %d belongs to product %d, not purchase item product %d", pwID, pwDetail.ProductId, item.ProductId),
)
}
newPWID = &pwID
deltaQty := prep.receivedQty - item.TotalQty
if newPWID != nil && deltaQty != 0 {
logEntries = append(logEntries, struct {
itemID uint
pwID uint
delta float64
}{itemID: item.Id, pwID: *newPWID, delta: deltaQty})
}
if newPWID != nil {
assignEarliestAsOf(reflowAsOfByPW, *newPWID, prep.receivedDate.UTC())
}
if deltaQty != 0 {
totalQtyDeltas[item.Id] += deltaQty
}
if deltaQty < 0 && newPWID != nil {
affected[*newPWID] = struct{}{}
}
dateCopy := prep.receivedDate
qtyCopy := prep.receivedQty
update := rPurchase.PurchaseReceivingUpdate{
ItemID: item.Id,
ReceivedDate: &dateCopy,
TravelNumber: prep.payload.TravelNumber,
TravelDocumentPath: prep.payload.TravelDocumentPath,
VehicleNumber: prep.payload.VehicleNumber,
ReceivedQty: &qtyCopy,
ProductWarehouseID: newPWID,
ClearProductWarehouse: false,
}
if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID {
warehouseCopy := prep.warehouseID
update.WarehouseID = &warehouseCopy
}
updates = append(updates, update)
if prep.receivedQty >= 0 {
priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{
ItemID: item.Id,
Price: item.Price,
TotalPrice: item.Price * prep.receivedQty,
})
}
}
if err := repoTx.UpdateReceivingDetails(c.Context(), purchase.Id, updates); err != nil {
return err
}
if len(priceUpdates) > 0 {
if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil {
return err
}
}
if len(totalQtyDeltas) > 0 {
for itemID, delta := range totalQtyDeltas {
if delta == 0 {
continue
}
if err := tx.Model(&entity.PurchaseItem{}).
Where("purchase_id = ? AND id = ?", purchase.Id, itemID).
Update("total_qty", gorm.Expr("COALESCE(total_qty,0) + ?", delta)).Error; err != nil {
return err
}
}
}
// Update due_date based on earliest received date when receiving approved.
if earliestReceived != nil {
due := earliestReceived.AddDate(0, 0, purchase.CreditTerm)
if err := tx.Model(&entity.Purchase{}).
Where("id = ?", purchase.Id).
Update("due_date", due).Error; err != nil {
return err
}
}
if len(reflowAsOfByPW) > 0 {
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
for pwID, asOf := range reflowAsOfByPW {
asOfCopy := asOf
if err := reflowPurchaseScope(c.Context(), s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil {
return err
}
}
}
if len(logEntries) > 0 {
logs := make([]*entity.StockLog, 0, len(logEntries))
for _, entry := range logEntries {
if entry.pwID == 0 || entry.delta == 0 {
continue
}
log := &entity.StockLog{
ProductWarehouseId: entry.pwID,
CreatedBy: actorID,
LoggableType: string(utils.StockLogTypePurchase),
LoggableId: purchase.Id,
Notes: receiveNote,
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, entry.pwID, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
} else {
log.Stock = 0
}
if entry.delta > 0 {
log.Increase = entry.delta
log.Stock += log.Increase
} else {
log.Decrease = -entry.delta
log.Stock -= log.Decrease
}
logs = append(logs, log)
}
if len(logs) > 0 {
if err := stockLogRepoTx.CreateMany(c.Context(), logs, nil); err != nil {
return err
}
}
}
if len(affected) > 0 {
if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil {
return err
}
}
return nil
})
if transactionErr != nil {
if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
return nil, utils.NotFound("Purchase item not found for receiving")
}
s.Log.Errorf("Failed to save purchase receiving %d: %+v", purchase.Id, transactionErr)
return nil, utils.Internal("Failed to record purchase receiving")
}
updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations)
if err != nil {
return nil, utils.Internal("Failed to load purchase ")
}
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
}
receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared))
for _, prep := range prepared {
date := prep.receivedDate
payload := ExpenseReceivingPayload{
PurchaseItemID: prep.item.Id,
ProductID: prep.item.ProductId,
WarehouseID: uint(prep.warehouseID),
SupplierID: prep.supplierID,
TransportPerItem: prep.transportPerItem,
ReceivedQty: prep.receivedQty,
ReceivedDate: &date,
VehicleNumber: prep.vehicleNumber,
}
receivingPayloads = append(receivingPayloads, payload)
}
if err := s.notifyExpenseItemsReceived(c, purchase.Id, receivingPayloads); err != nil {
s.Log.Errorf("Failed to sync expense for purchase %d: %+v", purchase.Id, err)
if fe, ok := err.(*fiber.Error); ok {
return nil, fe
}
return nil, utils.Internal("Failed to sync expense")
}
// Create approvals only after expense sync succeeds
if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil {
return nil, err
}
if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil {
return nil, err
}
return updated, nil
}
func (s *purchaseService) uploadTravelDocument(
ctx context.Context,
actorID uint,
itemID uint,
file *multipart.FileHeader,
) (string, error) {
if file == nil {
return "", errors.New("travel document file is required")
}
if s.DocumentSvc == nil {
return "", errors.New("document service not available")
}
documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(itemID))
if err != nil {
return "", err
}
if len(documents) > 0 {
var ids []uint
for _, doc := range documents {
if doc.Type == string(utils.DocumentTypePurchaseTravel) {
ids = append(ids, doc.Id)
}
}
if err := s.DocumentSvc.DeleteDocuments(ctx, ids, true); err != nil {
return "", err
}
}
documentFiles := []commonSvc.DocumentFile{{
File: file,
Type: string(utils.DocumentTypePurchaseTravel),
}}
results, err := s.DocumentSvc.UploadDocuments(ctx, commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypePurchaseItem),
DocumentableID: uint64(itemID),
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
return "", err
}
if len(results) == 0 {
return "", errors.New("upload result is empty")
}
return results[0].Document.Path, nil
}
func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
ctx := c.Context()
purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, utils.NotFound("Purchase not found")
}
return nil, utils.Internal("Failed to get purchase")
}
if err := s.attachLatestApproval(ctx, purchase); err != nil {
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
}
if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil {
return nil, err
}
if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber == uint16(utils.PurchaseStepPengajuan) {
return nil, utils.BadRequest("Purchase cannot delete items before staff purchase approval")
}
if len(purchase.Items) == 0 {
return nil, utils.BadRequest("Purchase has no items to delete")
}
requested := make(map[uint]struct{}, len(req.ItemIDs))
for _, id := range req.ItemIDs {
requested[id] = struct{}{}
}
toDelete := make([]uint, 0, len(req.ItemIDs))
var remainingTotal float64
for _, item := range purchase.Items {
if _, ok := requested[item.Id]; ok {
toDelete = append(toDelete, item.Id)
} else {
remainingTotal += item.TotalPrice
}
}
if len(toDelete) == 0 {
return nil, utils.BadRequest("Requested items were not found in this purchase")
}
toDeleteSet := make(map[uint]struct{}, len(toDelete))
for _, id := range toDelete {
toDeleteSet[id] = struct{}{}
}
itemsToDelete := make([]entity.PurchaseItem, 0, len(toDelete))
for _, item := range purchase.Items {
if _, ok := toDeleteSet[item.Id]; ok {
itemsToDelete = append(itemsToDelete, item)
}
}
if len(purchase.Items)-len(toDelete) <= 0 {
return nil, utils.BadRequest("Purchase must keep at least one item")
}
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
repoTx := rPurchase.NewPurchaseRepository(tx)
if err := repoTx.DeleteItems(ctx, purchase.Id, toDelete); err != nil {
return err
}
return nil
})
if transactionErr != nil {
if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
return nil, utils.NotFound("Purchase item not found")
}
return nil, utils.Internal("Failed to delete purchase items")
}
if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil {
return nil, utils.Internal("Failed to delete purchase documents")
}
if len(itemsToDelete) > 0 {
if err := s.notifyExpenseItemsDeleted(ctx, purchase.Id, itemsToDelete); err != nil {
s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", purchase.Id, err)
if fe, ok := err.(*fiber.Error); ok {
return nil, fe
}
return nil, utils.Internal("Failed to sync expense")
}
}
updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations)
if err != nil {
return nil, utils.Internal("Failed to load purchase")
}
if err := s.attachLatestApproval(ctx, updated); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
}
return updated, nil
}
func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
if id == 0 {
return utils.BadRequest("Invalid purchase id")
}
ctx := c.Context()
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return err
}
purchase, err := s.loadPurchase(ctx, id)
if err != nil {
return err
}
if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil {
return err
}
itemsToDelete := make([]entity.PurchaseItem, len(purchase.Items))
for i, item := range purchase.Items {
itemsToDelete[i] = item
}
note := fmt.Sprintf("Purchase-Delete#%d", purchase.Id)
if purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" {
note = fmt.Sprintf("%s#delete", strings.TrimSpace(*purchase.PoNumber))
}
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
itemIDs := make([]uint, 0, len(itemsToDelete))
for _, item := range itemsToDelete {
if item.Id == 0 {
continue
}
itemIDs = append(itemIDs, item.Id)
}
if len(itemIDs) > 0 {
var count int64
if err := tx.Model(&entity.StockAllocation{}).
Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?",
fifo.StockableKeyPurchaseItems.String(),
itemIDs,
fifo.UsableKeyProjectChickin.String(),
[]string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending},
).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return utils.BadRequest("Purchase already chickin, failed to delete purchase")
}
}
if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil {
return err
}
approvalRepoTx := commonRepo.NewApprovalRepository(tx)
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowPurchase.String(), uint(id)); err != nil {
return err
}
purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
if err := purchaseRepoTx.DeleteOne(ctx, uint(id)); err != nil {
return err
}
return nil
})
if transactionErr != nil {
var fe *fiber.Error
if errors.As(transactionErr, &fe) {
return fe
}
if errors.Is(transactionErr, gorm.ErrRecordNotFound) {
return utils.NotFound("Purchase not found")
}
return utils.Internal("Failed to delete purchase")
}
if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil {
return utils.Internal("Failed to delete purchase documents")
}
if len(itemsToDelete) > 0 {
if err := s.notifyExpenseItemsDeleted(ctx, uint(id), itemsToDelete); err != nil {
s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", id, err)
if fe, ok := err.(*fiber.Error); ok {
return fe
}
return utils.Internal("Failed to sync expense")
}
}
return nil
}
func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB, items []entity.PurchaseItem, note string, actorID uint) error {
if len(items) == 0 {
return nil
}
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
affected := make(map[uint]struct{})
reflowAsOfByPW := make(map[uint]time.Time)
logEntries := make([]struct {
pwID uint
qty float64
}, 0, len(items))
for _, item := range items {
if item.ProductWarehouseId == nil || *item.ProductWarehouseId == 0 {
continue
}
if item.TotalQty == 0 {
continue
}
pwID := *item.ProductWarehouseId
qty := item.TotalQty
if err := tx.WithContext(ctx).
Model(&entity.PurchaseItem{}).
Where("id = ?", item.Id).
Update("total_qty", 0).Error; err != nil {
return err
}
affected[pwID] = struct{}{}
if item.ReceivedDate != nil {
assignEarliestAsOf(reflowAsOfByPW, pwID, item.ReceivedDate.UTC())
} else {
assignEarliestAsOf(reflowAsOfByPW, pwID, time.Now().UTC())
}
logEntries = append(logEntries, struct {
pwID uint
qty float64
}{pwID: pwID, qty: qty})
}
if len(reflowAsOfByPW) > 0 {
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
for pwID, asOf := range reflowAsOfByPW {
asOfCopy := asOf
if err := reflowPurchaseScope(ctx, s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil {
return err
}
}
}
if len(affected) > 0 {
if err := rProductWarehouse.NewProductWarehouseRepository(tx).CleanupEmpty(ctx, affected); err != nil {
return err
}
}
if strings.TrimSpace(note) != "" && actorID != 0 && len(logEntries) > 0 {
logs := make([]*entity.StockLog, 0, len(logEntries))
for _, entry := range logEntries {
if entry.pwID == 0 || entry.qty <= 0 {
continue
}
logs = append(logs, &entity.StockLog{
ProductWarehouseId: entry.pwID,
CreatedBy: actorID,
Decrease: entry.qty,
LoggableType: string(utils.StockLogTypePurchase),
LoggableId: items[0].PurchaseId,
Notes: note,
})
}
if len(logs) > 0 {
if err := stockLogRepoTx.CreateMany(ctx, logs, nil); err != nil {
return err
}
}
}
return nil
}
func (s *purchaseService) createPurchaseApproval(
ctx context.Context,
db *gorm.DB,
purchaseID uint,
step approvalutils.ApprovalStep,
action entity.ApprovalAction,
actorID uint,
notes *string,
allowDuplicate bool,
) error {
if purchaseID == 0 {
return utils.BadRequest("Purchase is invalid for approval")
}
if actorID == 0 {
actorID = 1
}
svc := s.approvalServiceForDB(db)
if svc == nil {
return utils.Internal("Approval service not available")
}
modifier := func(db *gorm.DB) *gorm.DB {
return db.Where("step_number = ?", uint16(step))
}
latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier)
if err != nil {
return err
}
if !allowDuplicate && latest != nil &&
latest.Action != nil &&
*latest.Action == action {
return nil
}
actionCopy := action
_, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes)
return err
}
func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalService {
if db != nil {
return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db))
}
if s.ApprovalSvc != nil {
return s.ApprovalSvc
}
if s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil {
return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()))
}
return nil
}
func (s *purchaseService) notifyExpenseItemsReceived(c *fiber.Ctx, purchaseID uint, payloads []ExpenseReceivingPayload) error {
if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 {
return nil
}
return s.ExpenseBridge.OnItemsReceived(c, purchaseID, payloads)
}
func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error {
if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 {
return nil
}
return s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, items)
}
func (s *purchaseService) deletePurchaseItemDocuments(ctx context.Context, items []entity.PurchaseItem) error {
if s.DocumentSvc == nil || len(items) == 0 {
return nil
}
for _, item := range items {
if item.Id == 0 {
continue
}
if err := s.DocumentSvc.DeleteByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id), true); err != nil {
return err
}
}
return nil
}
func (s *purchaseService) buildStaffAdjustmentPayload(
ctx context.Context,
purchase *entity.Purchase,
req *validation.ApproveStaffPurchaseRequest,
syncReceiving bool,
) (*staffAdjustmentPayload, error) {
if len(req.Items) == 0 {
return nil, utils.BadRequest("Items must not be empty")
}
requestItems := make(map[uint]validation.StaffPurchaseApprovalItem, len(req.Items))
newPayloads := make([]validation.StaffPurchaseApprovalItem, 0)
for _, item := range req.Items {
if item.PurchaseItemID == 0 {
newPayloads = append(newPayloads, item)
continue
}
if _, exists := requestItems[item.PurchaseItemID]; exists {
return nil, utils.BadRequest(fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID))
}
requestItems[item.PurchaseItemID] = item
}
updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items))
existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads))
for _, item := range purchase.Items {
key := fmt.Sprintf("%d:%d", item.ProductId, item.WarehouseId)
existingCombos[key] = struct{}{}
}
allowedWarehouses := make(map[uint]struct{}, len(purchase.Items))
for _, item := range purchase.Items {
allowedWarehouses[item.WarehouseId] = struct{}{}
}
if len(allowedWarehouses) == 0 && len(newPayloads) > 0 {
return nil, utils.BadRequest("No available warehouses for this purchase")
}
for _, item := range purchase.Items {
data, ok := requestItems[item.Id]
if !ok {
return nil, utils.BadRequest(fmt.Sprintf("Missing pricing data for item %d", item.Id))
}
if data.ProductID != 0 && data.ProductID != item.ProductId {
return nil, utils.BadRequest(fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id))
}
if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId {
return nil, utils.BadRequest(fmt.Sprintf("Warehouse mismatch for item %d", item.Id))
}
effectiveQty := item.SubQty
if data.Qty != nil {
if *data.Qty <= 0 {
return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id))
}
if item.TotalUsed > 0 && *data.Qty < item.TotalUsed {
return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed))
}
if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving {
return nil, utils.BadRequest(fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id))
}
effectiveQty = *data.Qty
}
totalPriceInput := data.TotalPrice
totalPrice, err := calculateTotalPrice(effectiveQty, data.Price, &totalPriceInput, fmt.Sprintf("item %d", item.Id))
if err != nil {
return nil, err
}
update := rPurchase.PurchasePricingUpdate{
ItemID: item.Id,
Price: data.Price,
TotalPrice: totalPrice,
}
if data.Qty != nil {
qtyCopy := effectiveQty
update.Quantity = &qtyCopy
}
updates = append(updates, update)
delete(requestItems, item.Id)
}
if len(requestItems) > 0 {
return nil, utils.BadRequest("Found pricing data for items that do not belong to this purchase")
}
productSupplierCache := make(map[uint]bool)
newItems := make([]*entity.PurchaseItem, 0, len(newPayloads))
emptyVehicle := ""
for _, payload := range newPayloads {
if payload.ProductID == 0 || payload.WarehouseID == 0 {
return nil, utils.BadRequest("Product and warehouse must be provided for new items")
}
if payload.Qty == nil || *payload.Qty <= 0 {
return nil, utils.BadRequest(fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID))
}
if _, ok := allowedWarehouses[payload.WarehouseID]; !ok {
return nil, utils.BadRequest(fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID))
}
key := fmt.Sprintf("%d:%d", payload.ProductID, payload.WarehouseID)
if _, exists := existingCombos[key]; exists {
return nil, utils.BadRequest(fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID))
}
if _, checked := productSupplierCache[payload.ProductID]; !checked {
linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(payload.ProductID), uint(purchase.SupplierId))
if err != nil {
s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", payload.ProductID, purchase.SupplierId, err)
return nil, utils.Internal("Failed to validate product for supplier")
}
if !linked {
return nil, utils.BadRequest(fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId))
}
productSupplierCache[payload.ProductID] = true
}
qty := *payload.Qty
totalPriceInput := payload.TotalPrice
totalPrice, err := calculateTotalPrice(qty, payload.Price, &totalPriceInput, fmt.Sprintf("product %d in warehouse %d", payload.ProductID, payload.WarehouseID))
if err != nil {
return nil, err
}
newItem := &entity.PurchaseItem{
PurchaseId: purchase.Id,
ProductId: payload.ProductID,
WarehouseId: payload.WarehouseID,
SubQty: qty,
TotalQty: 0,
TotalUsed: 0,
Price: payload.Price,
TotalPrice: totalPrice,
VehicleNumber: &emptyVehicle,
}
newItems = append(newItems, newItem)
existingCombos[key] = struct{}{}
}
if len(updates) == 0 && len(newItems) == 0 {
return nil, utils.BadRequest("Purchase has no items to process")
}
return &staffAdjustmentPayload{
PricingUpdates: updates,
NewItems: newItems,
}, nil
}
// ? helper
func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) {
if quantity <= 0 {
return 0, utils.BadRequest(fmt.Sprintf("Quantity for %s must be greater than 0", ref))
}
if price <= 0 {
return 0, utils.BadRequest(fmt.Sprintf("Price for %s must be greater than 0", ref))
}
expectedTotal := price * quantity
if provided == nil {
return expectedTotal, nil
}
if *provided <= 0 {
return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must be greater than 0", ref))
}
if math.Abs(*provided-expectedTotal) > priceTolerance {
return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must equal quantity x price", ref))
}
return *provided, nil
}
func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error {
if item == nil || item.Id == 0 || s.ApprovalSvc == nil {
return nil
}
latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(item.Id), func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
return err
}
item.LatestApproval = latest
return nil
}
func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) {
value := strings.ToUpper(strings.TrimSpace(raw))
switch value {
case string(entity.ApprovalActionApproved):
return entity.ApprovalActionApproved, nil
case string(entity.ApprovalActionRejected):
return entity.ApprovalActionRejected, nil
default:
return "", utils.BadRequest("action must be APPROVED or REJECTED")
}
}
func (s *purchaseService) rejectAndReload(
c *fiber.Ctx,
step approvalutils.ApprovalStep,
purchaseID uint,
actorID uint,
notes *string,
) (*entity.Purchase, error) {
if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil {
return nil, err
}
return s.loadPurchase(c.Context(), purchaseID)
}
func (s *purchaseService) loadPurchase(
ctx context.Context,
id uint,
) (*entity.Purchase, error) {
purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, utils.NotFound("Purchase not found")
}
s.Log.Errorf("Failed to get purchase %d: %+v", id, err)
return nil, utils.Internal("Failed to get purchase")
}
if err := s.attachLatestApproval(ctx, purchase); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err)
}
s.applyTravelDocumentURLs(ctx, purchase)
return purchase, nil
}
func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase *entity.Purchase) {
if purchase == nil || s.DocumentSvc == nil {
return
}
for i := range purchase.Items {
item := &purchase.Items[i]
documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id))
if err != nil {
s.Log.Warnf("Unable to load travel documents for purchase item %d: %+v", item.Id, err)
} else {
var targetDoc *entity.Document
for j := len(documents) - 1; j >= 0; j-- {
if documents[j].Type == string(utils.DocumentTypePurchaseTravel) {
targetDoc = &documents[j]
break
}
}
if targetDoc != nil {
url, err := s.DocumentSvc.PresignURL(ctx, *targetDoc, 15*time.Minute)
if err != nil {
s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err)
} else if url != "" {
item.TravelNumberDocs = &url
continue
}
}
}
path := item.TravelNumberDocs
if path == nil || strings.TrimSpace(*path) == "" {
continue
}
url, err := commonSvc.ResolveDocumentURL(ctx, s.DocumentSvc, *path, 15*time.Minute)
if err != nil {
s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err)
continue
}
if url == "" {
continue
}
item.TravelNumberDocs = &url
}
}
func collectPFKIDsFromPurchase(p *entity.Purchase) []uint {
seen := make(map[uint]struct{})
ids := make([]uint, 0)
for _, item := range p.Items {
if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 {
continue
}
id := uint(*item.ProjectFlockKandangId)
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids
}
func (s *purchaseService) ensureProjectFlockNotClosedForPurchase(
ctx context.Context,
purchase *entity.Purchase,
) error {
pfkIDs := collectPFKIDsFromPurchase(purchase)
if len(pfkIDs) == 0 {
return nil
}
db := s.PurchaseRepo.DB()
if db == nil {
return utils.Internal("DB not available for project flock validation")
}
return commonSvc.EnsureProjectFlockNotClosedByProjectFlockKandangID(ctx, db, pfkIDs)
}