Compare commits

..

7 Commits

Author SHA1 Message Date
giovanni 5e9286428f add response detail recording 2026-06-09 09:44:55 +07:00
Giovanni Gabriel Septriadi 540434e33b Merge branch 'development' into 'production'
Development

See merge request mbugroup/lti-api!612
2026-06-08 05:51:32 +00:00
Giovanni Gabriel Septriadi 0ebad48348 Merge branch 'fix/depretiatio-response' into 'development'
adjust total bobot laporan keuangan

See merge request mbugroup/lti-api!611
2026-06-08 05:39:09 +00:00
Giovanni Gabriel Septriadi 0a900986e7 Merge branch 'fix/depretiatio-response' into 'development'
adjust response depretitation v2

See merge request mbugroup/lti-api!610
2026-06-08 05:32:28 +00:00
Giovanni Gabriel Septriadi b3887b8d08 Merge branch 'hotfix/manual-inputs' into 'production'
fix data manual input; remove update manual input from crud recording

See merge request mbugroup/lti-api!608
2026-06-07 15:20:01 +00:00
Giovanni Gabriel Septriadi 2ddfa57aed Merge branch 'hotfix/manual-inputs' into 'development'
Hotfix/manual inputs

See merge request mbugroup/lti-api!609
2026-06-07 15:01:22 +00:00
giovanni 085d2f9bfe fix data manual input; remove update manual input from crud recording 2026-06-07 21:59:23 +07:00
6 changed files with 250 additions and 181 deletions
@@ -0,0 +1,14 @@
-- Reverse UPSERT: hapus baris PFK 47 & 48 yang kemungkinan baru diinsert oleh up migration ini.
-- Jika sebelumnya sudah ada (ON CONFLICT DO UPDATE), baris ini akan terhapus —
-- restore manual dari backup jika diperlukan.
DELETE FROM farm_depreciation_manual_inputs
WHERE project_flock_id IN (47, 48);
-- UPDATE rows untuk PFK 427 tidak bisa di-reverse secara presisi:
-- nilai total_cost sebelum migration ini tidak tersimpan di migration history
-- (data awal di-load via cmd/import-farm-depreciation-manual-inputs dari Excel).
-- PFK 10 dan 11 tidak berubah (nilai sama dengan state dari migration 20260529144559).
-- Jika perlu rollback penuh: restore dari database backup atau re-import Excel lama.
-- Recompute snapshots setelah rollback
TRUNCATE TABLE farm_depreciation_snapshots;
@@ -0,0 +1,105 @@
UPDATE farm_depreciation_manual_inputs
SET total_cost = 1900157533.55,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 10;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 146658321.066,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 13;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 51824694.138,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 17;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 15491774.796,
cutover_date = DATE '2026-02-28',
updated_at = NOW()
WHERE project_flock_id = 8;
-- Cutover 2026-02-28 (lanjutan)
UPDATE farm_depreciation_manual_inputs
SET total_cost = 575074391.36, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 4;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 578360642.51, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 5;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 880983605.92, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 6;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 391669576.153, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 9;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 2521797832.14, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 11;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 139227054.164, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 12;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 380083106.836, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 14;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 705136853.847, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 15;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 209816474.000, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 18;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 557606867.000, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 19;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 239330456.11, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 20;
UPDATE farm_depreciation_manual_inputs
SET total_cost = 4724203916.72, cutover_date = DATE '2026-02-28', updated_at = NOW()
WHERE project_flock_id = 26;
-- Cutover 2026-05-15
UPDATE farm_depreciation_manual_inputs
SET total_cost = 5449963647.43, cutover_date = DATE '2026-05-15', updated_at = NOW()
WHERE project_flock_id = 27;
-- Cutover 2026-06-08 (upsert — row mungkin belum ada)
INSERT INTO farm_depreciation_manual_inputs (project_flock_id, total_cost, cutover_date, created_at, updated_at)
VALUES (47, 5395429899.42, DATE '2026-06-08', NOW(), NOW())
ON CONFLICT (project_flock_id) DO UPDATE
SET total_cost = EXCLUDED.total_cost,
cutover_date = EXCLUDED.cutover_date,
updated_at = NOW();
-- Cutover 2026-06-16 (upsert — row mungkin belum ada)
INSERT INTO farm_depreciation_manual_inputs (project_flock_id, total_cost, cutover_date, created_at, updated_at)
VALUES (48, 5514616442.08, DATE '2026-06-16', NOW(), NOW())
ON CONFLICT (project_flock_id) DO UPDATE
SET total_cost = EXCLUDED.total_cost,
cutover_date = EXCLUDED.cutover_date,
updated_at = NOW();
-- Pengaman: pastikan snapshot di-recompute dengan total_cost baru
-- saat user request /api/reports/expense/depreciation
TRUNCATE TABLE farm_depreciation_snapshots;
+13
View File
@@ -7,7 +7,20 @@ type RecordingStock struct {
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id;index"`
UsageQty *float64 `gorm:"column:usage_qty"`
PendingQty *float64 `gorm:"column:pending_qty"`
TotalPrice float64 `gorm:"-"`
Allocations []RecordingStockAlloc `gorm:"-"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
}
type RecordingStockAlloc struct {
SourceType string
SourceId uint
PrNumber string
PoNumber string
AdjNumber string
Qty float64
UnitPrice float64
Subtotal float64
}
@@ -131,10 +131,23 @@ type RecordingDepletionDTO struct {
ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"`
}
type RecordingStockAllocDTO struct {
SourceType string `json:"source_type"`
SourceId uint `json:"source_id"`
PrNumber string `json:"pr_number"`
PoNumber string `json:"po_number"`
AdjNumber string `json:"adj_number"`
Qty float64 `json:"qty"`
UnitPrice float64 `json:"unit_price"`
Subtotal float64 `json:"subtotal"`
}
type RecordingStockDTO struct {
ProductWarehouseId uint `json:"product_warehouse_id"`
UsageAmount float64 `json:"usage_amount"`
PendingQty float64 `json:"pending_qty"`
TotalPrice float64 `json:"total_price"`
Allocations []RecordingStockAllocDTO `json:"allocations"`
ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"`
}
@@ -197,10 +210,26 @@ func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO {
pendingQty = *s.PendingQty
}
allocs := make([]RecordingStockAllocDTO, len(s.Allocations))
for j, a := range s.Allocations {
allocs[j] = RecordingStockAllocDTO{
SourceType: a.SourceType,
SourceId: a.SourceId,
PrNumber: a.PrNumber,
PoNumber: a.PoNumber,
AdjNumber: a.AdjNumber,
Qty: a.Qty,
UnitPrice: a.UnitPrice,
Subtotal: a.Subtotal,
}
}
result[i] = RecordingStockDTO{
ProductWarehouseId: s.ProductWarehouseId,
UsageAmount: usageAmount,
PendingQty: pendingQty,
TotalPrice: s.TotalPrice,
Allocations: allocs,
ProductWarehouse: mapProductWarehouseDTO(&s.ProductWarehouse),
}
}
@@ -80,6 +80,7 @@ type RecordingRepository interface {
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
GetProgressRows(ctx context.Context, startDate, endDate time.Time, allowedLocationIDs []uint, restrict bool) ([]exportprogress.Row, error)
GetStockAllocationsByIDs(ctx context.Context, stockIDs []uint) (map[uint][]entity.RecordingStockAlloc, error)
}
type RecordingRepositoryImpl struct {
@@ -1231,3 +1232,71 @@ func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectF
return result.TotalWeight, err
}
func (r *RecordingRepositoryImpl) GetStockAllocationsByIDs(ctx context.Context, stockIDs []uint) (map[uint][]entity.RecordingStockAlloc, error) {
if len(stockIDs) == 0 {
return map[uint][]entity.RecordingStockAlloc{}, nil
}
type row struct {
RecordingStockId uint
SourceType string
SourceId uint
PrNumber string
PoNumber string
AdjNumber string
Qty float64
UnitPrice float64
Subtotal float64
}
var rows []row
err := r.DB().WithContext(ctx).Raw(`
SELECT
sa.usable_id AS recording_stock_id,
sa.stockable_type AS source_type,
sa.stockable_id AS source_id,
COALESCE(p.pr_number, '') AS pr_number,
COALESCE(p.po_number, '') AS po_number,
COALESCE(ast.adj_number, '') AS adj_number,
sa.qty AS qty,
COALESCE(CASE
WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN pi.price
WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN ast.price
END, 0) AS unit_price,
sa.qty * COALESCE(CASE
WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN pi.price
WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN ast.price
END, 0) AS subtotal
FROM stock_allocations sa
LEFT JOIN purchase_items pi
ON pi.id = sa.stockable_id AND sa.stockable_type = 'PURCHASE_ITEMS'
LEFT JOIN purchases p
ON p.id = pi.purchase_id
LEFT JOIN adjustment_stocks ast
ON ast.id = sa.stockable_id AND sa.stockable_type = 'ADJUSTMENT_IN'
WHERE sa.usable_type = 'RECORDING_STOCK'
AND sa.usable_id IN ?
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
ORDER BY sa.usable_id, sa.id
`, stockIDs).Scan(&rows).Error
if err != nil {
return nil, err
}
result := make(map[uint][]entity.RecordingStockAlloc)
for _, row := range rows {
result[row.RecordingStockId] = append(result[row.RecordingStockId], entity.RecordingStockAlloc{
SourceType: row.SourceType,
SourceId: row.SourceId,
PrNumber: row.PrNumber,
PoNumber: row.PoNumber,
AdjNumber: row.AdjNumber,
Qty: row.Qty,
UnitPrice: row.UnitPrice,
Subtotal: row.Subtotal,
})
}
return result, nil
}
@@ -34,7 +34,6 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type RecordingService interface {
@@ -279,6 +278,26 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro
s.Log.Errorf("Failed get recording by id: %+v", err)
return nil, err
}
if len(recording.Stocks) > 0 {
stockIDs := make([]uint, len(recording.Stocks))
for i, s := range recording.Stocks {
stockIDs[i] = s.Id
}
if allocMap, err := s.Repository.GetStockAllocationsByIDs(c.Context(), stockIDs); err != nil {
s.Log.Warnf("Failed to get stock allocations for recording %d: %+v", id, err)
} else {
for i := range recording.Stocks {
allocs := allocMap[recording.Stocks[i].Id]
recording.Stocks[i].Allocations = allocs
var total float64
for _, a := range allocs {
total += a.Subtotal
}
recording.Stocks[i].TotalPrice = total
}
}
}
if err := recordingutil.AttachLatestApproval(c.Context(), recording, s.ApprovalSvc, s.Log); err != nil {
return nil, err
}
@@ -586,10 +605,6 @@ 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 {
@@ -892,12 +907,6 @@ 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
@@ -1159,10 +1168,6 @@ 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
@@ -1949,172 +1954,6 @@ func (s *recordingService) getEarliestChickInDateByProjectFlockKandangID(ctx con
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,