mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
1176 lines
43 KiB
Go
1176 lines
43 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sirupsen/logrus"
|
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
|
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
|
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"
|
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations"
|
|
rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
|
|
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
|
|
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
|
|
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
|
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type TransferService interface {
|
|
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error)
|
|
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error)
|
|
CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error)
|
|
DeleteOne(ctx *fiber.Ctx, id uint) error
|
|
}
|
|
|
|
type transferService struct {
|
|
Log *logrus.Logger
|
|
Validate *validator.Validate
|
|
StockTransferRepo rStockTransfer.StockTransferRepository
|
|
StockTransferDetailRepo rStockTransfer.StockTransferDetailRepository
|
|
StockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository
|
|
StockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository
|
|
StockLogsRepository rStockLogs.StockLogRepository
|
|
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
|
|
SupplierRepo rSupplier.SupplierRepository
|
|
WarehouseRepo warehouseRepo.WarehouseRepository
|
|
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
|
|
ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository
|
|
DocumentSvc commonSvc.DocumentService
|
|
FifoStockV2Svc commonSvc.FifoStockV2Service
|
|
ExpenseBridge TransferExpenseBridge
|
|
}
|
|
|
|
const transferDeleteDownstreamGuardMessage = "Transfer stock tidak dapat dihapus karena stok transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu."
|
|
|
|
type downstreamDependency struct {
|
|
UsableType string `gorm:"column:usable_type"`
|
|
UsableID uint64 `gorm:"column:usable_id"`
|
|
FunctionCode string `gorm:"column:function_code"`
|
|
FlagGroupCode string `gorm:"column:flag_group_code"`
|
|
}
|
|
|
|
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, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService {
|
|
return &transferService{
|
|
Log: utils.Log,
|
|
Validate: validate,
|
|
StockTransferRepo: stockTransferRepo,
|
|
StockTransferDetailRepo: stockTransferDetailRepo,
|
|
StockTransferDeliveryRepo: stockTransferDeliveryRepo,
|
|
StockTransferDeliveryItemRepo: stockTransferDeliveryItemRepo,
|
|
StockLogsRepository: stockLogsRepo,
|
|
ProductWarehouseRepo: productWarehouseRepo,
|
|
SupplierRepo: supplierRepo,
|
|
WarehouseRepo: warehouseRepo,
|
|
ProjectFlockKandangRepo: projectFlockKandangRepo,
|
|
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
|
|
DocumentSvc: documentSvc,
|
|
FifoStockV2Svc: fifoStockV2Svc,
|
|
ExpenseBridge: expenseBridge,
|
|
}
|
|
}
|
|
|
|
func (s transferService) withRelations(db *gorm.DB) *gorm.DB {
|
|
return db.
|
|
Preload("CreatedUser").
|
|
Preload("FromWarehouse").
|
|
Preload("FromWarehouse.Location").
|
|
Preload("FromWarehouse.Area").
|
|
Preload("ToWarehouse").
|
|
Preload("ToWarehouse.Location").
|
|
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 {
|
|
return db.Where("documentable_type = ?", string(utils.DocumentableTypeTransfer))
|
|
})
|
|
}
|
|
|
|
func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) {
|
|
if err := s.Validate.Struct(params); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
offset := (params.Page - 1) * params.Limit
|
|
|
|
transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
|
db = s.withRelations(db)
|
|
db = db.Where("stock_transfers.deleted_at IS NULL")
|
|
if scope.Restrict {
|
|
if len(scope.IDs) == 0 {
|
|
return db.Where("1 = 0")
|
|
}
|
|
db = db.
|
|
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
|
|
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
|
|
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs)
|
|
}
|
|
if params.Search != "" {
|
|
searchTerm := "%" + strings.TrimSpace(params.Search) + "%"
|
|
db = db.Joins("LEFT JOIN warehouses AS from_warehouses ON from_warehouses.id = stock_transfers.from_warehouse_id").
|
|
Joins("LEFT JOIN warehouses AS to_warehouses ON to_warehouses.id = stock_transfers.to_warehouse_id").
|
|
Where("movement_number ILIKE ? OR from_warehouses.name ILIKE ? OR to_warehouses.name ILIKE ?",
|
|
searchTerm, searchTerm, searchTerm)
|
|
}
|
|
return db.Order("created_at DESC").Order("updated_at DESC")
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return transfers, total, nil
|
|
}
|
|
|
|
func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) {
|
|
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if scope.Restrict {
|
|
if len(scope.IDs) == 0 {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
|
|
}
|
|
var count int64
|
|
if err := s.StockTransferRepo.DB().WithContext(c.Context()).
|
|
Table("stock_transfers").
|
|
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
|
|
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
|
|
Where("stock_transfers.id = ?", id).
|
|
Where("stock_transfers.deleted_at IS NULL").
|
|
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
|
|
Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if count == 0 {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
|
|
}
|
|
}
|
|
|
|
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
|
|
return s.withRelations(db).Where("stock_transfers.deleted_at IS NULL")
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
|
|
}
|
|
s.Log.Errorf("Failed to fetch transfer by ID %d: %+v", id, err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
|
|
}
|
|
|
|
return transferPtr, nil
|
|
}
|
|
|
|
func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) {
|
|
|
|
pwIDs := make([]uint, 0, len(req.Products))
|
|
|
|
for _, product := range req.Products {
|
|
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
|
|
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
|
|
}
|
|
s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
|
|
}
|
|
if sourcePW.Quantity < product.ProductQty {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty))
|
|
}
|
|
pwIDs = append(pwIDs, sourcePW.Id)
|
|
}
|
|
|
|
if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(
|
|
c.Context(),
|
|
s.StockTransferRepo.DB(),
|
|
pwIDs,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if destPfkID > 0 {
|
|
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
|
|
}
|
|
if projectFlockKandang.ClosedAt != nil {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02")))
|
|
}
|
|
}
|
|
|
|
actorID, err := m.ActorIDFromContext(c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
deliveryQtyMap := make(map[uint]float64)
|
|
for _, delivery := range req.Deliveries {
|
|
for _, prod := range delivery.Products {
|
|
deliveryQtyMap[prod.ProductID] += prod.ProductQty
|
|
}
|
|
}
|
|
|
|
for _, product := range req.Products {
|
|
if deliveryQtyMap[product.ProductID] > product.ProductQty {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest,
|
|
fmt.Sprintf("Total qty delivery untuk produk %d (%v) melebihi qty transfer (%v)", product.ProductID, deliveryQtyMap[product.ProductID], product.ProductQty))
|
|
}
|
|
}
|
|
|
|
for _, delivery := range req.Deliveries {
|
|
|
|
if delivery.SupplierID == 0 {
|
|
continue
|
|
}
|
|
|
|
if delivery.VehiclePlate == "" {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Vehicle plate wajib diisi ketika supplier dipilih")
|
|
}
|
|
if delivery.DriverName == "" {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Driver name wajib diisi ketika supplier dipilih")
|
|
}
|
|
if delivery.DeliveryCost <= 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost harus lebih dari 0 ketika supplier dipilih")
|
|
}
|
|
if delivery.DeliveryCostPerItem <= 0 {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost per item harus lebih dari 0 ketika supplier dipilih")
|
|
}
|
|
|
|
supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID))
|
|
}
|
|
s.Log.Errorf("Failed to fetch supplier by ID %d: %+v", delivery.SupplierID, err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data supplier")
|
|
}
|
|
if supplier.Category != string(utils.SupplierCategoryBOP) {
|
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier '%s' (ID: %d) bukan kategori BOP. Kategori saat ini: %s", supplier.Name, delivery.SupplierID, supplier.Category))
|
|
}
|
|
}
|
|
|
|
movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context())
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to generate movement number: %+v", err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
|
|
}
|
|
|
|
transferDate, _ := utils.ParseDateString(req.TransferDate)
|
|
|
|
entityTransfer := &entity.StockTransfer{
|
|
FromWarehouseId: uint64(req.SourceWarehouseID),
|
|
ToWarehouseId: uint64(req.DestinationWarehouseID),
|
|
Reason: req.TransferReason,
|
|
TransferDate: transferDate,
|
|
MovementNumber: movementNumber,
|
|
CreatedBy: uint64(actorID),
|
|
}
|
|
|
|
expensePayloads := make([]TransferExpenseReceivingPayload, 0)
|
|
|
|
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
|
|
stockTransferRepoTX := s.StockTransferRepo.WithTx(tx)
|
|
stockTransferDetailRepoTX := s.StockTransferDetailRepo.WithTx(tx)
|
|
stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx)
|
|
stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx)
|
|
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
|
|
stocklogsRepoTx := s.StockLogsRepository.WithTx(tx)
|
|
|
|
if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil {
|
|
return err
|
|
}
|
|
|
|
details := make([]*entity.StockTransferDetail, 0, len(req.Products))
|
|
detailMap := make(map[uint64]*entity.StockTransferDetail)
|
|
|
|
for _, product := range req.Products {
|
|
|
|
sourcePW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
|
|
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
|
|
}
|
|
s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
|
|
}
|
|
|
|
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
|
|
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
|
|
)
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
|
|
}
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
ctx := c.Context()
|
|
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var pfkID *uint
|
|
if projectFlockKandangID > 0 {
|
|
pfkID = &projectFlockKandangID
|
|
}
|
|
|
|
destPW = &entity.ProductWarehouse{
|
|
ProductId: uint(product.ProductID),
|
|
WarehouseId: uint(req.DestinationWarehouseID),
|
|
Quantity: 0,
|
|
ProjectFlockKandangId: pfkID,
|
|
}
|
|
if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil {
|
|
s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
|
|
}
|
|
}
|
|
|
|
detail := &entity.StockTransferDetail{
|
|
StockTransferId: entityTransfer.Id,
|
|
ProductId: uint64(product.ProductID),
|
|
|
|
SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(),
|
|
UsageQty: 0,
|
|
PendingQty: 0,
|
|
|
|
DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(),
|
|
TotalQty: 0,
|
|
TotalUsed: 0,
|
|
}
|
|
details = append(details, detail)
|
|
detailMap[uint64(product.ProductID)] = detail
|
|
}
|
|
|
|
if err := stockTransferDetailRepoTX.CreateMany(c.Context(), details, nil); err != nil {
|
|
return err
|
|
}
|
|
|
|
var deliveries []*entity.StockTransferDelivery
|
|
for _, delivery := range req.Deliveries {
|
|
supplierId := func() *uint64 {
|
|
if delivery.SupplierID > 0 {
|
|
id := uint64(delivery.SupplierID)
|
|
return &id
|
|
}
|
|
return nil
|
|
}()
|
|
deliveries = append(deliveries, &entity.StockTransferDelivery{
|
|
StockTransferId: entityTransfer.Id,
|
|
SupplierId: supplierId,
|
|
VehiclePlate: delivery.VehiclePlate,
|
|
DriverName: delivery.DriverName,
|
|
ShippingCostItem: delivery.DeliveryCostPerItem,
|
|
ShippingCostTotal: delivery.DeliveryCost,
|
|
})
|
|
}
|
|
if err := stockTransferDeliveryRepoTX.CreateMany(c.Context(), deliveries, nil); err != nil {
|
|
return err
|
|
}
|
|
|
|
var deliveryItems []*entity.StockTransferDeliveryItem
|
|
|
|
for i, delivery := range deliveries {
|
|
item := req.Deliveries[i]
|
|
for _, prod := range item.Products {
|
|
detail, ok := detailMap[uint64(prod.ProductID)]
|
|
if !ok {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan dalam daftar transfer untuk delivery #%d", prod.ProductID, i+1))
|
|
}
|
|
deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{
|
|
StockTransferDeliveryId: delivery.Id,
|
|
StockTransferDetailId: detail.Id,
|
|
Quantity: prod.ProductQty,
|
|
})
|
|
}
|
|
}
|
|
if err := stockTransferDeliveryItemRepoTX.CreateMany(c.Context(), deliveryItems, nil); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.DocumentSvc != nil && len(files) > 0 {
|
|
|
|
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: &reqDelivery.DocumentIndex,
|
|
},
|
|
}
|
|
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
|
|
DocumentableType: string(utils.DocumentableTypeTransfer),
|
|
DocumentableID: delivery.Id,
|
|
CreatedBy: &actorID,
|
|
Files: documentFiles,
|
|
})
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to upload document for delivery %d (delivery_id=%d, filename=%s): %+v",
|
|
deliveryIdx+1, delivery.Id, file.Filename, err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengunggah dokumen")
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.FifoStockV2Svc == nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
|
|
}
|
|
flagGroupByProduct := make(map[uint]string, len(req.Products))
|
|
|
|
for _, product := range req.Products {
|
|
detail := detailMap[uint64(product.ProductID)]
|
|
if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid")
|
|
}
|
|
|
|
flagGroupCode, ok := flagGroupByProduct[uint(product.ProductID)]
|
|
if !ok {
|
|
flagGroupCode, err = s.resolveTransferFlagGroup(c.Context(), tx, uint(product.ProductID))
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err))
|
|
}
|
|
flagGroupByProduct[uint(product.ProductID)] = flagGroupCode
|
|
}
|
|
|
|
if err := tx.Model(&entity.StockTransferDetail{}).
|
|
Where("id = ?", detail.Id).
|
|
Updates(map[string]interface{}{
|
|
"usage_qty": product.ProductQty,
|
|
"pending_qty": 0,
|
|
"total_qty": product.ProductQty,
|
|
}).Error; err != nil {
|
|
s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
|
|
}
|
|
|
|
asOf := transferDate
|
|
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
|
|
FlagGroupCode: flagGroupCode,
|
|
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
|
|
AsOf: &asOf,
|
|
Tx: tx,
|
|
}); err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
|
|
}
|
|
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
|
|
FlagGroupCode: flagGroupCode,
|
|
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
|
|
AsOf: &asOf,
|
|
Tx: tx,
|
|
}); err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err))
|
|
}
|
|
|
|
type usageSnapshot struct {
|
|
UsageQty float64 `gorm:"column:usage_qty"`
|
|
PendingQty float64 `gorm:"column:pending_qty"`
|
|
}
|
|
var usage usageSnapshot
|
|
if err := tx.WithContext(c.Context()).
|
|
Table("stock_transfer_details").
|
|
Select("usage_qty, pending_qty").
|
|
Where("id = ?", detail.Id).
|
|
Take(&usage).Error; err != nil {
|
|
s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking")
|
|
}
|
|
outUsageQty := usage.UsageQty
|
|
outPendingQty := usage.PendingQty
|
|
if outPendingQty > 1e-6 {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID))
|
|
}
|
|
|
|
stockLogDecrease := &entity.StockLog{
|
|
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
|
|
CreatedBy: uint(actorID),
|
|
Increase: 0,
|
|
Decrease: outUsageQty,
|
|
LoggableType: string(utils.StockLogTypeTransfer),
|
|
LoggableId: uint(detail.Id),
|
|
Notes: "",
|
|
}
|
|
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 1)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to get stock logs: %+v", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
|
|
}
|
|
if len(stockLogs) > 0 {
|
|
latestStockLog := stockLogs[0]
|
|
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
|
|
} else {
|
|
stockLogDecrease.Stock -= stockLogDecrease.Decrease
|
|
}
|
|
|
|
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
|
|
}
|
|
|
|
inAddedQty := outUsageQty
|
|
|
|
stockLogIncrease := &entity.StockLog{
|
|
ProductWarehouseId: uint(*detail.DestProductWarehouseID),
|
|
CreatedBy: uint(actorID),
|
|
Increase: inAddedQty,
|
|
Decrease: 0,
|
|
LoggableType: string(utils.StockLogTypeTransfer),
|
|
LoggableId: uint(detail.Id),
|
|
Notes: "",
|
|
}
|
|
stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 1)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to get stock logs: %+v", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
|
|
}
|
|
if len(stockLogs) > 0 {
|
|
latestStockLog := stockLogs[0]
|
|
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
|
|
} else {
|
|
stockLogIncrease.Stock += stockLogIncrease.Increase
|
|
}
|
|
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
|
|
}
|
|
}
|
|
|
|
if len(req.Deliveries) > 0 {
|
|
for _, delivery := range req.Deliveries {
|
|
// Skip adding to expensePayloads if SupplierID is 0 (optional)
|
|
if delivery.SupplierID == 0 {
|
|
continue
|
|
}
|
|
|
|
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
|
|
})
|
|
|
|
if err != nil {
|
|
if fiberErr, ok := err.(*fiber.Error); ok {
|
|
return nil, fiberErr
|
|
}
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
|
|
}
|
|
|
|
result, err := s.GetOne(c, uint(entityTransfer.Id))
|
|
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_id=%d, movement_number=%s: %+v", entityTransfer.Id, entityTransfer.MovementNumber, err)
|
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense")
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|
if err := s.ensureTransferAccess(c.Context(), id, c); err != nil {
|
|
return err
|
|
}
|
|
if s.FifoStockV2Svc == nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
|
|
}
|
|
|
|
actorID, err := m.ActorIDFromContext(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var deletedDetails []entity.StockTransferDetail
|
|
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
|
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
|
|
|
|
var transfer entity.StockTransfer
|
|
if err := tx.WithContext(c.Context()).
|
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("id = ?", uint64(id)).
|
|
Where("deleted_at IS NULL").
|
|
Take(&transfer).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
|
|
}
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
|
|
}
|
|
|
|
var details []entity.StockTransferDetail
|
|
if err := tx.WithContext(c.Context()).
|
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("stock_transfer_id = ?", transfer.Id).
|
|
Where("deleted_at IS NULL").
|
|
Order("id ASC").
|
|
Find(&details).Error; err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer")
|
|
}
|
|
if len(details) == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk")
|
|
}
|
|
|
|
detailIDs := make([]uint64, 0, len(details))
|
|
for _, detail := range details {
|
|
detailIDs = append(detailIDs, detail.Id)
|
|
}
|
|
if err := s.ensureDeletePolicyForDownstreamConsumption(c.Context(), tx, detailIDs); err != nil {
|
|
return err
|
|
}
|
|
|
|
type reflowKey struct {
|
|
flagGroupCode string
|
|
productWarehouseID uint
|
|
}
|
|
destReflows := make(map[reflowKey]struct{})
|
|
|
|
for _, detail := range details {
|
|
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id))
|
|
}
|
|
if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id))
|
|
}
|
|
|
|
flagGroupCode, err := s.resolveTransferFlagGroup(c.Context(), tx, uint(detail.ProductId))
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err))
|
|
}
|
|
|
|
rollbackRes, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{
|
|
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
|
|
Usable: commonSvc.FifoStockV2Ref{
|
|
ID: uint(detail.Id),
|
|
LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(),
|
|
FunctionCode: "STOCK_TRANSFER_OUT",
|
|
},
|
|
Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber),
|
|
Tx: tx,
|
|
})
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err))
|
|
}
|
|
|
|
releasedQty := 0.0
|
|
if rollbackRes != nil {
|
|
releasedQty = rollbackRes.ReleasedQty
|
|
}
|
|
if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 {
|
|
return fiber.NewError(
|
|
fiber.StatusBadRequest,
|
|
fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty),
|
|
)
|
|
}
|
|
|
|
if releasedQty > 1e-6 {
|
|
if err := s.appendStockLog(
|
|
c.Context(),
|
|
stockLogRepoTx,
|
|
uint(*detail.SourceProductWarehouseID),
|
|
actorID,
|
|
releasedQty,
|
|
0,
|
|
uint(detail.Id),
|
|
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
destDecreaseQty := detail.TotalQty
|
|
if destDecreaseQty <= 1e-6 {
|
|
destDecreaseQty = detail.UsageQty
|
|
}
|
|
if destDecreaseQty > 1e-6 {
|
|
if err := s.appendStockLog(
|
|
c.Context(),
|
|
stockLogRepoTx,
|
|
uint(*detail.DestProductWarehouseID),
|
|
actorID,
|
|
0,
|
|
destDecreaseQty,
|
|
uint(detail.Id),
|
|
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
destReflows[reflowKey{
|
|
flagGroupCode: flagGroupCode,
|
|
productWarehouseID: uint(*detail.DestProductWarehouseID),
|
|
}] = struct{}{}
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
if err := tx.WithContext(c.Context()).
|
|
Where("stock_transfer_detail_id IN ?", detailIDs).
|
|
Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer")
|
|
}
|
|
if err := tx.WithContext(c.Context()).
|
|
Model(&entity.StockTransferDelivery{}).
|
|
Where("stock_transfer_id = ?", transfer.Id).
|
|
Where("deleted_at IS NULL").
|
|
Updates(map[string]any{
|
|
"deleted_at": now,
|
|
"updated_at": now,
|
|
}).Error; err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer")
|
|
}
|
|
if err := tx.WithContext(c.Context()).
|
|
Model(&entity.StockTransferDetail{}).
|
|
Where("id IN ?", detailIDs).
|
|
Where("deleted_at IS NULL").
|
|
Updates(map[string]any{
|
|
"deleted_at": now,
|
|
"updated_at": now,
|
|
}).Error; err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer")
|
|
}
|
|
|
|
asOf := transfer.TransferDate
|
|
for key := range destReflows {
|
|
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
|
|
FlagGroupCode: key.flagGroupCode,
|
|
ProductWarehouseID: key.productWarehouseID,
|
|
AsOf: &asOf,
|
|
Tx: tx,
|
|
}); err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err))
|
|
}
|
|
}
|
|
|
|
if err := tx.WithContext(c.Context()).
|
|
Model(&entity.StockTransfer{}).
|
|
Where("id = ?", transfer.Id).
|
|
Where("deleted_at IS NULL").
|
|
Updates(map[string]any{
|
|
"deleted_at": now,
|
|
"updated_at": now,
|
|
}).Error; err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
|
|
}
|
|
|
|
deletedDetails = append(deletedDetails, details...)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if fiberErr, ok := err.(*fiber.Error); ok {
|
|
return fiberErr
|
|
}
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
|
|
}
|
|
|
|
if len(deletedDetails) > 0 && s.ExpenseBridge != nil {
|
|
if err := s.ExpenseBridge.OnItemsDeleted(c.Context(), uint64(id), deletedDetails); err != nil {
|
|
s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *transferService) resolveTransferFlagGroup(
|
|
ctx context.Context,
|
|
tx *gorm.DB,
|
|
productID uint,
|
|
) (string, error) {
|
|
if productID == 0 {
|
|
return "", fmt.Errorf("product id is required")
|
|
}
|
|
|
|
type row struct {
|
|
FlagGroupCode string `gorm:"column:flag_group_code"`
|
|
}
|
|
var selected row
|
|
err := tx.WithContext(ctx).
|
|
Table("fifo_stock_v2_route_rules rr").
|
|
Select("rr.flag_group_code").
|
|
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
|
|
Where("rr.is_active = TRUE").
|
|
Where("rr.lane = ?", "USABLE").
|
|
Where("rr.function_code = ?", "STOCK_TRANSFER_OUT").
|
|
Where("rr.source_table = ?", "stock_transfer_details").
|
|
Where(`
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM flags f
|
|
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
|
WHERE f.flagable_type = ?
|
|
AND f.flagable_id = ?
|
|
AND fm.flag_group_code = rr.flag_group_code
|
|
)
|
|
`, entity.FlagableTypeProduct, productID).
|
|
Order("rr.id ASC").
|
|
Limit(1).
|
|
Take(&selected).Error
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return strings.TrimSpace(selected.FlagGroupCode), 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) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
|
|
warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID))
|
|
}
|
|
s.Log.Errorf("Failed to fetch warehouse by ID %d: %+v", warehouseID, err)
|
|
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang")
|
|
}
|
|
|
|
if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId))
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak ada project flock aktif untuk kandang %d", *warehouse.KandangId))
|
|
}
|
|
s.Log.Errorf("Failed to fetch active project flock kandang for kandang_id=%d: %+v", *warehouse.KandangId, err)
|
|
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
|
|
}
|
|
|
|
return uint(projectFlockKandang.Id), nil
|
|
}
|
|
|
|
func (s *transferService) ensureTransferAccess(ctx context.Context, id uint, c *fiber.Ctx) error {
|
|
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !scope.Restrict {
|
|
return nil
|
|
}
|
|
if len(scope.IDs) == 0 {
|
|
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
|
|
}
|
|
|
|
var count int64
|
|
if err := s.StockTransferRepo.DB().WithContext(ctx).
|
|
Table("stock_transfers").
|
|
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
|
|
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
|
|
Where("stock_transfers.id = ?", id).
|
|
Where("stock_transfers.deleted_at IS NULL").
|
|
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
|
|
Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *transferService) ensureDeletePolicyForDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error {
|
|
dependencies, err := s.loadActiveTransferDownstreamDependencies(ctx, tx, detailIDs)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to load downstream stock transfer consumption: %+v", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock")
|
|
}
|
|
if len(dependencies) == 0 {
|
|
return nil
|
|
}
|
|
ayamDependency, err := s.hasAyamDownstreamConsumption(ctx, tx, detailIDs)
|
|
if err != nil {
|
|
s.Log.Errorf("Failed to validate AYAM downstream dependency for transfer delete: %+v", err)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi dependensi AYAM pada transfer stock")
|
|
}
|
|
if ayamDependency {
|
|
return fiber.NewError(
|
|
fiber.StatusBadRequest,
|
|
fmt.Sprintf(
|
|
"%s Dependensi aktif: %s. Alasan block: produk AYAM yang sudah terpakai tidak dapat dihapus.",
|
|
transferDeleteDownstreamGuardMessage,
|
|
formatDownstreamDependencySummary(dependencies),
|
|
),
|
|
)
|
|
}
|
|
|
|
denyReason := ""
|
|
for _, dep := range dependencies {
|
|
policy, policyErr := commonSvc.ResolveFifoPendingPolicy(ctx, tx, commonSvc.FifoPendingPolicyInput{
|
|
Lane: "USABLE",
|
|
FlagGroupCode: dep.FlagGroupCode,
|
|
FunctionCode: dep.FunctionCode,
|
|
LegacyTypeKey: dep.UsableType,
|
|
})
|
|
if policyErr != nil {
|
|
s.Log.Errorf("Failed to resolve FIFO pending policy for transfer dependency: %+v", policyErr)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membaca konfigurasi FIFO v2")
|
|
}
|
|
if !policy.Found || !policy.AllowPending {
|
|
denyReason = "pending disabled by config"
|
|
break
|
|
}
|
|
}
|
|
if denyReason == "" {
|
|
return nil
|
|
}
|
|
|
|
return fiber.NewError(
|
|
fiber.StatusBadRequest,
|
|
fmt.Sprintf(
|
|
"%s Dependensi aktif: %s. Alasan block: %s.",
|
|
transferDeleteDownstreamGuardMessage,
|
|
formatDownstreamDependencySummary(dependencies),
|
|
denyReason,
|
|
),
|
|
)
|
|
}
|
|
|
|
func (s *transferService) loadActiveTransferDownstreamDependencies(
|
|
ctx context.Context,
|
|
tx *gorm.DB,
|
|
detailIDs []uint64,
|
|
) ([]downstreamDependency, error) {
|
|
if len(detailIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
db := s.StockTransferRepo.DB().WithContext(ctx)
|
|
if tx != nil {
|
|
db = tx.WithContext(ctx)
|
|
}
|
|
|
|
var rows []downstreamDependency
|
|
err := db.Table("stock_allocations").
|
|
Select("usable_type, usable_id, COALESCE(function_code,'') AS function_code, COALESCE(flag_group_code,'') AS flag_group_code").
|
|
Where("stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
|
|
Where("stockable_id IN ?", detailIDs).
|
|
Where("status = ?", entity.StockAllocationStatusActive).
|
|
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
|
Where("deleted_at IS NULL").
|
|
Group("usable_type, usable_id, function_code, flag_group_code").
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return rows, nil
|
|
}
|
|
|
|
func formatDownstreamDependencySummary(rows []downstreamDependency) string {
|
|
if len(rows) == 0 {
|
|
return "-"
|
|
}
|
|
|
|
dependencyMap := make(map[string]map[uint64]struct{})
|
|
for _, row := range rows {
|
|
label := mapTransferDownstreamUsableLabel(row.UsableType)
|
|
if _, ok := dependencyMap[label]; !ok {
|
|
dependencyMap[label] = make(map[uint64]struct{})
|
|
}
|
|
dependencyMap[label][row.UsableID] = struct{}{}
|
|
}
|
|
|
|
labels := make([]string, 0, len(dependencyMap))
|
|
for label := range dependencyMap {
|
|
labels = append(labels, label)
|
|
}
|
|
sort.Strings(labels)
|
|
|
|
details := make([]string, 0, len(labels))
|
|
for _, label := range labels {
|
|
ids := sortedUint64Keys(dependencyMap[label])
|
|
details = append(details, fmt.Sprintf("%s=%s", label, joinUint64(ids)))
|
|
}
|
|
|
|
return strings.Join(details, ", ")
|
|
}
|
|
|
|
func (s *transferService) hasAyamDownstreamConsumption(ctx context.Context, tx *gorm.DB, detailIDs []uint64) (bool, error) {
|
|
if len(detailIDs) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
db := s.StockTransferRepo.DB().WithContext(ctx)
|
|
if tx != nil {
|
|
db = tx.WithContext(ctx)
|
|
}
|
|
|
|
var found int64
|
|
err := db.Table("stock_allocations sa").
|
|
Joins("JOIN stock_transfer_details std ON std.id = sa.stockable_id AND std.deleted_at IS NULL").
|
|
Joins("JOIN flags f ON f.flagable_type = ? AND f.flagable_id = std.product_id", entity.FlagableTypeProduct).
|
|
Joins("JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.flag_group_code = ? AND fm.is_active = TRUE", "AYAM").
|
|
Where("sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
|
|
Where("sa.stockable_id IN ?", detailIDs).
|
|
Where("sa.status = ?", entity.StockAllocationStatusActive).
|
|
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
|
Where("sa.deleted_at IS NULL").
|
|
Count(&found).Error
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return found > 0, nil
|
|
}
|
|
|
|
func mapTransferDownstreamUsableLabel(usableType string) string {
|
|
switch strings.ToUpper(strings.TrimSpace(usableType)) {
|
|
case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String():
|
|
return "Recording"
|
|
case fifo.UsableKeyProjectChickin.String():
|
|
return "Chickin"
|
|
case fifo.UsableKeyMarketingDelivery.String():
|
|
return "Marketing"
|
|
case fifo.UsableKeyTransferToLayingOut.String():
|
|
return "TransferToLaying"
|
|
case fifo.UsableKeyStockTransferOut.String():
|
|
return "TransferStock"
|
|
case fifo.UsableKeyAdjustmentOut.String():
|
|
return "Adjustment"
|
|
default:
|
|
return strings.ToUpper(strings.TrimSpace(usableType))
|
|
}
|
|
}
|
|
|
|
func sortedUint64Keys(input map[uint64]struct{}) []uint64 {
|
|
if len(input) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]uint64, 0, len(input))
|
|
for id := range input {
|
|
if id == 0 {
|
|
continue
|
|
}
|
|
out = append(out, id)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out
|
|
}
|
|
|
|
func joinUint64(values []uint64) string {
|
|
if len(values) == 0 {
|
|
return "-"
|
|
}
|
|
parts := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
parts = append(parts, fmt.Sprintf("%d", value))
|
|
}
|
|
return strings.Join(parts, "|")
|
|
}
|
|
|
|
func (s *transferService) appendStockLog(
|
|
ctx context.Context,
|
|
stockLogRepo rStockLogs.StockLogRepository,
|
|
productWarehouseID uint,
|
|
actorID uint,
|
|
increase float64,
|
|
decrease float64,
|
|
loggableID uint,
|
|
notes string,
|
|
) error {
|
|
if productWarehouseID == 0 || (increase <= 1e-6 && decrease <= 1e-6) {
|
|
return nil
|
|
}
|
|
|
|
stockLog := &entity.StockLog{
|
|
ProductWarehouseId: productWarehouseID,
|
|
CreatedBy: actorID,
|
|
Increase: increase,
|
|
Decrease: decrease,
|
|
LoggableType: string(utils.StockLogTypeTransfer),
|
|
LoggableId: loggableID,
|
|
Notes: notes,
|
|
}
|
|
|
|
stockLogs, err := stockLogRepo.GetByProductWarehouse(ctx, productWarehouseID, 1)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
|
|
}
|
|
if len(stockLogs) > 0 {
|
|
latestStockLog := stockLogs[0]
|
|
stockLog.Stock = latestStockLog.Stock + increase - decrease
|
|
} else {
|
|
stockLog.Stock = increase - decrease
|
|
}
|
|
if err := stockLogRepo.CreateOne(ctx, stockLog, nil); err != nil {
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat stock log saat delete transfer")
|
|
}
|
|
|
|
return nil
|
|
}
|