Compare commits

..

13 Commits

Author SHA1 Message Date
Adnan Zahir 7f8013c5ed fix: reimplement transfer to laying logics separating effective financial date and physical transfer date 2026-03-05 12:53:00 +07:00
Adnan Zahir 1b6041073e Merge branch 'feat/BE/restriction_growing_trl' into 'dev/fifo-v2'
Fix config chickin

See merge request mbugroup/lti-api!346
2026-03-04 14:54:27 +07:00
ragilap 1724a5f846 Fix config chickin 2026-03-04 14:39:50 +07:00
Adnan Zahir f082c5c122 Merge branch 'feat/BE/restriction_growing_trl' into 'dev/fifo-v2'
[FEAT/BE] wiring recording,transfer_stock,transfer_laying,marketing for...

See merge request mbugroup/lti-api!345
2026-03-04 14:30:40 +07:00
ragilap d334f46829 [FEAT/BE] wiring recording,transfer_stock,transfer_laying,marketing for consumer chick in project flock population 2026-03-04 12:41:26 +07:00
Hafizh A. Y 80135466df fix: all implemented fifo v2 2026-03-03 16:15:35 +07:00
Hafizh A. Y 9d5f733172 fix: first push need support testing, and implemented fifo v2 to all modules 2026-03-03 16:10:12 +07:00
Adnan Zahir 4bb750fc98 dev: initiate adjustment recording and trf to laying 2026-03-03 15:47:29 +07:00
Hafizh A. Y. d335597bed Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'
[FIX/BE] Implementation Fifo V2 to all module

See merge request mbugroup/lti-api!343
2026-03-03 04:09:27 +00:00
Hafizh A. Y. 3bc0685b46 Merge branch 'revert-915302c4' into 'dev/fifo-v2'
Revert "Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'"

See merge request mbugroup/lti-api!342
2026-02-27 09:37:50 +00:00
Hafizh A. Y. f6c88b773d Revert "Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'"
This reverts merge request !340
2026-02-27 09:37:03 +00:00
Hafizh A. Y. 915302c445 Merge branch 'fix/implement-fifo-v2' into 'dev/fifo-v2'
feat: refactor module adjusment stock, adjust constant, adjust table migration and create command reflow and delete module adjusment stock

