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

739 lines
22 KiB
Go

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"
kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/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"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
type PurchaseExpenseBridge interface {
OnItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error
OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error
}
type ExpenseReceivingPayload struct {
PurchaseItemID uint
ProductID uint
WarehouseID uint
SupplierID uint
TransportPerItem *float64
ReceivedQty float64
ReceivedDate *time.Time
VehicleNumber *string
}
type groupedItem struct {
item *entity.PurchaseItem
payload ExpenseReceivingPayload
projectFK *uint
kandangID *uint
totalPrice float64
poNumber string
}
type expenseBridge struct {
db *gorm.DB
purchaseRepo rPurchase.PurchaseRepository
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
kandangRepo kandangRepo.KandangRepository
expenseSvc expenseSvc.ExpenseService
}
func NewExpenseBridge(
db *gorm.DB,
purchaseRepo rPurchase.PurchaseRepository,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
kandangRepo kandangRepo.KandangRepository,
expenseSvc expenseSvc.ExpenseService,
) PurchaseExpenseBridge {
return &expenseBridge{
db: db,
purchaseRepo: purchaseRepo,
projectFlockKandangRepo: projectFlockKandangRepo,
kandangRepo: kandangRepo,
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
})
}
func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[uint64]struct{}, actorID uint) error {
if len(expenseIDs) == 0 {
return nil
}
if actorID == 0 {
actorID = 1
}
approvalRepo := commonRepo.NewApprovalRepository(b.db)
svc := commonSvc.NewApprovalService(approvalRepo)
action := entity.ApprovalActionCreated
for id := range expenseIDs {
latestApproval, err := approvalRepo.LatestByTarget(ctx, string(utils.ApprovalWorkflowExpense), uint(id), nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if latestApproval == nil {
if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); 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()
filtered := make([]ExpenseReceivingPayload, 0, len(updates))
for _, upd := range updates {
if upd.SupplierID == 0 {
continue
}
if upd.TransportPerItem == nil || *upd.TransportPerItem <= 0 {
continue
}
if upd.VehicleNumber == nil || strings.TrimSpace(*upd.VehicleNumber) == "" {
continue
}
filtered = append(filtered, upd)
}
if len(filtered) == 0 {
return nil
}
// 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.Product").
Preload("Items.Warehouse").
Preload("Items.Warehouse.Kandang")
})
if err != nil {
return err
}
itemLinks := make(map[uint]itemLink)
updatedExpenses := make(map[uint64]struct{})
if len(filtered) > 0 {
ids := make([]uint, 0, len(filtered))
for _, upd := range filtered {
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
}
// Build quick lookup per item and per group key for existing expenses.
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)
for _, payload := range filtered {
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 create new.
link, hasLink := itemLinks[payload.PurchaseItemID]
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 supplier/date unchanged, update nonstock in place.
if oldSupplier == supplierID && oldDate.Equal(newDate) {
note := purchaseItemDisplayNote(item, payload.PurchaseItemID, purchasePoNumber(purchase))
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
}
if link.ExpenseID != 0 {
updatedExpenses[link.ExpenseID] = struct{}{}
}
continue
}
// Supplier/date changed: if the linked expense has only this nonstock, update it in place.
if link.ExpenseID != 0 {
var cnt int64
if err := b.db.WithContext(ctx).
Model(&entity.ExpenseNonstock{}).
Where("expense_id = ?", link.ExpenseID).
Count(&cnt).Error; err != nil {
return err
}
if cnt == 1 {
newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID)
if err != nil {
return err
}
note := purchaseItemDisplayNote(item, payload.PurchaseItemID, purchasePoNumber(purchase))
if err := b.db.WithContext(ctx).
Model(&entity.Expense{}).
Where("id = ?", link.ExpenseID).
Updates(map[string]interface{}{
"transaction_date": newDate,
"supplier_id": supplierID,
}).Error; err != nil {
return err
}
updateBody := map[string]interface{}{
"qty": payload.ReceivedQty,
"price": pricePerItem,
"notes": note,
"nonstock_id": newNonstockID,
}
if item.Warehouse != nil && item.Warehouse.KandangId != nil && *item.Warehouse.KandangId != 0 {
updateBody["kandang_id"] = uint64(*item.Warehouse.KandangId)
}
if err := b.db.WithContext(ctx).
Model(&entity.ExpenseNonstock{}).
Where("id = ?", link.ExpenseNonstockID).
Updates(updateBody).Error; err != nil {
return err
}
updatedExpenses[link.ExpenseID] = struct{}{}
continue
}
// Expense has multiple nonstocks: create new expense header for this item, then move existing nonstock to it.
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
gItem := groupedItem{
item: item,
payload: payload,
projectFK: projectFK,
kandangID: kandangID,
totalPrice: totalPrice,
poNumber: purchasePoNumber(purchase),
}
newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID)
if err != nil {
return err
}
expenseDetail, err := b.createExpenseViaService(c, purchase, []groupedItem{gItem}, newDate, newNonstockID, purchase.PoNumber, supplierID)
if err != nil {
return err
}
var createdNonstockID uint64
if expenseDetail != nil {
noteMap := mapExpenseNotes(expenseDetail)
createdNonstockID = noteMap[payload.PurchaseItemID]
}
note := purchaseItemDisplayNote(item, payload.PurchaseItemID, purchasePoNumber(purchase))
updateBody := map[string]interface{}{
"expense_id": expenseDetail.Id,
"qty": payload.ReceivedQty,
"price": pricePerItem,
"notes": note,
"nonstock_id": newNonstockID,
}
if kandangID != nil {
updateBody["kandang_id"] = uint64(*kandangID)
} else {
updateBody["kandang_id"] = nil
}
if projectFK != nil {
updateBody["project_flock_kandang_id"] = uint64(*projectFK)
} else {
updateBody["project_flock_kandang_id"] = nil
}
if err := b.db.WithContext(ctx).
Model(&entity.ExpenseNonstock{}).
Where("id = ?", link.ExpenseNonstockID).
Updates(updateBody).Error; err != nil {
return err
}
if createdNonstockID != 0 {
if err := b.db.WithContext(ctx).Delete(&entity.ExpenseNonstock{}, createdNonstockID).Error; err != nil {
return err
}
}
if link.ExpenseID != 0 {
updatedExpenses[link.ExpenseID] = struct{}{}
}
if expenseDetail != nil && expenseDetail.Id != 0 {
updatedExpenses[uint64(expenseDetail.Id)] = struct{}{}
}
continue
}
// Otherwise create new expense/nonstock in grouping flow.
}
baseKey := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID)
key := baseKey
if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 {
key = fmt.Sprintf("%s:%d", baseKey, payload.PurchaseItemID)
}
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,
poNumber: purchasePoNumber(purchase),
})
}
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
}
if expenseDetail != nil && expenseDetail.Id != 0 {
updatedExpenses[uint64(expenseDetail.Id)] = struct{}{}
}
}
if len(updatedExpenses) > 0 {
if err := b.markExpensesUpdated(ctx, updatedExpenses, purchase.CreatedBy); err != nil {
return err
}
}
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 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
var locationID uint64
var expenseKandangID *uint64
if kandangID != nil && *kandangID != 0 {
kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB {
return db.Select("id, location_id")
})
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID))
}
if kandang == nil {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID))
}
locationID = uint64(kandang.LocationId)
id := uint64(*kandangID)
expenseKandangID = &id
} else {
warehouse := items[0].item.Warehouse
if warehouse == nil || warehouse.LocationId == nil || *warehouse.LocationId == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse location is required for expense")
}
locationID = uint64(*warehouse.LocationId)
}
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: string(utils.ExpenseCategoryBOP),
SupplierID: uint64(supplierID),
LocationID: locationID,
ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{
KandangID: expenseKandangID,
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.ExpenseStepHeadArea, &action, actorID, nil); err != nil {
return nil, err
}
if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepUnitVicePresident, &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 := mapExpenseNotes(detail)
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
}
note := purchaseItemDisplayNote(gi.item, gi.payload.PurchaseItemID, gi.poNumber)
if err := b.db.WithContext(ctx).
Model(&entity.ExpenseNonstock{}).
Where("id = ?", expenseNonstockID).
Update("notes", note).Error; err != nil {
return err
}
}
return nil
}
func mapExpenseNotes(detail *expenseDto.ExpenseDetailDTO) map[uint]uint64 {
result := make(map[uint]uint64)
if detail == nil {
return result
}
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
}
result[itemID] = pengajuan.Id
}
}
return result
}
func purchaseItemDisplayNote(item *entity.PurchaseItem, itemID uint, poNumber string) string {
poLabel := "PO"
if strings.TrimSpace(poNumber) != "" {
poLabel = strings.TrimSpace(poNumber)
}
productName := fmt.Sprintf("Item %d", itemID)
if item != nil && item.Product != nil && strings.TrimSpace(item.Product.Name) != "" {
productName = item.Product.Name
}
return fmt.Sprintf("%s (%s)", poLabel, productName)
}
func purchasePoNumber(purchase *entity.Purchase) string {
if purchase == nil || purchase.PoNumber == nil {
return ""
}
return *purchase.PoNumber
}