feat(BE): implement expense tracking for stock transfers and enhance related services

This commit is contained in:
aguhh18
2026-01-08 15:14:06 +07:00
parent 18b0663dc6
commit 2650e919e7
12 changed files with 622 additions and 37 deletions
@@ -1,6 +1,8 @@
DROP TABLE IF EXISTS daily_checklist_tasks;
-- Drop tables in correct order (child tables before parent tables)
DROP TABLE IF EXISTS daily_checklist_activity_task_assignments; -- Child table with FK to daily_checklist_activity_tasks
DROP TABLE IF EXISTS daily_checklist_activity_task_assignees;
DROP TABLE IF EXISTS daily_checklist_activity_tasks;
DROP TABLE IF EXISTS daily_checklist_tasks;
DROP TABLE IF EXISTS daily_checklist_phases;
DROP TABLE IF EXISTS daily_checklists;
DROP TABLE IF EXISTS checklists;
@@ -0,0 +1,4 @@
-- Remove expense_nonstock_id from stock_transfer_details
ALTER TABLE stock_transfer_details DROP CONSTRAINT IF EXISTS fk_stock_transfer_details_expense_nonstock;
ALTER TABLE stock_transfer_details DROP COLUMN IF EXISTS expense_nonstock_id;
DROP INDEX IF EXISTS idx_stock_transfer_details_expense_nonstock_id;
@@ -0,0 +1,10 @@
-- Add expense_nonstock_id to stock_transfer_details
-- This allows tracking expedition/transport costs for stock transfers (same as purchase)
ALTER TABLE stock_transfer_details
ADD COLUMN expense_nonstock_id BIGINT,
ADD CONSTRAINT fk_stock_transfer_details_expense_nonstock
FOREIGN KEY (expense_nonstock_id) REFERENCES expense_nonstocks(id) ON DELETE SET NULL;
-- Create index for better query performance
CREATE INDEX idx_stock_transfer_details_expense_nonstock_id ON stock_transfer_details(expense_nonstock_id);
+9 -9
View File
@@ -5,15 +5,15 @@ import (
)
type ExpenseNonstock struct {
Id uint64 `gorm:"primaryKey;autoIncrement"`
ExpenseId *uint64 `gorm:""`
ProjectFlockKandangId *uint64 `gorm:""`
KandangId *uint64 `gorm:""`
NonstockId *uint64 `gorm:""`
Qty float64 `gorm:"type:numeric(15,3);not null"`
Price float64 `gorm:"type:numeric(15,3);not null;column:price"`
Notes string `gorm:"type:text;column:notes"`
CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"`
Id uint64 `gorm:"primaryKey;autoIncrement"`
ExpenseId *uint64 `gorm:""`
ProjectFlockKandangId *uint64 `gorm:""`
KandangId *uint64 `gorm:""`
NonstockId *uint64 `gorm:""`
Qty float64 `gorm:"type:numeric(15,3);not null"`
Price float64 `gorm:"type:numeric(15,3);not null;column:price"`
Notes string `gorm:"type:text;column:notes"`
CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"`
Expense *Expense `gorm:"foreignKey:ExpenseId;references:Id"`
ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
+8 -13
View File
@@ -8,27 +8,22 @@ type StockTransferDetail struct {
StockTransferId uint64
ProductId uint64
// === FIFO FIELDS - SOURCE WAREHOUSE (Usable) ===
// Tracking stock yang DIAMBIL dari source warehouse
SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"`
UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil
PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock)
// === FIFO FIELDS - DESTINATION WAREHOUSE (Stockable) ===
// Tracking stock yang DITAMBAHKAN ke destination warehouse
DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"`
TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia
TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini
// === METADATA ===
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `gorm:"index"`
DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"`
TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia
TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini
ExpenseNonstockId *uint64 `gorm:"column:expense_nonstock_id"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `gorm:"index"`
// === RELATIONS ===
StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"`
Product *Product `gorm:"foreignKey:ProductId"`
SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"`
DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"`
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"`
}
@@ -247,7 +247,7 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
}
func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error {
param := c.Params("project_flock_id")
param := c.Params("projectFlockId")
projectFlockID, err := strconv.Atoi(param)
if err != nil {
@@ -151,7 +151,7 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entit
return nil, err
}
if len(realisasi) == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Penjualan realisasi not found")
return []entity.MarketingDeliveryProduct{}, nil
}
return realisasi, nil
}
@@ -71,9 +71,11 @@ type TransferDetailDTO struct {
}
type TransferDetailItemDTO struct {
Id uint64 `json:"id"`
Product ProductSimpleDTO `json:"product"`
Quantity float64 `json:"quantity"`
Id uint64 `json:"id"`
Product ProductSimpleDTO `json:"product"`
Quantity float64 `json:"quantity"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"` // Biaya ekspedisi per item
ExpeditionVendor *SupplierSimpleDTO `json:"expedition_vendor,omitempty"` // Vendor ekspedisi
}
type TransferDeliveryDTO struct {
@@ -153,14 +155,30 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
var details []TransferDetailItemDTO
for _, d := range e.Details {
details = append(details, TransferDetailItemDTO{
detailDTO := TransferDetailItemDTO{
Id: d.Id,
Product: ProductSimpleDTO{
Id: d.Product.Id,
Name: d.Product.Name,
},
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
})
}
if d.ExpenseNonstock != nil {
priceCopy := d.ExpenseNonstock.Price
detailDTO.TransportPerItem = &priceCopy
if d.ExpenseNonstock.Expense != nil && d.ExpenseNonstock.Expense.Supplier != nil && d.ExpenseNonstock.Expense.Supplier.Id != 0 {
exp := d.ExpenseNonstock.Expense
detailDTO.ExpeditionVendor = &SupplierSimpleDTO{
Id: exp.Supplier.Id,
Name: exp.Supplier.Name,
}
}
}
details = append(details, detailDTO)
}
var deliveries []TransferDeliveryDTO
@@ -223,14 +241,30 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO {
func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
var details []TransferDetailItemDTO
for _, d := range e.Details {
details = append(details, TransferDetailItemDTO{
detailDTO := TransferDetailItemDTO{
Id: d.Id,
Product: ProductSimpleDTO{
Id: d.Product.Id,
Name: d.Product.Name,
},
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
})
}
if d.ExpenseNonstock != nil {
priceCopy := d.ExpenseNonstock.Price
detailDTO.TransportPerItem = &priceCopy
if d.ExpenseNonstock.Expense != nil && d.ExpenseNonstock.Expense.Supplier != nil && d.ExpenseNonstock.Expense.Supplier.Id != 0 {
exp := d.ExpenseNonstock.Expense
detailDTO.ExpeditionVendor = &SupplierSimpleDTO{
Id: exp.Supplier.Id,
Name: exp.Supplier.Name,
}
}
}
details = append(details, detailDTO)
}
var deliveries []TransferDeliveryDTO
+35 -1
View File
@@ -2,6 +2,7 @@ package transfers
import (
"context"
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -9,9 +10,13 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
expenseService "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/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"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
@@ -35,15 +40,44 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
kandangRepo := rKandang.NewKandangRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
expenseRepository := expenseRepo.NewExpenseRepository(db)
expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db)
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil {
panic(err)
}
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo)
if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register expense approval workflow: %v", err))
}
expenseServiceInstance := expenseService.NewExpenseService(
expenseRepository,
supplierRepo,
nonstockRepo,
approvalSvc,
expenseRealizationRepo,
projectFlockKandangRepo,
documentSvc,
validate,
)
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
expenseBridge := sTransfer.NewTransferExpenseBridge(
db,
stockTransferRepo,
projectFlockKandangRepo,
kandangRepo,
expenseServiceInstance,
)
err = fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyStockTransferIn,
@@ -77,7 +111,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
panic(err)
}
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService)
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService, expenseBridge)
userService := sUser.NewUserService(userRepo, validate)
TransferRoutes(router, userService, transferService)
@@ -46,9 +46,10 @@ type transferService struct {
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
DocumentSvc commonSvc.DocumentService
FifoSvc commonSvc.FifoService
ExpenseBridge TransferExpenseBridge
}
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService) TransferService {
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, expenseBridge TransferExpenseBridge) TransferService {
return &transferService{
Log: utils.Log,
Validate: validate,
@@ -63,6 +64,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc,
FifoSvc: fifoSvc,
ExpenseBridge: expenseBridge,
}
}
@@ -77,6 +79,9 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ToWarehouse.Area").
Preload("Details").
Preload("Details.Product").
Preload("Details.ExpenseNonstock").
Preload("Details.ExpenseNonstock.Expense").
Preload("Details.ExpenseNonstock.Expense.Supplier").
Preload("Deliveries.Items").
Preload("Deliveries.Supplier").
Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB {
@@ -192,7 +197,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek data supplier")
}
if supplier.Category != "BOP" {
if supplier.Category != string(utils.SupplierCategoryBOP) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID))
}
}
@@ -213,6 +218,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
CreatedBy: uint64(actorID),
}
expensePayloads := make([]TransferExpenseReceivingPayload, 0)
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil {
@@ -362,7 +369,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return fmt.Errorf("gagal update usage tracking: %w", err)
}
// Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN)
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyStockTransferIn,
@@ -385,6 +391,32 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}
}
if len(req.Deliveries) > 0 {
for _, delivery := range req.Deliveries {
for _, prod := range delivery.Products {
detail := detailMap[uint64(prod.ProductID)]
if detail == nil {
continue
}
warehouseID := uint(req.DestinationWarehouseID)
supplierID := uint(delivery.SupplierID)
deliveredDate := transferDate
deliveredQty := prod.ProductQty
payload := TransferExpenseReceivingPayload{
TransferDetailID: detail.Id,
ProductID: uint64(prod.ProductID),
WarehouseID: uint64(warehouseID),
SupplierID: uint64(supplierID),
DeliveredQty: deliveredQty,
DeliveredDate: &deliveredDate,
}
expensePayloads = append(expensePayloads, payload)
}
}
}
return nil
})
@@ -396,9 +428,31 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if err != nil {
return nil, err
}
if len(expensePayloads) > 0 {
if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil {
s.Log.Errorf("Failed to sync expense for transfer %d: %+v", entityTransfer.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to sync expense: %v", err))
}
}
return result, nil
}
func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error {
if s.ExpenseBridge == nil || transferID == 0 || len(payloads) == 0 {
return nil
}
return s.ExpenseBridge.OnItemsDelivered(c, transferID, payloads)
}
func (s *transferService) notifyExpenseDetailsDeleted(ctx context.Context, transferID uint64, items []entity.StockTransferDetail) error {
if s.ExpenseBridge == nil || transferID == 0 || len(items) == 0 {
return nil
}
return s.ExpenseBridge.OnItemsDeleted(ctx, transferID, items)
}
func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil)
if err != nil {
@@ -0,0 +1,452 @@
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"
rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
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"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
type TransferExpenseBridge interface {
OnItemsDeleted(ctx context.Context, transferID uint64, items []entity.StockTransferDetail) error
OnItemsDelivered(c *fiber.Ctx, transferID uint64, updates []TransferExpenseReceivingPayload) error
}
type TransferExpenseReceivingPayload struct {
TransferDetailID uint64
ProductID uint64
WarehouseID uint64
SupplierID uint64
TransportPerItem *float64
DeliveredQty float64
DeliveredDate *time.Time
}
type groupedTransferItem struct {
detail *entity.StockTransferDetail
payload TransferExpenseReceivingPayload
projectFK *uint
kandangID *uint
totalPrice float64
}
func groupingKey(supplierID uint, date time.Time, warehouseID uint) string {
return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID)
}
type transferExpenseBridge struct {
db *gorm.DB
transferRepo rStockTransfer.StockTransferRepository
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
kandangRepo kandangRepo.KandangRepository
expenseSvc expenseSvc.ExpenseService
}
func NewTransferExpenseBridge(
db *gorm.DB,
transferRepo rStockTransfer.StockTransferRepository,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
kandangRepo kandangRepo.KandangRepository,
expenseSvc expenseSvc.ExpenseService,
) TransferExpenseBridge {
return &transferExpenseBridge{
db: db,
transferRepo: transferRepo,
projectFlockKandangRepo: projectFlockKandangRepo,
kandangRepo: kandangRepo,
expenseSvc: expenseSvc,
}
}
func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, items []entity.StockTransferDetail) 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)
// Collect expense nonstock IDs from items
for _, item := range items {
if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 {
expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId)
}
}
if len(expenseNonstockIDs) > 0 {
// Get expense IDs from expense nonstocks
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{}{}
}
}
// Delete expense nonstocks
if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil {
return err
}
}
// Check remaining expense nonstocks for each expense
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 no more expense nonstocks, delete expense and approval
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 *transferExpenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[uint64]struct{}, actorID uint) error {
if len(expenseIDs) == 0 {
return nil
}
if actorID == 0 {
actorID = 1
}
svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db))
action := entity.ApprovalActionUpdated
for id := range expenseIDs {
if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil {
return err
}
}
return nil
}
func (b *transferExpenseBridge) 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 (b *transferExpenseBridge) createExpenseViaService(
c *fiber.Ctx,
transfer *entity.StockTransfer,
items []groupedTransferItem,
expenseDate time.Time,
expeditionNonstockID uint64,
movementNumber 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 {
// For transfer, use destination warehouse location
if transfer.ToWarehouse == nil || transfer.ToWarehouse.LocationId == nil || *transfer.ToWarehouse.LocationId == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Destination warehouse location is required for expense")
}
locationID = uint64(*transfer.ToWarehouse.LocationId)
}
costItems := make([]expenseValidation.CostItem, 0, len(items))
for _, gi := range items {
note := fmt.Sprintf("stock_transfer_detail:%d", gi.detail.Id)
price := gi.detail.TotalQty
if gi.payload.TransportPerItem != nil {
price = *gi.payload.TransportPerItem
}
costItems = append(costItems, expenseValidation.CostItem{
NonstockID: expeditionNonstockID,
Quantity: gi.payload.DeliveredQty,
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,
}},
}
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(transfer.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 *transferExpenseBridge) linkExpenseNonstocksToDetails(ctx context.Context, detail *expenseDto.ExpenseDetailDTO, items []groupedTransferItem) error {
if detail == nil || len(items) == 0 {
return nil
}
noteToExpenseNonstock := mapExpenseNotesForTransfer(detail)
if len(noteToExpenseNonstock) == 0 {
return nil
}
for _, gi := range items {
expenseNonstockID, ok := noteToExpenseNonstock[gi.detail.Id]
if !ok {
continue
}
if err := b.db.WithContext(ctx).
Model(&entity.StockTransferDetail{}).
Where("id = ?", gi.detail.Id).
Update("expense_nonstock_id", expenseNonstockID).Error; err != nil {
return err
}
}
return nil
}
func mapExpenseNotesForTransfer(detail *expenseDto.ExpenseDetailDTO) map[uint64]uint64 {
result := make(map[uint64]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 = "stock_transfer_detail:"
if !strings.HasPrefix(note, prefix) {
continue
}
idStr := strings.TrimPrefix(note, prefix)
var detailID uint64
if _, err := fmt.Sscanf(idStr, "%d", &detailID); err != nil {
continue
}
result[detailID] = pengajuan.Id
}
}
return result
}
func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64, updates []TransferExpenseReceivingPayload) error {
if transferID == 0 || len(updates) == 0 {
return nil
}
ctx := c.Context()
// Load transfer with details
transfer, err := b.transferRepo.GetByID(ctx, uint(transferID), func(db *gorm.DB) *gorm.DB {
return db.
Preload("Details").
Preload("Details.Product").
Preload("Details.DestProductWarehouse").
Preload("ToWarehouse")
})
if err != nil {
return err
}
detailMap := make(map[uint64]*entity.StockTransferDetail, len(transfer.Details))
for i := range transfer.Details {
detailMap[transfer.Details[i].Id] = &transfer.Details[i]
}
groups := make(map[string][]groupedTransferItem)
for _, payload := range updates {
if payload.DeliveredDate == nil {
return fiber.NewError(fiber.StatusBadRequest, "delivered_date is required")
}
detail := detailMap[payload.TransferDetailID]
if detail == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer detail %d not found", payload.TransferDetailID))
}
if payload.DeliveredQty <= 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Delivered quantity for detail %d must be greater than 0", payload.TransferDetailID))
}
deliveredDate := payload.DeliveredDate.UTC().Truncate(24 * time.Hour)
supplierID := payload.SupplierID
if supplierID == 0 {
supplierID = 1 // Default supplier
}
var kandangID *uint
var projectFK *uint
if detail.DestProductWarehouse.WarehouseId != 0 {
var kandangIDResult uint
if err := b.db.WithContext(ctx).
Table("warehouses").
Select("kandang_id").
Where("id = ?", detail.DestProductWarehouse.WarehouseId).
Scan(&kandangIDResult).Error; err == nil && kandangIDResult != 0 {
id := uint(kandangIDResult)
kandangID = &id
if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, kandangIDResult); err == nil && project != nil {
pid := uint(project.Id)
projectFK = &pid
}
}
}
pricePerItem := detail.TotalQty
if payload.TransportPerItem != nil {
pricePerItem = *payload.TransportPerItem
}
totalPrice := pricePerItem * payload.DeliveredQty
// Group by supplier:date:warehouse
warehouseID := uint(payload.WarehouseID)
if warehouseID == 0 && transfer.ToWarehouse != nil {
warehouseID = uint(transfer.ToWarehouse.Id)
}
if warehouseID == 0 && detail.DestProductWarehouse != nil {
warehouseID = uint(detail.DestProductWarehouse.WarehouseId)
}
key := groupingKey(uint(supplierID), deliveredDate, warehouseID)
groups[key] = append(groups[key], groupedTransferItem{
detail: detail,
payload: payload,
projectFK: projectFK,
kandangID: kandangID,
totalPrice: totalPrice,
})
}
updatedExpenses := make(map[uint64]struct{})
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, transfer, items, expenseDate, expeditionNonstockID, transfer.MovementNumber, uint(supplierID))
if err != nil {
return err
}
if err := b.linkExpenseNonstocksToDetails(ctx, expenseDetail, items); err != nil {
return err
}
if expenseDetail != nil && expenseDetail.Id != 0 {
updatedExpenses[uint64(expenseDetail.Id)] = struct{}{}
}
}
if len(updatedExpenses) > 0 {
actorID := uint(1) // Default actor
if err := b.markExpensesUpdated(ctx, updatedExpenses, actorID); err != nil {
return err
}
}
return nil
}
@@ -594,7 +594,7 @@ func (b *expenseBridge) createExpenseViaService(
req := &expenseValidation.Create{
PoNumber: "",
TransactionDate: utils.FormatDate(expenseDate),
Category: "BOP",
Category: string(utils.ExpenseCategoryBOP),
SupplierID: uint64(supplierID),
LocationID: locationID,
ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{