See merge request mbugroup/lti-api!340
2026-02-27 08:30:53 +00:00
Hafizh A. Y e7e065c320 fix: first push implementation fifo v2 to all stockable and useable 2026-02-27 15:21:55 +07:00
33 changed files with 1643 additions and 221 deletions
+3 -4
View File
@@ -467,9 +467,9 @@ func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlock
FROM stock_allocations sa FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE' AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME' AND sa.allocation_purpose = 'CONSUME'
GROUP BY sa.stockable_id GROUP BY sa.stockable_id
) )
UPDATE project_flock_populations p UPDATE project_flock_populations p
SET total_used_qty = LEAST(COALESCE(a.used_qty, 0), GREATEST(s.total_qty, 0)), SET total_used_qty = LEAST(COALESCE(a.used_qty, 0), GREATEST(s.total_qty, 0)),
updated_at = NOW() updated_at = NOW()
@@ -483,7 +483,6 @@ func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlock
return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil
} }
func resyncChickinTraceByProjectFlockKandang( func resyncChickinTraceByProjectFlockKandang(
ctx context.Context, ctx context.Context,
db *gorm.DB, db *gorm.DB,
@@ -299,6 +299,9 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec
Table("laying_transfer_targets AS ltt"). Table("laying_transfer_targets AS ltt").
Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty"). Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty").
Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id"). Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id").
Where("lt.deleted_at IS NULL").
Where("ltt.deleted_at IS NULL").
Where("lt.executed_at IS NOT NULL").
Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId). Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId).
Group("lt.from_project_flock_id"). Group("lt.from_project_flock_id").
Scan(&summary).Error Scan(&summary).Error
@@ -487,7 +487,6 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
if desiredQty <= 0 { if desiredQty <= 0 {
continue continue
} }
allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{ allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{
FlagGroupCode: req.FlagGroupCode, FlagGroupCode: req.FlagGroupCode,
ProductWarehouseID: req.ProductWarehouseID, ProductWarehouseID: req.ProductWarehouseID,
@@ -0,0 +1,131 @@
package fifo_stock_v2
import (
"context"
"errors"
"math"
"sort"
commonRepo "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"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
func ReleasePopulationConsumptionByUsable(
ctx context.Context,
tx *gorm.DB,
usableType string,
usableID uint,
) error {
if tx == nil {
return errors.New("transaction is required")
}
if usableType == "" || usableID == 0 {
return errors.New("usable type and id are required")
}
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
allocations, err := stockAllocationRepo.FindActiveByUsable(ctx, usableType, usableID, nil)
if err != nil {
return err
}
for _, allocation := range allocations {
if allocation.StockableType != fifo.StockableKeyProjectFlockPopulation.String() || allocation.StockableId == 0 || allocation.Qty <= 0 {
continue
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", allocation.StockableId).
Update("total_used_qty", gorm.Expr("GREATEST(total_used_qty - ?, 0)", allocation.Qty)).Error; err != nil {
return err
}
}
return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, nil)
}
func AllocatePopulationConsumption(
ctx context.Context,
tx *gorm.DB,
populations []entity.ProjectFlockPopulation,
productWarehouseID uint,
usableType string,
usableID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if productWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak valid")
}
if usableType == "" || usableID == 0 {
return errors.New("usable type and id are required")
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan")
}
if err := ReleasePopulationConsumptionByUsable(ctx, tx, usableType, usableID); err != nil {
return err
}
sort.Slice(populations, func(i, j int) bool {
if populations[i].CreatedAt.Equal(populations[j].CreatedAt) {
return populations[i].Id < populations[j].Id
}
return populations[i].CreatedAt.Before(populations[j].CreatedAt)
})
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
remaining := consumeQty
for _, pop := range populations {
available := pop.TotalQty - pop.TotalUsedQty
if available <= 0 {
continue
}
portion := math.Min(available, remaining)
if portion <= 0 {
continue
}
allocation := &entity.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
StockableId: pop.Id,
UsableType: usableType,
UsableId: usableID,
Qty: portion,
Status: entity.StockAllocationStatusActive,
AllocationPurpose: entity.StockAllocationPurposeConsume,
}
if err := stockAllocationRepo.CreateOne(ctx, allocation, nil); err != nil {
return err
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", pop.Id).
Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil {
return err
}
remaining -= portion
if remaining <= 1e-6 {
break
}
}
if remaining > 1e-6 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi")
}
return nil
}
+60 -54
View File
@@ -22,60 +22,61 @@ type SSOClientConfig struct {
} }
var ( var (
IsProd bool IsProd bool
AppHost string AppHost string
Version string Version string
LogLevel string LogLevel string
AppPort int AppPort int
DBHost string DBHost string
DBUser string DBUser string
DBPassword string DBPassword string
DBName string DBName string
DBPort int DBPort int
DBSSLMode string DBSSLMode string
DBSSLRootCert string DBSSLRootCert string
DBSSLCert string DBSSLCert string
DBSSLKey string DBSSLKey string
JWTSecret string JWTSecret string
JWTAccessExp int JWTAccessExp int
JWTRefreshExp int JWTRefreshExp int
JWTResetPasswordExp int JWTResetPasswordExp int
JWTVerifyEmailExp int JWTVerifyEmailExp int
RedisURL string RedisURL string
CORSAllowOrigins []string CORSAllowOrigins []string
CORSAllowMethods []string CORSAllowMethods []string
CORSAllowHeaders []string CORSAllowHeaders []string
CORSExposeHeaders []string CORSExposeHeaders []string
CORSAllowCredentials bool CORSAllowCredentials bool
CORSMaxAge int CORSMaxAge int
SSOIssuer string SSOIssuer string
SSOJWKSURL string SSOJWKSURL string
SSOAllowedAudiences []string SSOAllowedAudiences []string
SSOAuthorizeURL string SSOAuthorizeURL string
SSOTokenURL string SSOTokenURL string
SSOGetMeURL string SSOGetMeURL string
SSOPortalURL string SSOPortalURL string
SSOClients map[string]SSOClientConfig SSOClients map[string]SSOClientConfig
SSOAccessCookieName string SSOAccessCookieName string
SSORefreshCookieName string SSORefreshCookieName string
SSOCookieDomain string SSOCookieDomain string
SSOCookieSecure bool SSOCookieSecure bool
SSOCookieSameSite string SSOCookieSameSite string
SSOAccessTokenMaxBytes int SSOAccessTokenMaxBytes int
SSOTokenBlacklistPrefix string SSOTokenBlacklistPrefix string
SSOPKCETTL time.Duration SSOPKCETTL time.Duration
SSOUserSyncDrift time.Duration SSOUserSyncDrift time.Duration
SSOUserSyncNonceTTL time.Duration SSOUserSyncNonceTTL time.Duration
SSOUserSyncMaxBodyBytes int SSOUserSyncMaxBodyBytes int
S3Endpoint string S3Endpoint string
S3Region string S3Region string
S3Bucket string S3Bucket string
S3AccessKey string S3AccessKey string
S3SecretKey string S3SecretKey string
S3ForcePathStyle bool S3ForcePathStyle bool
S3PublicBaseURL string S3PublicBaseURL string
S3EnvPrefix string S3EnvPrefix string
S3DocumentKeyPrefix string S3DocumentKeyPrefix string
TransferToLayingGrowingMaxWeek int
) )
func init() { func init() {
@@ -117,6 +118,11 @@ func init() {
// Redis // Redis
RedisURL = viper.GetString("REDIS_URL") RedisURL = viper.GetString("REDIS_URL")
TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK")
if TransferToLayingGrowingMaxWeek <= 0 {
TransferToLayingGrowingMaxWeek = 19
}
// Object storage // Object storage
S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT")) S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT"))
S3Region = strings.TrimSpace(viper.GetString("S3_REGION")) S3Region = strings.TrimSpace(viper.GetString("S3_REGION"))
@@ -24,7 +24,8 @@ WHERE source_table IN (
'recording_depletions', 'recording_depletions',
'recording_eggs', 'recording_eggs',
'marketing_delivery_products', 'marketing_delivery_products',
'project_chickins' 'project_chickins',
'project_flock_populations'
); );
DELETE FROM fifo_stock_v2_flag_members DELETE FROM fifo_stock_v2_flag_members
@@ -79,7 +79,8 @@ VALUES
('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'), ('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'),
('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id') ('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id'),
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id')
ON CONFLICT (source_table, lane) DO UPDATE ON CONFLICT (source_table, lane) DO UPDATE
SET SET
date_table = EXCLUDED.date_table, date_table = EXCLUDED.date_table,
@@ -112,6 +113,7 @@ VALUES
('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), ('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE), ('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE), ('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE),
-- AYAM USABLE -- AYAM USABLE
('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), ('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
@@ -0,0 +1,15 @@
BEGIN;
DROP INDEX IF EXISTS idx_laying_transfers_executed_by;
DROP INDEX IF EXISTS idx_laying_transfers_executed_at;
DROP INDEX IF EXISTS idx_laying_transfers_effective_move_date;
ALTER TABLE laying_transfers
DROP CONSTRAINT IF EXISTS fk_laying_transfers_executed_by;
ALTER TABLE laying_transfers
DROP COLUMN IF EXISTS executed_by,
DROP COLUMN IF EXISTS executed_at,
DROP COLUMN IF EXISTS effective_move_date;
COMMIT;
@@ -0,0 +1,50 @@
BEGIN;
ALTER TABLE laying_transfers
ADD COLUMN IF NOT EXISTS effective_move_date DATE,
ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS executed_by BIGINT;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users')
AND NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_laying_transfers_executed_by'
) THEN
ALTER TABLE laying_transfers
ADD CONSTRAINT fk_laying_transfers_executed_by
FOREIGN KEY (executed_by)
REFERENCES users(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_laying_transfers_effective_move_date
ON laying_transfers(effective_move_date);
CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_at
ON laying_transfers(executed_at);
CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_by
ON laying_transfers(executed_by);
-- Backfill historical approved transfers. Before deferred execution,
-- approved transfers were executed immediately during approval.
UPDATE laying_transfers lt
SET
effective_move_date = COALESCE(lt.effective_move_date, lt.transfer_date),
executed_at = COALESCE(lt.executed_at, lt.updated_at),
executed_by = COALESCE(lt.executed_by, lt.created_by)
WHERE (
SELECT a.action
FROM approvals a
WHERE a.approvable_type = 'TRANSFER_TO_LAYINGS'
AND a.approvable_id = lt.id
ORDER BY a.id DESC
LIMIT 1
) = 'APPROVED';
COMMIT;
@@ -0,0 +1,9 @@
BEGIN;
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT;
@@ -0,0 +1,34 @@
BEGIN;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
updated_at = NOW(),
-- Keep existing is_active (do not override disable migration if it was intentional).
is_active = fifo_stock_v2_route_rules.is_active;
COMMIT;
@@ -0,0 +1,8 @@
BEGIN;
DROP INDEX IF EXISTS idx_laying_transfers_economic_cutoff_date;
ALTER TABLE laying_transfers
DROP COLUMN IF EXISTS economic_cutoff_date;
COMMIT;
@@ -0,0 +1,13 @@
BEGIN;
ALTER TABLE laying_transfers
ADD COLUMN IF NOT EXISTS economic_cutoff_date DATE;
CREATE INDEX IF NOT EXISTS idx_laying_transfers_economic_cutoff_date
ON laying_transfers(economic_cutoff_date);
UPDATE laying_transfers
SET economic_cutoff_date = COALESCE(economic_cutoff_date, effective_move_date, transfer_date)
WHERE economic_cutoff_date IS NULL;
COMMIT;
+11 -6
View File
@@ -12,16 +12,21 @@ type LayingTransfer struct {
FromProjectFlockId uint `gorm:"not null"` FromProjectFlockId uint `gorm:"not null"`
ToProjectFlockId uint `gorm:"not null"` ToProjectFlockId uint `gorm:"not null"`
TransferDate time.Time `gorm:"type:date;not null"` TransferDate time.Time `gorm:"type:date;not null"`
EconomicCutoffDate *time.Time `gorm:"type:date"`
EffectiveMoveDate *time.Time `gorm:"type:date"`
ExecutedAt *time.Time `gorm:"type:timestamptz"`
ExecutedBy *uint `gorm:"index"`
Notes string `gorm:"type:text"` Notes string `gorm:"type:text"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"` FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"` ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"` Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"`
} }
@@ -39,6 +39,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
kandangRepo := rKandang.NewKandangRepository(db) kandangRepo := rKandang.NewKandangRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db) nonstockRepo := rNonstock.NewNonstockRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db) documentRepo := commonRepo.NewDocumentRepository(db)
@@ -76,7 +77,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
expenseServiceInstance, expenseServiceInstance,
) )
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoStockV2Service, expenseBridge) transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, projectFlockPopulationRepo, documentSvc, fifoStockV2Service, expenseBridge)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
TransferRoutes(router, userService, transferService) TransferRoutes(router, userService, transferService)
@@ -11,6 +11,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
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"
@@ -21,6 +22,7 @@ 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"
) )
@@ -43,12 +45,13 @@ type transferService struct {
SupplierRepo rSupplier.SupplierRepository SupplierRepo rSupplier.SupplierRepository
WarehouseRepo warehouseRepo.WarehouseRepository WarehouseRepo warehouseRepo.WarehouseRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository
DocumentSvc commonSvc.DocumentService DocumentSvc commonSvc.DocumentService
FifoStockV2Svc commonSvc.FifoStockV2Service FifoStockV2Svc commonSvc.FifoStockV2Service
ExpenseBridge TransferExpenseBridge ExpenseBridge TransferExpenseBridge
} }
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, 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,
Validate: validate, Validate: validate,
@@ -61,6 +64,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
SupplierRepo: supplierRepo, SupplierRepo: supplierRepo,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
DocumentSvc: documentSvc, DocumentSvc: documentSvc,
FifoStockV2Svc: fifoStockV2Svc, FifoStockV2Svc: fifoStockV2Svc,
ExpenseBridge: expenseBridge, ExpenseBridge: expenseBridge,
@@ -509,6 +513,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID))
} }
if strings.EqualFold(flagGroupCode, "AYAM") && outUsageQty > 0 {
if err := s.allocatePopulationForStockTransferOut(
c.Context(),
tx,
detail,
uint(*detail.SourceProductWarehouseID),
outUsageQty,
); err != nil {
return err
}
}
stockLogDecrease := &entity.StockLog{ stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID), ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: uint(actorID), CreatedBy: uint(actorID),
@@ -617,6 +633,57 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return result, nil return result, nil
} }
func (s *transferService) allocatePopulationForStockTransferOut(
ctx context.Context,
tx *gorm.DB,
detail *entity.StockTransferDetail,
sourceProductWarehouseID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if detail == nil || detail.Id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Data transfer detail tidak valid")
}
if sourceProductWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Gudang sumber tidak valid")
}
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, sourceProductWarehouseID, nil)
if err != nil {
return err
}
if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 {
return nil
}
populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(
ctx,
*pw.ProjectFlockKandangId,
sourceProductWarehouseID,
)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk transfer")
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
sourceProductWarehouseID,
fifo.UsableKeyStockTransferOut.String(),
uint(detail.Id),
consumeQty,
)
}
func (s *transferService) resolveTransferFlagGroup( func (s *transferService) resolveTransferFlagGroup(
ctx context.Context, ctx context.Context,
tx *gorm.DB, tx *gorm.DB,
+2 -1
View File
@@ -31,6 +31,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
customerRepo := rCustomer.NewCustomerRepository(db) customerRepo := rCustomer.NewCustomerRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
stockLogRepo := rShared.NewStockLogRepository(db) stockLogRepo := rShared.NewStockLogRepository(db)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
@@ -46,7 +47,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoStockV2Service, warehouseRepo, projectFlockKandangRepo, validate) salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoStockV2Service, warehouseRepo, projectFlockKandangRepo, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoStockV2Service, validate) deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, productWarehouseRepo, projectFlockPopulationRepo, approvalSvc, fifoStockV2Service, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService)
@@ -4,17 +4,22 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
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"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rShared "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"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -34,6 +39,8 @@ type deliveryOrdersService struct {
MarketingProductRepo marketingRepo.MarketingProductRepository MarketingProductRepo marketingRepo.MarketingProductRepository
MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository
StockLogRepo rShared.StockLogRepository StockLogRepo rShared.StockLogRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoStockV2Svc commonSvc.FifoStockV2Service FifoStockV2Svc commonSvc.FifoStockV2Service
} }
@@ -43,6 +50,8 @@ func NewDeliveryOrdersService(
marketingProductRepo marketingRepo.MarketingProductRepository, marketingProductRepo marketingRepo.MarketingProductRepository,
marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository,
stockLogRepo rShared.StockLogRepository, stockLogRepo rShared.StockLogRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
fifoStockV2Svc commonSvc.FifoStockV2Service, fifoStockV2Svc commonSvc.FifoStockV2Service,
validate *validator.Validate, validate *validator.Validate,
@@ -53,6 +62,8 @@ func NewDeliveryOrdersService(
MarketingProductRepo: marketingProductRepo, MarketingProductRepo: marketingProductRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
StockLogRepo: stockLogRepo, StockLogRepo: stockLogRepo,
ProductWarehouseRepo: productWarehouseRepo,
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoStockV2Svc: fifoStockV2Svc, FifoStockV2Svc: fifoStockV2Svc,
} }
@@ -556,7 +567,6 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil { if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
} }
if err := reflowMarketingScope( if err := reflowMarketingScope(
ctx, ctx,
s.FifoStockV2Svc, s.FifoStockV2Svc,
@@ -575,6 +585,10 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
deliveryProduct.PendingQty = refreshed.PendingQty deliveryProduct.PendingQty = refreshed.PendingQty
deliveryProduct.CreatedAt = refreshed.CreatedAt deliveryProduct.CreatedAt = refreshed.CreatedAt
if err := s.allocatePopulationForMarketingDelivery(ctx, tx, deliveryProduct, marketingProduct.ProductWarehouseId); err != nil {
return err
}
allocatedDelta := deliveryProduct.UsageQty - previousUsage allocatedDelta := deliveryProduct.UsageQty - previousUsage
if actorID > 0 && allocatedDelta > 0 { if actorID > 0 && allocatedDelta > 0 {
decreaseLog := &entity.StockLog{ decreaseLog := &entity.StockLog{
@@ -642,6 +656,10 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
deliveryProduct.PendingQty = refreshed.PendingQty deliveryProduct.PendingQty = refreshed.PendingQty
deliveryProduct.CreatedAt = refreshed.CreatedAt deliveryProduct.CreatedAt = refreshed.CreatedAt
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
return err
}
releasedUsage := currentUsage - deliveryProduct.UsageQty releasedUsage := currentUsage - deliveryProduct.UsageQty
if actorID > 0 && releasedUsage > 0 { if actorID > 0 && releasedUsage > 0 {
increaseLog := &entity.StockLog{ increaseLog := &entity.StockLog{
@@ -668,3 +686,57 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
return nil return nil
} }
func (s deliveryOrdersService) allocatePopulationForMarketingDelivery(
ctx context.Context,
tx *gorm.DB,
deliveryProduct *entity.MarketingDeliveryProduct,
productWarehouseID uint,
) error {
if deliveryProduct == nil || deliveryProduct.Id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Delivery product tidak valid")
}
if tx == nil {
return errors.New("transaction is required")
}
if deliveryProduct.UsageQty <= 0 {
return nil
}
if productWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak ditemukan")
}
flagGroupCode, err := resolveMarketingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return err
}
if !strings.EqualFold(flagGroupCode, "AYAM") {
return nil
}
pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil)
if err != nil {
return err
}
if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 {
return nil
}
populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(ctx, *pw.ProjectFlockKandangId, productWarehouseID)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery")
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
productWarehouseID,
fifo.UsableKeyMarketingDelivery.String(),
deliveryProduct.Id,
deliveryProduct.UsageQty,
)
}
@@ -18,6 +18,7 @@ import (
sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
@@ -36,6 +37,7 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
projectflockkandangrepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectflockkandangrepo := rProjectFlock.NewProjectFlockKandangRepository(db)
projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
productRepo := rProduct.NewProductRepository(db) productRepo := rProduct.NewProductRepository(db)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
@@ -57,6 +59,7 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
projectflockkandangrepo, projectflockkandangrepo,
projectflockpopulationrepo, projectflockpopulationrepo,
chickinDetailRepo, chickinDetailRepo,
transferLayingRepo,
validate, validate,
fifoStockV2Service) fifoStockV2Service)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -19,6 +19,7 @@ import (
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/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" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -27,6 +28,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type ChickinService interface { type ChickinService interface {
@@ -51,11 +53,12 @@ type chickinService struct {
ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository
ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
ProjectChickinDetailRepo repository.ProjectChickinDetailRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository
TransferLayingRepo rTransferLaying.TransferLayingRepository
FifoStockV2Svc commonSvc.FifoStockV2Service FifoStockV2Svc commonSvc.FifoStockV2Service
StockLogRepo rStockLogs.StockLogRepository StockLogRepo rStockLogs.StockLogRepository
} }
func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoStockV2Svc commonSvc.FifoStockV2Service) ChickinService { func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, transferLayingRepo rTransferLaying.TransferLayingRepository, validate *validator.Validate, fifoStockV2Svc commonSvc.FifoStockV2Service) ChickinService {
return &chickinService{ return &chickinService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
@@ -68,6 +71,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan
ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockKandangRepo: projectflockkandangRepo,
ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectflockPopulationRepo: projectflockpopulationRepo,
ProjectChickinDetailRepo: projectChickinDetailRepo, ProjectChickinDetailRepo: projectChickinDetailRepo,
TransferLayingRepo: transferLayingRepo,
FifoStockV2Svc: fifoStockV2Svc, FifoStockV2Svc: fifoStockV2Svc,
StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()),
} }
@@ -120,11 +124,36 @@ func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, e
return chickin, nil return chickin, nil
} }
func (s chickinService) ensureNotTransferred(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 || s.TransferLayingRepo == nil {
return nil
}
transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
s.Log.Errorf("Failed to resolve transfer laying by source kandang %d: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
if transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sudah dipindahkan ke laying")
}
return nil
}
func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]entity.ProjectChickin, error) { func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]entity.ProjectChickin, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := s.ensureNotTransferred(c.Context(), req.ProjectFlockKandangId); err != nil {
return nil, err
}
projectFlockKandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId) projectFlockKandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
@@ -334,6 +363,17 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return nil, err return nil, err
} }
chickin, err := s.Repository.GetByID(c.Context(), id, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found")
}
return nil, err
}
if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil {
return nil, err
}
updateBody := make(map[string]any) updateBody := make(map[string]any)
if req.ChickInDate != "" { if req.ChickInDate != "" {
@@ -377,6 +417,10 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
return err return err
} }
if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil {
return err
}
actorID, err := m.ActorIDFromContext(c) actorID, err := m.ActorIDFromContext(c)
if err != nil { if err != nil {
return err return err
@@ -446,6 +490,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil {
return nil, err return nil, err
} }
if err := s.ensureNotTransferred(c.Context(), id); err != nil {
return nil, err
}
latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil)
if err != nil { if err != nil {
@@ -472,6 +519,21 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
touchedProductWarehouseIDs := make(map[uint]struct{}) touchedProductWarehouseIDs := make(map[uint]struct{})
for _, approvableID := range approvableIDs { for _, approvableID := range approvableIDs {
// Re-check latest approval inside transaction to prevent double-approve races.
var latest entity.Approval
if err := dbTransaction.WithContext(c.Context()).
Table("approvals").
Where("approvable_type = ? AND approvable_id = ?", utils.ApprovalWorkflowChickin.String(), approvableID).
Order("id DESC").
Limit(1).
Clauses(clause.Locking{Strength: "UPDATE"}).
Take(&latest).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to recheck approval status")
}
if latest.Id != 0 && latest.StepNumber != uint16(utils.ChickinStepPengajuan) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ProjectFlockKandang %d sudah tidak berada di tahap PENGAJUAN", approvableID))
}
if _, err := approvalSvc.CreateApproval( if _, err := approvalSvc.CreateApproval(
c.Context(), c.Context(),
utils.ApprovalWorkflowChickin, utils.ApprovalWorkflowChickin,
@@ -692,6 +754,9 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB
if tx == nil { if tx == nil {
return errors.New("transaction is required") return errors.New("transaction is required")
} }
if s.FifoStockV2Svc == nil {
return errors.New("fifo v2 service is not available")
}
if err := tx.WithContext(ctx). if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}). Model(&entity.ProjectFlockPopulation{}).
@@ -700,7 +765,11 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB
return err return err
} }
return nil asOf := chickin.ChickInDate
if asOf.IsZero() {
asOf = chickin.CreatedAt
}
return reflowChickinScope(ctx, s.FifoStockV2Svc, tx, targetPW.Id, &asOf)
} }
func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error {
@@ -779,8 +848,6 @@ func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context
gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
FlagGroupCode: flagGroupCode, FlagGroupCode: flagGroupCode,
Lane: "STOCKABLE", Lane: "STOCKABLE",
AllocationPurpose: entity.StockAllocationPurposeTraceChickin,
IgnoreSourceUsed: true,
ProductWarehouseID: productWarehouseID, ProductWarehouseID: productWarehouseID,
Limit: 50000, Limit: 50000,
Tx: tx, Tx: tx,
@@ -0,0 +1,87 @@
package service
import (
"context"
"fmt"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
const (
chickinOutFunctionCode = "CHICKIN_OUT"
chickinUsableLane = "USABLE"
chickinSourceTable = "project_chickins"
)
func reflowChickinScope(
ctx context.Context,
fifoStockV2Svc commonSvc.FifoStockV2Service,
tx *gorm.DB,
productWarehouseID uint,
asOf *time.Time,
) error {
if fifoStockV2Svc == nil {
return fmt.Errorf("FIFO v2 service is not available")
}
if tx == nil {
return fmt.Errorf("transaction is required")
}
if productWarehouseID == 0 {
return fmt.Errorf("product warehouse id is required")
}
flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return err
}
if strings.TrimSpace(flagGroupCode) == "" {
return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID)
}
_, err = fifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: flagGroupCode,
ProductWarehouseID: productWarehouseID,
AsOf: asOf,
Tx: tx,
})
return err
}
func resolveChickinFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var selected row
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.lane = ?", chickinUsableLane).
Where("rr.function_code = ?", chickinOutFunctionCode).
Where("rr.source_table = ?", chickinSourceTable).
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("rr.id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
return "", err
}
return strings.TrimSpace(selected.FlagGroupCode), nil
}
@@ -23,6 +23,7 @@ import (
sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/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"
@@ -46,6 +47,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
productRepo := rProduct.NewProductRepository(db) productRepo := rProduct.NewProductRepository(db)
chickinRepo := rChickin.NewChickinRepository(db) chickinRepo := rChickin.NewChickinRepository(db)
chickinDetailRepo := rChickin.NewChickinDetailRepository(db) chickinDetailRepo := rChickin.NewChickinDetailRepository(db)
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
@@ -96,6 +98,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockKandangRepo, projectFlockKandangRepo,
projectFlockPopulationRepo, projectFlockPopulationRepo,
chickinDetailRepo, chickinDetailRepo,
transferLayingRepo,
validate, validate,
fifoStockV2Service, fifoStockV2Service,
) )
@@ -112,6 +115,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
productionStandardService, productionStandardService,
projectFlockService, projectFlockService,
chickinService, chickinService,
transferLayingRepo,
validate, validate,
) )
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -10,6 +10,7 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
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"
@@ -19,9 +20,11 @@ import (
sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/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"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
fifo "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -52,6 +55,7 @@ type recordingService struct {
ProductionStandardSvc sProductionStandard.ProductionStandardService ProductionStandardSvc sProductionStandard.ProductionStandardService
ProjectFlockSvc sProjectFlock.ProjectflockService ProjectFlockSvc sProjectFlock.ProjectflockService
ChickinSvc sChickin.ChickinService ChickinSvc sChickin.ChickinService
TransferLayingRepo rTransferLaying.TransferLayingRepository
FifoStockV2Svc commonSvc.FifoStockV2Service FifoStockV2Svc commonSvc.FifoStockV2Service
StockLogRepo rStockLogs.StockLogRepository StockLogRepo rStockLogs.StockLogRepository
} }
@@ -68,6 +72,7 @@ func NewRecordingService(
productionStandardSvc sProductionStandard.ProductionStandardService, productionStandardSvc sProductionStandard.ProductionStandardService,
projectFlockSvc sProjectFlock.ProjectflockService, projectFlockSvc sProjectFlock.ProjectflockService,
chickinSvc sChickin.ChickinService, chickinSvc sChickin.ChickinService,
transferLayingRepo rTransferLaying.TransferLayingRepository,
validate *validator.Validate, validate *validator.Validate,
) RecordingService { ) RecordingService {
return &recordingService{ return &recordingService{
@@ -82,6 +87,7 @@ func NewRecordingService(
ProductionStandardSvc: productionStandardSvc, ProductionStandardSvc: productionStandardSvc,
ProjectFlockSvc: projectFlockSvc, ProjectFlockSvc: projectFlockSvc,
ChickinSvc: chickinSvc, ChickinSvc: chickinSvc,
TransferLayingRepo: transferLayingRepo,
FifoStockV2Svc: fifoStockV2Svc, FifoStockV2Svc: fifoStockV2Svc,
StockLogRepo: stockLogRepo, StockLogRepo: stockLogRepo,
} }
@@ -287,6 +293,11 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
category := strings.ToUpper(pfk.ProjectFlock.Category) category := strings.ToUpper(pfk.ProjectFlock.Category)
isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying))
routePayload := buildRecordingRoutePayloadFromCreate(req)
if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime, routePayload); err != nil {
return nil, err
}
if err := s.ProjectFlockSvc.EnsureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { if err := s.ProjectFlockSvc.EnsureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil {
return nil, err return nil, err
} }
@@ -408,6 +419,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if err := s.reflowApplyRecordingDepletionsIn(ctx, tx, mappedDepletions); err != nil { if err := s.reflowApplyRecordingDepletionsIn(ctx, tx, mappedDepletions); err != nil {
return err return err
} }
if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, createdRecording.ProjectFlockKandangId); err != nil {
s.Log.Errorf("Failed to resync project flock population usage: %+v", err)
return err
}
mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs) mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs)
if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil {
@@ -480,6 +495,23 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
recordingEntity = recording recordingEntity = recording
pfkForRoute := recordingEntity.ProjectFlockKandang
if pfkForRoute == nil || pfkForRoute.Id == 0 {
fetchedPfk, fetchErr := s.ProjectFlockKandangRepo.GetByIDLight(ctx, recordingEntity.ProjectFlockKandangId)
if fetchErr != nil {
if errors.Is(fetchErr, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found")
}
s.Log.Errorf("Failed to fetch project flock kandang for route validation: %+v", fetchErr)
return fetchErr
}
pfkForRoute = fetchedPfk
}
routePayload := buildRecordingRoutePayloadFromUpdate(req)
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
return err
}
hasStockChanges := req.Stocks != nil hasStockChanges := req.Stocks != nil
hasDepletionChanges := req.Depletions != nil hasDepletionChanges := req.Depletions != nil
hasEggChanges := req.Eggs != nil hasEggChanges := req.Eggs != nil
@@ -891,6 +923,202 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
}) })
} }
func (s *recordingService) enforceTransferRecordingRoute(
ctx context.Context,
pfk *entity.ProjectFlockKandang,
recordTime time.Time,
payload recordingRoutePayload,
) error {
if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil {
return nil
}
recordDate := normalizeDateOnlyUTC(recordTime)
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
switch category {
case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)):
transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
s.Log.Errorf("Failed to resolve approved transfer by target kandang %d: %+v", pfk.Id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer)
if physicalMoveDate.IsZero() {
return nil
}
if recordDate.Before(physicalMoveDate) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s (tanggal pindah fisik). Sebelumnya gunakan kandang growing", physicalMoveDate.Format("2006-01-02")),
)
}
if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Transfer laying %s dengan tanggal pindah fisik %s belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")),
)
}
if recordDate.Before(economicCutoffDate) && payload.StockCount > 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Periode transisi transfer laying %s (%s s.d. %s): input PAKAN/OVK harus dicatat di kandang growing hingga %s. Recording kandang laying pada periode ini hanya untuk deplesi (dan telur bila ada).",
transfer.TransferNumber,
physicalMoveDate.Format("2006-01-02"),
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
),
)
}
case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)):
transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, pfk.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
s.Log.Errorf("Failed to resolve approved transfer by source kandang %d: %+v", pfk.Id, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying")
}
physicalMoveDate, economicCutoffDate := transferRecordingWindow(transfer)
if physicalMoveDate.IsZero() {
return nil
}
if recordDate.Before(physicalMoveDate) {
return nil
}
if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Transfer laying %s sudah memasuki tanggal pindah fisik %s namun belum dieksekusi. Eksekusi transfer lalu lakukan recording transisi sesuai aturan", transfer.TransferNumber, physicalMoveDate.Format("2006-01-02")),
)
}
if !recordDate.Before(economicCutoffDate) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"), economicCutoffDate.Format("2006-01-02")),
)
}
if payload.DepletionCount > 0 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf(
"Periode transisi transfer laying %s (%s s.d. %s): deplesi harus dicatat di kandang laying tujuan agar mapping tidak ambigu. Kandang growing pada periode ini hanya untuk PAKAN/OVK.",
transfer.TransferNumber,
physicalMoveDate.Format("2006-01-02"),
economicCutoffDate.AddDate(0, 0, -1).Format("2006-01-02"),
),
)
}
}
return nil
}
type recordingRoutePayload struct {
StockCount int
DepletionCount int
EggCount int
}
func buildRecordingRoutePayloadFromCreate(req *validation.Create) recordingRoutePayload {
payload := recordingRoutePayload{}
if req == nil {
return payload
}
for _, stock := range req.Stocks {
if stock.Qty > 0 {
payload.StockCount++
}
}
for _, depletion := range req.Depletions {
if depletion.Qty > 0 {
payload.DepletionCount++
}
}
for _, egg := range req.Eggs {
if egg.Qty > 0 {
payload.EggCount++
}
}
return payload
}
func buildRecordingRoutePayloadFromUpdate(req *validation.Update) recordingRoutePayload {
payload := recordingRoutePayload{}
if req == nil {
return payload
}
for _, stock := range req.Stocks {
if stock.Qty > 0 {
payload.StockCount++
}
}
for _, depletion := range req.Depletions {
if depletion.Qty > 0 {
payload.DepletionCount++
}
}
for _, egg := range req.Eggs {
if egg.Qty > 0 {
payload.EggCount++
}
}
return payload
}
func transferPhysicalMoveDate(transfer *entity.LayingTransfer) time.Time {
if transfer == nil {
return time.Time{}
}
if !transfer.TransferDate.IsZero() {
return normalizeDateOnlyUTC(transfer.TransferDate)
}
return time.Time{}
}
func transferEconomicCutoffDate(transfer *entity.LayingTransfer) time.Time {
if transfer == nil {
return time.Time{}
}
if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() {
return normalizeDateOnlyUTC(*transfer.EconomicCutoffDate)
}
if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() {
return normalizeDateOnlyUTC(*transfer.EffectiveMoveDate)
}
return transferPhysicalMoveDate(transfer)
}
func transferRecordingWindow(transfer *entity.LayingTransfer) (time.Time, time.Time) {
physicalMoveDate := transferPhysicalMoveDate(transfer)
economicCutoffDate := transferEconomicCutoffDate(transfer)
if economicCutoffDate.IsZero() {
economicCutoffDate = physicalMoveDate
}
if !physicalMoveDate.IsZero() && economicCutoffDate.Before(physicalMoveDate) {
economicCutoffDate = physicalMoveDate
}
return physicalMoveDate, economicCutoffDate
}
func normalizeDateOnlyUTC(value time.Time) time.Time {
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
}
func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error {
idSet := make(map[uint]struct{}) idSet := make(map[uint]struct{})
@@ -1742,6 +1970,14 @@ func (s *recordingService) reflowApplyRecordingDepletionsOut(
} }
s.logDepletionTrace("reflow_apply:done", *refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, refreshed.UsageQty, refreshed.PendingQty)) s.logDepletionTrace("reflow_apply:done", *refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, refreshed.UsageQty, refreshed.PendingQty))
consumeQty := refreshed.UsageQty
if refreshed.PendingQty > 0 {
consumeQty += refreshed.PendingQty
}
if err := s.allocatePopulationForDepletion(ctx, tx, *refreshed, consumeQty); err != nil {
return err
}
logDecrease := refreshed.UsageQty logDecrease := refreshed.UsageQty
if refreshed.PendingQty > 0 { if refreshed.PendingQty > 0 {
logDecrease += refreshed.PendingQty logDecrease += refreshed.PendingQty
@@ -1801,11 +2037,15 @@ func (s *recordingService) reflowResetRecordingDepletionsOut(
return errors.New("stock log repository is not available") return errors.New("stock log repository is not available")
} }
logState := newRecordingStockLogState() logState := newRecordingStockLogState()
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
for _, depletion := range depletions { for _, depletion := range depletions {
if depletion.Id == 0 { if depletion.Id == 0 {
continue continue
} }
if err := stockAllocationRepo.ReleaseByUsable(ctx, fifo.UsableKeyRecordingDepletion.String(), depletion.Id, nil, nil); err != nil {
return err
}
s.logDepletionTrace("reflow_reset:start", depletion, "") s.logDepletionTrace("reflow_reset:start", depletion, "")
sourceWarehouseID := uint(0) sourceWarehouseID := uint(0)
@@ -1886,6 +2126,58 @@ func (s *recordingService) reflowResetRecordingDepletionsOut(
return nil return nil
} }
func (s *recordingService) allocatePopulationForDepletion(
ctx context.Context,
tx *gorm.DB,
depletion entity.RecordingDepletion,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
sourceWarehouseID = *depletion.SourceProductWarehouseId
}
if sourceWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan")
}
var projectFlockKandangID uint
if err := tx.WithContext(ctx).
Table("recordings").
Select("project_flock_kandangs_id").
Where("id = ?", depletion.RecordingId).
Scan(&projectFlockKandangID).Error; err != nil {
return err
}
if projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak ditemukan untuk depletion")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(ctx, projectFlockKandangID, sourceWarehouseID)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk depletion")
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
sourceWarehouseID,
fifo.UsableKeyRecordingDepletion.String(),
depletion.Id,
consumeQty,
)
}
func (s *recordingService) reflowApplyRecordingDepletionsIn( func (s *recordingService) reflowApplyRecordingDepletionsIn(
ctx context.Context, ctx context.Context,
tx *gorm.DB, tx *gorm.DB,
@@ -0,0 +1,87 @@
package service
import (
"testing"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
func mustDate(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse("2006-01-02", value)
if err != nil {
t.Fatalf("failed parsing date %s: %v", value, err)
}
return parsed
}
func TestTransferRecordingWindow(t *testing.T) {
t.Run("early transfer keeps transition until economic cutoff", func(t *testing.T) {
physical := mustDate(t, "2026-04-08")
cutoff := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EconomicCutoffDate: &cutoff,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-04-08" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
t.Run("standard transfer has no transition window", func(t *testing.T) {
physical := mustDate(t, "2026-05-13")
cutoff := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EconomicCutoffDate: &cutoff,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
t.Run("late transfer clamps economic cutoff to physical move", func(t *testing.T) {
physical := mustDate(t, "2026-06-03")
cutoff := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EconomicCutoffDate: &cutoff,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-06-03" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-06-03" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
t.Run("legacy data falls back to effective move date", func(t *testing.T) {
physical := mustDate(t, "2026-04-08")
legacyEffective := mustDate(t, "2026-05-13")
transfer := &entity.LayingTransfer{
TransferDate: physical,
EffectiveMoveDate: &legacyEffective,
}
gotPhysical, gotCutoff := transferRecordingWindow(transfer)
if gotPhysical.Format("2006-01-02") != "2026-04-08" {
t.Fatalf("unexpected physical date: %s", gotPhysical.Format("2006-01-02"))
}
if gotCutoff.Format("2006-01-02") != "2026-05-13" {
t.Fatalf("unexpected cutoff date: %s", gotCutoff.Format("2006-01-02"))
}
})
}
@@ -186,6 +186,28 @@ func (u *TransferLayingController) Approval(c *fiber.Ctx) error {
}) })
} }
func (u *TransferLayingController) Execute(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.TransferLayingService.Execute(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Execute transfer laying successfully",
Data: dto.ToTransferLayingDetailDTOWithSingleApproval(*result, result.LatestApproval),
})
}
func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error { func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error {
projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32) projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32)
if err != nil { if err != nil {
@@ -14,10 +14,13 @@ import (
// === DTO Structs === // === DTO Structs ===
type TransferLayingRelationDTO struct { type TransferLayingRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
TransferNumber string `json:"transfer_number"` TransferNumber string `json:"transfer_number"`
TransferDate time.Time `json:"transfer_date"` TransferDate time.Time `json:"transfer_date"`
Notes string `json:"notes"` EconomicCutoffDate *time.Time `json:"economic_cutoff_date,omitempty"`
EffectiveMoveDate *time.Time `json:"effective_move_date,omitempty"`
ExecutedAt *time.Time `json:"executed_at,omitempty"`
Notes string `json:"notes"`
} }
type ProjectFlockKandangWithKandangDTO struct { type ProjectFlockKandangWithKandangDTO struct {
@@ -47,6 +50,8 @@ type TransferLayingListDTO struct {
ToProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"to_project_flock,omitempty"` ToProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"to_project_flock,omitempty"`
CreatedBy uint `json:"created_by"` CreatedBy uint `json:"created_by"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
ExecutedBy *uint `json:"executed_by,omitempty"`
ExecutedUser *userDTO.UserRelationDTO `json:"executed_user,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"`
} }
@@ -88,10 +93,13 @@ type MaxTargetQtyForTransferDTO struct {
func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO { func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO {
return TransferLayingRelationDTO{ return TransferLayingRelationDTO{
Id: e.Id, Id: e.Id,
TransferNumber: e.TransferNumber, TransferNumber: e.TransferNumber,
TransferDate: e.TransferDate, TransferDate: e.TransferDate,
Notes: e.Notes, EconomicCutoffDate: e.EconomicCutoffDate,
EffectiveMoveDate: e.EffectiveMoveDate,
ExecutedAt: e.ExecutedAt,
Notes: e.Notes,
} }
} }
@@ -190,6 +198,12 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
createdUser = &mapped createdUser = &mapped
} }
var executedUser *userDTO.UserRelationDTO
if e.ExecutedUser != nil && e.ExecutedUser.Id != 0 {
mapped := userDTO.ToUserRelationDTO(*e.ExecutedUser)
executedUser = &mapped
}
var approval *approvalDTO.ApprovalRelationDTO var approval *approvalDTO.ApprovalRelationDTO
if e.LatestApproval != nil { if e.LatestApproval != nil {
mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval)
@@ -219,6 +233,8 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
ToProjectFlock: toProjectFlock, ToProjectFlock: toProjectFlock,
CreatedBy: e.CreatedBy, CreatedBy: e.CreatedBy,
CreatedUser: createdUser, CreatedUser: createdUser,
ExecutedBy: e.ExecutedBy,
ExecutedUser: executedUser,
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
Approval: approval, Approval: approval,
} }
@@ -2,6 +2,7 @@ package transfer_layings
import ( import (
"fmt" "fmt"
"strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -9,6 +10,7 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
@@ -32,8 +34,47 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
productWarehouseRepo := rInventory.NewProductWarehouseRepository(db) productWarehouseRepo := rInventory.NewProductWarehouseRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
// daftarin jadi stockable
if err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyTransferToLayingIn,
Table: "laying_transfer_targets",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register transfer to laying stockable workflow: %v", err))
}
}
// daftarin jadi usable
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyTransferToLayingOut,
Table: "laying_transfer_sources",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_usage_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register transfer to laying usable workflow: %v", err))
}
}
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo) approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowTransferToLaying, utils.TransferToLayingApprovalSteps); err != nil { if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowTransferToLaying, utils.TransferToLayingApprovalSteps); err != nil {
@@ -50,6 +91,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
productWarehouseRepo, productWarehouseRepo,
warehouseRepo, warehouseRepo,
approvalService, approvalService,
fifoService,
fifoStockV2Service, fifoStockV2Service,
validate, validate,
) )
@@ -7,6 +7,7 @@ import (
"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"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -14,6 +15,8 @@ type TransferLayingRepository interface {
repository.BaseRepository[entity.LayingTransfer] repository.BaseRepository[entity.LayingTransfer]
GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, error) GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, error)
IdExists(ctx context.Context, id uint) (bool, error) IdExists(ctx context.Context, id uint) (bool, error)
GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error)
GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error)
// Tambah method baru untuk query dengan filter lengkap // Tambah method baru untuk query dengan filter lengkap
GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error)
@@ -164,6 +167,7 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of
Preload("FromProjectFlock"). Preload("FromProjectFlock").
Preload("ToProjectFlock"). Preload("ToProjectFlock").
Preload("CreatedUser"). Preload("CreatedUser").
Preload("ExecutedUser").
Preload("Sources"). Preload("Sources").
Preload("Sources.SourceProjectFlockKandang"). Preload("Sources.SourceProjectFlockKandang").
Preload("Sources.SourceProjectFlockKandang.Kandang"). Preload("Sources.SourceProjectFlockKandang.Kandang").
@@ -180,3 +184,57 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of
return records, total, nil return records, total, nil
} }
func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error) {
if sourceProjectFlockKandangID == 0 {
return nil, gorm.ErrRecordNotFound
}
var transfer entity.LayingTransfer
err := r.db.WithContext(ctx).
Model(&entity.LayingTransfer{}).
Joins("JOIN laying_transfer_sources lts ON lts.laying_transfer_id = laying_transfers.id AND lts.deleted_at IS NULL").
Where("lts.source_project_flock_kandang_id = ?", sourceProjectFlockKandangID).
Where("laying_transfers.deleted_at IS NULL").
Where(`(
SELECT a.action
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = laying_transfers.id
ORDER BY a.id DESC
LIMIT 1
) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved).
Order("laying_transfers.id DESC").
First(&transfer).Error
if err != nil {
return nil, err
}
return &transfer, nil
}
func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) {
if targetProjectFlockKandangID == 0 {
return nil, gorm.ErrRecordNotFound
}
var transfer entity.LayingTransfer
err := r.db.WithContext(ctx).
Model(&entity.LayingTransfer{}).
Joins("JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = laying_transfers.id AND ltt.deleted_at IS NULL").
Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID).
Where("laying_transfers.deleted_at IS NULL").
Where(`(
SELECT a.action
FROM approvals a
WHERE a.approvable_type = ?
AND a.approvable_id = laying_transfers.id
ORDER BY a.id DESC
LIMIT 1
) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved).
Order("laying_transfers.id DESC").
First(&transfer).Error
if err != nil {
return nil, err
}
return &transfer, nil
}
@@ -27,6 +27,7 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying.
route.Patch("/:id", m.RequirePermissions(m.P_TransferToLaying_UpdateOne), ctrl.UpdateOne) route.Patch("/:id", m.RequirePermissions(m.P_TransferToLaying_UpdateOne), ctrl.UpdateOne)
route.Delete("/:id", m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne) route.Delete("/:id", m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne)
route.Post("/approvals", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval) route.Post("/approvals", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval)
route.Post("/:id/execute", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Execute)
route.Get("/project-flocks/:project_flock_id/available-qty", m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang) route.Get("/project-flocks/:project_flock_id/available-qty", m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang)
route.Get("/project-flocks/:project_flock_id/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang) route.Get("/project-flocks/:project_flock_id/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang)
} }
@@ -10,6 +10,8 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
@@ -19,6 +21,7 @@ import (
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/validations"
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"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -33,6 +36,7 @@ type TransferLayingService interface {
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error)
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.LayingTransfer, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.LayingTransfer, error)
Execute(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error)
GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error)
GetMaxTargetQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) GetMaxTargetQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (map[uint]float64, error)
} }
@@ -50,9 +54,14 @@ 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 (
transferToLayingFlagGroupCode = "AYAM"
)
func NewTransferLayingService( func NewTransferLayingService(
repo repository.TransferLayingRepository, repo repository.TransferLayingRepository,
layingTransferSourceRepo repository.LayingTransferSourceRepository, layingTransferSourceRepo repository.LayingTransferSourceRepository,
@@ -63,6 +72,7 @@ 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 {
@@ -79,6 +89,7 @@ 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,
} }
} }
@@ -86,6 +97,7 @@ func NewTransferLayingService(
func (s transferLayingService) withRelations(db *gorm.DB) *gorm.DB { func (s transferLayingService) withRelations(db *gorm.DB) *gorm.DB {
return db. return db.
Preload("CreatedUser"). Preload("CreatedUser").
Preload("ExecutedUser").
Preload("FromProjectFlock"). Preload("FromProjectFlock").
Preload("ToProjectFlock"). Preload("ToProjectFlock").
Preload("Sources"). Preload("Sources").
@@ -744,11 +756,9 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
repoTx := s.Repository.WithTx(dbTransaction) repoTx := s.Repository.WithTx(dbTransaction)
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction)
targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction)
stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction)
for _, approvableID := range approvableIDs { for _, approvableID := range approvableIDs {
transfer, err := repoTx.GetByID(c.Context(), approvableID, nil) _, err := repoTx.GetByID(c.Context(), approvableID, nil)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("TransferLaying %d not found", approvableID)) return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("TransferLaying %d not found", approvableID))
@@ -769,144 +779,22 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
} }
if action == entity.ApprovalActionApproved { if action == entity.ApprovalActionApproved {
if s.FifoStockV2Svc == nil {
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
}
sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID) sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer") return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer")
} }
economicCutoffDate, err := s.calculateEconomicCutoffDate(c.Context(), sources)
targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), approvableID)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil targets transfer") return err
} }
totalTargetQty := 0.0 if err := repoTx.PatchOne(c.Context(), approvableID, map[string]any{
for _, target := range targets { "economic_cutoff_date": economicCutoffDate,
totalTargetQty += target.TotalQty "effective_move_date": economicCutoffDate, // Backward-compatible alias for existing clients.
} "executed_at": nil,
"executed_by": nil,
totalSourceRequested := 0.0 }, nil); err != nil {
for _, source := range sources { return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying")
totalSourceRequested += source.RequestedQty
}
sourceBeforeUsage := make(map[uint]float64, len(sources))
affectedPW := make(map[uint]struct{})
for _, source := range sources {
if source.ProductWarehouseId == nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", approvableID))
}
sourceShare := 0.0
if totalSourceRequested > 0 {
sourceShare = (source.RequestedQty / totalSourceRequested) * totalTargetQty
}
sourceBeforeUsage[source.Id] = source.UsageQty
if err := sourceRepoTx.PatchOne(c.Context(), source.Id, map[string]interface{}{
"usage_qty": sourceShare,
"pending_usage_qty": 0,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty")
}
affectedPW[*source.ProductWarehouseId] = struct{}{}
}
for _, target := range targets {
if target.ProductWarehouseId == nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID))
}
if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{
"total_qty": target.TotalQty,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty")
}
affectedPW[*target.ProductWarehouseId] = struct{}{}
}
for pwID := range affectedPW {
asOfCopy := transfer.TransferDate
if err := reflowTransferLayingScope(c.Context(), s.FifoStockV2Svc, dbTransaction, pwID, &asOfCopy); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow FIFO stock transfer laying (pw=%d): %v", pwID, err))
}
}
for _, source := range sources {
if source.ProductWarehouseId == nil {
continue
}
refreshedSource, err := sourceRepoTx.GetByID(c.Context(), source.Id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal refresh source transfer setelah reflow")
}
usageDelta := refreshedSource.UsageQty - sourceBeforeUsage[source.Id]
if usageDelta <= 0 {
continue
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: *source.ProductWarehouseId,
CreatedBy: actorID,
Increase: 0,
Decrease: usageDelta,
LoggableType: string(utils.StockLogTypeTransferLaying),
LoggableId: approvableID,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *source.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
}
for _, target := range targets {
if target.ProductWarehouseId == nil {
continue
}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: *target.ProductWarehouseId,
CreatedBy: actorID,
Increase: target.TotalQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransferLaying),
LoggableId: approvableID,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *target.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
} }
} }
} }
@@ -933,6 +821,392 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
return updated, nil return updated, nil
} }
func (s transferLayingService) Execute(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) {
if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
repoTx := s.Repository.WithTx(dbTransaction)
sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction)
targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction)
approvalRepoTx := commonRepo.NewApprovalRepository(dbTransaction)
transfer, err := repoTx.GetByID(c.Context(), id, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Transfer laying tidak ditemukan")
}
return err
}
if transfer.ExecutedAt != nil {
return nil
}
latestApproval, err := approvalRepoTx.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if latestApproval == nil || latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved {
return fiber.NewError(fiber.StatusBadRequest, "Transfer laying harus disetujui sebelum dieksekusi")
}
sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), transfer.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sumber transfer laying")
}
targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), transfer.Id)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil target transfer laying")
}
if transfer.EconomicCutoffDate == nil || transfer.EconomicCutoffDate.IsZero() {
economicCutoffDate, calcErr := s.calculateEconomicCutoffDate(c.Context(), sources)
if calcErr != nil {
return calcErr
}
if patchErr := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{
"economic_cutoff_date": economicCutoffDate,
"effective_move_date": economicCutoffDate, // Keep legacy field in sync.
}, nil); patchErr != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying")
}
transfer.EconomicCutoffDate = &economicCutoffDate
transfer.EffectiveMoveDate = &economicCutoffDate
}
physicalMoveDate := normalizeDateOnlyUTC(transfer.TransferDate)
today := normalizeDateOnlyUTC(time.Now().UTC())
if today.Before(physicalMoveDate) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying baru bisa dieksekusi mulai tanggal pindah fisik %s", physicalMoveDate.Format("2006-01-02")))
}
if err := s.executeApprovedTransferMovement(c.Context(), dbTransaction, transfer, actorID, sources, targets); err != nil {
return err
}
executedAt := time.Now().UTC()
if err := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{
"executed_at": executedAt,
"executed_by": actorID,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan status eksekusi transfer laying")
}
return nil
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengeksekusi transfer laying")
}
transfer, _, err := s.GetOne(c, id)
if err != nil {
return nil, err
}
return transfer, nil
}
func (s *transferLayingService) executeApprovedTransferMovement(
ctx context.Context,
tx *gorm.DB,
transfer *entity.LayingTransfer,
actorID uint,
sources []entity.LayingTransferSource,
targets []entity.LayingTransferTarget,
) error {
if transfer == nil || transfer.Id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Transfer laying tidak valid")
}
if len(sources) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki sumber")
}
if len(targets) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki target")
}
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)
sourceRepoTx := repository.NewLayingTransferSourceRepository(tx)
targetRepoTx := repository.NewLayingTransferTargetRepository(tx)
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
totalTargetQty := 0.0
for _, target := range targets {
totalTargetQty += target.TotalQty
}
if totalTargetQty <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Total kuantitas target transfer laying harus lebih dari 0")
}
totalSourceRequested := 0.0
for _, source := range sources {
totalSourceRequested += source.RequestedQty
}
if totalSourceRequested <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Total kuantitas sumber transfer laying harus lebih dari 0")
}
for _, source := range sources {
if source.ProductWarehouseId == nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", transfer.Id))
}
if source.RequestedQty <= 0 {
continue
}
sourceShare := (source.RequestedQty / totalSourceRequested) * totalTargetQty
if sourceShare <= 0 {
continue
}
if err := sourceRepoTx.PatchOne(ctx, source.Id, map[string]any{
"usage_qty": source.UsageQty + sourceShare,
"pending_usage_qty": 0,
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty")
}
asOf := normalizeDateOnlyUTC(transfer.TransferDate)
if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: transferToLayingFlagGroupCode,
ProductWarehouseID: *source.ProductWarehouseId,
AsOf: &asOf,
Tx: tx,
}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal consume FIFO v2 stock: %v", err))
}
refreshedSource, err := sourceRepoTx.GetByID(ctx, source.Id, nil)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal refresh source transfer setelah reflow")
}
usageDelta := refreshedSource.UsageQty - source.UsageQty
pendingQty := refreshedSource.PendingUsageQty
if pendingQty > 1e-6 || usageDelta < sourceShare-1e-6 {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Stok sumber tidak mencukupi untuk mengeksekusi transfer laying %s", transfer.TransferNumber),
)
}
movedQty := usageDelta
if err := s.allocatePopulationForTransfer(ctx, tx, source, movedQty); err != nil {
return err
}
targetShares := distributeProportionalWithRounding(targets, totalTargetQty, movedQty)
for i, target := range targets {
roundedQty := math.Round(targetShares[i])
if roundedQty <= 0 {
continue
}
mappingAllocation := &entity.StockAllocation{
StockableType: fifo.UsableKeyTransferToLayingOut.String(),
StockableId: source.Id,
UsableType: fifo.StockableKeyTransferToLayingIn.String(),
UsableId: target.Id,
ProductWarehouseId: *source.ProductWarehouseId,
Qty: roundedQty,
Status: entity.StockAllocationStatusActive,
}
if err := stockAllocationRepo.CreateOne(ctx, mappingAllocation, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target")
}
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: *source.ProductWarehouseId,
CreatedBy: actorID,
Increase: 0,
Decrease: movedQty,
LoggableType: string(utils.StockLogTypeTransferLaying),
LoggableId: transfer.Id,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, *source.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stockLogRepoTx.CreateOne(ctx, stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
}
}
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")
}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: *target.ProductWarehouseId,
CreatedBy: actorID,
Increase: target.TotalQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransferLaying),
LoggableId: transfer.Id,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
}
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, *target.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stockLogRepoTx.CreateOne(ctx, stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
}
}
return nil
}
func (s *transferLayingService) allocatePopulationForTransfer(
ctx context.Context,
tx *gorm.DB,
source entity.LayingTransferSource,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if source.SourceProjectFlockKandangId == 0 || source.ProductWarehouseId == nil || *source.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sumber atau product warehouse tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(
ctx,
source.SourceProjectFlockKandangId,
*source.ProductWarehouseId,
)
if err != nil {
return err
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk transfer laying")
}
return fifoV2.AllocatePopulationConsumption(
ctx,
tx,
populations,
*source.ProductWarehouseId,
fifo.UsableKeyTransferToLayingOut.String(),
source.Id,
consumeQty,
)
}
func (s *transferLayingService) calculateEconomicCutoffDate(ctx context.Context, sources []entity.LayingTransferSource) (time.Time, error) {
if len(sources) == 0 {
return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Sumber transfer laying tidak ditemukan")
}
maxGrowingWeek := config.TransferToLayingGrowingMaxWeek
if maxGrowingWeek <= 0 {
maxGrowingWeek = 19
}
var baselineChickInDate time.Time
for _, source := range sources {
chickInDate, err := s.resolveSourceChickInDate(ctx, source.SourceProjectFlockKandangId)
if err != nil {
return time.Time{}, err
}
if baselineChickInDate.IsZero() || chickInDate.Before(baselineChickInDate) {
baselineChickInDate = chickInDate
}
}
if baselineChickInDate.IsZero() {
return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in sumber transfer laying tidak ditemukan")
}
economicCutoffDate := baselineChickInDate.AddDate(0, 0, maxGrowingWeek*7)
return normalizeDateOnlyUTC(economicCutoffDate), nil
}
func (s *transferLayingService) resolveSourceChickInDate(ctx context.Context, sourceProjectFlockKandangID uint) (time.Time, error) {
if sourceProjectFlockKandangID == 0 {
return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sumber tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, sourceProjectFlockKandangID)
if err != nil {
return time.Time{}, err
}
var earliestChickInDate time.Time
for _, population := range populations {
if population.ProjectChickin == nil || population.ProjectChickin.ChickInDate.IsZero() {
continue
}
chickInDate := normalizeDateOnlyUTC(population.ProjectChickin.ChickInDate)
if earliestChickInDate.IsZero() || chickInDate.Before(earliestChickInDate) {
earliestChickInDate = chickInDate
}
}
if earliestChickInDate.IsZero() {
return time.Time{}, fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Tanggal chick in untuk kandang sumber %d tidak ditemukan", sourceProjectFlockKandangID),
)
}
return earliestChickInDate, nil
}
func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayingID uint, actorID uint) error { func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayingID uint, actorID uint) error {
if transferLayingID == 0 || actorID == 0 { if transferLayingID == 0 || actorID == 0 {
return nil return nil
@@ -1047,6 +1321,10 @@ func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFl
return kandangMaxTargetQty, nil return kandangMaxTargetQty, nil
} }
func normalizeDateOnlyUTC(value time.Time) time.Time {
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
}
func distributeProportionalWithRounding(targets []entity.LayingTransferTarget, totalTargetQty, sourceShare float64) []float64 { func distributeProportionalWithRounding(targets []entity.LayingTransferTarget, totalTargetQty, sourceShare float64) []float64 {
if len(targets) == 0 { if len(targets) == 0 {
return []float64{} return []float64{}
@@ -787,6 +787,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
req.Items[idx].TravelDocumentPath = &uploadedURL req.Items[idx].TravelDocumentPath = &uploadedURL
} }
} }
lockedIDs := map[uint]struct{}{}
if action == entity.ApprovalActionApproved { if action == entity.ApprovalActionApproved {
itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items))
for i := range purchase.Items { for i := range purchase.Items {
@@ -795,11 +796,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
} }
itemByID[purchase.Items[i].Id] = purchase.Items[i] itemByID[purchase.Items[i].Id] = purchase.Items[i]
} }
lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items) locked, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(lockedIDs) > 0 { if len(locked) > 0 {
for id := range locked {
lockedIDs[id] = struct{}{}
}
for _, payload := range req.Items { for _, payload := range req.Items {
if _, used := lockedIDs[payload.PurchaseItemID]; !used { if _, used := lockedIDs[payload.PurchaseItemID]; !used {
continue continue
@@ -880,7 +884,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
if receivedQty > item.SubQty { if receivedQty > item.SubQty {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty))
} }
if receivedQty < item.TotalUsed { if receivedQty < item.TotalUsed && isReceivingBelowUsedBlocked(item, lockedIDs) {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed)) return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed))
} }
@@ -1659,7 +1663,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
if *data.Qty <= 0 { if *data.Qty <= 0 {
return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id))
} }
if item.TotalUsed > 0 && *data.Qty < item.TotalUsed { if item.TotalUsed > 0 && *data.Qty < item.TotalUsed && isReceivingBelowUsedBlocked(&item, nil) {
return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed))
} }
if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving { if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving {
@@ -1778,6 +1782,51 @@ func calculateTotalPrice(quantity float64, price float64, provided *float64, ref
return *provided, nil return *provided, nil
} }
func purchaseItemHasFlag(item *entity.PurchaseItem, flag utils.FlagType) bool {
if item == nil || item.Product == nil {
return false
}
target := utils.NormalizeFlag(string(flag))
for _, f := range item.Product.Flags {
if utils.NormalizeFlag(f.Name) == target {
return true
}
}
return false
}
func isReceivingBelowUsedBlocked(item *entity.PurchaseItem, lockedIDs map[uint]struct{}) bool {
if item == nil {
return false
}
if !purchaseItemHasAnyFlag(item, []utils.FlagType{
utils.FlagPullet,
utils.FlagLayer,
utils.FlagAyamAfkir,
utils.FlagAyamCulling,
utils.FlagAyamMati,
}) {
return false
}
if lockedIDs == nil {
return true
}
_, locked := lockedIDs[item.Id]
return locked
}
func purchaseItemHasAnyFlag(item *entity.PurchaseItem, flags []utils.FlagType) bool {
if item == nil || item.Product == nil || len(flags) == 0 {
return false
}
for _, flag := range flags {
if purchaseItemHasFlag(item, flag) {
return true
}
}
return false
}
func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error { func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error {
if item == nil || item.Id == 0 || s.ApprovalSvc == nil { if item == nil || item.Id == 0 || s.ApprovalSvc == nil {
return nil return nil