From 2650e919e7f5bbe24cbe79b891d910a80875c274 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 8 Jan 2026 15:14:06 +0700 Subject: [PATCH 1/5] 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{{ From 7af78d04dde33043cb1ca3798e3502d8b08622d9 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 8 Jan 2026 18:52:12 +0700 Subject: [PATCH 2/5] feat(BE): add file size validation and improve document indexing for transfer creation --- .../controllers/transfer.controller.go | 10 +++++-- .../transfers/services/transfer.service.go | 30 ++++++++++++------- .../validations/transfer.validation.go | 2 +- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index 4f060dc2..530d70dc 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -75,6 +75,8 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error { func (u *TransferController) CreateOne(c *fiber.Ctx) error { data := c.FormValue("data") + const maxFileSize = 5 * 1024 * 1024 + var req validation.TransferRequest if err := json.Unmarshal([]byte(data), &req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") @@ -87,9 +89,11 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { files := form.File["documents"] - if len(files) != len(req.Deliveries) { - return fiber.NewError(fiber.StatusBadRequest, - fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message) + for i, file := range files { + if file.Size > maxFileSize { + return fiber.NewError(fiber.StatusBadRequest, + "Dokumen ke-"+strconv.Itoa(i+1)+" melebihi ukuran maksimal 5MB") + } } result, err := u.TransferService.CreateOne(c, &req, files) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 86fdc69a..d14125ed 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -128,7 +128,6 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { - // === VALIDASI SOURCE WAREHOUSE === pwIDs := make([]uint, 0, len(req.Products)) for _, product := range req.Products { @@ -201,7 +200,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID)) } } - seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") @@ -226,12 +224,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - // Prepare details and fetch product warehouses details := make([]*entity.StockTransferDetail, 0, len(req.Products)) detailMap := make(map[uint64]*entity.StockTransferDetail) for _, product := range req.Products { - // Get source product warehouse + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), ) @@ -242,7 +239,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") } - // Get or create destination product warehouse destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) @@ -323,24 +319,38 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if s.DocumentSvc != nil && len(files) > 0 { - for idx, file := range files { + for deliveryIdx, delivery := range deliveries { + reqDelivery := req.Deliveries[deliveryIdx] + + if reqDelivery.DocumentIndex < 0 { + continue + } + + if reqDelivery.DocumentIndex >= len(files) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("DocumentIndex %d untuk delivery %d melebihi jumlah file yang diupload (%d)", + reqDelivery.DocumentIndex, deliveryIdx+1, len(files))) + } + + file := files[reqDelivery.DocumentIndex] + documentFiles := []commonSvc.DocumentFile{ { File: file, Type: string(utils.DocumentTypeTransfer), - Index: &idx, + Index: &reqDelivery.DocumentIndex, }, } _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ DocumentableType: string(utils.DocumentableTypeTransfer), - DocumentableID: deliveries[idx].Id, + DocumentableID: delivery.Id, CreatedBy: &actorID, Files: documentFiles, }) if err != nil { s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)", - idx+1, deliveries[idx].Id, file.Filename) - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", idx+1, err)) + deliveryIdx+1, delivery.Id, file.Filename) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", deliveryIdx+1, err)) } } } diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go index c64077ff..785295e2 100644 --- a/internal/modules/inventory/transfers/validations/transfer.validation.go +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -23,7 +23,7 @@ type TransferDeliveryProduct struct { type TransferDelivery struct { DeliveryCost float64 `json:"delivery_cost" validate:"required"` DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"` - DocumentIndex int `json:"document_index" validate:"min=0"` + DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"` DriverName string `json:"driver_name" validate:"required"` VehiclePlate string `json:"vehicle_plate" validate:"required"` SupplierID uint `json:"supplier_id" validate:"required"` From 5606b9c4a3118303142f27e030a4b5e4eb6a2254 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 8 Jan 2026 20:44:56 +0700 Subject: [PATCH 3/5] FIX(BE): fix closing marketing 500 --- .../modules/closings/dto/closingMarketing.dto.go | 9 +++++++-- .../modules/closings/services/closing.service.go | 12 +++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 4c7b4d35..42d95be2 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -55,16 +55,21 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { kandang = &mapped } + var realizationDate time.Time + if e.DeliveryDate != nil { + realizationDate = *e.DeliveryDate + } + doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) return SalesDTO{ Id: e.Id, - RealizationDate: *e.DeliveryDate, + RealizationDate: realizationDate, Age: age, DoNumber: doNumber, Product: product, Customer: customer, - Qty: e.UsageQty, // Show allocated quantity from FIFO + Qty: e.UsageQty, Weight: e.TotalWeight, AvgWeight: e.AvgWeight, Price: e.UnitPrice, diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 9659601e..c3e3108d 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -153,7 +153,17 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entit if len(realisasi) == 0 { return []entity.MarketingDeliveryProduct{}, nil } - return realisasi, nil + + filtered := make([]entity.MarketingDeliveryProduct, 0, len(realisasi)) + for _, item := range realisasi { + + if item.UsageQty != 0 || item.TotalWeight != 0 || item.AvgWeight != 0 || + item.UnitPrice != 0 || item.TotalPrice != 0 { + filtered = append(filtered, item) + } + } + + return filtered, nil } func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) { From 857a3c284b3ab398347d317eb90129f8e4d8eaa2 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 8 Jan 2026 22:00:32 +0700 Subject: [PATCH 4/5] feat(BE): implement movement number generation and refactor transfer creation logic --- .../repositories/stock_transfer.repository.go | 11 ++++++ .../transfers/services/transfer.service.go | 39 +++++++++++-------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go index e79d6310..cd314901 100644 --- a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go +++ b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "fmt" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -12,6 +13,7 @@ type StockTransferRepository interface { repository.BaseRepository[entity.StockTransfer] // get sequence for movement number GetNextMovementNumber(ctx context.Context) (int64, error) + GenerateMovementNumber(ctx context.Context) (string, error) } type StockTransferRepositoryImpl struct { @@ -32,3 +34,12 @@ func (r *StockTransferRepositoryImpl) GetNextMovementNumber(ctx context.Context) } return seq, nil } + +func (r *StockTransferRepositoryImpl) GenerateMovementNumber(ctx context.Context) (string, error) { + seq, err := r.GetNextMovementNumber(ctx) + if err != nil { + return "", err + } + movementNumber := fmt.Sprintf("ST-%05d", seq) + return movementNumber, nil +} diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index d14125ed..dc6399d5 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -159,14 +159,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, err } - if s.ProjectFlockKandangRepo != nil { - projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") - } - if projectFlockKandang.ClosedAt != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") - } + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + if projectFlockKandang.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") } actorID, err := m.ActorIDFromContext(c) @@ -200,11 +198,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID)) } } - seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) + + movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context()) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") } - movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum) + transferDate, _ := utils.ParseDateString(req.TransferDate) entityTransfer := &entity.StockTransfer{ @@ -220,7 +219,13 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques 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 { + stockTransferRepoTX := s.StockTransferRepo.WithTx(tx) + stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx) + stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx) + stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx) + productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx) + + if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil { return err } @@ -229,7 +234,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques for _, product := range req.Products { - sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), ) if err != nil { @@ -239,7 +244,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") } - destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -257,7 +262,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Quantity: 0, ProjectFlockKandangId: &projectFlockKandangID, } - if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { + if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") } } @@ -278,7 +283,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques detailMap[uint64(product.ProductID)] = detail } - if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { + if err := stockTransferDetailRepoTX.CreateMany(c.Context(), details, nil); err != nil { return err } @@ -293,7 +298,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ShippingCostTotal: delivery.DeliveryCost, }) } - if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { + if err := stockTransferDeliveryRepoTX.CreateMany(c.Context(), deliveries, nil); err != nil { return err } @@ -313,7 +318,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) } } - if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { + if err := stockTransferDeliveryItemRepoTX.CreateMany(c.Context(), deliveryItems, nil); err != nil { return err } From b11f03dfda56e7ff1b65b42f18e3f0d5a4d9db17 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 9 Jan 2026 09:15:53 +0700 Subject: [PATCH 5/5] feat(BE): fixing wrong perhitungan biaya --- .../repositories/closing.repository.go | 8 +- .../closings/services/closing.service.go | 13 +--- .../services/transfer_expense_bridge.go | 75 ++++++++++++------- .../repositories/projectflock.repository.go | 5 ++ .../hpp_per_kandang.repository.go | 10 +-- 5 files changed, 62 insertions(+), 49 deletions(-) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 4948ae5e..9d08d083 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -10,6 +10,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -914,9 +915,8 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C var rows []ActualUsageCostRow - // Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll) - purchaseStockableKey := "PURCHASE_ITEMS" - transferStockableKey := "STOCK_TRANSFER_DETAILS" + purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() + transferStockableKey := fifo.StockableKeyStockTransferIn.String() recordingQuery := db. Table("recordings AS r"). @@ -982,7 +982,6 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C return nil, err } - // Part 2: Get usage from project_chickins (DOC, Pullet) chickinQuery := db. Table("project_chickins AS pc"). Select(` @@ -1006,7 +1005,6 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C return nil, err } - // Merge results rows = append(rows, chickinRows...) return rows, nil diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index c3e3108d..a76085c4 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -413,18 +413,9 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint) (*dto.Ove } func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) { - if projectFlockID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") - } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: func(ctx context.Context, id uint) (bool, error) { - _, err := s.ProjectFlockRepo.GetByID(ctx, id, nil) - if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return err == nil, err - }}, + commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, ); err != nil { return nil, err } @@ -439,13 +430,11 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") } - // Get actual usage cost instead of purchase items actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost") } - // Convert actual usage rows to pseudo purchase items purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows) realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) diff --git a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go index b16cfd4f..90350c18 100644 --- a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -39,11 +39,12 @@ type TransferExpenseReceivingPayload struct { } type groupedTransferItem struct { - detail *entity.StockTransferDetail - payload TransferExpenseReceivingPayload - projectFK *uint - kandangID *uint - totalPrice float64 + detail *entity.StockTransferDetail + payload TransferExpenseReceivingPayload + projectFK *uint + kandangID *uint + totalPrice float64 + shippingCostTotal float64 } func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { @@ -83,7 +84,7 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it 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) @@ -91,7 +92,7 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it } if len(expenseNonstockIDs) > 0 { - // Get expense IDs from expense nonstocks + for _, nsID := range expenseNonstockIDs { var expenseID uint64 if err := tx.Model(&entity.ExpenseNonstock{}). @@ -105,13 +106,13 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it } } - // 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 @@ -121,7 +122,7 @@ func (b *transferExpenseBridge) OnItemsDeleted(ctx context.Context, _ uint64, it 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 @@ -208,7 +209,7 @@ func (b *transferExpenseBridge) createExpenseViaService( 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") } @@ -218,13 +219,16 @@ func (b *transferExpenseBridge) createExpenseViaService( 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 + + + price := gi.shippingCostTotal if gi.payload.TransportPerItem != nil { - price = *gi.payload.TransportPerItem + price = *gi.payload.TransportPerItem * gi.payload.DeliveredQty } + costItems = append(costItems, expenseValidation.CostItem{ NonstockID: expeditionNonstockID, - Quantity: gi.payload.DeliveredQty, + Quantity: 1, Price: price, Notes: note, }) @@ -247,7 +251,7 @@ func (b *transferExpenseBridge) createExpenseViaService( return nil, err } - // Mark approvals up to Finance so latest is Manager Finance + action := entity.ApprovalActionApproved actorID := uint(transfer.CreatedBy) if actorID == 0 { @@ -324,12 +328,14 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 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("Details.DeliveryItems"). + Preload("Details.DeliveryItems.StockTransferDelivery"). Preload("ToWarehouse") }) if err != nil { @@ -337,8 +343,18 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 } detailMap := make(map[uint64]*entity.StockTransferDetail, len(transfer.Details)) + shippingCostMap := make(map[uint64]float64) // detailID -> ShippingCostTotal + for i := range transfer.Details { detailMap[transfer.Details[i].Id] = &transfer.Details[i] + + + for _, deliveryItem := range transfer.Details[i].DeliveryItems { + if deliveryItem.StockTransferDelivery != nil { + shippingCostMap[transfer.Details[i].Id] = deliveryItem.StockTransferDelivery.ShippingCostTotal + break + } + } } groups := make(map[string][]groupedTransferItem) @@ -379,13 +395,17 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 } } - pricePerItem := detail.TotalQty - if payload.TransportPerItem != nil { - pricePerItem = *payload.TransportPerItem - } - totalPrice := pricePerItem * payload.DeliveredQty + + shippingCostTotal := shippingCostMap[detail.Id] + + + totalPrice := shippingCostTotal + if payload.TransportPerItem != nil { + + totalPrice = *payload.TransportPerItem * payload.DeliveredQty + } + - // Group by supplier:date:warehouse warehouseID := uint(payload.WarehouseID) if warehouseID == 0 && transfer.ToWarehouse != nil { warehouseID = uint(transfer.ToWarehouse.Id) @@ -396,11 +416,12 @@ func (b *transferExpenseBridge) OnItemsDelivered(c *fiber.Ctx, transferID uint64 key := groupingKey(uint(supplierID), deliveredDate, warehouseID) groups[key] = append(groups[key], groupedTransferItem{ - detail: detail, - payload: payload, - projectFK: projectFK, - kandangID: kandangID, - totalPrice: totalPrice, + detail: detail, + payload: payload, + projectFK: projectFK, + kandangID: kandangID, + totalPrice: totalPrice, + shippingCostTotal: shippingCostTotal, }) } diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 6cd98a8f..e65dfb4a 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -20,6 +20,7 @@ type ProjectflockRepository interface { GetCurrentProjectPeriod(ctx context.Context, projectFlockID uint) (int, error) GetKandangPeriodSummaryRows(ctx context.Context, locationID uint) ([]KandangPeriodRow, error) GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) + IdExists(ctx context.Context, id uint) (bool, error) AreaExists(ctx context.Context, id uint) (bool, error) FcrExists(ctx context.Context, id uint) (bool, error) ProductionStandardExists(ctx context.Context, id uint) (bool, error) @@ -161,6 +162,10 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s ) } +func (r *ProjectflockRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProjectFlock](ctx, r.DB(), id) +} + func (r *ProjectflockRepositoryImpl) AreaExists(ctx context.Context, id uint) (bool, error) { return repository.Exists[entity.Area](ctx, r.DB(), id) } diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 6d4185e8..4bd9aab4 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -107,21 +107,21 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs) purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() - transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_DETAILS").String() + transferStockableKey := fifo.StockableKeyStockTransferIn.String() query := r.db.WithContext(ctx). Table("recordings AS r"). Select(` k.id AS kandang_id, - COALESCE(SUM(CASE + COALESCE(SUM(CASE WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) - ELSE 0 + ELSE 0 END), 0) AS feed_cost, - COALESCE(SUM(CASE + COALESCE(SUM(CASE WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) - ELSE 0 + ELSE 0 END), 0) AS ovk_cost`, utils.FlagPakan, transferStockableKey, utils.FlagPakan, utils.FlagOVK, transferStockableKey, utils.FlagOVK).