mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
init adjustment recording
This commit is contained in:
@@ -151,7 +151,7 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
|
||||
).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("f.name = ?", utils.FlagPakan).
|
||||
Scan(&total).Error
|
||||
@@ -202,7 +202,7 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
|
||||
).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
|
||||
Scan(&total).Error
|
||||
|
||||
@@ -103,6 +103,7 @@ type HppV2CostRepository interface {
|
||||
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
|
||||
GetLatestTransferInputByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint, period time.Time) (*HppV2LatestTransferInputRow, error)
|
||||
GetManualDepreciationInputByProjectFlockID(ctx context.Context, projectFlockID uint) (*HppV2ManualDepreciationInputRow, error)
|
||||
GetRecordingStockRoutingAdjustmentCostByProjectFlockID(ctx context.Context, projectFlockID uint, periodDate time.Time) (float64, error)
|
||||
GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(ctx context.Context, projectFlockID uint, periodDate time.Time) (*HppV2FarmDepreciationSnapshotRow, error)
|
||||
GetEarliestChickInDateByProjectFlockID(ctx context.Context, projectFlockID uint) (*time.Time, error)
|
||||
GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error)
|
||||
@@ -249,6 +250,50 @@ func (r *HppV2RepositoryImpl) GetManualDepreciationInputByProjectFlockID(
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *HppV2RepositoryImpl) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(
|
||||
ctx context.Context,
|
||||
projectFlockID uint,
|
||||
periodDate time.Time,
|
||||
) (float64, error) {
|
||||
if projectFlockID == 0 || periodDate.IsZero() {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
flags := []utils.FlagType{
|
||||
utils.FlagPakan,
|
||||
utils.FlagOVK,
|
||||
utils.FlagObat,
|
||||
utils.FlagVitamin,
|
||||
utils.FlagKimia,
|
||||
}
|
||||
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("recording_stocks AS rs").
|
||||
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
|
||||
Joins("JOIN project_flock_kandangs AS pfk_rec ON pfk_rec.id = r.project_flock_kandangs_id").
|
||||
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
||||
Joins(
|
||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||
fifo.UsableKeyRecordingStock.String(),
|
||||
fifo.StockableKeyPurchaseItems.String(),
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
).
|
||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Where("pfk_rec.project_flock_id = ?", projectFlockID).
|
||||
Where("DATE(r.record_datetime) <= DATE(?)", periodDate).
|
||||
Where("(rs.project_flock_kandang_id IS NULL OR rs.project_flock_kandang_id <> r.project_flock_kandangs_id)").
|
||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *HppV2RepositoryImpl) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(
|
||||
ctx context.Context,
|
||||
projectFlockID uint,
|
||||
@@ -393,7 +438,7 @@ func (r *HppV2RepositoryImpl) ListUsageCostRowsByProductFlags(
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Joins("LEFT JOIN product_warehouses AS ast_pw ON ast_pw.id = ast.product_warehouse_id").
|
||||
Joins("LEFT JOIN products AS ast_prod ON ast_prod.id = ast_pw.product_id").
|
||||
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flagNames).
|
||||
Group(`
|
||||
@@ -755,7 +800,7 @@ func (r *HppV2RepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlock
|
||||
).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("f.name = ?", utils.FlagPakan).
|
||||
Scan(&total).Error
|
||||
|
||||
@@ -96,6 +96,54 @@ func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(
|
||||
assertFloatEquals(t, totalWeightKg, 1.4)
|
||||
}
|
||||
|
||||
func TestHppV2RepositoryGetRecordingStockRoutingAdjustmentCostByProjectFlockID(t *testing.T) {
|
||||
db := setupHppV2RepositoryTestDB(t)
|
||||
|
||||
mustExecHppV2(t, db,
|
||||
`INSERT INTO project_flock_kandangs (id, kandang_id, project_flock_id) VALUES (101, 1, 1), (201, 2, 2)`,
|
||||
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES
|
||||
(1, 101, '2026-04-10 08:00:00', NULL),
|
||||
(2, 101, '2026-04-11 08:00:00', NULL),
|
||||
(3, 101, '2026-04-12 08:00:00', NULL)`,
|
||||
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES
|
||||
(501, 201, 10, NULL),
|
||||
(502, 201, 11, NULL),
|
||||
(503, 201, 12, NULL)`,
|
||||
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES
|
||||
(10, 'products', 10, 'PAKAN'),
|
||||
(11, 'products', 11, 'OVK'),
|
||||
(12, 'products', 12, 'PAKAN')`,
|
||||
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, project_flock_kandang_id) VALUES
|
||||
(101, 1, 501, NULL),
|
||||
(102, 2, 502, 201),
|
||||
(103, 3, 503, 101)`,
|
||||
`INSERT INTO purchase_items (id, product_id, price) VALUES
|
||||
(601, 10, 100),
|
||||
(602, 11, 200),
|
||||
(603, 12, 300)`,
|
||||
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose, qty) VALUES
|
||||
(9001, 'RECORDING_STOCK', 101, 'PURCHASE_ITEMS', 601, 'ACTIVE', 'CONSUME', 2),
|
||||
(9002, 'RECORDING_STOCK', 102, 'PURCHASE_ITEMS', 602, 'ACTIVE', 'CONSUME', 1.5),
|
||||
(9003, 'RECORDING_STOCK', 103, 'PURCHASE_ITEMS', 603, 'ACTIVE', 'CONSUME', 1)`,
|
||||
)
|
||||
|
||||
repo := &HppV2RepositoryImpl{db: db}
|
||||
|
||||
periodDate := mustJakartaTime(t, "2026-04-30 00:00:00")
|
||||
total, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, periodDate)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
assertFloatEquals(t, total, 500)
|
||||
|
||||
earlyPeriod := mustJakartaTime(t, "2026-04-10 23:59:59")
|
||||
earlyTotal, err := repo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(context.Background(), 1, earlyPeriod)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
assertFloatEquals(t, earlyTotal, 200)
|
||||
}
|
||||
|
||||
func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
@@ -111,6 +159,12 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
record_datetime DATETIME NULL,
|
||||
deleted_at DATETIME NULL
|
||||
)`,
|
||||
`CREATE TABLE recording_stocks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NULL,
|
||||
product_warehouse_id INTEGER NULL,
|
||||
project_flock_kandang_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE recording_eggs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NULL,
|
||||
@@ -174,6 +228,11 @@ func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
id INTEGER PRIMARY KEY,
|
||||
product_warehouse_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE purchase_items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
product_id INTEGER NULL,
|
||||
price NUMERIC(15,3) NULL
|
||||
)`,
|
||||
`CREATE TABLE marketing_delivery_products (
|
||||
id INTEGER PRIMARY KEY,
|
||||
marketing_product_id INTEGER NULL,
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
hppV2ComponentBopRegular = "BOP_REGULAR"
|
||||
hppV2ComponentBopEksp = "BOP_EKSPEDISI"
|
||||
hppV2ComponentManualPulletCost = "MANUAL_PULLET_COST"
|
||||
hppV2ComponentRecordingStockRoute = "RECORDING_STOCK_ROUTE"
|
||||
hppV2ComponentDepreciation = "DEPRECIATION"
|
||||
hppV2PartGrowingNormal = "growing_normal"
|
||||
hppV2PartGrowingCutover = "growing_cutover"
|
||||
@@ -26,6 +27,7 @@ const (
|
||||
hppV2PartLayingDirect = "laying_direct"
|
||||
hppV2PartLayingFarm = "laying_farm"
|
||||
hppV2PartManualCutover = "manual_cutover"
|
||||
hppV2PartRecordingStockRoute = "recording_stock_route"
|
||||
hppV2PartDepreciationNormal = "normal_transfer"
|
||||
hppV2PartDepreciationCutover = "manual_cutover"
|
||||
hppV2PartDepreciationFarmSnapshot = "farm_snapshot"
|
||||
@@ -190,6 +192,12 @@ func (s *hppV2Service) CalculateHppBreakdown(projectFlockKandangId uint, date *t
|
||||
}
|
||||
appendComponent(hppV2ComponentManualPulletCost, manualPulletComponent)
|
||||
|
||||
recordingStockRouteComponent, err := s.getRecordingStockRouteComponent(projectFlockKandangId, contextRow, startOfDay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendComponent(hppV2ComponentRecordingStockRoute, recordingStockRouteComponent)
|
||||
|
||||
depreciationComponent, err := s.getDepreciationComponent(projectFlockKandangId, contextRow, startOfDay, endOfDay, totalPulletCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1064,6 +1072,100 @@ func (s *hppV2Service) getManualPulletCostComponent(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *hppV2Service) getRecordingStockRouteComponent(
|
||||
projectFlockKandangId uint,
|
||||
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
|
||||
periodDate time.Time,
|
||||
) (*HppV2Component, error) {
|
||||
if s.hppRepo == nil || contextRow == nil || periodDate.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
farmTotalCost, err := s.hppRepo.GetRecordingStockRoutingAdjustmentCostByProjectFlockID(
|
||||
context.Background(),
|
||||
contextRow.ProjectFlockID,
|
||||
periodDate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if farmTotalCost <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
farmPFKIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), contextRow.ProjectFlockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(farmPFKIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
totalPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), farmPFKIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if totalPopulation <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
targetPopulation, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetPopulation <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ratio := targetPopulation / totalPopulation
|
||||
if ratio <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
appliedTotal := farmTotalCost * ratio
|
||||
if appliedTotal <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
part := HppV2ComponentPart{
|
||||
Code: hppV2PartRecordingStockRoute,
|
||||
Title: "Recording Stock Route",
|
||||
Scopes: []string{hppV2ScopePulletCost},
|
||||
Total: appliedTotal,
|
||||
Proration: &HppV2Proration{
|
||||
Basis: hppV2ProrationPopulation,
|
||||
Numerator: targetPopulation,
|
||||
Denominator: totalPopulation,
|
||||
Ratio: ratio,
|
||||
},
|
||||
Details: map[string]any{
|
||||
"period_date": formatDateOnly(periodDate),
|
||||
"farm_total_cost": farmTotalCost,
|
||||
"target_population": targetPopulation,
|
||||
"farm_population": totalPopulation,
|
||||
"project_flock_id": contextRow.ProjectFlockID,
|
||||
"project_flock_kandang_id": projectFlockKandangId,
|
||||
},
|
||||
References: []HppV2Reference{
|
||||
{
|
||||
Type: "recording_stock_route",
|
||||
Date: formatDateOnly(periodDate),
|
||||
Qty: 1,
|
||||
Total: farmTotalCost,
|
||||
AppliedTotal: appliedTotal,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &HppV2Component{
|
||||
Code: hppV2ComponentRecordingStockRoute,
|
||||
Title: "Recording Stock Route",
|
||||
Scopes: []string{hppV2ScopePulletCost},
|
||||
Total: appliedTotal,
|
||||
Parts: []HppV2ComponentPart{part},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *hppV2Service) getDepreciationComponent(
|
||||
projectFlockKandangId uint,
|
||||
contextRow *commonRepo.HppV2ProjectFlockKandangContext,
|
||||
|
||||
@@ -25,6 +25,7 @@ type hppV2RepoStub struct {
|
||||
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
|
||||
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
|
||||
routeCostByProject map[uint]float64
|
||||
totalPopulationByKey map[string]float64
|
||||
transferSummaryByPFK map[uint]struct {
|
||||
projectFlockID uint
|
||||
@@ -60,6 +61,10 @@ func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Con
|
||||
return s.manualInputByProject[projectFlockID], nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetRecordingStockRoutingAdjustmentCostByProjectFlockID(_ context.Context, projectFlockID uint, _ time.Time) (float64, error) {
|
||||
return s.routeCostByProject[projectFlockID], nil
|
||||
}
|
||||
|
||||
func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) {
|
||||
if s.snapshotByProjectKey == nil {
|
||||
return nil, nil
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS idx_recording_stocks_project_flock_kandang_id;
|
||||
|
||||
ALTER TABLE recording_stocks
|
||||
DROP CONSTRAINT IF EXISTS fk_recording_stocks_project_flock_kandang_id;
|
||||
|
||||
ALTER TABLE recording_stocks
|
||||
DROP COLUMN IF EXISTS project_flock_kandang_id;
|
||||
|
||||
ALTER TABLE house_depreciation_standards
|
||||
DROP CONSTRAINT IF EXISTS chk_house_depreciation_standards_standard_week_positive;
|
||||
|
||||
ALTER TABLE house_depreciation_standards
|
||||
DROP COLUMN IF EXISTS standard_week;
|
||||
|
||||
COMMIT;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE recording_stocks
|
||||
ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'fk_recording_stocks_project_flock_kandang_id'
|
||||
) THEN
|
||||
ALTER TABLE recording_stocks
|
||||
ADD CONSTRAINT fk_recording_stocks_project_flock_kandang_id
|
||||
FOREIGN KEY (project_flock_kandang_id)
|
||||
REFERENCES project_flock_kandangs(id)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recording_stocks_project_flock_kandang_id
|
||||
ON recording_stocks(project_flock_kandang_id);
|
||||
|
||||
ALTER TABLE house_depreciation_standards
|
||||
ADD COLUMN IF NOT EXISTS standard_week INT;
|
||||
|
||||
UPDATE house_depreciation_standards
|
||||
SET standard_week = CASE house_type::text
|
||||
WHEN 'close_house' THEN 22
|
||||
WHEN 'open_house' THEN 25
|
||||
ELSE standard_week
|
||||
END
|
||||
WHERE standard_week IS NULL OR standard_week <= 0;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_house_depreciation_standards_standard_week_positive'
|
||||
) THEN
|
||||
ALTER TABLE house_depreciation_standards
|
||||
ADD CONSTRAINT chk_house_depreciation_standards_standard_week_positive
|
||||
CHECK (standard_week > 0);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE house_depreciation_standards
|
||||
ALTER COLUMN standard_week SET NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -6,6 +6,7 @@ type HouseDepreciationStandard struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
HouseType string `gorm:"type:house_type_enum;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:1"`
|
||||
DayNumber int `gorm:"column:day;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:2"`
|
||||
StandardWeek int `gorm:"column:standard_week;not null"`
|
||||
DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
|
||||
@@ -4,6 +4,7 @@ type RecordingStock struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id;index"`
|
||||
UsageQty *float64 `gorm:"column:usage_qty"`
|
||||
PendingQty *float64 `gorm:"column:pending_qty"`
|
||||
|
||||
|
||||
@@ -98,6 +98,9 @@ func sapronakIncomingPurchaseQueryParts(params SapronakQueryParams) (string, []a
|
||||
fifo.StockableKeyPurchaseItems.String(),
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
fifo.UsableKeyRecordingStock.String(),
|
||||
params.ProjectFlockKandangIDs,
|
||||
fifo.UsableKeyProjectChickin.String(),
|
||||
params.ProjectFlockKandangIDs,
|
||||
params.ProjectFlockKandangIDs,
|
||||
params.WarehouseIDs,
|
||||
@@ -323,7 +326,7 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c
|
||||
Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id").
|
||||
Joins("JOIN products prod ON prod.id = pw.product_id").
|
||||
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
|
||||
Where("rec.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
|
||||
Where("f.name = ?", "PAKAN").
|
||||
Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used").
|
||||
Scan(&usageAgg).Error
|
||||
@@ -905,7 +908,11 @@ WITH scoped_farm_allocations AS (
|
||||
WHERE sa.stockable_type = ?
|
||||
AND sa.status = ?
|
||||
AND sa.allocation_purpose = ?
|
||||
AND COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) IN ?
|
||||
AND (
|
||||
(sa.usable_type = ? AND rs.project_flock_kandang_id IN ?)
|
||||
OR
|
||||
(sa.usable_type = ? AND pc.project_flock_kandang_id IN ?)
|
||||
)
|
||||
GROUP BY sa.stockable_id
|
||||
)
|
||||
SELECT
|
||||
@@ -1167,7 +1174,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID ui
|
||||
"recording_stocks rs",
|
||||
"pw.id = rs.product_warehouse_id",
|
||||
[]string{"JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"},
|
||||
"r.project_flock_kandangs_id = ? AND f.name IN ?",
|
||||
"rs.project_flock_kandang_id = ? AND f.name IN ?",
|
||||
pfkID,
|
||||
sapronakFlagsUsage,
|
||||
)
|
||||
@@ -1208,7 +1215,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p
|
||||
COALESCE(rs.usage_qty,0) AS qty_out,
|
||||
COALESCE(p.product_price,0) AS price
|
||||
`,
|
||||
"r.project_flock_kandangs_id = ? AND f.name IN ?",
|
||||
"rs.project_flock_kandang_id = ? AND f.name IN ?",
|
||||
pfkID,
|
||||
sapronakFlagsUsage,
|
||||
)
|
||||
@@ -1294,7 +1301,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
|
||||
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
|
||||
Where("f.name IN ?", sapronakFlagsAll).
|
||||
Where(`
|
||||
(sa.usable_type = ? AND r.project_flock_kandangs_id = ? AND f.name IN ?)
|
||||
(sa.usable_type = ? AND rs.project_flock_kandang_id = ? AND f.name IN ?)
|
||||
OR
|
||||
(sa.usable_type = ? AND pc_used.project_flock_kandang_id = ? AND f.name IN ?)
|
||||
`,
|
||||
@@ -1347,7 +1354,12 @@ func (r *ClosingRepositoryImpl) incomingFarmPurchaseAllocationBase(ctx context.C
|
||||
Where("sa.status = ?", entity.StockAllocationStatusActive).
|
||||
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Where("w.kandang_id IS NULL").
|
||||
Where("COALESCE(rec.project_flock_kandangs_id, pc.project_flock_kandang_id) = ?", projectFlockKandangID).
|
||||
Where("(sa.usable_type = ? AND rs.project_flock_kandang_id = ?) OR (sa.usable_type = ? AND pc.project_flock_kandang_id = ?)",
|
||||
fifo.UsableKeyRecordingStock.String(),
|
||||
projectFlockKandangID,
|
||||
fifo.UsableKeyProjectChickin.String(),
|
||||
projectFlockKandangID,
|
||||
).
|
||||
Where("f.name IN ?", sapronakFlagsAll).
|
||||
Where("pi.received_date IS NOT NULL")
|
||||
db = applyDateRange(db, "pi.received_date", start, end)
|
||||
|
||||
@@ -20,8 +20,8 @@ func TestSapronakIncomingPurchaseQueryPartsUsesAttributedPurchasesWhenProjectFlo
|
||||
if sql != sapronakIncomingPurchasesScopedSQL() {
|
||||
t.Fatalf("expected scoped purchase SQL, got %q", sql)
|
||||
}
|
||||
if len(args) != 8 {
|
||||
t.Fatalf("expected 8 argument groups, got %d", len(args))
|
||||
if len(args) != 11 {
|
||||
t.Fatalf("expected 11 argument groups, got %d", len(args))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFetchSapronakIncomingIncludesAttributedFarmPurchasesAndHistoricalWareho
|
||||
(2, 10, 'products', 'OBAT')`,
|
||||
`INSERT INTO purchases (id, po_number, deleted_at) VALUES (1, 'PO-LTI-0005', NULL)`,
|
||||
`INSERT INTO recordings (id, project_flock_kandangs_id, deleted_at) VALUES (11, 101, NULL), (12, 999, NULL)`,
|
||||
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty) VALUES (21, 11, 501, 150), (22, 12, 502, 10)`,
|
||||
`INSERT INTO recording_stocks (id, recording_id, product_warehouse_id, usage_qty, project_flock_kandang_id) VALUES (21, 11, 501, 150, 101), (22, 12, 502, 10, 999)`,
|
||||
`INSERT INTO purchase_items (id, purchase_id, product_id, warehouse_id, project_flock_kandang_id, total_qty, price, received_date) VALUES
|
||||
(1, 1, 10, 1, NULL, 100, 261700, '` + receivedAt.Format(time.RFC3339) + `'),
|
||||
(2, 1, 20, 1, NULL, 50, 15000, '` + receivedAt.Format(time.RFC3339) + `'),
|
||||
@@ -148,7 +148,8 @@ func setupClosingRepositoryTestDB(t *testing.T) *gorm.DB {
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NOT NULL,
|
||||
product_warehouse_id INTEGER NOT NULL,
|
||||
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0
|
||||
usage_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
|
||||
project_flock_kandang_id INTEGER NULL
|
||||
)`,
|
||||
`CREATE TABLE project_chickins (
|
||||
id INTEGER PRIMARY KEY,
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type RecordingService interface {
|
||||
@@ -365,6 +366,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
}
|
||||
}
|
||||
|
||||
var stockOwnerProjectFlockKandangID *uint
|
||||
if len(req.Stocks) > 0 {
|
||||
stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfk, recordTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
day, err := s.computeRecordingDay(ctx, pfk.Id, recordTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -441,7 +450,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
return err
|
||||
}
|
||||
|
||||
mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks)
|
||||
mappedStocks := recordingutil.MapStocks(createdRecording.Id, stockOwnerProjectFlockKandangID, req.Stocks)
|
||||
stockDesired := resetStockQuantitiesForFIFO(mappedStocks)
|
||||
if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil {
|
||||
s.Log.Errorf("Failed to persist stocks: %+v", err)
|
||||
@@ -514,6 +523,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
||||
s.Log.Errorf("Failed to recalculate recordings after create: %+v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, createdRecording.ProjectFlockKandangId, createdRecording.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to sync farm depreciation manual input after create: %+v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
action := entity.ApprovalActionCreated
|
||||
if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil {
|
||||
@@ -587,6 +600,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
}
|
||||
pfkForRoute = fetchedPfk
|
||||
}
|
||||
if err := s.tryAutoExecuteTransferForRecordingCreate(c, pfkForRoute, recordingEntity.RecordDatetime); err != nil {
|
||||
return err
|
||||
}
|
||||
routePayload := buildRecordingRoutePayloadFromUpdate(req)
|
||||
if err := s.enforceTransferRecordingRoute(ctx, pfkForRoute, recordingEntity.RecordDatetime, routePayload); err != nil {
|
||||
return err
|
||||
@@ -600,10 +616,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
var existingDepletions []entity.RecordingDepletion
|
||||
var existingEggs []entity.RecordingEgg
|
||||
var mappedDepletions []entity.RecordingDepletion
|
||||
var stockOwnerProjectFlockKandangID *uint
|
||||
|
||||
note := recordingutil.RecordingNote("Edit", recordingEntity.Id)
|
||||
|
||||
if hasStockChanges {
|
||||
stockOwnerProjectFlockKandangID, err = s.resolveRecordingStockOwnerProjectFlockKandangID(ctx, pfkForRoute, recordingEntity.RecordDatetime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to list existing stocks: %+v", err)
|
||||
@@ -611,7 +632,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
}
|
||||
existingUsage := recordingutil.StockUsageByWarehouse(existingStocks)
|
||||
incomingUsage := recordingutil.StockUsageByWarehouseReq(req.Stocks)
|
||||
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage)
|
||||
match := recordingutil.FloatMapsEqual(existingUsage, incomingUsage) && recordingStocksAllOwnedBy(existingStocks, stockOwnerProjectFlockKandangID)
|
||||
if match {
|
||||
hasStockChanges = false
|
||||
} else {
|
||||
@@ -622,7 +643,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil {
|
||||
if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, stockOwnerProjectFlockKandangID, note, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -798,6 +819,12 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hasStockChanges {
|
||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recordingEntity.ProjectFlockKandangId, recordingEntity.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to sync farm depreciation manual input after update: %+v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
action := entity.ApprovalActionUpdated
|
||||
actorID := recordingEntity.CreatedBy
|
||||
@@ -1059,6 +1086,10 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
|
||||
return err
|
||||
}
|
||||
if err := s.syncFarmDepreciationManualInputFromRecordingStocks(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime); err != nil {
|
||||
s.Log.Errorf("Failed to sync farm depreciation manual input after delete: %+v", err)
|
||||
return err
|
||||
}
|
||||
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
|
||||
|
||||
return nil
|
||||
@@ -1435,12 +1466,13 @@ func (s *recordingService) tryAutoExecuteTransferForRecordingCreate(c *fiber.Ctx
|
||||
return nil
|
||||
}
|
||||
|
||||
businessDate := recordDate
|
||||
physicalMoveDate := transferPhysicalMoveDate(transfer)
|
||||
if physicalMoveDate.IsZero() || recordDate.Before(physicalMoveDate) {
|
||||
return nil
|
||||
if !physicalMoveDate.IsZero() && businessDate.Before(physicalMoveDate) {
|
||||
businessDate = physicalMoveDate
|
||||
}
|
||||
|
||||
if _, err := s.TransferLayingSvc.ExecuteWithBusinessDate(c, transfer.Id, recordDate); err != nil {
|
||||
if _, err := s.TransferLayingSvc.ExecuteWithBusinessDate(c, transfer.Id, businessDate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1453,102 +1485,10 @@ func (s *recordingService) enforceTransferRecordingRoute(
|
||||
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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_ = ctx
|
||||
_ = pfk
|
||||
_ = recordTime
|
||||
_ = payload
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1658,6 +1598,356 @@ func boolPtr(value bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func recordingStocksAllOwnedBy(stocks []entity.RecordingStock, owner *uint) bool {
|
||||
for _, stock := range stocks {
|
||||
if !uintPtrEqual(stock.ProjectFlockKandangId, owner) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func uintPtrEqual(a *uint, b *uint) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func (s *recordingService) resolveRecordingStockOwnerProjectFlockKandangID(
|
||||
ctx context.Context,
|
||||
pfk *entity.ProjectFlockKandang,
|
||||
recordTime time.Time,
|
||||
) (*uint, error) {
|
||||
if pfk == nil || pfk.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category))
|
||||
if category == "" {
|
||||
loaded, err := s.ProjectFlockKandangRepo.GetByIDLight(ctx, pfk.Id)
|
||||
if err == nil && loaded != nil {
|
||||
pfk = loaded
|
||||
category = strings.ToUpper(strings.TrimSpace(loaded.ProjectFlock.Category))
|
||||
}
|
||||
}
|
||||
|
||||
if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) {
|
||||
owner := pfk.Id
|
||||
return &owner, nil
|
||||
}
|
||||
|
||||
if s.TransferLayingRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
s.Log.Errorf("Failed to resolve transfer laying for recording stock owner (target_pfk=%d): %+v", pfk.Id, err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan atribusi recording stock")
|
||||
}
|
||||
if transfer == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sourceProjectFlockKandangID, err := s.resolveTransferSourceProjectFlockKandangID(ctx, transfer)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to resolve transfer source kandang for transfer %d: %+v", transfer.Id, err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan sumber kandang growing")
|
||||
}
|
||||
if sourceProjectFlockKandangID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sourceChickinDate, err := s.getEarliestChickInDateByProjectFlockKandangID(ctx, sourceProjectFlockKandangID)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to resolve earliest chick-in date for source kandang %d: %+v", sourceProjectFlockKandangID, err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan umur ayam dari growing")
|
||||
}
|
||||
if sourceChickinDate == nil || sourceChickinDate.IsZero() {
|
||||
owner := sourceProjectFlockKandangID
|
||||
return &owner, nil
|
||||
}
|
||||
|
||||
thresholdDay, err := s.resolveLayingDepreciationThresholdDay(ctx, pfk)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to resolve laying threshold day for kandang %d: %+v", pfk.Id, err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menentukan standar umur laying")
|
||||
}
|
||||
if thresholdDay <= 0 {
|
||||
thresholdDay = commonSvc.DepreciationStartAgeDay(resolveHouseType(pfk))
|
||||
}
|
||||
if thresholdDay <= 0 {
|
||||
thresholdDay = commonSvc.DepreciationStartAgeDay("close_house")
|
||||
}
|
||||
|
||||
recordDate := normalizeDateOnlyUTC(recordTime)
|
||||
chickinDate := normalizeDateOnlyUTC(*sourceChickinDate)
|
||||
ageDay := commonSvc.FlockAgeDay(chickinDate, recordDate)
|
||||
if ageDay < thresholdDay {
|
||||
owner := sourceProjectFlockKandangID
|
||||
return &owner, nil
|
||||
}
|
||||
|
||||
owner := pfk.Id
|
||||
return &owner, nil
|
||||
}
|
||||
|
||||
func resolveHouseType(pfk *entity.ProjectFlockKandang) string {
|
||||
if pfk == nil || pfk.Kandang.HouseType == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*pfk.Kandang.HouseType)
|
||||
}
|
||||
|
||||
func (s *recordingService) resolveLayingDepreciationThresholdDay(ctx context.Context, pfk *entity.ProjectFlockKandang) (int, error) {
|
||||
houseType := commonSvc.NormalizeDepreciationHouseType(resolveHouseType(pfk))
|
||||
if houseType == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
StandardWeek int `gorm:"column:standard_week"`
|
||||
}
|
||||
err := s.Repository.DB().WithContext(ctx).
|
||||
Table("house_depreciation_standards").
|
||||
Select("standard_week").
|
||||
Where("house_type::text = ?", houseType).
|
||||
Where("standard_week > 0").
|
||||
Order("effective_date DESC NULLS LAST").
|
||||
Order("id DESC").
|
||||
Limit(1).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if row.StandardWeek <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return (row.StandardWeek * 7) + 1, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) resolveTransferSourceProjectFlockKandangID(ctx context.Context, transfer *entity.LayingTransfer) (uint, error) {
|
||||
if transfer == nil || transfer.Id == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if transfer.SourceProjectFlockKandangId != nil && *transfer.SourceProjectFlockKandangId != 0 {
|
||||
return *transfer.SourceProjectFlockKandangId, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
SourceProjectFlockKandangID uint `gorm:"column:source_project_flock_kandang_id"`
|
||||
}
|
||||
err := s.Repository.DB().WithContext(ctx).
|
||||
Table("laying_transfer_sources").
|
||||
Select("source_project_flock_kandang_id").
|
||||
Where("laying_transfer_id = ?", transfer.Id).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("source_project_flock_kandang_id > 0").
|
||||
Order("id ASC").
|
||||
Limit(1).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.SourceProjectFlockKandangID, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) getEarliestChickInDateByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (*time.Time, error) {
|
||||
if projectFlockKandangID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
ChickInDate *time.Time `gorm:"column:chick_in_date"`
|
||||
}
|
||||
err := s.Repository.DB().WithContext(ctx).
|
||||
Table("project_chickins").
|
||||
Select("MIN(chick_in_date) AS chick_in_date").
|
||||
Where("project_flock_kandang_id = ?", projectFlockKandangID).
|
||||
Where("deleted_at IS NULL").
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row.ChickInDate, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) syncFarmDepreciationManualInputFromRecordingStocks(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
projectFlockKandangID uint,
|
||||
fallbackCutoverDate time.Time,
|
||||
) error {
|
||||
if projectFlockKandangID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetDB := s.Repository.DB()
|
||||
if tx != nil {
|
||||
targetDB = tx
|
||||
}
|
||||
|
||||
projectFlockID, err := s.resolveProjectFlockIDByProjectFlockKandangID(ctx, targetDB, projectFlockKandangID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if projectFlockID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
totalCost, err := s.sumNoTransferRecordingStockCostByProjectFlockID(ctx, targetDB, projectFlockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := s.getFarmDepreciationManualInputByProjectFlockID(ctx, targetDB, projectFlockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cutoverDate := normalizeDateOnlyUTC(fallbackCutoverDate)
|
||||
if existing != nil && !existing.CutoverDate.IsZero() {
|
||||
cutoverDate = normalizeDateOnlyUTC(existing.CutoverDate)
|
||||
}
|
||||
if cutoverDate.IsZero() {
|
||||
earliestDate, dateErr := s.getEarliestNoTransferRecordingDateByProjectFlockID(ctx, targetDB, projectFlockID)
|
||||
if dateErr != nil {
|
||||
return dateErr
|
||||
}
|
||||
if earliestDate != nil && !earliestDate.IsZero() {
|
||||
cutoverDate = normalizeDateOnlyUTC(*earliestDate)
|
||||
}
|
||||
}
|
||||
if cutoverDate.IsZero() {
|
||||
cutoverDate = normalizeDateOnlyUTC(time.Now().UTC())
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
row := entity.FarmDepreciationManualInput{
|
||||
ProjectFlockId: projectFlockID,
|
||||
TotalCost: totalCost,
|
||||
CutoverDate: cutoverDate,
|
||||
}
|
||||
if existing != nil {
|
||||
row.Note = existing.Note
|
||||
}
|
||||
|
||||
return targetDB.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "project_flock_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"total_cost": row.TotalCost,
|
||||
"cutover_date": row.CutoverDate,
|
||||
"updated_at": now,
|
||||
}),
|
||||
}).
|
||||
Create(&row).Error
|
||||
}
|
||||
|
||||
func (s *recordingService) resolveProjectFlockIDByProjectFlockKandangID(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (uint, error) {
|
||||
var row struct {
|
||||
ProjectFlockID uint `gorm:"column:project_flock_id"`
|
||||
}
|
||||
err := db.WithContext(ctx).
|
||||
Table("project_flock_kandangs").
|
||||
Select("project_flock_id").
|
||||
Where("id = ?", projectFlockKandangID).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return row.ProjectFlockID, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) sumNoTransferRecordingStockCostByProjectFlockID(ctx context.Context, db *gorm.DB, projectFlockID uint) (float64, error) {
|
||||
if projectFlockID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var total float64
|
||||
err := db.WithContext(ctx).
|
||||
Table("recording_stocks AS rs").
|
||||
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
|
||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
|
||||
Joins(
|
||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||
fifo.UsableKeyRecordingStock.String(),
|
||||
fifo.StockableKeyPurchaseItems.String(),
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
).
|
||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Where("pfk.project_flock_id = ?", projectFlockID).
|
||||
Where("rs.project_flock_kandang_id IS NULL").
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) getFarmDepreciationManualInputByProjectFlockID(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
projectFlockID uint,
|
||||
) (*entity.FarmDepreciationManualInput, error) {
|
||||
if projectFlockID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var row entity.FarmDepreciationManualInput
|
||||
err := db.WithContext(ctx).
|
||||
Where("project_flock_id = ?", projectFlockID).
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) getEarliestNoTransferRecordingDateByProjectFlockID(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
projectFlockID uint,
|
||||
) (*time.Time, error) {
|
||||
if projectFlockID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
RecordDate *time.Time `gorm:"column:record_date"`
|
||||
}
|
||||
err := db.WithContext(ctx).
|
||||
Table("recording_stocks AS rs").
|
||||
Select("MIN(r.record_datetime) AS record_date").
|
||||
Joins("JOIN recordings AS r ON r.id = rs.recording_id AND r.deleted_at IS NULL").
|
||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
|
||||
Where("pfk.project_flock_id = ?", projectFlockID).
|
||||
Where("rs.project_flock_kandang_id IS NULL").
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row.RecordDate, nil
|
||||
}
|
||||
|
||||
func (s *recordingService) resolveEggRequestsToFarmWarehouses(
|
||||
ctx context.Context,
|
||||
pfk *entity.ProjectFlockKandang,
|
||||
@@ -2588,6 +2878,7 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
||||
recordingID uint,
|
||||
existing []entity.RecordingStock,
|
||||
incoming []validation.Stock,
|
||||
ownerProjectFlockKandangID *uint,
|
||||
note string,
|
||||
actorID uint,
|
||||
) error {
|
||||
@@ -2613,6 +2904,7 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
||||
stock = entity.RecordingStock{
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
ProjectFlockKandangId: ownerProjectFlockKandangID,
|
||||
UsageQty: &zero,
|
||||
PendingQty: &zero,
|
||||
}
|
||||
@@ -2620,6 +2912,16 @@ func (s *recordingService) reflowSyncRecordingStocks(
|
||||
return err
|
||||
}
|
||||
}
|
||||
stock.ProjectFlockKandangId = ownerProjectFlockKandangID
|
||||
if stock.Id != 0 {
|
||||
if err := tx.Model(&entity.RecordingStock{}).
|
||||
Where("id = ?", stock.Id).
|
||||
Updates(map[string]any{
|
||||
"project_flock_kandang_id": ownerProjectFlockKandangID,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
desired := item.Qty
|
||||
stock.UsageQty = &desired
|
||||
|
||||
@@ -231,6 +231,9 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateTargetSourceLineage(c.Context(), sourceDetail.ProjectFlockKandangId, targetKandangIDs, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transferDate, err := utils.ParseDateString(req.TransferDate)
|
||||
if err != nil {
|
||||
@@ -451,6 +454,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateTargetSourceLineage(c.Context(), sourceDetail.ProjectFlockKandangId, targetKandangIDs, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transferDate, err := time.Parse("2006-01-02", req.TransferDate)
|
||||
if err != nil {
|
||||
@@ -1611,6 +1617,80 @@ func (s *transferLayingService) validateKandangOwnership(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *transferLayingService) validateTargetSourceLineage(
|
||||
ctx context.Context,
|
||||
sourceProjectFlockKandangID uint,
|
||||
targetKandangIDs []uint,
|
||||
excludeTransferID uint,
|
||||
) error {
|
||||
if sourceProjectFlockKandangID == 0 || len(targetKandangIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[uint]struct{}, len(targetKandangIDs))
|
||||
for _, targetKandangID := range targetKandangIDs {
|
||||
if targetKandangID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[targetKandangID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[targetKandangID] = struct{}{}
|
||||
|
||||
existingTransfer, err := s.Repository.GetLatestApprovedByTargetKandang(ctx, targetKandangID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
s.Log.Errorf("Failed to validate transfer lineage for target kandang %d: %+v", targetKandangID, err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying")
|
||||
}
|
||||
if existingTransfer == nil {
|
||||
continue
|
||||
}
|
||||
if excludeTransferID != 0 && existingTransfer.Id == excludeTransferID {
|
||||
continue
|
||||
}
|
||||
|
||||
existingSourceID := uint(0)
|
||||
if existingTransfer.SourceProjectFlockKandangId != nil && *existingTransfer.SourceProjectFlockKandangId != 0 {
|
||||
existingSourceID = *existingTransfer.SourceProjectFlockKandangId
|
||||
}
|
||||
if existingSourceID == 0 && s.LayingTransferSourceRepo != nil {
|
||||
sources, sourceErr := s.LayingTransferSourceRepo.GetByLayingTransferId(ctx, existingTransfer.Id)
|
||||
if sourceErr != nil {
|
||||
s.Log.Errorf("Failed to resolve transfer sources for lineage validation transfer=%d: %+v", existingTransfer.Id, sourceErr)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi sumber transfer ke laying")
|
||||
}
|
||||
for _, source := range sources {
|
||||
if source.SourceProjectFlockKandangId != 0 {
|
||||
existingSourceID = source.SourceProjectFlockKandangId
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if existingSourceID == 0 {
|
||||
continue
|
||||
}
|
||||
if existingSourceID == sourceProjectFlockKandangID {
|
||||
continue
|
||||
}
|
||||
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf(
|
||||
"Kandang tujuan %d sudah memiliki lineage sumber kandang %d dari transfer %s. Tidak boleh ganti ke sumber kandang %d.",
|
||||
targetKandangID,
|
||||
existingSourceID,
|
||||
existingTransfer.TransferNumber,
|
||||
sourceProjectFlockKandangID,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) {
|
||||
|
||||
if err := commonSvc.EnsureRelations(c.Context(),
|
||||
|
||||
@@ -221,7 +221,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
|
||||
feedQuery := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select(`
|
||||
r.project_flock_kandangs_id AS project_flock_kandang_id,
|
||||
rs.project_flock_kandang_id AS project_flock_kandang_id,
|
||||
COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS feed_cost,
|
||||
s.id AS supplier_id,
|
||||
s.name AS supplier_name,
|
||||
@@ -233,10 +233,10 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
|
||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
|
||||
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
|
||||
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
|
||||
Where("rs.project_flock_kandang_id IN ?", projectFlockKandangIDs).
|
||||
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
|
||||
Where("f.name = ?", utils.FlagPakan).
|
||||
Group("r.project_flock_kandangs_id, s.id, s.name, s.alias")
|
||||
Group("rs.project_flock_kandang_id, s.id, s.name, s.alias")
|
||||
|
||||
if err := feedQuery.Scan(&feedRows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
|
||||
)
|
||||
|
||||
func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingStock {
|
||||
func MapStocks(recordingID uint, ownerProjectFlockKandangID *uint, items []validation.Stock) []entity.RecordingStock {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto
|
||||
result = append(result, entity.RecordingStock{
|
||||
RecordingId: recordingID,
|
||||
ProductWarehouseId: item.ProductWarehouseId,
|
||||
ProjectFlockKandangId: ownerProjectFlockKandangID,
|
||||
UsageQty: usagePtr,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user