Merge branch 'development' into dev/fifo-v2

This commit is contained in:
giovanni
2026-02-20 13:36:40 +07:00
23 changed files with 1985 additions and 892 deletions
@@ -139,12 +139,11 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date). Where("r.record_datetime <= ?", *date).
Where("f.name IN ?", flags). 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 Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -0,0 +1,12 @@
BEGIN;
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS chk_recording_depletions_pending_zero;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS total_used_qty;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS usage_qty;
COMMIT;
@@ -0,0 +1,15 @@
BEGIN;
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS usage_qty numeric(15, 3) NOT NULL DEFAULT 0;
UPDATE recording_depletions
SET pending_qty = 0
WHERE pending_qty IS NULL OR pending_qty <> 0;
ALTER TABLE recording_depletions
ADD CONSTRAINT chk_recording_depletions_pending_zero
CHECK (pending_qty = 0);
COMMIT;
+1
View File
@@ -6,6 +6,7 @@ type RecordingDepletion struct {
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
Qty float64 `gorm:"column:qty;not null"` Qty float64 `gorm:"column:qty;not null"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"` PendingQty float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
@@ -347,12 +347,12 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
} }
result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag) result, productFlags, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
if err != nil { if err != nil {
return err return err
} }
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag) payload := dto.ToSapronakProjectAggregatedFromReports(result, flag, productFlags)
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{ JSON(response.Success{
@@ -377,12 +377,12 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
} }
result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag) result, productFlags, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
if err != nil { if err != nil {
return err return err
} }
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag) payload := dto.ToSapronakProjectAggregatedFromReport(result, flag, productFlags)
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{ JSON(response.Success{
@@ -1,6 +1,7 @@
package dto package dto
import ( import (
"sort"
"strings" "strings"
"time" "time"
) )
@@ -64,7 +65,7 @@ type SapronakCategoryRowDTO struct {
QtyOut float64 `json:"qty_out"` QtyOut float64 `json:"qty_out"`
QtyUsed float64 `json:"qty_used"` QtyUsed float64 `json:"qty_used"`
Description string `json:"description"` Description string `json:"description"`
ProductCategory string `json:"product_category"` ProductCategory []string `json:"product_category"`
UnitPrice float64 `json:"unit_price"` UnitPrice float64 `json:"unit_price"`
TotalAmount float64 `json:"total_amount"` TotalAmount float64 `json:"total_amount"`
Notes string `json:"notes"` Notes string `json:"notes"`
@@ -127,7 +128,7 @@ type UomSummaryDTO struct {
// === Mapper Functions for Aggregated Sapronak Response === // === Mapper Functions for Aggregated Sapronak Response ===
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{} result := SapronakProjectAggregatedDTO{}
if len(reports) == 0 { if len(reports) == 0 {
@@ -135,10 +136,10 @@ func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag st
} }
rep := reports[0] rep := reports[0]
return ToSapronakProjectAggregatedFromReport(&rep, flag) return ToSapronakProjectAggregatedFromReport(&rep, flag, productFlags)
} }
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{} result := SapronakProjectAggregatedDTO{}
if report == nil { if report == nil {
@@ -175,6 +176,53 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
return t.Format("02-Jan-2006") return t.Format("02-Jan-2006")
} }
flagOrder := map[string]int{
"DOC": 0,
"PAKAN": 0,
"OVK": 0,
"PULLET": 0,
}
buildFlagList := func(productID uint, fallback string) []string {
rawFlags := productFlags[productID]
if len(rawFlags) == 0 {
if fallback == "" {
return []string{}
}
return []string{fallback}
}
seen := make(map[string]struct{}, len(rawFlags))
ordered := make([]string, 0, len(rawFlags))
for _, f := range rawFlags {
flagName := strings.ToUpper(strings.TrimSpace(f))
if flagName == "" {
continue
}
if _, ok := seen[flagName]; ok {
continue
}
seen[flagName] = struct{}{}
ordered = append(ordered, flagName)
}
sort.SliceStable(ordered, func(i, j int) bool {
li := ordered[i]
lj := ordered[j]
ri, iok := flagOrder[li]
rj, jok := flagOrder[lj]
if iok != jok {
if iok {
return true
}
return false
}
if iok && jok && ri != rj {
return ri < rj
}
return li < lj
})
return ordered
}
for _, group := range report.Groups { for _, group := range report.Groups {
flagKey := normalizeFlag(group.Flag) flagKey := normalizeFlag(group.Flag)
ptr := byFlag[flagKey] ptr := byFlag[flagKey]
@@ -206,7 +254,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
Date: formatDate(item.Tanggal), Date: formatDate(item.Tanggal),
ReferenceNumber: item.NoReferensi, ReferenceNumber: item.NoReferensi,
Description: item.ProductName, Description: item.ProductName,
ProductCategory: item.ProductName, ProductCategory: buildFlagList(item.ProductID, flagKey),
UnitPrice: item.Harga, UnitPrice: item.Harga,
Notes: "-", Notes: "-",
} }
@@ -991,8 +991,8 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
query := r.withCtx(ctx). query := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
pw.product_id AS product_id, p_resolve.id AS product_id,
p.name AS product_name, p_resolve.name AS product_name,
f.name AS flag, f.name AS flag,
COALESCE( COALESCE(
pi.received_date, pi.received_date,
@@ -1013,10 +1013,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
) AS reference, ) AS reference,
0 AS qty_in, 0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out, COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p.product_price, 0) AS price COALESCE(pi.price, p_resolve.product_price, 0) AS price
`). `).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()). Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()).
Joins("LEFT JOIN recordings r ON r.id = rs.recording_id"). Joins("LEFT JOIN recordings r ON r.id = rs.recording_id").
Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
@@ -1026,9 +1025,11 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)").
Where("sa.status = ?", entity.StockAllocationStatusActive). Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
@@ -1040,12 +1041,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C
fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, fifo.UsableKeyRecordingStock.String(), projectFlockKandangID,
fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, fifo.UsableKeyProjectChickin.String(), projectFlockKandangID,
) )
query = r.joinSapronakProductFlag(query, "p"). query = r.joinSapronakProductFlag(query, "p_resolve").
Group(` Group(`
pw.product_id, p.name, f.name, p_resolve.id, p_resolve.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime, pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime,
po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id, po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id,
pi.price, p.product_price pi.price, p_resolve.product_price
`) `)
return scanAndGroupDetails(query) return scanAndGroupDetails(query)
@@ -294,6 +294,9 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo
func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection { func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection {
actualPopulation := production.TotalPopulationIn - production.TotalDepletion actualPopulation := production.TotalPopulationIn - production.TotalDepletion
if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok {
actualPopulation = lastPopulation
}
totalWeightProduced := production.TotalWeightProduced totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg totalEggWeightKg := production.TotalEggWeightKg
@@ -529,6 +532,35 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
return dto.ToProfitLossSection(plItems, plSummary) return dto.ToProfitLossSection(plItems, plSummary)
} }
func (s closingKeuanganService) getLastPopulationFromRecordings(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) (float64, bool) {
if s.RecordingRepo == nil || len(projectFlockKandangs) == 0 {
return 0, false
}
total := 0.0
recordedCount := 0
for _, kandang := range projectFlockKandangs {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(c.Context(), kandang.Id)
if err != nil {
s.Log.Errorf("Failed to fetch latest recording for project_flock_kandang_id=%d: %+v", kandang.Id, err)
return 0, false
}
if latest == nil || latest.TotalChickQty == nil {
continue
}
recordedCount++
if *latest.TotalChickQty > 0 {
total += *latest.TotalChickQty
}
}
if recordedCount != len(projectFlockKandangs) {
return 0, false
}
return total, true
}
func containsFlag(flags []entity.Flag, name string) bool { func containsFlag(flags []entity.Flag, name string) bool {
for _, flag := range flags { for _, flag := range flags {
if flag.Name == name { if flag.Name == name {
@@ -18,8 +18,8 @@ import (
) )
type SapronakService interface { type SapronakService interface {
GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error)
GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error)
} }
type sapronakService struct { type sapronakService struct {
@@ -42,9 +42,9 @@ func NewSapronakService(
} }
} }
func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) { func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) {
if projectFlockID == 0 { if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required")
} }
reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
ProjectFlockID: projectFlockID, ProjectFlockID: projectFlockID,
@@ -52,19 +52,27 @@ func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint,
Flag: flag, Flag: flag,
}) })
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
if len(reports) <= 1 { if len(reports) <= 1 {
return reports, nil flags, err := s.collectProductFlags(c.Context(), reports)
if err != nil {
return nil, nil, err
}
return reports, flags, nil
} }
combined := s.combineSapronakReports(reports, projectFlockID) combined := s.combineSapronakReports(reports, projectFlockID)
return []dto.SapronakReportDTO{combined}, nil flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{combined})
if err != nil {
return nil, nil, err
}
return []dto.SapronakReportDTO{combined}, flags, nil
} }
func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) { func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) {
if projectFlockID == 0 || pfkID == 0 { if projectFlockID == 0 || pfkID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required")
} }
results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
@@ -74,16 +82,20 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint,
Flag: flag, Flag: flag,
}) })
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
for _, res := range results { for _, res := range results {
if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID { if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID {
return &res, nil flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{res})
if err != nil {
return nil, nil, err
}
return &res, flags, nil
} }
} }
return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") return nil, nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found")
} }
func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) {
@@ -136,6 +148,52 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
return results, nil return results, nil
} }
func (s sapronakService) collectProductFlags(ctx context.Context, reports []dto.SapronakReportDTO) (map[uint][]string, error) {
productIDs := make(map[uint]struct{})
for _, report := range reports {
for _, group := range report.Groups {
for _, item := range group.Items {
if item.ProductID > 0 {
productIDs[item.ProductID] = struct{}{}
}
}
}
}
if len(productIDs) == 0 {
return map[uint][]string{}, nil
}
ids := make([]uint, 0, len(productIDs))
for id := range productIDs {
ids = append(ids, id)
}
products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, ids)
if err != nil {
return nil, err
}
result := make(map[uint][]string, len(products))
for _, product := range products {
if len(product.Flags) == 0 {
continue
}
flags := make([]string, 0, len(product.Flags))
for _, flag := range product.Flags {
name := strings.TrimSpace(flag.Name)
if name == "" {
continue
}
flags = append(flags, strings.ToUpper(name))
}
if len(flags) > 0 {
result[product.Id] = flags
}
}
return result, nil
}
func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) {
db := s.ProjectFlockKandangRepo.DB().WithContext(ctx). db := s.ProjectFlockKandangRepo.DB().WithContext(ctx).
Preload("ProjectFlock"). Preload("ProjectFlock").
@@ -483,7 +483,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
} }
if len(stockLogs) > 0 { if len(stockLogs) > 0 {
latestStockLog := stockLogs[0] latestStockLog := stockLogs[0]
stockLogDecrease.Stock -= latestStockLog.Stock - stockLogDecrease.Decrease stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else { } else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease stockLogDecrease.Stock -= stockLogDecrease.Decrease
} }
@@ -36,6 +36,7 @@ type ChickinService interface {
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectChickin, error)
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error)
EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error
} }
type chickinService struct { type chickinService struct {
@@ -731,6 +732,30 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
return nil return nil
} }
func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in")
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui sehingga belum dapat membuat recording")
}
for _, population := range populations {
if population.TotalQty > 0 {
return nil
}
}
return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording")
}
func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error {
if len(deltas) == 0 { if len(deltas) == 0 {
return nil return nil
@@ -13,6 +13,7 @@ import (
type ProjectFlockKandangRepository interface { type ProjectFlockKandangRepository interface {
GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error)
GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error)
GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error)
GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error)
UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error
@@ -342,6 +343,17 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint
return record, nil return record, nil
} }
// GetByIDLight loads only the minimal relations needed for recording flows.
func (r *projectFlockKandangRepositoryImpl) GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) {
record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx).
Preload("ProjectFlock").
First(record, id).Error; err != nil {
return nil, err
}
return record, nil
}
func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) {
record := new(entity.ProjectFlockKandang) record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
@@ -48,6 +48,7 @@ type ProjectflockService interface {
GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error)
EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error
} }
type projectflockService struct { type projectflockService struct {
@@ -112,6 +113,32 @@ func (s projectflockService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
} }
} }
func (s projectflockService) EnsureProjectFlockApproved(ctx context.Context, projectFlockID uint) error {
if projectFlockID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
approvalSvc := s.ApprovalSvc
if approvalSvc == nil {
approvalSvc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.Repository.DB()))
}
latest, err := approvalSvc.LatestByTarget(ctx, s.approvalWorkflow, projectFlockID, nil)
if err != nil {
s.Log.Errorf("Failed to check project flock %d approval status: %+v", projectFlockID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa status project flock")
}
if latest == nil {
return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording")
}
if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved {
return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording")
}
return nil
}
func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) { func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, nil, err return nil, 0, nil, err
@@ -459,6 +486,15 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
} }
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population")
}
if total > 0 {
return total, nil
}
if s.RecordingRepo != nil { if s.RecordingRepo != nil {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil { if err != nil {
@@ -470,12 +506,6 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr
} }
} }
total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population")
}
return total, nil return total, nil
} }
@@ -550,21 +580,22 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt
} }
func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) {
if s.PopulationRepo == nil {
return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured")
}
wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID) pfk, err := s.PivotRepo.GetActiveByKandangID(ctx.Context(), kandangID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found")
}
return 0, err return 0, err
} }
productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(ctx.Context(), "DOC", wh.Id) total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), pfk.Id)
if err != nil { if err != nil {
return 0, err return 0, err
} }
total := 0.0
for _, pw := range productWarehouses {
total += pw.Quantity
}
return total, nil return total, nil
} }
@@ -27,9 +27,14 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin
func (u *RecordingController) GetAll(c *fiber.Ctx) error { func (u *RecordingController) GetAll(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0) projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10)
offset := (page - 1) * limit
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: page,
Limit: c.QueryInt("limit", 10), Limit: limit,
Offset: offset,
Search: c.Query("search"), Search: c.Query("search"),
} }
if projectFlockID > 0 { if projectFlockID > 0 {
@@ -79,25 +84,27 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error {
} }
func (u *RecordingController) GetNextDay(c *fiber.Ctx) error { func (u *RecordingController) GetNextDay(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0) req := new(validation.GetRecordingNextDay)
if projectFlockID <= 0 {
if err := c.QueryParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid query params")
}
if req.ProjectFlockKandangId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
} }
if req.RecordTime == nil || strings.TrimSpace(*req.RecordTime) == "" {
recordTime := time.Now().UTC() return fiber.NewError(fiber.StatusBadRequest, "record_date is required")
if recordDate := strings.TrimSpace(c.Query("record_date")); recordDate != "" {
parsed, err := time.Parse("2006-01-02", recordDate)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format")
}
recordTime = parsed.UTC()
} }
recordTime, err := time.Parse("2006-01-02", strings.TrimSpace(*req.RecordTime))
nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID), recordTime) if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_time must be in YYYY-MM-DD format")
}
req.RecordTimeValue = &recordTime
nextDay, err := u.RecordingService.GetNextDay(c, req)
if err != nil { if err != nil {
return err return err
} }
projectFlockID := req.ProjectFlockKandangId
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{ JSON(response.Success{
Code: fiber.StatusOK, Code: fiber.StatusOK,
@@ -11,9 +11,17 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services"
rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
@@ -28,9 +36,18 @@ type RecordingModule struct{}
func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
recordingRepo := rRecording.NewRecordingRepository(db) recordingRepo := rRecording.NewRecordingRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
flockRepo := rFlock.NewFlockRepository(db)
kandangRepo := rKandang.NewKandangRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
nonstockRepo := rNonstock.NewNonstockRepository(db)
productRepo := rProduct.NewProductRepository(db)
chickinRepo := rChickin.NewChickinRepository(db)
chickinDetailRepo := rChickin.NewChickinDetailRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
@@ -53,14 +70,30 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
ProductWarehouseID: "product_warehouse_id", ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty", TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used", TotalUsedQuantity: "total_used",
CreatedAt: "created_at", CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id)",
}, },
OrderBy: []string{"created_at ASC", "id ASC"}, OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id) ASC", "id ASC"},
}); err != nil { }); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") { if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err)) panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err))
} }
} }
if err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyRecordingDepletion,
Table: "recording_depletions",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "qty",
TotalUsedQuantity: "total_used_qty",
CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)",
},
OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id) ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording depletion stockable workflow: %v", err))
}
}
if err := fifoService.RegisterUsable(fifo.UsableConfig{ if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingStock, Key: fifo.UsableKeyRecordingStock,
Table: "recording_stocks", Table: "recording_stocks",
@@ -69,7 +102,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
ProductWarehouseID: "product_warehouse_id", ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty", UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty", PendingQuantity: "pending_qty",
CreatedAt: "id", CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_stocks.recording_id)",
}, },
}); err != nil { }); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") { if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
@@ -82,9 +115,9 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
Columns: fifo.UsableColumns{ Columns: fifo.UsableColumns{
ID: "id", ID: "id",
ProductWarehouseID: "source_product_warehouse_id", ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "qty", UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty", PendingQuantity: "pending_qty",
CreatedAt: "id", CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)",
}, },
ExcludedStockables: []fifo.StockableKey{ ExcludedStockables: []fifo.StockableKey{
fifo.StockableKeyTransferToLayingIn, fifo.StockableKeyTransferToLayingIn,
@@ -104,9 +137,41 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil { if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register recording approval workflow: %v", err)) panic(fmt.Sprintf("failed to register recording approval workflow: %v", err))
} }
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlock, utils.ProjectFlockApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err))
}
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
projectFlockService := sProjectFlock.NewProjectflockService(
projectFlockRepo,
flockRepo,
kandangRepo,
projectFlockKandangRepo,
warehouseRepo,
productWarehouseRepo,
projectBudgetRepo,
nonstockRepo,
projectFlockPopulationRepo,
recordingRepo,
approvalService,
validate,
)
chickinService := sChickin.NewChickinService(
chickinRepo,
kandangRepo,
warehouseRepo,
productWarehouseRepo,
productRepo,
projectFlockRepo,
projectFlockKandangRepo,
projectFlockPopulationRepo,
chickinDetailRepo,
validate,
fifoService,
)
recordingService := sRecording.NewRecordingService( recordingService := sRecording.NewRecordingService(
recordingRepo, recordingRepo,
projectFlockKandangRepo, projectFlockKandangRepo,
@@ -117,6 +182,8 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
fifoService, fifoService,
stockLogRepo, stockLogRepo,
productionStandardService, productionStandardService,
projectFlockService,
chickinService,
validate, validate,
) )
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -17,8 +17,13 @@ type RecordingRepository interface {
repository.BaseRepository[entity.Recording] repository.BaseRepository[entity.Recording]
WithRelations(db *gorm.DB) *gorm.DB WithRelations(db *gorm.DB) *gorm.DB
WithRelationsList(db *gorm.DB) *gorm.DB
ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB
ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB
GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error)
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error)
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
@@ -40,9 +45,13 @@ type RecordingRepository interface {
SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error)
GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error)
GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error)
GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error) GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error)
FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error)
GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error)
GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error)
GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error)
GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error)
GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error)
GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error)
GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error)
@@ -55,6 +64,8 @@ type RecordingRepository interface {
GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error)
GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error)
GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error)
ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
} }
type RecordingRepositoryImpl struct { type RecordingRepositoryImpl struct {
@@ -113,6 +124,64 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("Eggs.ProductWarehouse.Warehouse.Location") Preload("Eggs.ProductWarehouse.Warehouse.Location")
} }
func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.Kandang").
Preload("ProjectFlockKandang.Kandang.Location").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard")
}
func (r *RecordingRepositoryImpl) ApplyListFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB {
db = r.WithRelationsList(db)
db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
if projectFlockKandangId != 0 {
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
}
db = r.ApplySearchFilters(db, search)
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
}
func (r *RecordingRepositoryImpl) ApplyListCountFilters(db *gorm.DB, search string, projectFlockKandangId uint) *gorm.DB {
db = db.
Joins("JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id")
if projectFlockKandangId != 0 {
db = db.Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId)
}
db = r.ApplySearchFilters(db, search)
return db
}
func (r *RecordingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, search string, projectFlockKandangId uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Recording, int64, error) {
var (
records []entity.Recording
total int64
)
countQ := r.ApplyListCountFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId)
if modifier != nil {
countQ = modifier(countQ)
}
if err := countQ.Count(&total).Error; err != nil {
return nil, 0, err
}
listQ := r.ApplyListFilters(r.DB().WithContext(ctx).Model(&entity.Recording{}), search, projectFlockKandangId)
if modifier != nil {
listQ = modifier(listQ)
}
if err := listQ.Offset(offset).Limit(limit).Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}
func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB { func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB {
normalized := strings.ToLower(strings.TrimSpace(rawSearch)) normalized := strings.ToLower(strings.TrimSpace(rawSearch))
if normalized == "" { if normalized == "" {
@@ -170,6 +239,27 @@ func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.C
return &record, nil return &record, nil
} }
func (r *RecordingRepositoryImpl) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) {
if projectFlockKandangId == 0 {
return nil, errors.New("project_flock_kandang_id is required")
}
db := tx.WithContext(ctx).
Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangId).
Where("deleted_at IS NULL")
if from != nil {
db = db.Where("record_datetime >= ?", *from)
}
var records []entity.Recording
if err := db.Order("record_datetime ASC").Order("created_at ASC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) {
var days []int var days []int
if err := tx.Model(&entity.Recording{}). if err := tx.Model(&entity.Recording{}).
@@ -332,6 +422,41 @@ func (r *RecordingRepositoryImpl) GetCumulativeDepletionByProjectFlockKandangUnt
return total, err return total, err
} }
func (r *RecordingRepositoryImpl) GetCumulativeDepletionByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]float64, error) {
result := make(map[uint]float64)
if len(recordingIDs) == 0 {
return result, nil
}
type row struct {
RecordingID uint `gorm:"column:recording_id"`
Total float64 `gorm:"column:total_qty"`
}
var rows []row
err := tx.
Table("recordings r").
Select("r.id AS recording_id, COALESCE(SUM(rd.qty), 0) AS total_qty").
Joins(`
LEFT JOIN recordings r2
ON r2.project_flock_kandangs_id = r.project_flock_kandangs_id
AND r2.record_datetime <= r.record_datetime
AND r2.deleted_at IS NULL`).
Joins("LEFT JOIN recording_depletions rd ON rd.recording_id = r2.id").
Where("r.id IN ?", recordingIDs).
Where("r.deleted_at IS NULL").
Group("r.id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
result[row.RecordingID] = row.Total
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error) { func (r *RecordingRepositoryImpl) GetUniformityMeanBwByWeek(tx *gorm.DB, projectFlockKandangId uint, week int) (float64, bool, error) {
if projectFlockKandangId == 0 || week <= 0 { if projectFlockKandangId == 0 || week <= 0 {
return 0, false, nil return 0, false, nil
@@ -381,6 +506,46 @@ func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFloc
return &prev, nil return &prev, nil
} }
func (r *RecordingRepositoryImpl) GetPreviousTotalChickByRecordingIDs(tx *gorm.DB, recordingIDs []uint) (map[uint]*float64, error) {
result := make(map[uint]*float64)
if len(recordingIDs) == 0 {
return result, nil
}
type row struct {
RecordingID uint `gorm:"column:recording_id"`
PrevTotalChickQty *float64 `gorm:"column:prev_total_chick_qty"`
}
var rows []row
err := tx.
Table("recordings r").
Select(`
r.id AS recording_id,
(
SELECT r2.total_chick_qty
FROM recordings r2
WHERE r2.project_flock_kandangs_id = r.project_flock_kandangs_id
AND r2.day IS NOT NULL
AND r.day IS NOT NULL
AND r2.day < r.day
AND r2.deleted_at IS NULL
ORDER BY r2.day DESC
LIMIT 1
) AS prev_total_chick_qty`).
Where("r.id IN ?", recordingIDs).
Where("r.deleted_at IS NULL").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
result[row.RecordingID] = row.PrevTotalChickQty
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) { func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) {
var total float64 var total float64
err := tx. err := tx.
@@ -400,6 +565,57 @@ func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandang
return int64(math.Round(total)), nil return int64(math.Round(total)), nil
} }
func (r *RecordingRepositoryImpl) GetRemainingPopulationByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) {
var total float64
err := tx.
Table("project_flock_populations").
Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
if err != nil {
return 0, err
}
if total < 0 {
total = 0
}
return total, nil
}
func (r *RecordingRepositoryImpl) GetTotalChickByProjectFlockKandangIDs(tx *gorm.DB, projectFlockKandangIds []uint) (map[uint]int64, error) {
result := make(map[uint]int64)
if len(projectFlockKandangIds) == 0 {
return result, nil
}
type row struct {
ProjectFlockKandangId uint `gorm:"column:project_flock_kandang_id"`
Total float64 `gorm:"column:total_qty"`
}
var rows []row
err := tx.
Table("project_flock_populations pfp").
Select("project_chickins.project_flock_kandang_id, COALESCE(SUM(pfp.total_qty - pfp.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = pfp.project_chickin_id").
Where("project_chickins.project_flock_kandang_id IN ?", projectFlockKandangIds).
Group("project_chickins.project_flock_kandang_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
total := math.Round(row.Total)
if total < 0 {
total = 0
}
result[row.ProjectFlockKandangId] = int64(total)
}
return result, nil
}
func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) { func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) {
if projectFlockKandangId == 0 { if projectFlockKandangId == 0 {
return 0, nil return 0, nil
@@ -585,6 +801,82 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID
return result, nil return result, nil
} }
func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return nil
}
idsSubquery := `
SELECT pfp.id
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
`
updateWithAlloc := `
UPDATE project_flock_populations p
SET total_used_qty = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE stockable_type = 'PROJECT_FLOCK_POPULATION'
AND status = 'ACTIVE'
GROUP BY stockable_id
) a
WHERE p.id = a.stockable_id
AND p.id IN (` + idsSubquery + `)
`
resetMissing := `
UPDATE project_flock_populations p
SET total_used_qty = 0
WHERE p.id IN (` + idsSubquery + `)
AND NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.stockable_id = p.id
)
`
db := r.DB().WithContext(ctx)
if tx != nil {
db = tx.WithContext(ctx)
}
if err := db.Exec(updateWithAlloc, projectFlockKandangID).Error; err != nil {
return err
}
if err := db.Exec(resetMissing, projectFlockKandangID).Error; err != nil {
return err
}
return nil
}
func (r *RecordingRepositoryImpl) ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error) {
if len(ids) == 0 {
return 0, nil
}
var invalidIDs []uint
if err := r.DB().WithContext(ctx).
Table("product_warehouses pw").
Where("pw.id IN ?", ids).
Where(`NOT EXISTS (
SELECT 1 FROM flags f
WHERE f.flagable_type = 'products'
AND f.flagable_id = pw.product_id
AND UPPER(f.name) IN ?
)`, flags).
Pluck("pw.id", &invalidIDs).Error; err != nil {
return 0, err
}
if len(invalidIDs) > 0 {
return invalidIDs[0], nil
}
return 0, nil
}
func nextRecordingDay(days []int) int { func nextRecordingDay(days []int) int {
if len(days) == 0 { if len(days) == 0 {
return 1 return 1
File diff suppressed because it is too large Load Diff
@@ -3,43 +3,92 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"math"
"strings" "strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
) )
type RecordingFIFOIntegrationService interface {
ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
}
var recordingStockUsableKey = fifo.UsableKeyRecordingStock var recordingStockUsableKey = fifo.UsableKeyRecordingStock
var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion
func NewRecordingFIFOIntegrationService( const depletionUsageTolerance = 0.000001
repo repository.RecordingRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, func (s *recordingService) logStockTrace(action string, stock entity.RecordingStock, extra string) {
fifoSvc commonSvc.FifoService, if s == nil || s.Log == nil {
stockLogRepo rStockLogs.StockLogRepository, return
) RecordingFIFOIntegrationService {
return &recordingService{
Log: utils.Log,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
} }
usage := 0.0
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
pending := 0.0
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
s.Log.Infof(
"[recording-stock] action=%s recording_id=%d stock_id=%d pw=%d usage=%.3f pending=%.3f %s",
action,
stock.RecordingId,
stock.Id,
stock.ProductWarehouseId,
usage,
pending,
extra,
)
}
func (s *recordingService) logEggTrace(action string, egg entity.RecordingEgg, extra string) {
if s == nil || s.Log == nil {
return
}
weight := 0.0
if egg.Weight != nil {
weight = *egg.Weight
}
s.Log.Infof(
"[recording-egg] action=%s recording_id=%d egg_id=%d pw=%d qty=%d weight=%.3f total_qty=%.3f total_used=%.3f %s",
action,
egg.RecordingId,
egg.Id,
egg.ProductWarehouseId,
egg.Qty,
weight,
egg.TotalQty,
egg.TotalUsed,
extra,
)
}
func (s *recordingService) logDepletionTrace(action string, dep entity.RecordingDepletion, extra string) {
if s == nil || s.Log == nil {
return
}
sourceWarehouseID := uint(0)
if dep.SourceProductWarehouseId != nil {
sourceWarehouseID = *dep.SourceProductWarehouseId
}
s.Log.Infof(
"[recording-depletion] action=%s recording_id=%d depletion_id=%d source_pw=%d dest_pw=%d qty=%.3f usage=%.3f pending=%.3f %s",
action,
dep.RecordingId,
dep.Id,
sourceWarehouseID,
dep.ProductWarehouseId,
dep.Qty,
dep.UsageQty,
dep.PendingQty,
extra,
)
} }
func (s *recordingService) consumeRecordingStocks( func (s *recordingService) consumeRecordingStocks(
@@ -49,9 +98,13 @@ func (s *recordingService) consumeRecordingStocks(
note string, note string,
actorID uint, actorID uint,
) error { ) error {
if len(stocks) == 0 || s.FifoSvc == nil { if len(stocks) == 0 {
return nil return nil
} }
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for consuming recording stocks")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available") return errors.New("stock log repository is not available")
} }
@@ -60,6 +113,7 @@ func (s *recordingService) consumeRecordingStocks(
if stock.Id == 0 { if stock.Id == 0 {
continue continue
} }
s.logStockTrace("consume:start", stock, "")
var desired float64 var desired float64
if stock.UsageQty != nil { if stock.UsageQty != nil {
@@ -87,6 +141,7 @@ func (s *recordingService) consumeRecordingStocks(
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err return err
} }
s.logStockTrace("consume:done", stock, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, result.UsageQuantity, result.PendingQuantity))
logDecrease := result.UsageQuantity logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 { if result.PendingQuantity > 0 {
@@ -129,9 +184,13 @@ func (s *recordingService) consumeRecordingDepletions(
note string, note string,
actorID uint, actorID uint,
) error { ) error {
if len(depletions) == 0 || s.FifoSvc == nil { if len(depletions) == 0 {
return nil return nil
} }
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for consuming recording depletions")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available") return errors.New("stock log repository is not available")
} }
@@ -140,6 +199,7 @@ func (s *recordingService) consumeRecordingDepletions(
if depletion.Id == 0 { if depletion.Id == 0 {
continue continue
} }
s.logDepletionTrace("consume:start", depletion, "")
sourceWarehouseID := uint(0) sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil { if depletion.SourceProductWarehouseId != nil {
@@ -166,6 +226,7 @@ func (s *recordingService) consumeRecordingDepletions(
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil {
return err return err
} }
s.logDepletionTrace("consume:done", depletion, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, result.UsageQuantity, result.PendingQuantity))
logDecrease := result.UsageQuantity logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 { if result.PendingQuantity > 0 {
@@ -231,16 +292,6 @@ func (s *recordingService) consumeRecordingDepletions(
return nil return nil
} }
func (s *recordingService) ConsumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID)
}
func (s *recordingService) releaseRecordingStocks( func (s *recordingService) releaseRecordingStocks(
ctx context.Context, ctx context.Context,
tx *gorm.DB, tx *gorm.DB,
@@ -248,9 +299,13 @@ func (s *recordingService) releaseRecordingStocks(
note string, note string,
actorID uint, actorID uint,
) error { ) error {
if len(stocks) == 0 || s.FifoSvc == nil { if len(stocks) == 0 {
return nil return nil
} }
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for releasing recording stocks")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available") return errors.New("stock log repository is not available")
} }
@@ -259,6 +314,26 @@ func (s *recordingService) releaseRecordingStocks(
if stock.Id == 0 { if stock.Id == 0 {
continue continue
} }
if stock.UsageQty != nil && *stock.UsageQty > 0 {
activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id)
if err != nil {
return err
}
if activeCount == 0 {
s.Log.Warnf("recording-stock release: no active allocations, forcing usage/pending to 0 (stock_id=%d)", stock.Id)
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err
}
continue
}
if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil {
return err
}
if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil {
return err
}
}
s.logStockTrace("release:start", stock, "")
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingStockUsableKey, UsableKey: recordingStockUsableKey,
UsableID: stock.Id, UsableID: stock.Id,
@@ -271,6 +346,7 @@ func (s *recordingService) releaseRecordingStocks(
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err return err
} }
s.logStockTrace("release:done", stock, "")
if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{ log := &entity.StockLog{
@@ -309,9 +385,13 @@ func (s *recordingService) releaseRecordingDepletions(
note string, note string,
actorID uint, actorID uint,
) error { ) error {
if len(depletions) == 0 || s.FifoSvc == nil { if len(depletions) == 0 {
return nil return nil
} }
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for releasing recording depletions")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available") return errors.New("stock log repository is not available")
} }
@@ -320,6 +400,36 @@ func (s *recordingService) releaseRecordingDepletions(
if depletion.Id == 0 { if depletion.Id == 0 {
continue continue
} }
if depletion.UsageQty > 0 {
activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id)
if err != nil {
return err
}
if activeCount == 0 {
s.Log.Warnf("recording-depletion release: no active allocations, forcing usage/pending to 0 (depletion_id=%d)", depletion.Id)
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
return err
}
if err := tx.WithContext(ctx).
Table("recording_depletions").
Where("id = ?", depletion.Id).
Update("usage_qty", 0).Error; err != nil {
return err
}
continue
}
if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil {
return err
}
if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil {
return err
}
}
s.logDepletionTrace("release:start", depletion, "")
if err := validateDepletionUsage(depletion); err != nil {
s.Log.Errorf("FIFO depletion mismatch for recording %d (depletion %d): qty=%.3f usage=%.3f pending=%.3f", depletion.RecordingId, depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty)
return err
}
sourceWarehouseID := uint(0) sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil { if depletion.SourceProductWarehouseId != nil {
@@ -340,6 +450,7 @@ func (s *recordingService) releaseRecordingDepletions(
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
return err return err
} }
s.logDepletionTrace("release:done", depletion, "")
logIncrease := depletion.Qty logIncrease := depletion.Qty
if depletion.PendingQty > 0 { if depletion.PendingQty > 0 {
@@ -405,14 +516,15 @@ func (s *recordingService) releaseRecordingDepletions(
return nil return nil
} }
func (s *recordingService) ReleaseRecordingStocks( func validateDepletionUsage(depletion entity.RecordingDepletion) error {
ctx context.Context, desired := depletion.Qty + depletion.PendingQty
tx *gorm.DB, if math.Abs(depletion.UsageQty-desired) <= depletionUsageTolerance {
stocks []entity.RecordingStock, return nil
note string, }
actorID uint, return fiber.NewError(
) error { fiber.StatusConflict,
return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID) fmt.Sprintf("FIFO depletion mismatch (id=%d): qty=%.3f usage=%.3f pending=%.3f", depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty),
)
} }
func (s *recordingService) logRecordingEggUsage( func (s *recordingService) logRecordingEggUsage(
@@ -503,9 +615,13 @@ func (s *recordingService) replenishRecordingEggs(
note string, note string,
actorID uint, actorID uint,
) error { ) error {
if len(eggs) == 0 || s.FifoSvc == nil { if len(eggs) == 0 {
return nil return nil
} }
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for replenishing recording eggs")
return errors.New("fifo service is not available")
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available") return errors.New("stock log repository is not available")
} }
@@ -514,6 +630,7 @@ func (s *recordingService) replenishRecordingEggs(
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue continue
} }
s.logEggTrace("replenish:start", egg, "")
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingEgg, StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id, StockableID: egg.Id,
@@ -524,6 +641,7 @@ func (s *recordingService) replenishRecordingEggs(
s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err)
return err return err
} }
s.logEggTrace("replenish:done", egg, "")
if strings.TrimSpace(note) != "" && actorID != 0 { if strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{ log := &entity.StockLog{
@@ -555,6 +673,210 @@ func (s *recordingService) replenishRecordingEggs(
return nil return nil
} }
func (s *recordingService) replenishRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
) error {
if len(depletions) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for replenishing recording depletions")
return errors.New("fifo service is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 {
continue
}
s.logDepletionTrace("replenish:start", depletion, "")
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingDepletion,
StockableID: depletion.Id,
ProductWarehouseID: depletion.ProductWarehouseId,
Quantity: depletion.Qty,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to replenish FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
s.logDepletionTrace("replenish:done", depletion, "")
}
return nil
}
func (s *recordingService) reduceRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
) error {
if len(depletions) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for reducing recording depletions")
return errors.New("fifo service is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 {
continue
}
s.logDepletionTrace("reduce:start", depletion, "")
if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyRecordingDepletion,
StockableID: depletion.Id,
ProductWarehouseID: depletion.ProductWarehouseId,
Quantity: -depletion.Qty,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to reduce FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
s.logDepletionTrace("reduce:done", depletion, "")
}
return nil
}
func (s *recordingService) reduceRecordingEggs(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
) error {
if len(eggs) == 0 {
return nil
}
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for reducing recording eggs")
return errors.New("fifo service is not available")
}
for _, egg := range eggs {
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
s.logEggTrace("reduce:start", egg, "")
if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id,
ProductWarehouseID: egg.ProductWarehouseId,
Quantity: -float64(egg.Qty),
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to reduce FIFO stock for recording egg %d: %+v", egg.Id, err)
return err
}
s.logEggTrace("reduce:done", egg, "")
}
return nil
}
func (s *recordingService) ensureActiveAllocations(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
) error {
if usableID == 0 {
return nil
}
var count int64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusConflict, fmt.Sprintf("no active allocations for usable %s id=%d", usableKey, usableID))
}
return nil
}
func (s *recordingService) countActiveAllocations(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
) (int64, error) {
if usableID == 0 {
return 0, nil
}
var count int64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *recordingService) resyncStockableUsageFromAllocations(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
) error {
if usableID == 0 {
return nil
}
type stockableRef struct {
StockableType string
StockableID uint
}
var refs []stockableRef
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Select("stockable_type, stockable_id").
Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive).
Group("stockable_type, stockable_id").
Scan(&refs).Error; err != nil {
return err
}
if len(refs) == 0 {
return nil
}
for _, ref := range refs {
var total float64
if err := tx.WithContext(ctx).
Model(&entity.StockAllocation{}).
Select("COALESCE(SUM(qty),0)").
Where("stockable_type = ? AND stockable_id = ? AND status = ?", ref.StockableType, ref.StockableID, entity.StockAllocationStatusActive).
Scan(&total).Error; err != nil {
return err
}
switch ref.StockableType {
case string(fifo.StockableKeyProjectFlockPopulation):
if err := tx.WithContext(ctx).
Table("project_flock_populations").
Where("id = ?", ref.StockableID).
Update("total_used_qty", total).Error; err != nil {
return err
}
case string(fifo.StockableKeyPurchaseItems):
if err := tx.WithContext(ctx).
Table("purchase_items").
Where("id = ?", ref.StockableID).
Update("total_used", total).Error; err != nil {
return err
}
default:
// no-op for other stockables
}
}
return nil
}
type desiredStock struct { type desiredStock struct {
Usage float64 Usage float64
Pending float64 Pending float64
@@ -565,7 +887,7 @@ type desiredDepletion struct {
Pending float64 Pending float64
} }
func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock) []desiredStock {
desired := make([]desiredStock, len(stocks)) desired := make([]desiredStock, len(stocks))
for i := range stocks { for i := range stocks {
if stocks[i].UsageQty != nil { if stocks[i].UsageQty != nil {
@@ -574,9 +896,6 @@ func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) [
if stocks[i].PendingQty != nil { if stocks[i].PendingQty != nil {
desired[i].Pending = *stocks[i].PendingQty desired[i].Pending = *stocks[i].PendingQty
} }
if !enabled {
continue
}
zero := 0.0 zero := 0.0
stocks[i].UsageQty = &zero stocks[i].UsageQty = &zero
stocks[i].PendingQty = &zero stocks[i].PendingQty = &zero
@@ -584,39 +903,19 @@ func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) [
return desired return desired
} }
func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) { func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion) []desiredDepletion {
if !enabled {
return
}
for i := range stocks {
if i >= len(desired) {
break
}
usage := desired[i].Usage
pending := desired[i].Pending
stocks[i].UsageQty = &usage
stocks[i].PendingQty = &pending
}
}
func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion {
desired := make([]desiredDepletion, len(depletions)) desired := make([]desiredDepletion, len(depletions))
for i := range depletions { for i := range depletions {
desired[i].Qty = depletions[i].Qty desired[i].Qty = depletions[i].Qty
desired[i].Pending = depletions[i].PendingQty desired[i].Pending = depletions[i].PendingQty
if !enabled {
continue
}
depletions[i].Qty = 0 depletions[i].Qty = 0
depletions[i].UsageQty = 0
depletions[i].PendingQty = 0 depletions[i].PendingQty = 0
} }
return desired return desired
} }
func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) { func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion) {
if !enabled {
return
}
for i := range depletions { for i := range depletions {
if i >= len(desired) { if i >= len(desired) {
break break
@@ -636,11 +935,8 @@ func (s *recordingService) syncRecordingStocks(
actorID uint, actorID uint,
) error { ) error {
if s.FifoSvc == nil { if s.FifoSvc == nil {
if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { s.Log.Errorf("FIFO service is not available for syncing recording stocks")
return err return errors.New("fifo service is not available")
}
mapped := recordingutil.MapStocks(recordingID, incoming)
return s.Repository.CreateStocks(tx, mapped)
} }
existingByWarehouse := make(map[uint][]entity.RecordingStock) existingByWarehouse := make(map[uint][]entity.RecordingStock)
@@ -701,3 +997,137 @@ func (s *recordingService) syncRecordingStocks(
} }
return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID) return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID)
} }
func sumDepletionQty(items []entity.RecordingDepletion) float64 {
var total float64
for _, item := range items {
if item.Qty > 0 {
total += item.Qty
}
}
return total
}
func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error {
if projectFlockKandangId == 0 || newTotal <= 0 {
return nil
}
totalChick, err := s.Repository.GetTotalChick(tx, projectFlockKandangId)
if err != nil {
return err
}
// totalChick already reflects existing depletions; add them back to compare the delta.
available := float64(totalChick) + existingTotal
if newTotal > available {
return fiber.NewError(fiber.StatusBadRequest, "Depletion melebihi populasi yang tersedia")
}
return nil
}
func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
for _, egg := range eggs {
if egg.TotalUsed > 0 {
return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah")
}
}
return nil
}
func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
if projectFlockKandangID == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi")
}
for _, pop := range populations {
if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 {
return pop.ProductWarehouseId, nil
}
}
for _, pop := range populations {
if pop.ProductWarehouseId > 0 {
return pop.ProductWarehouseId, nil
}
}
return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan")
}
func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error {
if tx == nil || projectFlockKandangId == 0 || from.IsZero() {
return nil
}
fromUTC := from.UTC()
records, err := s.Repository.ListByProjectFlockKandangID(ctx, tx, projectFlockKandangId, &fromUTC)
if err != nil {
return err
}
for i := range records {
if err := s.computeAndUpdateMetrics(ctx, tx, &records[i]); err != nil {
return err
}
}
return nil
}
func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error {
if recordingID == 0 || tx == nil {
return nil
}
if err := s.requireFIFO(); err != nil {
return err
}
oldDepletions, err := s.Repository.ListDepletions(tx, recordingID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to list depletions: %+v", err)
return err
}
oldEggs, err := s.Repository.ListEggs(tx, recordingID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to list eggs: %+v", err)
return err
}
if err := ensureRecordingEggsUnused(oldEggs); err != nil {
return err
}
if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil {
return err
}
oldStocks, err := s.Repository.ListStocks(tx, recordingID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to list stocks: %+v", err)
return err
}
if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil {
return err
}
if err := s.reduceRecordingDepletions(ctx, tx, oldDepletions); err != nil {
return err
}
if err := s.reduceRecordingEggs(ctx, tx, oldEggs); err != nil {
return err
}
if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil {
return err
}
return nil
}
func (s *recordingService) requireFIFO() error {
if s.FifoSvc == nil {
s.Log.Errorf("FIFO service is not available for recording operations")
return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is required for recording operations")
}
return nil
}
@@ -1,5 +1,7 @@
package validation package validation
import "time"
type ( type (
Stock struct { Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
@@ -35,6 +37,7 @@ type Update struct {
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Offset int `query:"-" validate:"omitempty,number,min=0"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
} }
@@ -44,3 +47,9 @@ type Approve struct {
ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
} }
type GetRecordingNextDay struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" query:"project_flock_kandang_id" validate:"required,number,min=1"`
RecordTime *string `json:"record_date" query:"record_date" validate:"required,datetime=2006-01-02"`
RecordTimeValue *time.Time `query:"-" validate:"-"`
}
@@ -1064,6 +1064,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
if err != nil { if err != nil {
return err return err
} }
// Safety: ensure the PW we got matches the purchase item product.
if pwDetail, err := pwRepoTx.GetDetailByID(c.Context(), pwID); err != nil {
return err
} else if pwDetail.ProductId != uint(item.ProductId) {
return fiber.NewError(
fiber.StatusBadRequest,
fmt.Sprintf("Product warehouse %d belongs to product %d, not purchase item product %d", pwID, pwDetail.ProductId, item.ProductId),
)
}
newPWID = &pwID newPWID = &pwID
deltaQty := prep.receivedQty - item.TotalQty deltaQty := prep.receivedQty - item.TotalQty
+1
View File
@@ -17,4 +17,5 @@ const (
StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS"
StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION"
StockableKeyRecordingEgg StockableKey = "RECORDING_EGG" StockableKeyRecordingEgg StockableKey = "RECORDING_EGG"
StockableKeyRecordingDepletion StockableKey = "RECORDING_DEPLETION_IN"
) )
@@ -0,0 +1,317 @@
package recording
import (
"context"
"fmt"
"strings"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
)
type warnLogger interface {
Warnf(format string, args ...any)
}
type productWarehouseExistsRepo interface {
ExistsByID(ctx context.Context, id uint) (bool, error)
}
type recordingValidationRepo interface {
ValidateProductWarehousesByFlags(ctx context.Context, ids []uint, flags []string) (uint, error)
}
func EnsureProductWarehousesExist(ctx context.Context, repo productWarehouseExistsRepo, ids []uint) error {
if repo == nil || len(ids) == 0 {
return nil
}
for _, id := range ids {
ok, err := repo.ExistsByID(ctx, id)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("product warehouse %d not found", id)
}
}
return nil
}
func EnsureProductWarehousesByFlags(ctx context.Context, repo recordingValidationRepo, ids []uint, flags []string, label string) error {
if repo == nil || len(ids) == 0 {
return nil
}
invalidID, err := repo.ValidateProductWarehousesByFlags(ctx, ids, flags)
if err != nil {
return err
}
if invalidID != 0 {
return fmt.Errorf("product warehouse %d is not a %s warehouse", invalidID, label)
}
return nil
}
type idGetter[T any] func(T) uint
func CollectWarehouseIDs[T any](items []T, getID idGetter[T]) []uint {
if len(items) == 0 {
return nil
}
ids := make([]uint, 0, len(items))
for _, item := range items {
if id := getID(item); id != 0 {
ids = append(ids, id)
}
}
return ids
}
func EnsureProductWarehousesByFlagsForItems[T any](
ctx context.Context,
repo recordingValidationRepo,
items []T,
getID idGetter[T],
flags []string,
label string,
) error {
ids := CollectWarehouseIDs(items, getID)
return EnsureProductWarehousesByFlags(ctx, repo, ids, flags, label)
}
func ComputeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 {
base := 0.0
if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 {
base = *prevRecording.TotalChickQty
} else if totalChick > 0 {
base = float64(totalChick) + currentDepletion
}
if base <= 0 {
return 0
}
return (currentDepletion / base) * 100
}
func AttachLatestApprovals(ctx context.Context, items []entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error {
if len(items) == 0 || approvalSvc == nil {
return nil
}
ids := make([]uint, 0, len(items))
visited := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.Id == 0 {
continue
}
if _, ok := visited[item.Id]; ok {
continue
}
visited[item.Id] = struct{}{}
ids = append(ids, item.Id)
}
if len(ids) == 0 {
return nil
}
latestMap, err := approvalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
if logger != nil {
logger.Warnf("Unable to load latest approvals for recordings: %+v", err)
}
return nil
}
if len(latestMap) == 0 {
return nil
}
for i := range items {
if items[i].Id == 0 {
continue
}
if approval, ok := latestMap[items[i].Id]; ok {
items[i].LatestApproval = approval
}
}
return nil
}
func AttachLatestApproval(ctx context.Context, item *entity.Recording, approvalSvc commonSvc.ApprovalService, logger warnLogger) error {
if item == nil || item.Id == 0 || approvalSvc == nil {
return nil
}
latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
})
if err != nil {
if logger != nil {
logger.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err)
}
return nil
}
item.LatestApproval = latest
return nil
}
type productionStandardValues struct {
HenDay *float64
HenHouse *float64
FeedIntake *float64
MaxDepletion *float64
EggMass *float64
EggWeight *float64
}
func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, logger warnLogger, items ...*entity.Recording) error {
if len(items) == 0 {
return nil
}
type standardKey struct {
standardID uint
week int
}
type standardCacheEntry struct {
values productionStandardValues
fcr *float64
}
if db == nil {
return nil
}
standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db)
cache := make(map[standardKey]standardCacheEntry, len(items))
standardIDs := make(map[uint]struct{}, len(items))
for _, item := range items {
if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 {
continue
}
if item.ProjectFlockKandang.ProjectFlock.ProductionStandardId > 0 {
standardIDs[item.ProjectFlockKandang.ProjectFlock.ProductionStandardId] = struct{}{}
}
}
standardDetailByStd := make(map[uint]map[int]*entity.ProductionStandardDetail, len(standardIDs))
growthDetailByStd := make(map[uint]map[int]*entity.StandardGrowthDetail, len(standardIDs))
for standardID := range standardIDs {
details, err := standardDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
if warnOnly {
if logger != nil {
logger.Warnf("Unable to preload production standard detail for standard %d: %+v", standardID, err)
}
} else {
return err
}
continue
}
detailMap := make(map[int]*entity.ProductionStandardDetail, len(details))
for i := range details {
detail := details[i]
detailMap[detail.Week] = &detail
}
standardDetailByStd[standardID] = detailMap
growths, err := growthDetailRepo.GetByProductionStandardID(ctx, standardID)
if err != nil {
if warnOnly {
if logger != nil {
logger.Warnf("Unable to preload standard growth detail for standard %d: %+v", standardID, err)
}
} else {
return err
}
continue
}
growthMap := make(map[int]*entity.StandardGrowthDetail, len(growths))
for i := range growths {
growth := growths[i]
growthMap[growth.Week] = &growth
}
growthDetailByStd[standardID] = growthMap
}
for _, item := range items {
if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 {
continue
}
standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId
if standardID == 0 {
continue
}
week := RecordingWeekValue(*item)
cacheKey := standardKey{standardID: standardID, week: week}
if cached, ok := cache[cacheKey]; ok {
applyProductionStandardValues(item, cached.values, cached.fcr)
continue
}
values := productionStandardValues{}
var fcr *float64
if detailMap, ok := standardDetailByStd[standardID]; ok {
if detail, ok := detailMap[week]; ok {
values.HenDay = detail.TargetHenDayProduction
values.HenHouse = detail.TargetHenHouseProduction
values.EggMass = detail.TargetEggMass
values.EggWeight = detail.TargetEggWeight
fcr = detail.StandardFCR
}
}
if growthMap, ok := growthDetailByStd[standardID]; ok {
if growth, ok := growthMap[week]; ok {
values.FeedIntake = growth.FeedIntake
values.MaxDepletion = growth.MaxDepletion
}
}
cache[cacheKey] = standardCacheEntry{values: values, fcr: fcr}
applyProductionStandardValues(item, values, fcr)
}
return nil
}
func applyProductionStandardValues(item *entity.Recording, values productionStandardValues, fcr *float64) {
item.StandardHenDay = values.HenDay
item.StandardHenHouse = values.HenHouse
item.StandardFeedIntake = values.FeedIntake
item.StandardMaxDepletion = values.MaxDepletion
item.StandardEggMass = values.EggMass
item.StandardEggWeight = values.EggWeight
item.StandardFcr = fcr
}
func RecordingWeekValue(e entity.Recording) int {
day := intValue(e.Day)
if day <= 0 {
return 0
}
weekBase := 1
if IsLayingRecording(e) {
weekBase = 18
}
return ((day - 1) / 7) + weekBase
}
func IsLayingRecording(e entity.Recording) bool {
if e.ProjectFlockKandang == nil {
return false
}
return strings.EqualFold(e.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying))
}
func intValue(value *int) int {
if value == nil {
return 0
}
return *value
}
@@ -1,6 +1,9 @@
package recording package recording
import ( import (
"fmt"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
) )
@@ -70,3 +73,87 @@ func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity.
} }
return result return result
} }
type EggTotals struct {
Qty int
Weight float64
}
func StockUsageByWarehouse(items []entity.RecordingStock) map[uint]float64 {
return TotalsByWarehouse(items, func(stock entity.RecordingStock) (uint, float64) {
var usage float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
return stock.ProductWarehouseId, usage
})
}
func StockUsageByWarehouseReq(items []validation.Stock) map[uint]float64 {
return TotalsByWarehouse(items, func(item validation.Stock) (uint, float64) {
return item.ProductWarehouseId, item.Qty
})
}
func FloatMapsEqual(a, b map[uint]float64) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
other, ok := b[key]
if !ok || !floatNearlyEqual(value, other) {
return false
}
}
return true
}
func EggTotalsEqual(a, b map[uint]EggTotals) bool {
if len(a) != len(b) {
return false
}
for key, value := range a {
other, ok := b[key]
if !ok || value.Qty != other.Qty || !floatNearlyEqual(value.Weight, other.Weight) {
return false
}
}
return true
}
func floatNearlyEqual(a, b float64) bool {
return a-b <= 0.000001 && b-a <= 0.000001
}
func TotalsByWarehouse[T any](items []T, get func(T) (uint, float64)) map[uint]float64 {
result := make(map[uint]float64)
for _, item := range items {
warehouseID, qty := get(item)
result[warehouseID] += qty
}
return result
}
func EggTotalsByWarehouse[T any](items []T, get func(T) (uint, int, *float64)) map[uint]EggTotals {
result := make(map[uint]EggTotals)
for _, item := range items {
warehouseID, qty, weightPtr := get(item)
weight := 0.0
if weightPtr != nil {
weight = *weightPtr
}
current := result[warehouseID]
current.Qty += qty
current.Weight += weight
result[warehouseID] = current
}
return result
}
func RecordingNote(action string, id uint) string {
action = strings.TrimSpace(action)
if action == "" {
return fmt.Sprintf("Recording#%d", id)
}
return fmt.Sprintf("Recording-%s#%d", action, id)
}