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
@@ -91,7 +91,6 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
productWarehouseRepo,
warehouseRepo,
approvalService,
fifoService,
fifoStockV2Service,
validate,
)
@@ -2,15 +2,22 @@ package repository
import (
"context"
"errors"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type LayingTransferTargetRepository interface {
repository.BaseRepository[entity.LayingTransferTarget]
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 {
@@ -18,6 +25,11 @@ type LayingTransferTargetRepositoryImpl struct {
db *gorm.DB
}
type TargetDownstreamConsumption struct {
UsableType string `gorm:"column:usable_type"`
UsableID uint `gorm:"column:usable_id"`
}
func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository {
return &LayingTransferTargetRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db),
@@ -36,3 +48,123 @@ func (r *LayingTransferTargetRepositoryImpl) GetByLayingTransferId(ctx context.C
}
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"
"errors"
"fmt"
"sort"
"strings"
"time"
@@ -56,12 +57,12 @@ type transferLayingService struct {
WarehouseRepo rWarehouse.WarehouseRepository
StockLogRepo rStockLogs.StockLogRepository
ApprovalService commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service
}
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(
@@ -74,7 +75,6 @@ func NewTransferLayingService(
productWarehouseRepo rInventory.ProductWarehouseRepository,
warehouseRepo rWarehouse.WarehouseRepository,
approvalService commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService,
fifoStockV2Svc commonSvc.FifoStockV2Service,
validate *validator.Validate,
) TransferLayingService {
@@ -91,7 +91,6 @@ func NewTransferLayingService(
WarehouseRepo: warehouseRepo,
StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()),
ApprovalService: approvalService,
FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc,
}
}
@@ -610,6 +609,9 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
if isLegacyTransfer(transfer) {
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())
@@ -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 {
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 {
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 {
if target.ProductWarehouseId == nil || *target.ProductWarehouseId == 0 {
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 {
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{
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 {
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{
@@ -1229,9 +1251,6 @@ func (s *transferLayingService) executeApprovedTransferMovement(
if s.FifoStockV2Svc == nil {
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)
targetRepoTx := repository.NewLayingTransferTargetRepository(tx)
@@ -1327,29 +1346,22 @@ func (s *transferLayingService) executeApprovedTransferMovement(
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
type targetReflowKey struct {
productWarehouseID uint
}
targetReflow := make(map[targetReflowKey]struct{})
for _, target := range targets {
if target.ProductWarehouseId == nil {
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{
"total_qty": target.TotalQty,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty")
}
targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: *target.ProductWarehouseId,
@@ -1376,6 +1388,11 @@ func (s *transferLayingService) executeApprovedTransferMovement(
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
}
@@ -1595,31 +1612,19 @@ func (s *transferLayingService) hasDownstreamRecordingOnTarget(
targetProjectFlockKandangID uint,
sinceDate time.Time,
) (bool, time.Time, error) {
if targetProjectFlockKandangID == 0 {
return false, time.Time{}, nil
}
db := s.Repository.DB().WithContext(ctx)
targetRepo := s.LayingTransferTargetRepo
if tx != nil {
db = tx.WithContext(ctx)
targetRepo = repository.NewLayingTransferTargetRepository(tx)
}
var earliest entity.Recording
query := db.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 false, time.Time{}, nil
}
recordDate, err := targetRepo.GetEarliestRecordingDateByTarget(ctx, targetProjectFlockKandangID, sinceDate)
if err != nil {
return false, time.Time{}, err
}
return true, normalizeDateOnlyUTC(earliest.RecordDatetime), nil
if recordDate == nil {
return false, time.Time{}, nil
}
return true, normalizeDateOnlyUTC(*recordDate), nil
}
func (s *transferLayingService) countActiveTransferSourceConsumeAllocations(
@@ -1628,81 +1633,45 @@ func (s *transferLayingService) countActiveTransferSourceConsumeAllocations(
transferID uint,
productWarehouseID uint,
) (int64, error) {
if transferID == 0 || productWarehouseID == 0 {
return 0, nil
targetRepo := s.LayingTransferTargetRepo
if tx != nil {
targetRepo = repository.NewLayingTransferTargetRepository(tx)
}
if tx == nil {
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
return targetRepo.CountActiveTransferSourceConsumeAllocations(ctx, transferID, productWarehouseID)
}
func (s *transferLayingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
db := s.Repository.DB().WithContext(ctx)
targetRepo := s.LayingTransferTargetRepo
if tx != nil {
db = tx.WithContext(ctx)
targetRepo = repository.NewLayingTransferTargetRepository(tx)
}
return targetRepo.SyncPopulationUsageByProjectFlockKandang(ctx, projectFlockKandangID)
}
var populationIDs []uint
if err := db.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 {
func sortedIDs(input map[uint]struct{}) []uint {
if len(input) == 0 {
return nil
}
type usageRow struct {
StockableID uint `gorm:"column:stockable_id"`
Used float64 `gorm:"column:used"`
}
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 := make([]uint, 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
}
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 {
@@ -1721,3 +1690,84 @@ func isLegacyTransfer(transfer *entity.LayingTransfer) bool {
}
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))
}
}