Merge branch 'dev/teguh' into 'development'

FEAT[BE]: add expense to transfer stock, make  document optional on transfer stock,  fixing closing penjualan bad request

See merge request mbugroup/lti-api!144
This commit is contained in:
Hafizh A. Y.
2026-01-09 03:51:48 +00:00
19 changed files with 735 additions and 92 deletions
@@ -1,6 +1,8 @@
DROP TABLE IF EXISTS daily_checklist_tasks; -- Drop tables in correct order (child tables before parent tables)
DROP TABLE IF EXISTS daily_checklist_activity_task_assignments; -- Child table with FK to daily_checklist_activity_tasks
DROP TABLE IF EXISTS daily_checklist_activity_task_assignees; DROP TABLE IF EXISTS daily_checklist_activity_task_assignees;
DROP TABLE IF EXISTS daily_checklist_activity_tasks; 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_checklist_phases;
DROP TABLE IF EXISTS daily_checklists; DROP TABLE IF EXISTS daily_checklists;
DROP TABLE IF EXISTS checklists; DROP TABLE IF EXISTS checklists;
@@ -0,0 +1,4 @@
-- Remove expense_nonstock_id from stock_transfer_details
ALTER TABLE stock_transfer_details DROP CONSTRAINT IF EXISTS fk_stock_transfer_details_expense_nonstock;
ALTER TABLE stock_transfer_details DROP COLUMN IF EXISTS expense_nonstock_id;
DROP INDEX IF EXISTS idx_stock_transfer_details_expense_nonstock_id;
@@ -0,0 +1,10 @@
-- Add expense_nonstock_id to stock_transfer_details
-- This allows tracking expedition/transport costs for stock transfers (same as purchase)
ALTER TABLE stock_transfer_details
ADD COLUMN expense_nonstock_id BIGINT,
ADD CONSTRAINT fk_stock_transfer_details_expense_nonstock
FOREIGN KEY (expense_nonstock_id) REFERENCES expense_nonstocks(id) ON DELETE SET NULL;
-- Create index for better query performance
CREATE INDEX idx_stock_transfer_details_expense_nonstock_id ON stock_transfer_details(expense_nonstock_id);
+9 -9
View File
@@ -5,15 +5,15 @@ import (
) )
type ExpenseNonstock struct { type ExpenseNonstock struct {
Id uint64 `gorm:"primaryKey;autoIncrement"` Id uint64 `gorm:"primaryKey;autoIncrement"`
ExpenseId *uint64 `gorm:""` ExpenseId *uint64 `gorm:""`
ProjectFlockKandangId *uint64 `gorm:""` ProjectFlockKandangId *uint64 `gorm:""`
KandangId *uint64 `gorm:""` KandangId *uint64 `gorm:""`
NonstockId *uint64 `gorm:""` NonstockId *uint64 `gorm:""`
Qty float64 `gorm:"type:numeric(15,3);not null"` Qty float64 `gorm:"type:numeric(15,3);not null"`
Price float64 `gorm:"type:numeric(15,3);not null;column:price"` Price float64 `gorm:"type:numeric(15,3);not null;column:price"`
Notes string `gorm:"type:text;column:notes"` Notes string `gorm:"type:text;column:notes"`
CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"` CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"`
Expense *Expense `gorm:"foreignKey:ExpenseId;references:Id"` Expense *Expense `gorm:"foreignKey:ExpenseId;references:Id"`
ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
+8 -13
View File
@@ -8,27 +8,22 @@ type StockTransferDetail struct {
StockTransferId uint64 StockTransferId uint64
ProductId uint64 ProductId uint64
// === FIFO FIELDS - SOURCE WAREHOUSE (Usable) ===
// Tracking stock yang DIAMBIL dari source warehouse
SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"` SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"`
UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil
PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock) PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock)
DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"`
// === FIFO FIELDS - DESTINATION WAREHOUSE (Stockable) === TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia
// Tracking stock yang DITAMBAHKAN ke destination warehouse TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini
DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"` ExpenseNonstockId *uint64 `gorm:"column:expense_nonstock_id"`
TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia CreatedAt time.Time
TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini UpdatedAt time.Time
DeletedAt *time.Time `gorm:"index"`
// === METADATA ===
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `gorm:"index"`
// === RELATIONS === // === RELATIONS ===
StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"`
Product *Product `gorm:"foreignKey:ProductId"` Product *Product `gorm:"foreignKey:ProductId"`
SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"` SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"`
DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"` DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"`
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"`
} }
@@ -247,7 +247,7 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
} }
func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error { func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error {
param := c.Params("project_flock_id") param := c.Params("projectFlockId")
projectFlockID, err := strconv.Atoi(param) projectFlockID, err := strconv.Atoi(param)
if err != nil { if err != nil {
@@ -55,16 +55,21 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
kandang = &mapped 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) doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id)
return SalesDTO{ return SalesDTO{
Id: e.Id, Id: e.Id,
RealizationDate: *e.DeliveryDate, RealizationDate: realizationDate,
Age: age, Age: age,
DoNumber: doNumber, DoNumber: doNumber,
Product: product, Product: product,
Customer: customer, Customer: customer,
Qty: e.UsageQty, // Show allocated quantity from FIFO Qty: e.UsageQty,
Weight: e.TotalWeight, Weight: e.TotalWeight,
AvgWeight: e.AvgWeight, AvgWeight: e.AvgWeight,
Price: e.UnitPrice, Price: e.UnitPrice,
@@ -10,6 +10,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" 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"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -914,9 +915,8 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C
var rows []ActualUsageCostRow var rows []ActualUsageCostRow
// Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll) purchaseStockableKey := fifo.StockableKeyPurchaseItems.String()
purchaseStockableKey := "PURCHASE_ITEMS" transferStockableKey := fifo.StockableKeyStockTransferIn.String()
transferStockableKey := "STOCK_TRANSFER_DETAILS"
recordingQuery := db. recordingQuery := db.
Table("recordings AS r"). Table("recordings AS r").
@@ -982,7 +982,6 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C
return nil, err return nil, err
} }
// Part 2: Get usage from project_chickins (DOC, Pullet)
chickinQuery := db. chickinQuery := db.
Table("project_chickins AS pc"). Table("project_chickins AS pc").
Select(` Select(`
@@ -1006,7 +1005,6 @@ func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.C
return nil, err return nil, err
} }
// Merge results
rows = append(rows, chickinRows...) rows = append(rows, chickinRows...)
return rows, nil return rows, nil
@@ -151,9 +151,19 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entit
return nil, err return nil, err
} }
if len(realisasi) == 0 { if len(realisasi) == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Penjualan realisasi not found") 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) { func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) {
@@ -403,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) { 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(), if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: func(ctx context.Context, id uint) (bool, error) { commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists},
_, err := s.ProjectFlockRepo.GetByID(ctx, id, nil)
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return err == nil, err
}},
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -429,13 +430,11 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") 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) actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost") 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) purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows)
realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID)
@@ -75,6 +75,8 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error {
func (u *TransferController) CreateOne(c *fiber.Ctx) error { func (u *TransferController) CreateOne(c *fiber.Ctx) error {
data := c.FormValue("data") data := c.FormValue("data")
const maxFileSize = 5 * 1024 * 1024
var req validation.TransferRequest var req validation.TransferRequest
if err := json.Unmarshal([]byte(data), &req); err != nil { if err := json.Unmarshal([]byte(data), &req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
@@ -87,9 +89,11 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error {
files := form.File["documents"] files := form.File["documents"]
if len(files) != len(req.Deliveries) { for i, file := range files {
return fiber.NewError(fiber.StatusBadRequest, if file.Size > maxFileSize {
fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message) return fiber.NewError(fiber.StatusBadRequest,
"Dokumen ke-"+strconv.Itoa(i+1)+" melebihi ukuran maksimal 5MB")
}
} }
result, err := u.TransferService.CreateOne(c, &req, files) result, err := u.TransferService.CreateOne(c, &req, files)
@@ -71,9 +71,11 @@ type TransferDetailDTO struct {
} }
type TransferDetailItemDTO struct { type TransferDetailItemDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
Product ProductSimpleDTO `json:"product"` Product ProductSimpleDTO `json:"product"`
Quantity float64 `json:"quantity"` 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 { type TransferDeliveryDTO struct {
@@ -153,14 +155,30 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
var details []TransferDetailItemDTO var details []TransferDetailItemDTO
for _, d := range e.Details { for _, d := range e.Details {
details = append(details, TransferDetailItemDTO{ detailDTO := TransferDetailItemDTO{
Id: d.Id, Id: d.Id,
Product: ProductSimpleDTO{ Product: ProductSimpleDTO{
Id: d.Product.Id, Id: d.Product.Id,
Name: d.Product.Name, Name: d.Product.Name,
}, },
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated 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 var deliveries []TransferDeliveryDTO
@@ -223,14 +241,30 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO {
func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
var details []TransferDetailItemDTO var details []TransferDetailItemDTO
for _, d := range e.Details { for _, d := range e.Details {
details = append(details, TransferDetailItemDTO{ detailDTO := TransferDetailItemDTO{
Id: d.Id, Id: d.Id,
Product: ProductSimpleDTO{ Product: ProductSimpleDTO{
Id: d.Product.Id, Id: d.Product.Id,
Name: d.Product.Name, Name: d.Product.Name,
}, },
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated 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 var deliveries []TransferDeliveryDTO
+35 -1
View File
@@ -2,6 +2,7 @@ package transfers
import ( import (
"context" "context"
"fmt"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -9,9 +10,13 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" 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" 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" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" 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" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/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" 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) userRepo := rUser.NewUserRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
kandangRepo := rKandang.NewKandangRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db) documentRepo := commonRepo.NewDocumentRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db) stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
expenseRepository := expenseRepo.NewExpenseRepository(db)
expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db)
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil { if err != nil {
panic(err) 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) fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
expenseBridge := sTransfer.NewTransferExpenseBridge(
db,
stockTransferRepo,
projectFlockKandangRepo,
kandangRepo,
expenseServiceInstance,
)
err = fifoService.RegisterStockable(fifo.StockableConfig{ err = fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyStockTransferIn, Key: fifo.StockableKeyStockTransferIn,
@@ -77,7 +111,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
panic(err) 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) userService := sUser.NewUserService(userRepo, validate)
TransferRoutes(router, userService, transferService) TransferRoutes(router, userService, transferService)
@@ -2,6 +2,7 @@ package repositories
import ( import (
"context" "context"
"fmt"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -12,6 +13,7 @@ type StockTransferRepository interface {
repository.BaseRepository[entity.StockTransfer] repository.BaseRepository[entity.StockTransfer]
// get sequence for movement number // get sequence for movement number
GetNextMovementNumber(ctx context.Context) (int64, error) GetNextMovementNumber(ctx context.Context) (int64, error)
GenerateMovementNumber(ctx context.Context) (string, error)
} }
type StockTransferRepositoryImpl struct { type StockTransferRepositoryImpl struct {
@@ -32,3 +34,12 @@ func (r *StockTransferRepositoryImpl) GetNextMovementNumber(ctx context.Context)
} }
return seq, nil 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
}
@@ -46,9 +46,10 @@ type transferService struct {
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
DocumentSvc commonSvc.DocumentService DocumentSvc commonSvc.DocumentService
FifoSvc commonSvc.FifoService 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{ return &transferService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
@@ -63,6 +64,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc, DocumentSvc: documentSvc,
FifoSvc: fifoSvc, FifoSvc: fifoSvc,
ExpenseBridge: expenseBridge,
} }
} }
@@ -77,6 +79,9 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ToWarehouse.Area"). Preload("ToWarehouse.Area").
Preload("Details"). Preload("Details").
Preload("Details.Product"). Preload("Details.Product").
Preload("Details.ExpenseNonstock").
Preload("Details.ExpenseNonstock.Expense").
Preload("Details.ExpenseNonstock.Expense.Supplier").
Preload("Deliveries.Items"). Preload("Deliveries.Items").
Preload("Deliveries.Supplier"). Preload("Deliveries.Supplier").
Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB { Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB {
@@ -123,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) { 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)) pwIDs := make([]uint, 0, len(req.Products))
for _, product := range req.Products { for _, product := range req.Products {
@@ -155,14 +159,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, err return nil, err
} }
if s.ProjectFlockKandangRepo != nil { projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) if err != nil {
if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") }
} if projectFlockKandang.ClosedAt != nil {
if projectFlockKandang.ClosedAt != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing")
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing")
}
} }
actorID, err := m.ActorIDFromContext(c) actorID, err := m.ActorIDFromContext(c)
@@ -192,16 +194,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
} }
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek data supplier") 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)) 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 { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number")
} }
movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum)
transferDate, _ := utils.ParseDateString(req.TransferDate) transferDate, _ := utils.ParseDateString(req.TransferDate)
entityTransfer := &entity.StockTransfer{ entityTransfer := &entity.StockTransfer{
@@ -213,19 +215,26 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
CreatedBy: uint64(actorID), CreatedBy: uint64(actorID),
} }
expensePayloads := make([]TransferExpenseReceivingPayload, 0)
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { 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 return err
} }
// Prepare details and fetch product warehouses
details := make([]*entity.StockTransferDetail, 0, len(req.Products)) details := make([]*entity.StockTransferDetail, 0, len(req.Products))
detailMap := make(map[uint64]*entity.StockTransferDetail) detailMap := make(map[uint64]*entity.StockTransferDetail)
for _, product := range req.Products { for _, product := range req.Products {
// Get source product warehouse
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
) )
if err != nil { if err != nil {
@@ -235,8 +244,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source")
} }
// Get or create destination product warehouse destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
) )
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -254,7 +262,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Quantity: 0, Quantity: 0,
ProjectFlockKandangId: &projectFlockKandangID, 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") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination")
} }
} }
@@ -275,7 +283,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
detailMap[uint64(product.ProductID)] = detail 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 return err
} }
@@ -290,7 +298,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
ShippingCostTotal: delivery.DeliveryCost, 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 return err
} }
@@ -310,30 +318,44 @@ 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 return err
} }
if s.DocumentSvc != nil && len(files) > 0 { 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{ documentFiles := []commonSvc.DocumentFile{
{ {
File: file, File: file,
Type: string(utils.DocumentTypeTransfer), Type: string(utils.DocumentTypeTransfer),
Index: &idx, Index: &reqDelivery.DocumentIndex,
}, },
} }
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeTransfer), DocumentableType: string(utils.DocumentableTypeTransfer),
DocumentableID: deliveries[idx].Id, DocumentableID: delivery.Id,
CreatedBy: &actorID, CreatedBy: &actorID,
Files: documentFiles, Files: documentFiles,
}) })
if err != nil { if err != nil {
s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)", s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)",
idx+1, deliveries[idx].Id, file.Filename) deliveryIdx+1, delivery.Id, file.Filename)
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", idx+1, err)) return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", deliveryIdx+1, err))
} }
} }
} }
@@ -362,7 +384,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return fmt.Errorf("gagal update usage tracking: %w", err) 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) note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyStockTransferIn, StockableKey: fifo.StockableKeyStockTransferIn,
@@ -385,6 +406,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 return nil
}) })
@@ -396,9 +443,31 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if err != nil { if err != nil {
return nil, err 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 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) { func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil)
if err != nil { if err != nil {
@@ -0,0 +1,473 @@
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
shippingCostTotal 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)
for _, item := range items {
if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 {
expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId)
}
}
if len(expenseNonstockIDs) > 0 {
for _, nsID := range expenseNonstockIDs {
var expenseID uint64
if err := tx.Model(&entity.ExpenseNonstock{}).
Select("expense_id").
Where("id = ?", nsID).
Scan(&expenseID).Error; err != nil {
return err
}
if expenseID != 0 {
expenseIDs[expenseID] = struct{}{}
}
}
if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil {
return err
}
}
approvalRepoTx := commonRepo.NewApprovalRepository(tx)
for expenseID := range expenseIDs {
var count int64
if err := tx.Model(&entity.ExpenseNonstock{}).
Where("expense_id = ?", expenseID).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil {
return err
}
if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil {
return err
}
}
}
return nil
})
}
func (b *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 {
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.shippingCostTotal
if gi.payload.TransportPerItem != nil {
price = *gi.payload.TransportPerItem * gi.payload.DeliveredQty
}
costItems = append(costItems, expenseValidation.CostItem{
NonstockID: expeditionNonstockID,
Quantity: 1,
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
}
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()
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 {
return err
}
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)
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
}
}
}
shippingCostTotal := shippingCostMap[detail.Id]
totalPrice := shippingCostTotal
if payload.TransportPerItem != nil {
totalPrice = *payload.TransportPerItem * payload.DeliveredQty
}
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,
shippingCostTotal: shippingCostTotal,
})
}
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
}
@@ -23,7 +23,7 @@ type TransferDeliveryProduct struct {
type TransferDelivery struct { type TransferDelivery struct {
DeliveryCost float64 `json:"delivery_cost" validate:"required"` DeliveryCost float64 `json:"delivery_cost" validate:"required"`
DeliveryCostPerItem float64 `json:"delivery_cost_per_item" 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"` DriverName string `json:"driver_name" validate:"required"`
VehiclePlate string `json:"vehicle_plate" validate:"required"` VehiclePlate string `json:"vehicle_plate" validate:"required"`
SupplierID uint `json:"supplier_id" validate:"required"` SupplierID uint `json:"supplier_id" validate:"required"`
@@ -20,6 +20,7 @@ type ProjectflockRepository interface {
GetCurrentProjectPeriod(ctx context.Context, projectFlockID uint) (int, error) GetCurrentProjectPeriod(ctx context.Context, projectFlockID uint) (int, error)
GetKandangPeriodSummaryRows(ctx context.Context, locationID uint) ([]KandangPeriodRow, error) GetKandangPeriodSummaryRows(ctx context.Context, locationID uint) ([]KandangPeriodRow, error)
GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, 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) AreaExists(ctx context.Context, id uint) (bool, error)
FcrExists(ctx context.Context, id uint) (bool, error) FcrExists(ctx context.Context, id uint) (bool, error)
ProductionStandardExists(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) { func (r *ProjectflockRepositoryImpl) AreaExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.Area](ctx, r.DB(), id) return repository.Exists[entity.Area](ctx, r.DB(), id)
} }
@@ -594,7 +594,7 @@ func (b *expenseBridge) createExpenseViaService(
req := &expenseValidation.Create{ req := &expenseValidation.Create{
PoNumber: "", PoNumber: "",
TransactionDate: utils.FormatDate(expenseDate), TransactionDate: utils.FormatDate(expenseDate),
Category: "BOP", Category: string(utils.ExpenseCategoryBOP),
SupplierID: uint64(supplierID), SupplierID: uint64(supplierID),
LocationID: locationID, LocationID: locationID,
ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{
@@ -107,21 +107,21 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs) recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs)
purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() purchaseStockableKey := fifo.StockableKeyPurchaseItems.String()
transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_DETAILS").String() transferStockableKey := fifo.StockableKeyStockTransferIn.String()
query := r.db.WithContext(ctx). query := r.db.WithContext(ctx).
Table("recordings AS r"). Table("recordings AS r").
Select(` Select(`
k.id AS kandang_id, k.id AS kandang_id,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) 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) 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, END), 0) AS feed_cost,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) 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) 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`, END), 0) AS ovk_cost`,
utils.FlagPakan, transferStockableKey, utils.FlagPakan, utils.FlagPakan, transferStockableKey, utils.FlagPakan,
utils.FlagOVK, transferStockableKey, utils.FlagOVK). utils.FlagOVK, transferStockableKey, utils.FlagOVK).