implement bop for expedition must recheck and qty in staff purchase need info

This commit is contained in:
ragilap
2025-12-05 14:08:54 +07:00
parent c064fb1765
commit ee2db748ea
15 changed files with 1062 additions and 292 deletions
@@ -2,42 +2,663 @@ package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
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"
expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// PurchaseExpenseBridge defines hooks that allow purchase flows to stay in sync with expense data once it exists.
// PurchaseExpenseBridge allows purchase flows to sync expense data on receiving/deletion.
type PurchaseExpenseBridge interface {
OnItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error
OnItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) error
OnItemsReceived(ctx context.Context, purchaseID uint, updates []ExpenseReceivingPayload) error
OnItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error
OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error
}
// ExpenseReceivingPayload captures the minimum data expense integration will need once available.
type ExpenseReceivingPayload struct {
PurchaseItemID uint
ProductID uint
WarehouseID uint
ReceivedQty float64
ReceivedDate *time.Time
PurchaseItemID uint
ProductID uint
WarehouseID uint
SupplierID uint
TransportPerItem *float64
ReceivedQty float64
ReceivedDate *time.Time
}
// noopPurchaseExpenseBridge is the default implementation until the expense module is ready.
type noopPurchaseExpenseBridge struct{}
func NewNoopPurchaseExpenseBridge() PurchaseExpenseBridge {
return &noopPurchaseExpenseBridge{}
type groupedItem struct {
item *entity.PurchaseItem
payload ExpenseReceivingPayload
projectFK *uint
kandangID *uint
totalPrice float64
}
func (n *noopPurchaseExpenseBridge) OnItemsCreated(_ context.Context, _ uint, _ []entity.PurchaseItem) error {
// expenseBridge is the real implementation that syncs purchases to expenses on receiving/deletion.
type expenseBridge struct {
db *gorm.DB
purchaseRepo rPurchase.PurchaseRepository
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
expenseSvc expenseSvc.ExpenseService
}
func NewExpenseBridge(
db *gorm.DB,
purchaseRepo rPurchase.PurchaseRepository,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
expenseSvc expenseSvc.ExpenseService,
) PurchaseExpenseBridge {
return &expenseBridge{
db: db,
purchaseRepo: purchaseRepo,
projectFlockKandangRepo: projectFlockKandangRepo,
expenseSvc: expenseSvc,
}
}
func (b *expenseBridge) OnItemsDeleted(ctx context.Context, _ uint, items []entity.PurchaseItem) error {
if len(items) == 0 {
return nil
}
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
expenseIDs := make(map[uint64]struct{})
expenseNonstockIDs := make([]uint64, 0)
for _, item := range items {
if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 {
expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId)
}
}
if len(expenseNonstockIDs) > 0 {
for _, nsID := range expenseNonstockIDs {
var expenseID uint64
if err := tx.
Model(&entity.ExpenseNonstock{}).
Select("expense_id").
Where("id = ?", nsID).
Scan(&expenseID).Error; err != nil {
return err
}
if expenseID != 0 {
expenseIDs[expenseID] = struct{}{}
}
}
if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil {
return err
}
}
var links []struct {
ItemID uint
ExpenseNonstockID *uint64
}
if err := tx.
Model(&entity.PurchaseItem{}).
Select("id as item_id, expense_nonstock_id").
Where("id IN ?", extractIDs(items)).
Scan(&links).Error; err != nil {
return err
}
for _, link := range links {
if link.ExpenseNonstockID == nil || *link.ExpenseNonstockID == 0 {
continue
}
var expenseID uint64
if err := tx.
Model(&entity.ExpenseNonstock{}).
Select("expense_id").
Where("id = ?", *link.ExpenseNonstockID).
Scan(&expenseID).Error; err != nil {
return err
}
if expenseID != 0 {
expenseIDs[expenseID] = struct{}{}
}
if err := tx.Delete(&entity.ExpenseNonstock{}, *link.ExpenseNonstockID).Error; err != nil {
return err
}
}
approvalRepoTx := commonRepo.NewApprovalRepository(tx)
for expenseID := range expenseIDs {
var count int64
if err := tx.Model(&entity.ExpenseNonstock{}).
Where("expense_id = ?", expenseID).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil {
return err
}
if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil {
return err
}
}
}
return nil
})
}
// cleanupExistingNonstocks deletes expense_nonstocks (and their approvals/expenses if empty) for the given payloads.
func (b *expenseBridge) cleanupExistingNonstocks(ctx context.Context, updates []ExpenseReceivingPayload) error {
if len(updates) == 0 {
return nil
}
itemIDs := make([]uint, 0, len(updates))
for _, upd := range updates {
if upd.PurchaseItemID != 0 {
itemIDs = append(itemIDs, upd.PurchaseItemID)
}
}
if len(itemIDs) == 0 {
return nil
}
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var links []struct {
ItemID uint
ExpenseNonstockID *uint64
}
if err := tx.Model(&entity.PurchaseItem{}).
Select("id as item_id, expense_nonstock_id").
Where("id IN ?", itemIDs).
Scan(&links).Error; err != nil {
return err
}
expenseIDs := make(map[uint64]struct{})
expenseNonstockIDs := make([]uint64, 0)
for _, link := range links {
if link.ExpenseNonstockID != nil && *link.ExpenseNonstockID != 0 {
expenseNonstockIDs = append(expenseNonstockIDs, *link.ExpenseNonstockID)
}
}
if len(expenseNonstockIDs) == 0 {
return nil
}
for _, nsID := range expenseNonstockIDs {
var expenseID uint64
if err := tx.Model(&entity.ExpenseNonstock{}).
Select("expense_id").
Where("id = ?", nsID).
Scan(&expenseID).Error; err != nil {
return err
}
if expenseID != 0 {
expenseIDs[expenseID] = struct{}{}
}
}
if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil {
return err
}
approvalRepoTx := commonRepo.NewApprovalRepository(tx)
for expenseID := range expenseIDs {
var count int64
if err := tx.Model(&entity.ExpenseNonstock{}).
Where("expense_id = ?", expenseID).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil {
return err
}
if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil {
return err
}
}
}
return nil
})
}
func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error {
if purchaseID == 0 || len(updates) == 0 {
return nil
}
ctx := c.Context()
// Load current links to decide whether to update in place or recreate.
type itemLink struct {
ExpenseNonstockID uint64
ExpenseID uint64
SupplierID uint
TransactionDate time.Time
Qty float64
Price float64
}
purchase, err := b.purchaseRepo.GetByID(ctx, purchaseID, func(db *gorm.DB) *gorm.DB {
return db.
Preload("Items").
Preload("Items.Warehouse").
Preload("Items.Warehouse.Kandang")
})
if err != nil {
return err
}
itemLinks := make(map[uint]itemLink)
if len(updates) > 0 {
ids := make([]uint, 0, len(updates))
for _, upd := range updates {
if upd.PurchaseItemID != 0 {
ids = append(ids, upd.PurchaseItemID)
}
}
if len(ids) > 0 {
rows := make([]struct {
ItemID uint
ExpenseNonstockID uint64
ExpenseID uint64
SupplierID uint
TransactionDate time.Time
Qty float64
Price float64
}, 0)
if err := b.db.WithContext(ctx).
Table("purchase_items pi").
Select("pi.id as item_id, en.id as expense_nonstock_id, en.expense_id, e.supplier_id, e.transaction_date, en.qty, en.price").
Joins("LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id").
Joins("LEFT JOIN expenses e ON e.id = en.expense_id").
Where("pi.id IN ?", ids).
Scan(&rows).Error; err != nil {
return err
}
for _, row := range rows {
itemLinks[row.ItemID] = itemLink{
ExpenseNonstockID: row.ExpenseNonstockID,
ExpenseID: row.ExpenseID,
SupplierID: row.SupplierID,
TransactionDate: row.TransactionDate,
Qty: row.Qty,
Price: row.Price,
}
}
}
}
itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items {
itemMap[purchase.Items[i].Id] = &purchase.Items[i]
}
groups := make(map[string][]groupedItem)
toRecreate := make([]ExpenseReceivingPayload, 0)
for _, payload := range updates {
if payload.ReceivedDate == nil {
return fiber.NewError(fiber.StatusBadRequest, "received_date is required")
}
item := itemMap[payload.PurchaseItemID]
if item == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID))
}
if payload.ReceivedQty <= 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d must be greater than 0", payload.PurchaseItemID))
}
receivedDate := payload.ReceivedDate.UTC().Truncate(24 * time.Hour)
supplierID := payload.SupplierID
if supplierID == 0 {
supplierID = purchase.SupplierId
}
// Decide whether to update existing expense_nonstock or recreate.
link, hasLink := itemLinks[payload.PurchaseItemID]
requiresDelete := false
handledUpdate := false
if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 {
oldDate := link.TransactionDate.UTC().Truncate(24 * time.Hour)
newDate := receivedDate
oldSupplier := link.SupplierID
pricePerItem := item.Price
if payload.TransportPerItem != nil {
pricePerItem = *payload.TransportPerItem
}
// If any change other than received_date / supplier occurs (e.g., price or qty), delete-then-create.
if (link.Price != pricePerItem) || (link.Qty != payload.ReceivedQty) {
requiresDelete = true
} else if oldSupplier != supplierID || !oldDate.Equal(newDate) {
// Supplier/date change: keep (update) if this expense only has one nonstock; otherwise recreate to avoid affecting others.
var count int64
if err := b.db.WithContext(ctx).
Model(&entity.ExpenseNonstock{}).
Where("expense_id = ?", link.ExpenseID).
Count(&count).Error; err != nil {
return err
}
if count <= 1 {
// Update expense header supplier/date in-place.
if err := b.db.WithContext(ctx).
Model(&entity.Expense{}).
Where("id = ?", link.ExpenseID).
Updates(map[string]interface{}{
"supplier_id": supplierID,
"transaction_date": newDate,
}).Error; err != nil {
return err
}
// Update note just in case.
note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID)
if err := b.db.WithContext(ctx).
Model(&entity.ExpenseNonstock{}).
Where("id = ?", link.ExpenseNonstockID).
Updates(map[string]interface{}{
"notes": note,
}).Error; err != nil {
return err
}
// Continue to grouping with updated header.
} else {
requiresDelete = true
}
}
// If we reach here and no delete is required, update the existing nonstock fields and skip creation.
if !requiresDelete {
pricePerItem := item.Price
if payload.TransportPerItem != nil {
pricePerItem = *payload.TransportPerItem
}
note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID)
if err := b.db.WithContext(ctx).
Model(&entity.ExpenseNonstock{}).
Where("id = ?", link.ExpenseNonstockID).
Updates(map[string]interface{}{
"qty": payload.ReceivedQty,
"price": pricePerItem,
"notes": note,
}).Error; err != nil {
return err
}
handledUpdate = true
}
}
if requiresDelete {
toRecreate = append(toRecreate, payload)
continue
}
if handledUpdate {
continue
}
key := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID)
var kandangID *uint
var projectFK *uint
if item.Warehouse != nil && item.Warehouse.KandangId != nil {
id := uint(*item.Warehouse.KandangId)
kandangID = &id
if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil {
pid := uint(project.Id)
projectFK = &pid
}
}
pricePerItem := item.Price
if payload.TransportPerItem != nil {
pricePerItem = *payload.TransportPerItem
}
totalPrice := pricePerItem * payload.ReceivedQty
groups[key] = append(groups[key], groupedItem{
item: item,
payload: payload,
projectFK: projectFK,
kandangID: kandangID,
totalPrice: totalPrice,
})
}
// For payloads that require delete/recreate, clean up their old links first.
if len(toRecreate) > 0 {
if err := b.cleanupExistingNonstocks(ctx, toRecreate); err != nil {
return err
}
// Then add them back into grouping for creation.
for _, payload := range toRecreate {
item := itemMap[payload.PurchaseItemID]
if item == nil || payload.ReceivedDate == nil {
continue
}
receivedDate := payload.ReceivedDate.UTC().Truncate(24 * time.Hour)
supplierID := payload.SupplierID
if supplierID == 0 {
supplierID = purchase.SupplierId
}
key := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID)
var kandangID *uint
var projectFK *uint
if item.Warehouse != nil && item.Warehouse.KandangId != nil {
id := uint(*item.Warehouse.KandangId)
kandangID = &id
if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil {
pid := uint(project.Id)
projectFK = &pid
}
}
pricePerItem := item.Price
if payload.TransportPerItem != nil {
pricePerItem = *payload.TransportPerItem
}
totalPrice := pricePerItem * payload.ReceivedQty
groups[key] = append(groups[key], groupedItem{
item: item,
payload: payload,
projectFK: projectFK,
kandangID: kandangID,
totalPrice: totalPrice,
})
}
}
for key, items := range groups {
if len(items) == 0 {
continue
}
parts := strings.Split(key, ":")
if len(parts) != 3 {
return errors.New("invalid expense grouping key")
}
expenseDate, err := utils.ParseDateString(parts[1])
if err != nil {
return err
}
supplierID, err := strconv.ParseUint(parts[0], 10, 64)
if err != nil {
return err
}
expeditionNonstockID, err := b.findExpeditionNonstockID(ctx, uint(supplierID))
if err != nil {
return err
}
expenseDetail, err := b.createExpenseViaService(c, purchase, items, expenseDate, expeditionNonstockID, purchase.PoNumber, uint(supplierID))
if err != nil {
return err
}
if err := b.linkExpenseNonstocksToItems(ctx, expenseDetail, items); err != nil {
return err
}
}
return nil
}
func (n *noopPurchaseExpenseBridge) OnItemsDeleted(_ context.Context, _ uint, _ []uint) error {
return nil
func (b *expenseBridge) findExpeditionNonstockID(ctx context.Context, supplierID uint) (uint64, error) {
var id uint64
err := b.db.WithContext(ctx).
Table("nonstocks AS ns").
Select("ns.id").
Joins("JOIN nonstock_suppliers nss ON nss.nonstock_id = ns.id").
Joins("JOIN flags f ON f.flagable_id = ns.id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))).
Where("nss.supplier_id = ?", supplierID).
Order("ns.id").
Limit(1).
Scan(&id).Error
if err != nil {
return 0, err
}
if id == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "supplier id tidak sesuai dengan expedisi")
}
return id, nil
}
func (n *noopPurchaseExpenseBridge) OnItemsReceived(_ context.Context, _ uint, _ []ExpenseReceivingPayload) error {
func extractIDs(items []entity.PurchaseItem) []uint {
result := make([]uint, 0, len(items))
for _, item := range items {
if item.Id != 0 {
result = append(result, item.Id)
}
}
return result
}
func (b *expenseBridge) createExpenseViaService(
c *fiber.Ctx,
purchase *entity.Purchase,
items []groupedItem,
expenseDate time.Time,
expeditionNonstockID uint64,
poNumber *string,
supplierID uint,
) (*expenseDto.ExpenseDetailDTO, error) {
ctx := c.Context()
if b.expenseSvc == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "expense service not available")
}
if len(items) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "no items to create expense")
}
kandangID := items[0].kandangID
if kandangID == nil || *kandangID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs")
}
costItems := make([]expenseValidation.CostItem, 0, len(items))
for _, gi := range items {
note := fmt.Sprintf("purchase_item:%d", gi.payload.PurchaseItemID)
price := gi.item.Price
if gi.payload.TransportPerItem != nil {
price = *gi.payload.TransportPerItem
}
costItems = append(costItems, expenseValidation.CostItem{
NonstockID: expeditionNonstockID,
Quantity: gi.payload.ReceivedQty,
Price: price,
Notes: note,
})
}
req := &expenseValidation.Create{
PoNumber: "",
TransactionDate: utils.FormatDate(expenseDate),
Category: "BOP",
SupplierID: uint64(supplierID),
ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{
KandangID: uint64(*kandangID),
CostItems: costItems,
}},
}
if poNumber != nil {
req.PoNumber = *poNumber
}
detail, err := b.expenseSvc.CreateOne(c, req)
if err != nil {
return nil, err
}
// Mark approvals up to Finance so latest is Manager Finance
action := entity.ApprovalActionApproved
actorID := uint(purchase.CreatedBy)
if actorID == 0 {
actorID = 1
}
approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db))
if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil {
return nil, err
}
if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil {
return nil, err
}
return detail, nil
}
func (b *expenseBridge) linkExpenseNonstocksToItems(ctx context.Context, detail *expenseDto.ExpenseDetailDTO, items []groupedItem) error {
if detail == nil || len(items) == 0 {
return nil
}
noteToExpenseNonstock := make(map[uint]uint64)
for _, kandang := range detail.Kandangs {
for _, pengajuan := range kandang.Pengajuans {
note := strings.TrimSpace(pengajuan.Notes)
if note == "" {
continue
}
const prefix = "purchase_item:"
if !strings.HasPrefix(note, prefix) {
continue
}
idStr := strings.TrimPrefix(note, prefix)
var itemID uint
if _, err := fmt.Sscanf(idStr, "%d", &itemID); err != nil {
continue
}
noteToExpenseNonstock[itemID] = pengajuan.Id
}
}
if len(noteToExpenseNonstock) == 0 {
return nil
}
for _, gi := range items {
expenseNonstockID, ok := noteToExpenseNonstock[gi.payload.PurchaseItemID]
if !ok {
continue
}
if err := b.db.WithContext(ctx).
Model(&entity.PurchaseItem{}).
Where("id = ?", gi.payload.PurchaseItemID).
Update("expense_nonstock_id", expenseNonstockID).Error; err != nil {
return err
}
}
return nil
}
@@ -58,7 +58,6 @@ type purchaseService struct {
type staffAdjustmentPayload struct {
PricingUpdates []rPurchase.PurchasePricingUpdate
NewItems []*entity.PurchaseItem
GrandTotal float64
}
func NewPurchaseService(
@@ -71,9 +70,6 @@ func NewPurchaseService(
approvalSvc commonSvc.ApprovalService,
expenseBridge PurchaseExpenseBridge,
) PurchaseService {
if expenseBridge == nil {
expenseBridge = NewNoopPurchaseExpenseBridge()
}
return &purchaseService{
Log: utils.Log,
Validate: validate,
@@ -237,9 +233,9 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
if warehouse, ok := warehouseCache[id]; ok {
return warehouse, nil
}
warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Preload("Area").Preload("location")
})
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) {
@@ -291,21 +287,25 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
indexMap[key] = len(aggregated) - 1
}
creditTermValue := req.CreditTerm
creditTerm := &creditTermValue
dueDateValue := time.Now().UTC().AddDate(0, 0, creditTermValue)
dueDate := &dueDateValue
var dueDate *time.Time
if req.DueDate != nil && strings.TrimSpace(*req.DueDate) != "" {
parsed, err := utils.ParseDateString(strings.TrimSpace(*req.DueDate))
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid due_date, expected YYYY-MM-DD")
}
parsed = parsed.UTC()
dueDate = &parsed
}
purchase := &entity.Purchase{
SupplierId: uint(req.SupplierID),
CreditTerm: creditTerm,
DueDate: dueDate,
GrandTotal: 0,
Notes: req.Notes,
CreatedBy: uint(actorID),
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,
@@ -315,6 +315,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
TotalUsed: 0,
Price: 0,
TotalPrice: 0,
VehicleNumber: &emptyVehicle,
})
}
@@ -361,6 +362,8 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
return nil, err
}
ctx := c.Context()
action, err := parseApprovalActionInput(req.Action)
if err != nil {
return nil, err
@@ -371,7 +374,7 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
return nil, err
}
purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations)
purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found")
@@ -379,7 +382,7 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase")
}
if err := s.attachLatestApproval(c.Context(), purchase); err != nil {
if err := s.attachLatestApproval(ctx, purchase); err != nil {
s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err)
}
@@ -418,12 +421,10 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
purchaseRepoTx := rPurchase.NewPurchaseRepository(tx)
grandTotalUpdated := false
if len(payload.PricingUpdates) > 0 {
if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil {
if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates); err != nil {
return err
}
grandTotalUpdated = true
}
if len(payload.NewItems) > 0 {
@@ -432,12 +433,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
}
}
if !grandTotalUpdated {
if err := purchaseRepoTx.UpdateGrandTotal(c.Context(), purchase.Id, payload.GrandTotal); 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
@@ -481,17 +476,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
}
if len(payload.NewItems) > 0 {
newItems := make([]entity.PurchaseItem, len(payload.NewItems))
for i, item := range payload.NewItems {
if item == nil {
continue
}
newItems[i] = *item
}
s.notifyExpenseItemsCreated(c.Context(), purchase.Id, newItems)
}
return updated, nil
}
@@ -611,6 +595,8 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return nil, err
}
ctx := c.Context()
action, err := parseApprovalActionInput(req.Action)
if err != nil {
return nil, err
@@ -621,7 +607,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return nil, err
}
purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations)
purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found")
@@ -647,14 +633,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
}
if action == entity.ApprovalActionRejected {
if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil {
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(c.Context(), purchase.Id, s.withRelations)
updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase")
}
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
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
@@ -670,6 +656,8 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
payload validation.ReceivePurchaseItemRequest
receivedDate time.Time
warehouseID uint
supplierID uint
transportPerItem *float64
overrideWarehouse bool
receivedQty float64
}
@@ -682,7 +670,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID))
}
receivedDate, err := time.Parse("2006-01-02", payload.ReceivedDate)
receivedDate, err := utils.ParseDateString(payload.ReceivedDate)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID))
}
@@ -716,11 +704,27 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
}
visitedItems[payload.PurchaseItemID] = struct{}{}
supplierID := purchase.SupplierId
if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 {
supplierID = *payload.ExpeditionVendorID
}
var transportPerItem *float64
if payload.TransportPerItem != nil {
if *payload.TransportPerItem < 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("transport_per_item for item %d cannot be negative", payload.PurchaseItemID))
}
val := *payload.TransportPerItem
transportPerItem = &val
}
prepared = append(prepared, preparedReceiving{
item: item,
payload: payload,
receivedDate: receivedDate,
warehouseID: warehouseID,
supplierID: supplierID,
transportPerItem: transportPerItem,
overrideWarehouse: overrideWarehouse,
receivedQty: receivedQty,
})
@@ -737,7 +741,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
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 {
@@ -830,14 +834,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return err
}
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil {
return err
}
if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil {
return err
}
return nil
})
if transactionErr != nil {
@@ -863,12 +859,28 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
PurchaseItemID: prep.item.Id,
ProductID: prep.item.ProductId,
WarehouseID: uint(prep.warehouseID),
SupplierID: prep.supplierID,
TransportPerItem: prep.transportPerItem,
ReceivedQty: prep.receivedQty,
ReceivedDate: &date,
}
receivingPayloads = append(receivingPayloads, payload)
}
s.notifyExpenseItemsReceived(c.Context(), purchase.Id, receivingPayloads)
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, fiber.NewError(fiber.StatusInternalServerError, "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
}
@@ -918,6 +930,17 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
return nil, fiber.NewError(fiber.StatusBadRequest, "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, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item")
}
@@ -929,10 +952,6 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
return err
}
if err := repoTx.UpdateGrandTotal(ctx, purchase.Id, remainingTotal); err != nil {
return err
}
return nil
})
if transactionErr != nil {
@@ -942,8 +961,14 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items")
}
if len(toDelete) > 0 {
s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete)
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, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense")
}
}
updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations)
@@ -972,8 +997,10 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
}
itemIDs := make([]uint, 0, len(purchase.Items))
for _, item := range purchase.Items {
itemsToDelete := make([]entity.PurchaseItem, len(purchase.Items))
for i, item := range purchase.Items {
itemIDs = append(itemIDs, item.Id)
itemsToDelete[i] = item
}
transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@@ -995,38 +1022,130 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase")
}
if len(itemIDs) > 0 {
s.notifyExpenseItemsDeleted(ctx, uint(id), itemIDs)
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 fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense")
}
}
return nil
}
func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) {
if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 {
return
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 fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval")
}
if err := s.ExpenseBridge.OnItemsCreated(ctx, purchaseID, items); err != nil {
s.Log.Warnf("Failed to notify expense bridge for created purchase %d: %+v", purchaseID, err)
if actorID == 0 {
actorID = 1
}
svc := s.approvalServiceForDB(db)
if svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "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) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint, payloads []ExpenseReceivingPayload) {
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) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error {
if len(items) == 0 || s.ApprovalSvc == nil {
return nil
}
ids := make([]uint, 0, len(items))
visited := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.Id == 0 {
continue
}
if _, ok := visited[item.Id]; ok {
continue
}
visited[item.Id] = struct{}{}
ids = append(ids, uint(item.Id))
}
if len(ids) == 0 {
return nil
}
latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowPurchase, ids, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
return err
}
for i := range items {
if items[i].Id == 0 {
continue
}
if approval, ok := latestMap[uint(items[i].Id)]; ok {
items[i].LatestApproval = approval
} else {
items[i].LatestApproval = nil
}
}
return nil
}
func (s *purchaseService) notifyExpenseItemsReceived(c *fiber.Ctx, purchaseID uint, payloads []ExpenseReceivingPayload) error {
if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 {
return
}
if err := s.ExpenseBridge.OnItemsReceived(ctx, purchaseID, payloads); err != nil {
s.Log.Warnf("Failed to notify expense bridge for received purchase %d: %+v", purchaseID, err)
return nil
}
return s.ExpenseBridge.OnItemsReceived(c, purchaseID, payloads)
}
func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) {
if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 {
return
}
if err := s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, itemIDs); err != nil {
s.Log.Warnf("Failed to notify expense bridge for deleted purchase %d: %+v", purchaseID, err)
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) buildStaffAdjustmentPayload(
@@ -1054,7 +1173,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
}
updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items))
var grandTotal float64
existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads))
for _, item := range purchase.Items {
@@ -1119,16 +1237,16 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
update.TotalQty = &qtyCopy
}
updates = append(updates, update)
grandTotal += totalPrice
delete(requestItems, item.Id)
}
updates = append(updates, update)
delete(requestItems, item.Id)
}
if len(requestItems) > 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "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 {
@@ -1183,11 +1301,11 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
TotalUsed: 0,
Price: payload.Price,
TotalPrice: totalPrice,
VehicleNumber: &emptyVehicle,
}
newItems = append(newItems, newItem)
existingCombos[key] = struct{}{}
}
newItems = append(newItems, newItem)
existingCombos[key] = struct{}{}
grandTotal += totalPrice
}
if len(updates) == 0 && len(newItems) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to process")
@@ -1196,7 +1314,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
return &staffAdjustmentPayload{
PricingUpdates: updates,
NewItems: newItems,
GrandTotal: grandTotal,
}, nil
}
@@ -1240,32 +1357,10 @@ func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity
}
func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) {
var fromPtr *time.Time
var toPtr *time.Time
const queryDateLayout = "2006-01-02"
if strings.TrimSpace(fromStr) != "" {
parsed, err := time.Parse(queryDateLayout, fromStr)
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must use format YYYY-MM-DD")
}
fromValue := parsed
fromPtr = &fromValue
fromPtr, toPtr, err := utils.ParseDateRangeForQuery(fromStr, toStr)
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if strings.TrimSpace(toStr) != "" {
parsed, err := time.Parse(queryDateLayout, toStr)
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_to must use format YYYY-MM-DD")
}
toValue := parsed.AddDate(0, 0, 1)
toPtr = &toValue
}
if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must be earlier than created_to")
}
return fromPtr, toPtr, nil
}
@@ -1302,53 +1397,3 @@ func (s *purchaseService) rejectAndReload(
}
return updated, 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 fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval")
}
if actorID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "ActorId is invalid for approval")
}
var svc commonSvc.ApprovalService
switch {
case db != nil:
svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db))
case s.ApprovalSvc != nil:
svc = s.ApprovalSvc
case s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil:
svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()))
}
if svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "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
}