mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 21:41:55 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f8013c5ed | |||
| 1b6041073e | |||
| 1724a5f846 | |||
| f082c5c122 | |||
| d334f46829 | |||
| 80135466df | |||
| 9d5f733172 | |||
| 4bb750fc98 | |||
| d335597bed | |||
| 3bc0685b46 | |||
| f6c88b773d | |||
| 915302c445 | |||
| e7e065c320 |
@@ -467,9 +467,9 @@ func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlock
|
||||
FROM stock_allocations sa
|
||||
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
|
||||
AND sa.status = 'ACTIVE'
|
||||
AND sa.allocation_purpose = 'CONSUME'
|
||||
GROUP BY sa.stockable_id
|
||||
)
|
||||
AND sa.allocation_purpose = 'CONSUME'
|
||||
GROUP BY sa.stockable_id
|
||||
)
|
||||
UPDATE project_flock_populations p
|
||||
SET total_used_qty = LEAST(COALESCE(a.used_qty, 0), GREATEST(s.total_qty, 0)),
|
||||
updated_at = NOW()
|
||||
@@ -483,7 +483,6 @@ func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlock
|
||||
|
||||
return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil
|
||||
}
|
||||
|
||||
func resyncChickinTraceByProjectFlockKandang(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
|
||||
@@ -299,6 +299,9 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec
|
||||
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").
|
||||
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).
|
||||
Group("lt.from_project_flock_id").
|
||||
Scan(&summary).Error
|
||||
|
||||
@@ -487,7 +487,6 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re
|
||||
if desiredQty <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{
|
||||
FlagGroupCode: req.FlagGroupCode,
|
||||
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
@@ -22,60 +22,61 @@ type SSOClientConfig struct {
|
||||
}
|
||||
|
||||
var (
|
||||
IsProd bool
|
||||
AppHost string
|
||||
Version string
|
||||
LogLevel string
|
||||
AppPort int
|
||||
DBHost string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
DBPort int
|
||||
DBSSLMode string
|
||||
DBSSLRootCert string
|
||||
DBSSLCert string
|
||||
DBSSLKey string
|
||||
JWTSecret string
|
||||
JWTAccessExp int
|
||||
JWTRefreshExp int
|
||||
JWTResetPasswordExp int
|
||||
JWTVerifyEmailExp int
|
||||
RedisURL string
|
||||
CORSAllowOrigins []string
|
||||
CORSAllowMethods []string
|
||||
CORSAllowHeaders []string
|
||||
CORSExposeHeaders []string
|
||||
CORSAllowCredentials bool
|
||||
CORSMaxAge int
|
||||
SSOIssuer string
|
||||
SSOJWKSURL string
|
||||
SSOAllowedAudiences []string
|
||||
SSOAuthorizeURL string
|
||||
SSOTokenURL string
|
||||
SSOGetMeURL string
|
||||
SSOPortalURL string
|
||||
SSOClients map[string]SSOClientConfig
|
||||
SSOAccessCookieName string
|
||||
SSORefreshCookieName string
|
||||
SSOCookieDomain string
|
||||
SSOCookieSecure bool
|
||||
SSOCookieSameSite string
|
||||
SSOAccessTokenMaxBytes int
|
||||
SSOTokenBlacklistPrefix string
|
||||
SSOPKCETTL time.Duration
|
||||
SSOUserSyncDrift time.Duration
|
||||
SSOUserSyncNonceTTL time.Duration
|
||||
SSOUserSyncMaxBodyBytes int
|
||||
S3Endpoint string
|
||||
S3Region string
|
||||
S3Bucket string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3ForcePathStyle bool
|
||||
S3PublicBaseURL string
|
||||
S3EnvPrefix string
|
||||
S3DocumentKeyPrefix string
|
||||
IsProd bool
|
||||
AppHost string
|
||||
Version string
|
||||
LogLevel string
|
||||
AppPort int
|
||||
DBHost string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
DBPort int
|
||||
DBSSLMode string
|
||||
DBSSLRootCert string
|
||||
DBSSLCert string
|
||||
DBSSLKey string
|
||||
JWTSecret string
|
||||
JWTAccessExp int
|
||||
JWTRefreshExp int
|
||||
JWTResetPasswordExp int
|
||||
JWTVerifyEmailExp int
|
||||
RedisURL string
|
||||
CORSAllowOrigins []string
|
||||
CORSAllowMethods []string
|
||||
CORSAllowHeaders []string
|
||||
CORSExposeHeaders []string
|
||||
CORSAllowCredentials bool
|
||||
CORSMaxAge int
|
||||
SSOIssuer string
|
||||
SSOJWKSURL string
|
||||
SSOAllowedAudiences []string
|
||||
SSOAuthorizeURL string
|
||||
SSOTokenURL string
|
||||
SSOGetMeURL string
|
||||
SSOPortalURL string
|
||||
SSOClients map[string]SSOClientConfig
|
||||
SSOAccessCookieName string
|
||||
SSORefreshCookieName string
|
||||
SSOCookieDomain string
|
||||
SSOCookieSecure bool
|
||||
SSOCookieSameSite string
|
||||
SSOAccessTokenMaxBytes int
|
||||
SSOTokenBlacklistPrefix string
|
||||
SSOPKCETTL time.Duration
|
||||
SSOUserSyncDrift time.Duration
|
||||
SSOUserSyncNonceTTL time.Duration
|
||||
SSOUserSyncMaxBodyBytes int
|
||||
S3Endpoint string
|
||||
S3Region string
|
||||
S3Bucket string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3ForcePathStyle bool
|
||||
S3PublicBaseURL string
|
||||
S3EnvPrefix string
|
||||
S3DocumentKeyPrefix string
|
||||
TransferToLayingGrowingMaxWeek int
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -117,6 +118,11 @@ func init() {
|
||||
// Redis
|
||||
RedisURL = viper.GetString("REDIS_URL")
|
||||
|
||||
TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK")
|
||||
if TransferToLayingGrowingMaxWeek <= 0 {
|
||||
TransferToLayingGrowingMaxWeek = 19
|
||||
}
|
||||
|
||||
// Object storage
|
||||
S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT"))
|
||||
S3Region = strings.TrimSpace(viper.GetString("S3_REGION"))
|
||||
|
||||
@@ -24,7 +24,8 @@ WHERE source_table IN (
|
||||
'recording_depletions',
|
||||
'recording_eggs',
|
||||
'marketing_delivery_products',
|
||||
'project_chickins'
|
||||
'project_chickins',
|
||||
'project_flock_populations'
|
||||
);
|
||||
|
||||
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'),
|
||||
|
||||
('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
|
||||
SET
|
||||
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', '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', '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', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
|
||||
|
||||
+15
@@ -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;
|
||||
+50
@@ -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;
|
||||
+8
@@ -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;
|
||||
+13
@@ -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;
|
||||
@@ -12,16 +12,21 @@ type LayingTransfer struct {
|
||||
FromProjectFlockId uint `gorm:"not null"`
|
||||
ToProjectFlockId uint `gorm:"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"`
|
||||
CreatedBy uint `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
|
||||
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
|
||||
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
|
||||
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
||||
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
|
||||
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
|
||||
LatestApproval *Approval `gorm:"-" json:"-"`
|
||||
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
|
||||
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
|
||||
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
||||
ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"`
|
||||
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
|
||||
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)
|
||||
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
|
||||
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
|
||||
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
|
||||
kandangRepo := rKandang.NewKandangRepository(db)
|
||||
nonstockRepo := rNonstock.NewNonstockRepository(db)
|
||||
documentRepo := commonRepo.NewDocumentRepository(db)
|
||||
@@ -76,7 +77,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
||||
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)
|
||||
|
||||
TransferRoutes(router, userService, transferService)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
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"
|
||||
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
||||
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"
|
||||
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -43,12 +45,13 @@ type transferService struct {
|
||||
SupplierRepo rSupplier.SupplierRepository
|
||||
WarehouseRepo warehouseRepo.WarehouseRepository
|
||||
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
|
||||
ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository
|
||||
DocumentSvc commonSvc.DocumentService
|
||||
FifoStockV2Svc commonSvc.FifoStockV2Service
|
||||
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{
|
||||
Log: utils.Log,
|
||||
Validate: validate,
|
||||
@@ -61,6 +64,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
|
||||
SupplierRepo: supplierRepo,
|
||||
WarehouseRepo: warehouseRepo,
|
||||
ProjectFlockKandangRepo: projectFlockKandangRepo,
|
||||
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
|
||||
DocumentSvc: documentSvc,
|
||||
FifoStockV2Svc: fifoStockV2Svc,
|
||||
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))
|
||||
}
|
||||
|
||||
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{
|
||||
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
|
||||
CreatedBy: uint(actorID),
|
||||
@@ -617,6 +633,57 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
|
||||
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(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
|
||||
@@ -31,6 +31,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
||||
userRepo := rUser.NewUserRepository(db)
|
||||
customerRepo := rCustomer.NewCustomerRepository(db)
|
||||
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
|
||||
projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db)
|
||||
stockLogRepo := rShared.NewStockLogRepository(db)
|
||||
|
||||
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
|
||||
@@ -46,7 +47,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
||||
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
|
||||
|
||||
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)
|
||||
|
||||
RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService)
|
||||
|
||||
@@ -4,17 +4,22 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
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"
|
||||
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"
|
||||
marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
|
||||
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"
|
||||
"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/gofiber/fiber/v2"
|
||||
@@ -34,6 +39,8 @@ type deliveryOrdersService struct {
|
||||
MarketingProductRepo marketingRepo.MarketingProductRepository
|
||||
MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository
|
||||
StockLogRepo rShared.StockLogRepository
|
||||
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
|
||||
ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
|
||||
ApprovalSvc commonSvc.ApprovalService
|
||||
FifoStockV2Svc commonSvc.FifoStockV2Service
|
||||
}
|
||||
@@ -43,6 +50,8 @@ func NewDeliveryOrdersService(
|
||||
marketingProductRepo marketingRepo.MarketingProductRepository,
|
||||
marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository,
|
||||
stockLogRepo rShared.StockLogRepository,
|
||||
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
|
||||
projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository,
|
||||
approvalSvc commonSvc.ApprovalService,
|
||||
fifoStockV2Svc commonSvc.FifoStockV2Service,
|
||||
validate *validator.Validate,
|
||||
@@ -53,6 +62,8 @@ func NewDeliveryOrdersService(
|
||||
MarketingProductRepo: marketingProductRepo,
|
||||
MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
|
||||
StockLogRepo: stockLogRepo,
|
||||
ProductWarehouseRepo: productWarehouseRepo,
|
||||
ProjectFlockPopulationRepo: projectFlockPopulationRepo,
|
||||
ApprovalSvc: approvalSvc,
|
||||
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 {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
|
||||
}
|
||||
|
||||
if err := reflowMarketingScope(
|
||||
ctx,
|
||||
s.FifoStockV2Svc,
|
||||
@@ -575,6 +585,10 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
|
||||
deliveryProduct.PendingQty = refreshed.PendingQty
|
||||
deliveryProduct.CreatedAt = refreshed.CreatedAt
|
||||
|
||||
if err := s.allocatePopulationForMarketingDelivery(ctx, tx, deliveryProduct, marketingProduct.ProductWarehouseId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
allocatedDelta := deliveryProduct.UsageQty - previousUsage
|
||||
if actorID > 0 && allocatedDelta > 0 {
|
||||
decreaseLog := &entity.StockLog{
|
||||
@@ -642,6 +656,10 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
|
||||
deliveryProduct.PendingQty = refreshed.PendingQty
|
||||
deliveryProduct.CreatedAt = refreshed.CreatedAt
|
||||
|
||||
if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
releasedUsage := currentUsage - deliveryProduct.UsageQty
|
||||
if actorID > 0 && releasedUsage > 0 {
|
||||
increaseLog := &entity.StockLog{
|
||||
@@ -668,3 +686,57 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
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)
|
||||
projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
|
||||
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
|
||||
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
|
||||
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
|
||||
productRepo := rProduct.NewProductRepository(db)
|
||||
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
|
||||
@@ -57,6 +59,7 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
|
||||
projectflockkandangrepo,
|
||||
projectflockpopulationrepo,
|
||||
chickinDetailRepo,
|
||||
transferLayingRepo,
|
||||
validate,
|
||||
fifoStockV2Service)
|
||||
userService := sUser.NewUserService(userRepo, validate)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
|
||||
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"
|
||||
rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories"
|
||||
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ChickinService interface {
|
||||
@@ -51,11 +53,12 @@ type chickinService struct {
|
||||
ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository
|
||||
ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository
|
||||
ProjectChickinDetailRepo repository.ProjectChickinDetailRepository
|
||||
TransferLayingRepo rTransferLaying.TransferLayingRepository
|
||||
FifoStockV2Svc commonSvc.FifoStockV2Service
|
||||
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{
|
||||
Log: utils.Log,
|
||||
Validate: validate,
|
||||
@@ -68,6 +71,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan
|
||||
ProjectflockKandangRepo: projectflockkandangRepo,
|
||||
ProjectflockPopulationRepo: projectflockpopulationRepo,
|
||||
ProjectChickinDetailRepo: projectChickinDetailRepo,
|
||||
TransferLayingRepo: transferLayingRepo,
|
||||
FifoStockV2Svc: fifoStockV2Svc,
|
||||
StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()),
|
||||
}
|
||||
@@ -120,11 +124,36 @@ func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, e
|
||||
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) {
|
||||
if err := s.Validate.Struct(req); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if req.ChickInDate != "" {
|
||||
@@ -377,6 +417,10 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actorID, err := m.ActorIDFromContext(c)
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
|
||||
if err != nil {
|
||||
@@ -472,6 +519,21 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
|
||||
touchedProductWarehouseIDs := make(map[uint]struct{})
|
||||
|
||||
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(
|
||||
c.Context(),
|
||||
utils.ApprovalWorkflowChickin,
|
||||
@@ -692,6 +754,9 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB
|
||||
if tx == nil {
|
||||
return errors.New("transaction is required")
|
||||
}
|
||||
if s.FifoStockV2Svc == nil {
|
||||
return errors.New("fifo v2 service is not available")
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&entity.ProjectFlockPopulation{}).
|
||||
@@ -700,7 +765,11 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB
|
||||
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 {
|
||||
@@ -779,8 +848,6 @@ func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context
|
||||
gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
|
||||
FlagGroupCode: flagGroupCode,
|
||||
Lane: "STOCKABLE",
|
||||
AllocationPurpose: entity.StockAllocationPurposeTraceChickin,
|
||||
IgnoreSourceUsed: true,
|
||||
ProductWarehouseID: productWarehouseID,
|
||||
Limit: 50000,
|
||||
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"
|
||||
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
|
||||
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"
|
||||
"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)
|
||||
chickinRepo := rChickin.NewChickinRepository(db)
|
||||
chickinDetailRepo := rChickin.NewChickinDetailRepository(db)
|
||||
transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db)
|
||||
stockLogRepo := rStockLogs.NewStockLogRepository(db)
|
||||
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
|
||||
productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
|
||||
@@ -96,6 +98,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
||||
projectFlockKandangRepo,
|
||||
projectFlockPopulationRepo,
|
||||
chickinDetailRepo,
|
||||
transferLayingRepo,
|
||||
validate,
|
||||
fifoStockV2Service,
|
||||
)
|
||||
@@ -112,6 +115,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
||||
productionStandardService,
|
||||
projectFlockService,
|
||||
chickinService,
|
||||
transferLayingRepo,
|
||||
validate,
|
||||
)
|
||||
userService := sUser.NewUserService(userRepo, validate)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
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"
|
||||
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
||||
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"
|
||||
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
|
||||
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"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
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"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
@@ -52,6 +55,7 @@ type recordingService struct {
|
||||
ProductionStandardSvc sProductionStandard.ProductionStandardService
|
||||
ProjectFlockSvc sProjectFlock.ProjectflockService
|
||||
ChickinSvc sChickin.ChickinService
|
||||
TransferLayingRepo rTransferLaying.TransferLayingRepository
|
||||
FifoStockV2Svc commonSvc.FifoStockV2Service
|
||||
StockLogRepo rStockLogs.StockLogRepository
|
||||
}
|
||||
@@ -68,6 +72,7 @@ func NewRecordingService(
|
||||
productionStandardSvc sProductionStandard.ProductionStandardService,
|
||||
projectFlockSvc sProjectFlock.ProjectflockService,
|
||||
chickinSvc sChickin.ChickinService,
|
||||
transferLayingRepo rTransferLaying.TransferLayingRepository,
|
||||
validate *validator.Validate,
|
||||
) RecordingService {
|
||||
return &recordingService{
|
||||
@@ -82,6 +87,7 @@ func NewRecordingService(
|
||||
ProductionStandardSvc: productionStandardSvc,
|
||||
ProjectFlockSvc: projectFlockSvc,
|
||||
ChickinSvc: chickinSvc,
|
||||
TransferLayingRepo: transferLayingRepo,
|
||||
FifoStockV2Svc: fifoStockV2Svc,
|
||||
StockLogRepo: stockLogRepo,
|
||||
}
|
||||
@@ -287,6 +293,11 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
category := strings.ToUpper(pfk.ProjectFlock.Category)
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
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
|
||||
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
|
||||
hasDepletionChanges := req.Depletions != 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 {
|
||||
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))
|
||||
|
||||
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
|
||||
if refreshed.PendingQty > 0 {
|
||||
logDecrease += refreshed.PendingQty
|
||||
@@ -1801,11 +2037,15 @@ func (s *recordingService) reflowResetRecordingDepletionsOut(
|
||||
return errors.New("stock log repository is not available")
|
||||
}
|
||||
logState := newRecordingStockLogState()
|
||||
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
|
||||
|
||||
for _, depletion := range depletions {
|
||||
if depletion.Id == 0 {
|
||||
continue
|
||||
}
|
||||
if err := stockAllocationRepo.ReleaseByUsable(ctx, fifo.UsableKeyRecordingDepletion.String(), depletion.Id, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logDepletionTrace("reflow_reset:start", depletion, "")
|
||||
|
||||
sourceWarehouseID := uint(0)
|
||||
@@ -1886,6 +2126,58 @@ func (s *recordingService) reflowResetRecordingDepletionsOut(
|
||||
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(
|
||||
ctx context.Context,
|
||||
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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
+22
@@ -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 {
|
||||
projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,10 +14,13 @@ import (
|
||||
// === DTO Structs ===
|
||||
|
||||
type TransferLayingRelationDTO struct {
|
||||
Id uint `json:"id"`
|
||||
TransferNumber string `json:"transfer_number"`
|
||||
TransferDate time.Time `json:"transfer_date"`
|
||||
Notes string `json:"notes"`
|
||||
Id uint `json:"id"`
|
||||
TransferNumber string `json:"transfer_number"`
|
||||
TransferDate time.Time `json:"transfer_date"`
|
||||
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 {
|
||||
@@ -47,6 +50,8 @@ type TransferLayingListDTO struct {
|
||||
ToProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"to_project_flock,omitempty"`
|
||||
CreatedBy uint `json:"created_by"`
|
||||
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"`
|
||||
Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"`
|
||||
}
|
||||
@@ -88,10 +93,13 @@ type MaxTargetQtyForTransferDTO struct {
|
||||
|
||||
func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO {
|
||||
return TransferLayingRelationDTO{
|
||||
Id: e.Id,
|
||||
TransferNumber: e.TransferNumber,
|
||||
TransferDate: e.TransferDate,
|
||||
Notes: e.Notes,
|
||||
Id: e.Id,
|
||||
TransferNumber: e.TransferNumber,
|
||||
TransferDate: e.TransferDate,
|
||||
EconomicCutoffDate: e.EconomicCutoffDate,
|
||||
EffectiveMoveDate: e.EffectiveMoveDate,
|
||||
ExecutedAt: e.ExecutedAt,
|
||||
Notes: e.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +198,12 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
|
||||
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
|
||||
if e.LatestApproval != nil {
|
||||
mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval)
|
||||
@@ -219,6 +233,8 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
|
||||
ToProjectFlock: toProjectFlock,
|
||||
CreatedBy: e.CreatedBy,
|
||||
CreatedUser: createdUser,
|
||||
ExecutedBy: e.ExecutedBy,
|
||||
ExecutedUser: executedUser,
|
||||
CreatedAt: e.CreatedAt,
|
||||
Approval: approval,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package transfer_layings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
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"
|
||||
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"
|
||||
@@ -32,8 +34,47 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
|
||||
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
|
||||
productWarehouseRepo := rInventory.NewProductWarehouseRepository(db)
|
||||
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
|
||||
|
||||
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
|
||||
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, 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)
|
||||
approvalService := commonSvc.NewApprovalService(approvalRepo)
|
||||
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,
|
||||
warehouseRepo,
|
||||
approvalService,
|
||||
fifoService,
|
||||
fifoStockV2Service,
|
||||
validate,
|
||||
)
|
||||
|
||||
+58
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -14,6 +15,8 @@ type TransferLayingRepository interface {
|
||||
repository.BaseRepository[entity.LayingTransfer]
|
||||
GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, 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
|
||||
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("ToProjectFlock").
|
||||
Preload("CreatedUser").
|
||||
Preload("ExecutedUser").
|
||||
Preload("Sources").
|
||||
Preload("Sources.SourceProjectFlockKandang").
|
||||
Preload("Sources.SourceProjectFlockKandang.Kandang").
|
||||
@@ -180,3 +184,57 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of
|
||||
|
||||
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.Delete("/:id", m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne)
|
||||
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/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang)
|
||||
}
|
||||
|
||||
+412
-134
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
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"
|
||||
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
||||
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"
|
||||
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -33,6 +36,7 @@ type TransferLayingService interface {
|
||||
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error)
|
||||
DeleteOne(ctx *fiber.Ctx, id uint) 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)
|
||||
GetMaxTargetQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (map[uint]float64, error)
|
||||
}
|
||||
@@ -50,9 +54,14 @@ type transferLayingService struct {
|
||||
WarehouseRepo rWarehouse.WarehouseRepository
|
||||
StockLogRepo rStockLogs.StockLogRepository
|
||||
ApprovalService commonSvc.ApprovalService
|
||||
FifoSvc commonSvc.FifoService
|
||||
FifoStockV2Svc commonSvc.FifoStockV2Service
|
||||
}
|
||||
|
||||
const (
|
||||
transferToLayingFlagGroupCode = "AYAM"
|
||||
)
|
||||
|
||||
func NewTransferLayingService(
|
||||
repo repository.TransferLayingRepository,
|
||||
layingTransferSourceRepo repository.LayingTransferSourceRepository,
|
||||
@@ -63,6 +72,7 @@ func NewTransferLayingService(
|
||||
productWarehouseRepo rInventory.ProductWarehouseRepository,
|
||||
warehouseRepo rWarehouse.WarehouseRepository,
|
||||
approvalService commonSvc.ApprovalService,
|
||||
fifoSvc commonSvc.FifoService,
|
||||
fifoStockV2Svc commonSvc.FifoStockV2Service,
|
||||
validate *validator.Validate,
|
||||
) TransferLayingService {
|
||||
@@ -79,6 +89,7 @@ func NewTransferLayingService(
|
||||
WarehouseRepo: warehouseRepo,
|
||||
StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()),
|
||||
ApprovalService: approvalService,
|
||||
FifoSvc: fifoSvc,
|
||||
FifoStockV2Svc: fifoStockV2Svc,
|
||||
}
|
||||
}
|
||||
@@ -86,6 +97,7 @@ func NewTransferLayingService(
|
||||
func (s transferLayingService) withRelations(db *gorm.DB) *gorm.DB {
|
||||
return db.
|
||||
Preload("CreatedUser").
|
||||
Preload("ExecutedUser").
|
||||
Preload("FromProjectFlock").
|
||||
Preload("ToProjectFlock").
|
||||
Preload("Sources").
|
||||
@@ -744,11 +756,9 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
|
||||
repoTx := s.Repository.WithTx(dbTransaction)
|
||||
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
|
||||
sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction)
|
||||
targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction)
|
||||
stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction)
|
||||
|
||||
for _, approvableID := range approvableIDs {
|
||||
transfer, err := repoTx.GetByID(c.Context(), approvableID, nil)
|
||||
_, err := repoTx.GetByID(c.Context(), approvableID, nil)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
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 s.FifoStockV2Svc == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
|
||||
}
|
||||
|
||||
sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer")
|
||||
}
|
||||
|
||||
targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), approvableID)
|
||||
economicCutoffDate, err := s.calculateEconomicCutoffDate(c.Context(), sources)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil targets transfer")
|
||||
return err
|
||||
}
|
||||
|
||||
totalTargetQty := 0.0
|
||||
for _, target := range targets {
|
||||
totalTargetQty += target.TotalQty
|
||||
}
|
||||
|
||||
totalSourceRequested := 0.0
|
||||
for _, source := range sources {
|
||||
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")
|
||||
}
|
||||
if err := repoTx.PatchOne(c.Context(), approvableID, map[string]any{
|
||||
"economic_cutoff_date": economicCutoffDate,
|
||||
"effective_move_date": economicCutoffDate, // Backward-compatible alias for existing clients.
|
||||
"executed_at": nil,
|
||||
"executed_by": nil,
|
||||
}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -933,6 +821,392 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
|
||||
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: ¬e,
|
||||
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 {
|
||||
if transferLayingID == 0 || actorID == 0 {
|
||||
return nil
|
||||
@@ -1047,6 +1321,10 @@ func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFl
|
||||
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 {
|
||||
if len(targets) == 0 {
|
||||
return []float64{}
|
||||
|
||||
@@ -787,6 +787,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
||||
req.Items[idx].TravelDocumentPath = &uploadedURL
|
||||
}
|
||||
}
|
||||
lockedIDs := map[uint]struct{}{}
|
||||
if action == entity.ApprovalActionApproved {
|
||||
itemByID := make(map[uint]entity.PurchaseItem, len(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]
|
||||
}
|
||||
lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items)
|
||||
locked, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lockedIDs) > 0 {
|
||||
if len(locked) > 0 {
|
||||
for id := range locked {
|
||||
lockedIDs[id] = struct{}{}
|
||||
}
|
||||
for _, payload := range req.Items {
|
||||
if _, used := lockedIDs[payload.PurchaseItemID]; !used {
|
||||
continue
|
||||
@@ -880,7 +884,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
||||
if receivedQty > 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))
|
||||
}
|
||||
|
||||
@@ -1659,7 +1663,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
|
||||
if *data.Qty <= 0 {
|
||||
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))
|
||||
}
|
||||
if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving {
|
||||
@@ -1778,6 +1782,51 @@ func calculateTotalPrice(quantity float64, price float64, provided *float64, ref
|
||||
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 {
|
||||
if item == nil || item.Id == 0 || s.ApprovalSvc == nil {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user