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
@@ -32,6 +32,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
Flags: c.Query("flags", ""),
KandangId: uint(c.QueryInt("kandang_id", 0)),
TransferContext: c.Query(utils.TransferContextKey, ""),
StockMode: c.Query("stock_mode", ""),
Type: c.Query("type", ""),
}
@@ -12,10 +12,11 @@ import (
// === DTO Structs ===
type ProductWarehouseRelationDTO struct {
Id uint `json:"id"`
ProductId uint `json:"product_id"`
WarehouseId uint `json:"warehouse_id"`
Quantity float64 `json:"quantity"`
Id uint `json:"id"`
ProductId uint `json:"product_id"`
WarehouseId uint `json:"warehouse_id"`
Quantity float64 `json:"quantity"`
AvailableQty *float64 `json:"available_qty,omitempty"`
}
type ProductWarehouseListDTO struct {
@@ -61,10 +62,11 @@ type ProjectFlockRelationDTO struct {
func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO {
return ProductWarehouseRelationDTO{
Id: e.Id,
ProductId: e.ProductId, // Field yang benar dari entity
WarehouseId: e.WarehouseId, // Field yang benar dari entity
Quantity: e.Quantity,
Id: e.Id,
ProductId: e.ProductId, // Field yang benar dari entity
WarehouseId: e.WarehouseId, // Field yang benar dari entity
Quantity: e.Quantity,
AvailableQty: e.AvailableQty,
}
}
@@ -2,6 +2,7 @@ package service
import (
"errors"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -12,6 +13,7 @@ import (
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"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
@@ -27,6 +29,8 @@ type productWarehouseService struct {
KandangRepo kandangrepo.KandangRepository
}
const stockModeExcludeChickin = "exclude_chickin"
func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService {
return &productWarehouseService{
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)
return nil, 0, err
}
productWarehouses, err = s.applyTransferAvailableQty(c, params, productWarehouses)
if err != nil {
return nil, 0, err
}
return productWarehouses, total, nil
}
@@ -229,3 +238,80 @@ func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductW
}
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"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
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"`
}
@@ -109,3 +109,23 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error {
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.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne)
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"
"fmt"
"mime/multipart"
"sort"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -21,14 +23,17 @@ import (
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 {
@@ -49,6 +54,8 @@ type transferService struct {
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 {
return &transferService{
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 {
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")
@@ -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_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
@@ -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 {
return s.withRelations(db)
return s.withRelations(db).Where("stock_transfers.deleted_at IS NULL")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -619,6 +628,210 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
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(
ctx context.Context,
tx *gorm.DB,
@@ -692,3 +905,179 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
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 (
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 {
@@ -264,16 +264,16 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
availableQty = 0
}
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 {
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")
}
if transferAvailable < 0 {
transferAvailable = 0
if sourceAvailable < 0 {
sourceAvailable = 0
}
if transferAvailable < availableQty {
availableQty = transferAvailable
if sourceAvailable < availableQty {
availableQty = sourceAvailable
}
}
@@ -554,36 +554,44 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
return nil
}
func (s *chickinService) resolveLayingTransferAvailableQty(ctx context.Context, tx *gorm.DB, targetProjectFlockKandangID, productWarehouseID uint) (float64, error) {
if targetProjectFlockKandangID == 0 || productWarehouseID == 0 {
func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) {
if productWarehouseID == 0 || s.FifoStockV2Svc == nil {
return 0, nil
}
db := s.Repository.DB().WithContext(ctx)
db := s.Repository.DB()
if tx != nil {
db = tx.WithContext(ctx)
db = tx
}
var available float64
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
flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, db, 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
}
available := 0.0
for _, row := range gatherRows {
if row.AvailableQuantity <= 0 {
continue
}
available += row.AvailableQuantity
}
return available, nil
}
@@ -650,6 +658,8 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
Where("sa.usable_type IN ?", []string{
fifo.UsableKeyMarketingDelivery.String(),
fifo.UsableKeyRecordingDepletion.String(),
fifo.UsableKeyStockTransferOut.String(),
fifo.UsableKeyAdjustmentOut.String(),
fifo.UsableKeyTransferToLayingOut.String(),
}).
Group("sa.usable_type, sa.usable_id").
@@ -664,6 +674,8 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
marketingIDs := make(map[uint]struct{})
recordingIDs := make(map[uint]struct{})
transferIDs := make(map[uint]struct{})
adjustmentIDs := make(map[uint]struct{})
transferLayingIDs := make(map[uint]struct{})
for _, row := range rows {
@@ -672,18 +684,28 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
marketingIDs[row.UsableID] = struct{}{}
case fifo.UsableKeyRecordingDepletion.String():
recordingIDs[row.UsableID] = struct{}{}
case fifo.UsableKeyStockTransferOut.String():
transferIDs[row.UsableID] = struct{}{}
case fifo.UsableKeyAdjustmentOut.String():
adjustmentIDs[row.UsableID] = struct{}{}
case fifo.UsableKeyTransferToLayingOut.String():
transferLayingIDs[row.UsableID] = struct{}{}
}
}
details := make([]string, 0, 3)
details := make([]string, 0, 5)
if ids := sortedIDs(marketingIDs); len(ids) > 0 {
details = append(details, fmt.Sprintf("Marketing=%s", joinUint(ids)))
}
if ids := sortedIDs(recordingIDs); len(ids) > 0 {
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 {
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 {
var affectedTransferTargetIDs []uint
if err := tx.WithContext(ctx).
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 {
affectedStockables, err := s.listActiveConsumeStockableRefsByUsable(ctx, tx, chickin.Id)
if err != nil {
return err
}
@@ -1325,12 +1338,15 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
return err
}
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,
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
}
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 {
if tx == nil || len(transferTargetIDs) == 0 {
return nil
func (s *chickinService) listActiveConsumeStockableRefsByUsable(ctx context.Context, tx *gorm.DB, chickinID uint) (map[string][]uint, error) {
result := map[string][]uint{
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))
seen := make(map[uint]struct{}, len(transferTargetIDs))
for _, id := range transferTargetIDs {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
type row struct {
StockableType string `gorm:"column:stockable_type"`
StockableID uint `gorm:"column:stockable_id"`
}
if len(unique) == 0 {
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
var rows []row
if err := tx.WithContext(ctx).
Table("stock_allocations").
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("stockable_id IN ?", unique).
Group("stockable_id").
Scan(&usageRows).Error; err != nil {
Select("stockable_type, stockable_id").
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?",
fifo.UsableKeyProjectChickin.String(),
chickinID,
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
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
}
for _, row := range usageRows {
if err := tx.WithContext(ctx).
Model(&entity.LayingTransferTarget{}).
Where("id = ?", row.StockableID).
Update("total_used", row.Used).Error; err != nil {
return err
}
if err := s.resetAndResyncUsedQuantity(
ctx,
tx,
"stock_transfer_details",
"id",
"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
}
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 {
if value.IsZero() {
return time.Time{}
@@ -7,14 +7,14 @@ import (
"github.com/gofiber/fiber/v2"
"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"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/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"
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"
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)
approvalService := commonSvc.NewApprovalService(approvalRepo)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
// register workflow steps for chickin approvals
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err))
}
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)
ProjectFlockKandangRoutes(router, userService, projectFlockKandangService)
@@ -1,8 +1,10 @@
package service
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
@@ -35,6 +37,7 @@ type projectFlockKandangService struct {
Validate *validator.Validate
Repository repository.ProjectFlockKandangRepository
ApprovalSvc commonSvc.ApprovalService
FifoStockV2Svc commonSvc.FifoStockV2Service
ExpenseRepo expenseRepo.ExpenseRepository
WarehouseRepo rWarehouse.WarehouseRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
@@ -69,12 +72,13 @@ type ExpenseSummary struct {
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{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
FifoStockV2Svc: fifoStockV2Svc,
ExpenseRepo: expenseRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
@@ -671,7 +675,91 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous
if 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
}
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,
warehouseRepo,
approvalService,
fifoService,
fifoStockV2Service,
validate,
)
@@ -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))
}
}