fixing filter pw for transfer, add transfer delete

This commit is contained in:
ragilap
2026-03-13 11:22:10 +07:00
parent 9dcccabc6a
commit 29956528e5
16 changed files with 1122 additions and 219 deletions
+6 -5
View File
@@ -1,11 +1,12 @@
package entities package entities
type ProductWarehouse struct { type ProductWarehouse struct {
Id uint `gorm:"primaryKey;column:id"` Id uint `gorm:"primaryKey;column:id"`
ProductId uint `gorm:"column:product_id;not null"` ProductId uint `gorm:"column:product_id;not null"`
WarehouseId uint `gorm:"column:warehouse_id;not null"` WarehouseId uint `gorm:"column:warehouse_id;not null"`
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
AvailableQty *float64 `gorm:"-"`
// Relations // Relations
Product Product `gorm:"foreignKey:ProductId;references:Id"` Product Product `gorm:"foreignKey:ProductId;references:Id"`
+1
View File
@@ -70,6 +70,7 @@ const (
P_TransferGetAll = "lti.inventory.transfer.list" P_TransferGetAll = "lti.inventory.transfer.list"
P_TransferGetOne = "lti.inventory.transfer.detail" P_TransferGetOne = "lti.inventory.transfer.detail"
P_TransferCreateOne = "lti.inventory.transfer.create" P_TransferCreateOne = "lti.inventory.transfer.create"
P_TransferDeleteOne = "lti.inventory.transfer.delete"
) )
const ( const (
@@ -32,6 +32,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
Flags: c.Query("flags", ""), Flags: c.Query("flags", ""),
KandangId: uint(c.QueryInt("kandang_id", 0)), KandangId: uint(c.QueryInt("kandang_id", 0)),
TransferContext: c.Query(utils.TransferContextKey, ""), TransferContext: c.Query(utils.TransferContextKey, ""),
StockMode: c.Query("stock_mode", ""),
Type: c.Query("type", ""), Type: c.Query("type", ""),
} }
@@ -12,10 +12,11 @@ import (
// === DTO Structs === // === DTO Structs ===
type ProductWarehouseRelationDTO struct { type ProductWarehouseRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
ProductId uint `json:"product_id"` ProductId uint `json:"product_id"`
WarehouseId uint `json:"warehouse_id"` WarehouseId uint `json:"warehouse_id"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
AvailableQty *float64 `json:"available_qty,omitempty"`
} }
type ProductWarehouseListDTO struct { type ProductWarehouseListDTO struct {
@@ -61,10 +62,11 @@ type ProjectFlockRelationDTO struct {
func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO {
return ProductWarehouseRelationDTO{ return ProductWarehouseRelationDTO{
Id: e.Id, Id: e.Id,
ProductId: e.ProductId, // Field yang benar dari entity ProductId: e.ProductId, // Field yang benar dari entity
WarehouseId: e.WarehouseId, // Field yang benar dari entity WarehouseId: e.WarehouseId, // Field yang benar dari entity
Quantity: e.Quantity, Quantity: e.Quantity,
AvailableQty: e.AvailableQty,
} }
} }
@@ -2,6 +2,7 @@ package service
import ( import (
"errors" "errors"
"strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -12,6 +13,7 @@ import (
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations"
kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
"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"
) )
@@ -27,6 +29,8 @@ type productWarehouseService struct {
KandangRepo kandangrepo.KandangRepository KandangRepo kandangrepo.KandangRepository
} }
const stockModeExcludeChickin = "exclude_chickin"
func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService { func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService {
return &productWarehouseService{ return &productWarehouseService{
Log: utils.Log, Log: utils.Log,
@@ -189,6 +193,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
s.Log.Errorf("Failed to get productWarehouses: %+v", err) s.Log.Errorf("Failed to get productWarehouses: %+v", err)
return nil, 0, err return nil, 0, err
} }
productWarehouses, err = s.applyTransferAvailableQty(c, params, productWarehouses)
if err != nil {
return nil, 0, err
}
return productWarehouses, total, nil return productWarehouses, total, nil
} }
@@ -229,3 +238,80 @@ func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductW
} }
return productWarehouse, nil return productWarehouse, nil
} }
func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params *validation.Query, rows []entity.ProductWarehouse) ([]entity.ProductWarehouse, error) {
if len(rows) == 0 {
return rows, nil
}
if params == nil ||
params.TransferContext != utils.TransferContextInventoryTransfer ||
params.StockMode != stockModeExcludeChickin {
return rows, nil
}
ayamPWIDs := make([]uint, 0)
for i := range rows {
if isAyamProductByFlags(rows[i].Product.Flags) {
ayamPWIDs = append(ayamPWIDs, rows[i].Id)
}
}
if len(ayamPWIDs) == 0 {
return rows, nil
}
type usageRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
UsedQty float64 `gorm:"column:used_qty"`
}
usageRows := make([]usageRow, 0)
if err := s.Repository.DB().WithContext(c.Context()).
Table("stock_allocations").
Select("product_warehouse_id, COALESCE(SUM(qty), 0) AS used_qty").
Where("product_warehouse_id IN ?", ayamPWIDs).
Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Group("product_warehouse_id").
Scan(&usageRows).Error; err != nil {
s.Log.Errorf("Failed to calculate available transfer stock after chickin consumption: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghitung stok tersedia untuk transfer")
}
usageMap := make(map[uint]float64, len(usageRows))
for _, row := range usageRows {
usageMap[row.ProductWarehouseID] = row.UsedQty
}
filtered := make([]entity.ProductWarehouse, 0, len(rows))
for i := range rows {
row := rows[i]
if !isAyamProductByFlags(row.Product.Flags) {
filtered = append(filtered, row)
continue
}
available := row.Quantity - usageMap[row.Id]
if available < 0 {
available = 0
}
row.AvailableQty = &available
if available <= 0 {
continue
}
filtered = append(filtered, row)
}
return filtered, nil
}
func isAyamProductByFlags(flags []entity.Flag) bool {
for _, flag := range flags {
if utils.CanonicalFlagType(strings.TrimSpace(flag.Name)) == utils.FlagAyam {
return true
}
}
return false
}
@@ -20,5 +20,6 @@ type Query struct {
Flags string `query:"flags" validate:"omitempty"` Flags string `query:"flags" validate:"omitempty"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
StockMode string `query:"stock_mode" validate:"omitempty,oneof=exclude_chickin"`
Type string `query:"type" validate:"omitempty"` Type string `query:"type" validate:"omitempty"`
} }
@@ -109,3 +109,23 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error {
Data: dto.ToTransferDetailDTO(*result), Data: dto.ToTransferDetailDTO(*result),
}) })
} }
func (u *TransferController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.TransferService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete transfer successfully",
})
}
@@ -18,5 +18,6 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ
route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne)
route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne)
route.Delete("/:id", m.RequirePermissions(m.P_TransferDeleteOne), ctrl.DeleteOne)
} }
@@ -5,7 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"mime/multipart" "mime/multipart"
"sort"
"strings" "strings"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -21,14 +23,17 @@ import (
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/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" 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"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type TransferService interface { type TransferService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error)
CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*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 { type transferService struct {
@@ -49,6 +54,8 @@ type transferService struct {
ExpenseBridge TransferExpenseBridge ExpenseBridge TransferExpenseBridge
} }
const transferDeleteDownstreamGuardMessage = "Transfer stock tidak dapat dihapus karena stok transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu."
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 { 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{ return &transferService{
Log: utils.Log, Log: utils.Log,
@@ -104,6 +111,7 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db = db.Where("stock_transfers.deleted_at IS NULL")
if scope.Restrict { if scope.Restrict {
if len(scope.IDs) == 0 { if len(scope.IDs) == 0 {
return db.Where("1 = 0") return db.Where("1 = 0")
@@ -145,6 +153,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id"). 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"). Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
Where("stock_transfers.id = ?", 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). Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
Count(&count).Error; err != nil { Count(&count).Error; err != nil {
return nil, err return nil, err
@@ -155,7 +164,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
} }
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db) return s.withRelations(db).Where("stock_transfers.deleted_at IS NULL")
}) })
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -619,6 +628,210 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return result, nil 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.ensureNoDownstreamConsumptionForDelete(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( func (s *transferService) resolveTransferFlagGroup(
ctx context.Context, ctx context.Context,
tx *gorm.DB, tx *gorm.DB,
@@ -692,3 +905,179 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
return uint(projectFlockKandang.Id), nil 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) ensureNoDownstreamConsumptionForDelete(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error {
if len(detailIDs) == 0 {
return nil
}
db := s.StockTransferRepo.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
type downstreamRow struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint64 `gorm:"column:usable_id"`
}
var rows []downstreamRow
if err := db.Table("stock_allocations").
Select("usable_type, usable_id").
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").
Scan(&rows).Error; err != nil {
s.Log.Errorf("Failed to validate downstream stock transfer consumption: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock")
}
if len(rows) == 0 {
return nil
}
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 fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("%s Dependensi aktif: %s.", transferDeleteDownstreamGuardMessage, strings.Join(details, ", ")),
)
}
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
}
@@ -36,7 +36,7 @@ import (
const ( const (
chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif" chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif"
chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, dan Transfer to Laying terlebih dahulu." chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, Transfer, Adjustment, dan Transfer to Laying terlebih dahulu."
) )
type ChickinService interface { type ChickinService interface {
@@ -264,16 +264,16 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
availableQty = 0 availableQty = 0
} }
if flockCategory == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { if flockCategory == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) {
transferAvailable, err := s.resolveLayingTransferAvailableQty(c.Context(), nil, req.ProjectFlockKandangId, chickinReq.ProductWarehouseId) sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, chickinReq.ProductWarehouseId, &chickinDate)
if err != nil { if err != nil {
s.Log.Errorf("Failed to resolve laying transfer availability for pfk=%d pw=%d: %+v", req.ProjectFlockKandangId, chickinReq.ProductWarehouseId, err) s.Log.Errorf("Failed to resolve laying transfer availability for pfk=%d pw=%d: %+v", req.ProjectFlockKandangId, chickinReq.ProductWarehouseId, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi stok transfer laying") return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi stok transfer laying")
} }
if transferAvailable < 0 { if sourceAvailable < 0 {
transferAvailable = 0 sourceAvailable = 0
} }
if transferAvailable < availableQty { if sourceAvailable < availableQty {
availableQty = transferAvailable availableQty = sourceAvailable
} }
} }
@@ -554,36 +554,44 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
return nil return nil
} }
func (s *chickinService) resolveLayingTransferAvailableQty(ctx context.Context, tx *gorm.DB, targetProjectFlockKandangID, productWarehouseID uint) (float64, error) { func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) {
if targetProjectFlockKandangID == 0 || productWarehouseID == 0 { if productWarehouseID == 0 || s.FifoStockV2Svc == nil {
return 0, nil return 0, nil
} }
db := s.Repository.DB().WithContext(ctx) db := s.Repository.DB()
if tx != nil { if tx != nil {
db = tx.WithContext(ctx) db = tx
} }
var available float64 flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, db, productWarehouseID)
err := db.Table("laying_transfer_targets ltt").
Select("COALESCE(SUM(GREATEST(0, COALESCE(ltt.total_qty,0) - COALESCE(ltt.total_used,0))), 0) AS available").
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id AND lt.deleted_at IS NULL").
Where("ltt.deleted_at IS NULL").
Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID).
Where("ltt.product_warehouse_id = ?", productWarehouseID).
Where("lt.executed_at IS NOT NULL").
Where(`(
SELECT a.action
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = lt.id
ORDER BY a.id DESC
LIMIT 1
) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved).
Scan(&available).Error
if err != nil { if err != nil {
return 0, err return 0, err
} }
if strings.TrimSpace(flagGroupCode) == "" {
return 0, nil
}
gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: commonSvc.FifoStockV2Lane("STOCKABLE"),
AllocationPurpose: entity.StockAllocationPurposeConsume,
ProductWarehouseID: productWarehouseID,
AsOf: asOf,
Limit: 10000,
Tx: tx,
})
if err != nil {
return 0, err
}
available := 0.0
for _, row := range gatherRows {
if row.AvailableQuantity <= 0 {
continue
}
available += row.AvailableQuantity
}
return available, nil return available, nil
} }
@@ -650,6 +658,8 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
Where("sa.usable_type IN ?", []string{ Where("sa.usable_type IN ?", []string{
fifo.UsableKeyMarketingDelivery.String(), fifo.UsableKeyMarketingDelivery.String(),
fifo.UsableKeyRecordingDepletion.String(), fifo.UsableKeyRecordingDepletion.String(),
fifo.UsableKeyStockTransferOut.String(),
fifo.UsableKeyAdjustmentOut.String(),
fifo.UsableKeyTransferToLayingOut.String(), fifo.UsableKeyTransferToLayingOut.String(),
}). }).
Group("sa.usable_type, sa.usable_id"). Group("sa.usable_type, sa.usable_id").
@@ -664,6 +674,8 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
marketingIDs := make(map[uint]struct{}) marketingIDs := make(map[uint]struct{})
recordingIDs := make(map[uint]struct{}) recordingIDs := make(map[uint]struct{})
transferIDs := make(map[uint]struct{})
adjustmentIDs := make(map[uint]struct{})
transferLayingIDs := make(map[uint]struct{}) transferLayingIDs := make(map[uint]struct{})
for _, row := range rows { for _, row := range rows {
@@ -672,18 +684,28 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
marketingIDs[row.UsableID] = struct{}{} marketingIDs[row.UsableID] = struct{}{}
case fifo.UsableKeyRecordingDepletion.String(): case fifo.UsableKeyRecordingDepletion.String():
recordingIDs[row.UsableID] = struct{}{} recordingIDs[row.UsableID] = struct{}{}
case fifo.UsableKeyStockTransferOut.String():
transferIDs[row.UsableID] = struct{}{}
case fifo.UsableKeyAdjustmentOut.String():
adjustmentIDs[row.UsableID] = struct{}{}
case fifo.UsableKeyTransferToLayingOut.String(): case fifo.UsableKeyTransferToLayingOut.String():
transferLayingIDs[row.UsableID] = struct{}{} transferLayingIDs[row.UsableID] = struct{}{}
} }
} }
details := make([]string, 0, 3) details := make([]string, 0, 5)
if ids := sortedIDs(marketingIDs); len(ids) > 0 { if ids := sortedIDs(marketingIDs); len(ids) > 0 {
details = append(details, fmt.Sprintf("Marketing=%s", joinUint(ids))) details = append(details, fmt.Sprintf("Marketing=%s", joinUint(ids)))
} }
if ids := sortedIDs(recordingIDs); len(ids) > 0 { if ids := sortedIDs(recordingIDs); len(ids) > 0 {
details = append(details, fmt.Sprintf("Recording=%s", joinUint(ids))) details = append(details, fmt.Sprintf("Recording=%s", joinUint(ids)))
} }
if ids := sortedIDs(transferIDs); len(ids) > 0 {
details = append(details, fmt.Sprintf("Transfer=%s", joinUint(ids)))
}
if ids := sortedIDs(adjustmentIDs); len(ids) > 0 {
details = append(details, fmt.Sprintf("Adjustment=%s", joinUint(ids)))
}
if ids := sortedIDs(transferLayingIDs); len(ids) > 0 { if ids := sortedIDs(transferLayingIDs); len(ids) > 0 {
details = append(details, fmt.Sprintf("TransferToLaying=%s", joinUint(ids))) details = append(details, fmt.Sprintf("TransferToLaying=%s", joinUint(ids)))
} }
@@ -1292,17 +1314,8 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
} }
if !shouldRestoreWarehouseQty { if !shouldRestoreWarehouseQty {
var affectedTransferTargetIDs []uint affectedStockables, err := s.listActiveConsumeStockableRefsByUsable(ctx, tx, chickin.Id)
if err := tx.WithContext(ctx). if err != nil {
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ? AND stockable_type = ?",
fifo.UsableKeyProjectChickin.String(),
chickin.Id,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
fifo.StockableKeyTransferToLayingIn.String(),
).
Pluck("stockable_id", &affectedTransferTargetIDs).Error; err != nil {
return err return err
} }
@@ -1325,12 +1338,15 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
return err return err
} }
s.Log.Infof( s.Log.Infof(
"Release chickin stock laying id=%d released_consume_alloc=%d transfer_targets=%d", "Release chickin stock laying id=%d released_consume_alloc=%d transfer_targets=%d stock_transfer_sources=%d purchase_sources=%d adjustment_sources=%d",
chickin.Id, chickin.Id,
releaseResult.RowsAffected, releaseResult.RowsAffected,
len(affectedTransferTargetIDs), len(affectedStockables[fifo.StockableKeyTransferToLayingIn.String()]),
len(affectedStockables[fifo.StockableKeyStockTransferIn.String()]),
len(affectedStockables[fifo.StockableKeyPurchaseItems.String()]),
len(affectedStockables[fifo.StockableKeyAdjustmentIn.String()]),
) )
if err := s.resyncTransferTargetUsageFromAllocations(ctx, tx, affectedTransferTargetIDs); err != nil { if err := s.resyncStockableSourceUsageAfterRelease(ctx, tx, affectedStockables); err != nil {
return err return err
} }
if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil {
@@ -1465,63 +1481,179 @@ func (s *chickinService) logWarehouseQtySnapshot(
) )
} }
func (s *chickinService) resyncTransferTargetUsageFromAllocations(ctx context.Context, tx *gorm.DB, transferTargetIDs []uint) error { func (s *chickinService) listActiveConsumeStockableRefsByUsable(ctx context.Context, tx *gorm.DB, chickinID uint) (map[string][]uint, error) {
if tx == nil || len(transferTargetIDs) == 0 { result := map[string][]uint{
return nil fifo.StockableKeyTransferToLayingIn.String(): nil,
fifo.StockableKeyStockTransferIn.String(): nil,
fifo.StockableKeyPurchaseItems.String(): nil,
fifo.StockableKeyAdjustmentIn.String(): nil,
}
if tx == nil || chickinID == 0 {
return result, nil
} }
unique := make([]uint, 0, len(transferTargetIDs)) type row struct {
seen := make(map[uint]struct{}, len(transferTargetIDs)) StockableType string `gorm:"column:stockable_type"`
for _, id := range transferTargetIDs { StockableID uint `gorm:"column:stockable_id"`
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
} }
if len(unique) == 0 { var rows []row
return nil
}
if err := tx.WithContext(ctx).
Model(&entity.LayingTransferTarget{}).
Where("id IN ?", unique).
Update("total_used", 0).Error; err != nil {
return err
}
type usageRow struct {
StockableID uint `gorm:"column:stockable_id"`
Used float64 `gorm:"column:used"`
}
var usageRows []usageRow
if err := tx.WithContext(ctx). if err := tx.WithContext(ctx).
Table("stock_allocations"). Table("stock_allocations").
Select("stockable_id, COALESCE(SUM(qty), 0) AS used"). Select("stockable_type, stockable_id").
Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?",
Where("status = ?", entity.StockAllocationStatusActive). fifo.UsableKeyProjectChickin.String(),
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). chickinID,
Where("stockable_id IN ?", unique). entity.StockAllocationStatusActive,
Group("stockable_id"). entity.StockAllocationPurposeConsume,
Scan(&usageRows).Error; err != nil { ).
Where("stockable_type IN ?", []string{
fifo.StockableKeyTransferToLayingIn.String(),
fifo.StockableKeyStockTransferIn.String(),
fifo.StockableKeyPurchaseItems.String(),
fifo.StockableKeyAdjustmentIn.String(),
}).
Group("stockable_type, stockable_id").
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.StockableID == 0 {
continue
}
result[row.StockableType] = append(result[row.StockableType], row.StockableID)
}
for key, ids := range result {
result[key] = uniqueUint(ids)
}
return result, nil
}
func (s *chickinService) resyncStockableSourceUsageAfterRelease(ctx context.Context, tx *gorm.DB, stockableRefs map[string][]uint) error {
if tx == nil || len(stockableRefs) == 0 {
return nil
}
if err := s.resetAndResyncUsedQuantity(
ctx,
tx,
"laying_transfer_targets",
"id",
"total_used",
fifo.StockableKeyTransferToLayingIn.String(),
stockableRefs[fifo.StockableKeyTransferToLayingIn.String()],
); err != nil {
return err return err
} }
for _, row := range usageRows { if err := s.resetAndResyncUsedQuantity(
if err := tx.WithContext(ctx). ctx,
Model(&entity.LayingTransferTarget{}). tx,
Where("id = ?", row.StockableID). "stock_transfer_details",
Update("total_used", row.Used).Error; err != nil { "id",
return err "total_used",
} fifo.StockableKeyStockTransferIn.String(),
stockableRefs[fifo.StockableKeyStockTransferIn.String()],
); err != nil {
return err
}
if err := s.resetAndResyncUsedQuantity(
ctx,
tx,
"purchase_items",
"id",
"total_used",
fifo.StockableKeyPurchaseItems.String(),
stockableRefs[fifo.StockableKeyPurchaseItems.String()],
); err != nil {
return err
}
if err := s.resetAndResyncUsedQuantity(
ctx,
tx,
"adjustment_stocks",
"id",
"total_used",
fifo.StockableKeyAdjustmentIn.String(),
stockableRefs[fifo.StockableKeyAdjustmentIn.String()],
); err != nil {
return err
} }
return nil return nil
} }
func (s *chickinService) resetAndResyncUsedQuantity(
ctx context.Context,
tx *gorm.DB,
tableName string,
idColumn string,
usedColumn string,
stockableType string,
ids []uint,
) error {
ids = uniqueUint(ids)
if tx == nil || len(ids) == 0 {
return nil
}
if err := tx.WithContext(ctx).
Table(tableName).
Where(fmt.Sprintf("%s IN ?", idColumn), ids).
Update(usedColumn, 0).Error; err != nil {
return err
}
query := fmt.Sprintf(`
UPDATE %s AS t
SET %s = a.used
FROM (
SELECT stockable_id, COALESCE(SUM(qty), 0) AS used
FROM stock_allocations
WHERE stockable_type = ?
AND status = ?
AND allocation_purpose = ?
AND stockable_id IN ?
GROUP BY stockable_id
) AS a
WHERE t.%s = a.stockable_id
`, tableName, usedColumn, idColumn)
if err := tx.WithContext(ctx).Exec(
query,
stockableType,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
ids,
).Error; err != nil {
return err
}
return nil
}
func uniqueUint(values []uint) []uint {
if len(values) == 0 {
return nil
}
out := make([]uint, 0, len(values))
seen := make(map[uint]struct{}, len(values))
for _, value := range values {
if value == 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func normalizeDateOnlyUTC(value time.Time) time.Time { func normalizeDateOnlyUTC(value time.Time) time.Time {
if value.IsZero() { if value.IsZero() {
return time.Time{} return time.Time{}
@@ -7,14 +7,14 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
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"
rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
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"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/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"
sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
@@ -33,13 +33,14 @@ func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo) approvalService := commonSvc.NewApprovalService(approvalRepo)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
// register workflow steps for chickin approvals // register workflow steps for chickin approvals
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err))
} }
expenseRepo := rExpense.NewExpenseRepository(db) expenseRepo := rExpense.NewExpenseRepository(db)
projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo,kandangRepo, validate) projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, fifoStockV2Service, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, kandangRepo, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ProjectFlockKandangRoutes(router, userService, projectFlockKandangService) ProjectFlockKandangRoutes(router, userService, projectFlockKandangService)
@@ -1,8 +1,10 @@
package service package service
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"math"
"strings" "strings"
"time" "time"
@@ -35,6 +37,7 @@ type projectFlockKandangService struct {
Validate *validator.Validate Validate *validator.Validate
Repository repository.ProjectFlockKandangRepository Repository repository.ProjectFlockKandangRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoStockV2Svc commonSvc.FifoStockV2Service
ExpenseRepo expenseRepo.ExpenseRepository ExpenseRepo expenseRepo.ExpenseRepository
WarehouseRepo rWarehouse.WarehouseRepository WarehouseRepo rWarehouse.WarehouseRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
@@ -69,12 +72,13 @@ type ExpenseSummary struct {
Reference string `json:"reference_number"` Reference string `json:"reference_number"`
} }
func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService { func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService {
return &projectFlockKandangService{ return &projectFlockKandangService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoStockV2Svc: fifoStockV2Svc,
ExpenseRepo: expenseRepo, ExpenseRepo: expenseRepo,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo, ProductWarehouseRepo: productWarehouseRepo,
@@ -671,7 +675,91 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous
if availableQty < 0 { if availableQty < 0 {
availableQty = 0 availableQty = 0
} }
sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, productWarehouse.Id, nil)
if err != nil {
return 0, err
}
if sourceAvailable < availableQty {
availableQty = sourceAvailable
}
} }
return availableQty, nil return availableQty, nil
} }
func (s projectFlockKandangService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) {
if productWarehouseID == 0 || s.FifoStockV2Svc == nil {
return 0, nil
}
flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return 0, err
}
if strings.TrimSpace(flagGroupCode) == "" {
return 0, nil
}
gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: commonSvc.FifoStockV2Lane("STOCKABLE"),
AllocationPurpose: entity.StockAllocationPurposeConsume,
ProductWarehouseID: productWarehouseID,
AsOf: asOf,
Limit: 10000,
Tx: tx,
})
if err != nil {
return 0, err
}
total := 0.0
for _, row := range gatherRows {
if row.AvailableQuantity <= 0 {
continue
}
total += row.AvailableQuantity
}
return math.Max(total, 0), nil
}
func (s projectFlockKandangService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
selected := row{}
db := s.Repository.DB()
if tx != nil {
db = tx
}
err := db.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 = 'STOCKABLE'").
Where(`
EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = ?
AND f.flagable_type = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, productWarehouseID, entity.FlagableTypeProduct).
Order("fg.priority ASC, rr.id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return "", err
}
return strings.TrimSpace(selected.FlagGroupCode), nil
}
@@ -155,7 +155,6 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
productWarehouseRepo, productWarehouseRepo,
warehouseRepo, warehouseRepo,
approvalService, approvalService,
fifoService,
fifoStockV2Service, fifoStockV2Service,
validate, validate,
) )
@@ -91,7 +91,6 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
productWarehouseRepo, productWarehouseRepo,
warehouseRepo, warehouseRepo,
approvalService, approvalService,
fifoService,
fifoStockV2Service, fifoStockV2Service,
validate, validate,
) )
@@ -2,15 +2,22 @@ package repository
import ( import (
"context" "context"
"errors"
"time"
"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"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm" "gorm.io/gorm"
) )
type LayingTransferTargetRepository interface { type LayingTransferTargetRepository interface {
repository.BaseRepository[entity.LayingTransferTarget] repository.BaseRepository[entity.LayingTransferTarget]
GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error) GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error)
GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error)
GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error)
CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error)
SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error
} }
type LayingTransferTargetRepositoryImpl struct { type LayingTransferTargetRepositoryImpl struct {
@@ -18,6 +25,11 @@ type LayingTransferTargetRepositoryImpl struct {
db *gorm.DB db *gorm.DB
} }
type TargetDownstreamConsumption struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint `gorm:"column:usable_id"`
}
func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository { func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository {
return &LayingTransferTargetRepositoryImpl{ return &LayingTransferTargetRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db), BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db),
@@ -36,3 +48,123 @@ func (r *LayingTransferTargetRepositoryImpl) GetByLayingTransferId(ctx context.C
} }
return targets, nil return targets, nil
} }
func (r *LayingTransferTargetRepositoryImpl) GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error) {
if len(targetIDs) == 0 {
return nil, nil
}
var rows []TargetDownstreamConsumption
err := r.db.WithContext(ctx).
Table("stock_allocations").
Select("usable_type, usable_id").
Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Where("stockable_id IN ?", targetIDs).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("deleted_at IS NULL").
Group("usable_type, usable_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func (r *LayingTransferTargetRepositoryImpl) GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error) {
if targetProjectFlockKandangID == 0 {
return nil, nil
}
var earliest entity.Recording
query := r.db.WithContext(ctx).
Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID).
Where("deleted_at IS NULL")
if !sinceDate.IsZero() {
query = query.Where("record_datetime >= ?", sinceDate)
}
if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
d := earliest.RecordDatetime.UTC()
return &d, nil
}
func (r *LayingTransferTargetRepositoryImpl) CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error) {
if transferID == 0 || productWarehouseID == 0 {
return 0, nil
}
var count int64
err := r.db.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("product_warehouse_id = ?", productWarehouseID).
Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
Where("usable_id = ?", transferID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
func (r *LayingTransferTargetRepositoryImpl) SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
var populationIDs []uint
if err := r.db.WithContext(ctx).
Table("project_flock_populations pfp").
Select("pfp.id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
Pluck("pfp.id", &populationIDs).Error; err != nil {
return err
}
if len(populationIDs) == 0 {
return nil
}
type usageRow struct {
StockableID uint `gorm:"column:stockable_id"`
Used float64 `gorm:"column:used"`
}
var usageRows []usageRow
if err := r.db.WithContext(ctx).
Table("stock_allocations").
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("stockable_id IN ?", populationIDs).
Group("stockable_id").
Scan(&usageRows).Error; err != nil {
return err
}
if err := r.db.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id IN ?", populationIDs).
Update("total_used_qty", 0).Error; err != nil {
return err
}
for _, row := range usageRows {
if err := r.db.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", row.StockableID).
Update("total_used_qty", row.Used).Error; err != nil {
return err
}
}
return nil
}
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"time" "time"
@@ -56,12 +57,12 @@ type transferLayingService struct {
WarehouseRepo rWarehouse.WarehouseRepository WarehouseRepo rWarehouse.WarehouseRepository
StockLogRepo rStockLogs.StockLogRepository StockLogRepo rStockLogs.StockLogRepository
ApprovalService commonSvc.ApprovalService ApprovalService commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service FifoStockV2Svc commonSvc.FifoStockV2Service
} }
const ( const (
transferToLayingFlagGroupCode = "AYAM" transferToLayingFlagGroupCode = "AYAM"
transferLayingDeleteDownstreamGuardMessage = "Transfer laying tidak dapat dihapus karena stok target transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu."
) )
func NewTransferLayingService( func NewTransferLayingService(
@@ -74,7 +75,6 @@ func NewTransferLayingService(
productWarehouseRepo rInventory.ProductWarehouseRepository, productWarehouseRepo rInventory.ProductWarehouseRepository,
warehouseRepo rWarehouse.WarehouseRepository, warehouseRepo rWarehouse.WarehouseRepository,
approvalService commonSvc.ApprovalService, approvalService commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService,
fifoStockV2Svc commonSvc.FifoStockV2Service, fifoStockV2Svc commonSvc.FifoStockV2Service,
validate *validator.Validate, validate *validator.Validate,
) TransferLayingService { ) TransferLayingService {
@@ -91,7 +91,6 @@ func NewTransferLayingService(
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()),
ApprovalService: approvalService, ApprovalService: approvalService,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc, FifoStockV2Svc: fifoStockV2Svc,
} }
} }
@@ -610,6 +609,9 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
if isLegacyTransfer(transfer) { if isLegacyTransfer(transfer) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat dihapus", transfer.TransferNumber)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat dihapus", transfer.TransferNumber))
} }
if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), nil, transfer.TransferNumber, transfer.Targets); err != nil {
return err
}
approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB())
@@ -635,6 +637,16 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
repoTx := s.Repository.WithTx(dbTransaction) repoTx := s.Repository.WithTx(dbTransaction)
// Lock header row to keep delete deterministic after single downstream guard check.
if _, err := repoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return db.Clauses(clause.Locking{Strength: "UPDATE"})
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "TransferLaying not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer laying")
}
if err := repoTx.DeleteOne(c.Context(), id); err != nil { if err := repoTx.DeleteOne(c.Context(), id); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying") return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying")
} }
@@ -1053,6 +1065,11 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
) )
} }
type targetReflowKey struct {
productWarehouseID uint
}
targetReflow := make(map[targetReflowKey]struct{})
for _, target := range targets { for _, target := range targets {
if target.ProductWarehouseId == nil || *target.ProductWarehouseId == 0 { if target.ProductWarehouseId == nil || *target.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id))
@@ -1060,15 +1077,6 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
if target.TotalQty <= 0 { if target.TotalQty <= 0 {
continue continue
} }
if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyTransferToLayingIn,
StockableID: target.Id,
ProductWarehouseID: *target.ProductWarehouseId,
Quantity: -target.TotalQty,
Tx: dbTransaction,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback stok target transfer laying: %v", err))
}
stockLogDecrease := &entity.StockLog{ stockLogDecrease := &entity.StockLog{
ProductWarehouseId: *target.ProductWarehouseId, ProductWarehouseId: *target.ProductWarehouseId,
@@ -1092,6 +1100,20 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar target saat unexecute") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar target saat unexecute")
} }
if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]any{
"total_qty": 0,
"total_used": 0,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal rollback kuantitas target transfer laying")
}
targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{}
}
asOf := normalizeDateOnlyUTC(transfer.TransferDate)
for key := range targetReflow {
if err := reflowTransferLayingScope(c.Context(), s.FifoStockV2Svc, dbTransaction, key.productWarehouseID, &asOf); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 target transfer laying: %v", err))
}
} }
rollbackResult, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{ rollbackResult, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{
@@ -1229,9 +1251,6 @@ func (s *transferLayingService) executeApprovedTransferMovement(
if s.FifoStockV2Svc == nil { if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
} }
if s.FifoSvc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is not available")
}
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
targetRepoTx := repository.NewLayingTransferTargetRepository(tx) targetRepoTx := repository.NewLayingTransferTargetRepository(tx)
@@ -1327,29 +1346,22 @@ func (s *transferLayingService) executeApprovedTransferMovement(
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
} }
type targetReflowKey struct {
productWarehouseID uint
}
targetReflow := make(map[targetReflowKey]struct{})
for _, target := range targets { for _, target := range targets {
if target.ProductWarehouseId == nil { if target.ProductWarehouseId == nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id))
} }
note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber)
_, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyTransferToLayingIn,
StockableID: target.Id,
ProductWarehouseID: *target.ProductWarehouseId,
Quantity: target.TotalQty,
Note: &note,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err))
}
if err := targetRepoTx.PatchOne(ctx, target.Id, map[string]any{ if err := targetRepoTx.PatchOne(ctx, target.Id, map[string]any{
"total_qty": target.TotalQty, "total_qty": target.TotalQty,
}, nil); err != nil { }, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty")
} }
targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{}
stockLogIncrease := &entity.StockLog{ stockLogIncrease := &entity.StockLog{
ProductWarehouseId: *target.ProductWarehouseId, ProductWarehouseId: *target.ProductWarehouseId,
@@ -1376,6 +1388,11 @@ func (s *transferLayingService) executeApprovedTransferMovement(
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
} }
} }
for key := range targetReflow {
if err := reflowTransferLayingScope(ctx, s.FifoStockV2Svc, tx, key.productWarehouseID, &asOf); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow FIFO v2 target transfer laying: %v", err))
}
}
return nil return nil
} }
@@ -1595,31 +1612,19 @@ func (s *transferLayingService) hasDownstreamRecordingOnTarget(
targetProjectFlockKandangID uint, targetProjectFlockKandangID uint,
sinceDate time.Time, sinceDate time.Time,
) (bool, time.Time, error) { ) (bool, time.Time, error) {
if targetProjectFlockKandangID == 0 { targetRepo := s.LayingTransferTargetRepo
return false, time.Time{}, nil
}
db := s.Repository.DB().WithContext(ctx)
if tx != nil { if tx != nil {
db = tx.WithContext(ctx) targetRepo = repository.NewLayingTransferTargetRepository(tx)
} }
var earliest entity.Recording recordDate, err := targetRepo.GetEarliestRecordingDateByTarget(ctx, targetProjectFlockKandangID, sinceDate)
query := db.Model(&entity.Recording{}). if err != nil {
Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID).
Where("deleted_at IS NULL")
if !sinceDate.IsZero() {
query = query.Where("record_datetime >= ?", sinceDate)
}
if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, time.Time{}, nil
}
return false, time.Time{}, err return false, time.Time{}, err
} }
if recordDate == nil {
return true, normalizeDateOnlyUTC(earliest.RecordDatetime), nil return false, time.Time{}, nil
}
return true, normalizeDateOnlyUTC(*recordDate), nil
} }
func (s *transferLayingService) countActiveTransferSourceConsumeAllocations( func (s *transferLayingService) countActiveTransferSourceConsumeAllocations(
@@ -1628,81 +1633,45 @@ func (s *transferLayingService) countActiveTransferSourceConsumeAllocations(
transferID uint, transferID uint,
productWarehouseID uint, productWarehouseID uint,
) (int64, error) { ) (int64, error) {
if transferID == 0 || productWarehouseID == 0 { targetRepo := s.LayingTransferTargetRepo
return 0, nil if tx != nil {
targetRepo = repository.NewLayingTransferTargetRepository(tx)
} }
if tx == nil { return targetRepo.CountActiveTransferSourceConsumeAllocations(ctx, transferID, productWarehouseID)
return 0, errors.New("transaction is required")
}
var count int64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("product_warehouse_id = ?", productWarehouseID).
Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
Where("usable_id = ?", transferID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
} }
func (s *transferLayingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error { func (s *transferLayingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 { targetRepo := s.LayingTransferTargetRepo
return nil
}
db := s.Repository.DB().WithContext(ctx)
if tx != nil { if tx != nil {
db = tx.WithContext(ctx) targetRepo = repository.NewLayingTransferTargetRepository(tx)
} }
return targetRepo.SyncPopulationUsageByProjectFlockKandang(ctx, projectFlockKandangID)
}
var populationIDs []uint func sortedIDs(input map[uint]struct{}) []uint {
if err := db.Table("project_flock_populations pfp"). if len(input) == 0 {
Select("pfp.id").
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
Pluck("pfp.id", &populationIDs).Error; err != nil {
return err
}
if len(populationIDs) == 0 {
return nil return nil
} }
out := make([]uint, 0, len(input))
type usageRow struct { for id := range input {
StockableID uint `gorm:"column:stockable_id"` if id == 0 {
Used float64 `gorm:"column:used"` continue
}
var usageRows []usageRow
if err := db.Table("stock_allocations").
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("stockable_id IN ?", populationIDs).
Group("stockable_id").
Scan(&usageRows).Error; err != nil {
return err
}
if err := db.Model(&entity.ProjectFlockPopulation{}).
Where("id IN ?", populationIDs).
Update("total_used_qty", 0).Error; err != nil {
return err
}
for _, row := range usageRows {
if err := db.Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", row.StockableID).
Update("total_used_qty", row.Used).Error; err != nil {
return err
} }
out = append(out, id)
} }
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
return nil func joinUint(values []uint) 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 normalizeDateOnlyUTC(value time.Time) time.Time { func normalizeDateOnlyUTC(value time.Time) time.Time {
@@ -1721,3 +1690,84 @@ func isLegacyTransfer(transfer *entity.LayingTransfer) bool {
} }
return false return false
} }
func (s *transferLayingService) ensureNoDownstreamConsumptionForDelete(
ctx context.Context,
tx *gorm.DB,
transferNumber string,
targets []entity.LayingTransferTarget,
) error {
targetIDs := make([]uint, 0, len(targets))
for _, target := range targets {
if target.Id == 0 {
continue
}
targetIDs = append(targetIDs, target.Id)
}
if len(targetIDs) == 0 {
return nil
}
targetRepo := s.LayingTransferTargetRepo
if tx != nil {
targetRepo = repository.NewLayingTransferTargetRepository(tx)
}
rows, err := targetRepo.GetActiveDownstreamConsumptions(ctx, targetIDs)
if err != nil {
s.Log.Errorf("Failed to validate downstream consumption for transfer laying %s: %+v", transferNumber, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer laying")
}
if len(rows) == 0 {
return nil
}
dependencyMap := make(map[string]map[uint]struct{})
for _, row := range rows {
label := mapTransferLayingDownstreamUsableLabel(row.UsableType)
if _, ok := dependencyMap[label]; !ok {
dependencyMap[label] = make(map[uint]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 {
details = append(details, fmt.Sprintf("%s=%s", label, joinUint(sortedIDs(dependencyMap[label]))))
}
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"%s Transfer %s. Dependensi aktif: %s.",
transferLayingDeleteDownstreamGuardMessage,
transferNumber,
strings.Join(details, ", "),
),
)
}
func mapTransferLayingDownstreamUsableLabel(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))
}
}