From 2650e919e7f5bbe24cbe79b891d910a80875c274 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 8 Jan 2026 15:14:06 +0700 Subject: [PATCH] feat(BE): implement expense tracking for stock transfers and enhance related services --- ...44_create_daily_checklists_tables.down.sql | 4 +- ...expense_to_stock_transfer_details.down.sql | 4 + ...d_expense_to_stock_transfer_details.up.sql | 10 + internal/entities/expense_nonstock.go | 18 +- internal/entities/stock_transfer_detail.go | 21 +- .../controllers/closing.controller.go | 2 +- .../closings/services/closing.service.go | 2 +- .../inventory/transfers/dto/transfer.dto.go | 48 +- .../modules/inventory/transfers/module.go | 36 +- .../transfers/services/transfer.service.go | 60 ++- .../services/transfer_expense_bridge.go | 452 ++++++++++++++++++ .../purchases/services/expense_bridge.go | 2 +- 12 files changed, 622 insertions(+), 37 deletions(-) create mode 100644 internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.down.sql create mode 100644 internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.up.sql create mode 100644 internal/modules/inventory/transfers/services/transfer_expense_bridge.go diff --git a/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql b/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql index 7be30be1..20182fe3 100644 --- a/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql +++ b/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql @@ -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; diff --git a/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.down.sql b/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.down.sql new file mode 100644 index 00000000..a8b3dfaa --- /dev/null +++ b/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.down.sql @@ -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; diff --git a/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.up.sql b/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.up.sql new file mode 100644 index 00000000..25d6b199 --- /dev/null +++ b/internal/database/migrations/20260107080257_add_expense_to_stock_transfer_details.up.sql @@ -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); diff --git a/internal/entities/expense_nonstock.go b/internal/entities/expense_nonstock.go index ccd4194c..946b7a08 100644 --- a/internal/entities/expense_nonstock.go +++ b/internal/entities/expense_nonstock.go @@ -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"` diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go index 9ab27824..dd24aadb 100644 --- a/internal/entities/stock_transfer_detail.go +++ b/internal/entities/stock_transfer_detail.go @@ -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"` } diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index c4580efb..6ab2d398 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -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 { diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index ddf52b49..9659601e 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -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 } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 652b2a70..8fa4d158 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -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 diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index bbb3c4aa..fde5e55a 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -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) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index afbb4627..86fdc69a 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -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 { diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go new file mode 100644 index 00000000..b16cfd4f --- /dev/null +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -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 +} diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 6c74a1fc..23b95c58 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -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{{