codex/fix: store stocks on farm warehouse when recording egg

This commit is contained in:
Adnan Zahir
2026-04-04 11:21:50 +07:00
parent 34a3fc44a8
commit 2a39342d55
3 changed files with 403 additions and 4 deletions
@@ -347,7 +347,10 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint
func (r *projectFlockKandangRepositoryImpl) GetByIDLight(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) {
record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx).
Preload("Kandang").
Preload("Kandang.Location").
Preload("ProjectFlock").
Preload("ProjectFlock.Location").
First(record, id).Error; err != nil {
return nil, err
}
@@ -355,6 +355,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks")
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
if req.Eggs, err = s.resolveEggRequestsToFarmWarehouses(ctx, pfk, actorID, req.Eggs); err != nil {
return nil, err
}
if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil {
return nil, err
}
@@ -379,10 +387,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
if err := s.ensureProductWarehousesByFlags(ctx, eggIDs, []string{"TELUR-UTUH", "TELUR-PECAH", "TELUR-PUTIH", "TELUR-RETAK", "TELUR"}, "egg"); err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
var createdRecording entity.Recording
transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if s.ProductionStandardSvc != nil {
@@ -687,6 +691,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
s.Log.Errorf("Failed to list existing eggs: %+v", err)
return err
}
normalizeEggWarehouses, err := s.shouldNormalizeEggRequestsOnUpdate(ctx, existingEggs)
if err != nil {
return err
}
if normalizeEggWarehouses {
if req.Eggs, err = s.resolveEggRequestsToFarmWarehouses(ctx, pfkForRoute, actorID, req.Eggs); err != nil {
return err
}
}
existingTotals := recordingutil.EggTotalsByWarehouse(existingEggs, func(egg entity.RecordingEgg) (uint, int, *float64) {
return egg.ProductWarehouseId, egg.Qty, egg.Weight
})
@@ -1492,6 +1505,194 @@ func boolPtr(value bool) *bool {
return &v
}
func (s *recordingService) resolveEggRequestsToFarmWarehouses(
ctx context.Context,
pfk *entity.ProjectFlockKandang,
actorID uint,
eggs []validation.Egg,
) ([]validation.Egg, error) {
if len(eggs) == 0 {
return eggs, nil
}
locationID, farmName := farmContextFromProjectFlockKandang(pfk)
if locationID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Farm recording tidak valid")
}
farmWarehouse, err := s.findFirstFarmWarehouse(ctx, locationID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Farm %s belum memiliki gudang", farmName))
}
s.Log.Errorf("Failed to resolve farm warehouse for egg recording: %+v", err)
return nil, err
}
idSet := make(map[uint]struct{}, len(eggs))
for _, egg := range eggs {
if egg.ProductWarehouseId != 0 {
idSet[egg.ProductWarehouseId] = struct{}{}
}
}
if len(idSet) == 0 {
return eggs, nil
}
ids := make([]uint, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
var sourceWarehouses []entity.ProductWarehouse
if err := s.ProductWarehouseRepo.DB().WithContext(ctx).
Preload("Warehouse").
Where("id IN ?", ids).
Find(&sourceWarehouses).Error; err != nil {
s.Log.Errorf("Failed to load egg source product warehouses: %+v", err)
return nil, err
}
if len(sourceWarehouses) != len(ids) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Product warehouse telur tidak ditemukan")
}
sourceByID := make(map[uint]entity.ProductWarehouse, len(sourceWarehouses))
resolvedBySource := make(map[uint]uint, len(sourceWarehouses))
for _, source := range sourceWarehouses {
if err := ensureEggSourceMatchesRecordingScope(source, locationID, pfk.KandangId); err != nil {
return nil, err
}
sourceByID[source.Id] = source
}
normalized := make([]validation.Egg, len(eggs))
copy(normalized, eggs)
for i := range normalized {
source := sourceByID[normalized[i].ProductWarehouseId]
if resolvedID, ok := resolvedBySource[source.Id]; ok {
normalized[i].ProductWarehouseId = resolvedID
continue
}
resolvedID, err := s.ProductWarehouseRepo.EnsureProductWarehouse(ctx, source.ProductId, farmWarehouse.Id, nil, actorID)
if err != nil {
s.Log.Errorf("Failed to ensure egg farm product warehouse: %+v", err)
return nil, err
}
resolvedBySource[source.Id] = resolvedID
normalized[i].ProductWarehouseId = resolvedID
}
return normalized, nil
}
func farmContextFromProjectFlockKandang(pfk *entity.ProjectFlockKandang) (uint, string) {
if pfk == nil {
return 0, "tidak diketahui"
}
if pfk.ProjectFlock.LocationId != 0 {
name := strings.TrimSpace(pfk.ProjectFlock.Location.Name)
if name == "" {
name = strings.TrimSpace(pfk.Kandang.Location.Name)
}
if name == "" {
name = fmt.Sprintf("#%d", pfk.ProjectFlock.LocationId)
}
return pfk.ProjectFlock.LocationId, name
}
if pfk.Kandang.LocationId != 0 {
name := strings.TrimSpace(pfk.Kandang.Location.Name)
if name == "" {
name = fmt.Sprintf("#%d", pfk.Kandang.LocationId)
}
return pfk.Kandang.LocationId, name
}
return 0, "tidak diketahui"
}
func (s *recordingService) findFirstFarmWarehouse(ctx context.Context, locationID uint) (*entity.Warehouse, error) {
var warehouse entity.Warehouse
if err := s.Repository.DB().WithContext(ctx).
Model(&entity.Warehouse{}).
Where("location_id = ? AND type = ?", locationID, utils.WarehouseTypeLokasi).
Order("id ASC").
First(&warehouse).Error; err != nil {
return nil, err
}
return &warehouse, nil
}
func ensureEggSourceMatchesRecordingScope(source entity.ProductWarehouse, locationID uint, kandangID uint) error {
if source.Id == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse telur tidak ditemukan")
}
if source.ProductId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Produk telur tidak valid")
}
if source.WarehouseId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Gudang telur tidak valid")
}
if source.Warehouse.LocationId == nil || *source.Warehouse.LocationId != locationID {
return fiber.NewError(fiber.StatusBadRequest, "Produk telur harus berasal dari farm yang sama")
}
switch strings.ToUpper(strings.TrimSpace(source.Warehouse.Type)) {
case string(utils.WarehouseTypeLokasi):
return nil
case string(utils.WarehouseTypeKandang):
if source.Warehouse.KandangId == nil || *source.Warehouse.KandangId != kandangID {
return fiber.NewError(fiber.StatusBadRequest, "Produk telur harus berasal dari kandang recording yang sama")
}
return nil
default:
return fiber.NewError(fiber.StatusBadRequest, "Produk telur harus berasal dari gudang farm atau kandang recording")
}
}
func (s *recordingService) shouldNormalizeEggRequestsOnUpdate(ctx context.Context, existingEggs []entity.RecordingEgg) (bool, error) {
if len(existingEggs) == 0 {
return true, nil
}
idSet := make(map[uint]struct{}, len(existingEggs))
for _, egg := range existingEggs {
if egg.ProductWarehouseId != 0 {
idSet[egg.ProductWarehouseId] = struct{}{}
}
}
if len(idSet) == 0 {
return true, nil
}
ids := make([]uint, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
var productWarehouses []entity.ProductWarehouse
if err := s.ProductWarehouseRepo.DB().WithContext(ctx).
Preload("Warehouse").
Where("id IN ?", ids).
Find(&productWarehouses).Error; err != nil {
s.Log.Errorf("Failed to load existing egg product warehouses: %+v", err)
return false, err
}
if len(productWarehouses) != len(ids) {
return false, fiber.NewError(fiber.StatusBadRequest, "Product warehouse telur tidak ditemukan")
}
for _, productWarehouse := range productWarehouses {
if strings.EqualFold(strings.TrimSpace(productWarehouse.Warehouse.Type), string(utils.WarehouseTypeLokasi)) {
return true, nil
}
}
return false, nil
}
func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error {
idSet := make(map[uint]struct{})
@@ -0,0 +1,195 @@
package service
import (
"context"
"strings"
"testing"
"github.com/glebarez/sqlite"
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"
"gorm.io/gorm"
)
func TestResolveEggRequestsToFarmWarehousesChoosesFirstFarmWarehouse(t *testing.T) {
db := setupRecordingServiceTestDB(t)
repo := repository.NewRecordingRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
svc := &recordingService{
Log: nil,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
}
pfk := &entity.ProjectFlockKandang{
Id: 10,
KandangId: 59,
ProjectFlock: entity.ProjectFlock{
LocationId: 16,
Location: entity.Location{Name: "Jamali"},
},
Kandang: entity.Kandang{
Id: 59,
LocationId: 16,
Location: entity.Location{Name: "Jamali"},
},
}
got, err := svc.resolveEggRequestsToFarmWarehouses(context.Background(), pfk, 9, []validation.Egg{
{ProductWarehouseId: 101, Qty: 120},
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 egg row, got %d", len(got))
}
if got[0].ProductWarehouseId == 101 {
t.Fatalf("expected egg warehouse to be remapped to farm warehouse")
}
var resolved entity.ProductWarehouse
if err := db.WithContext(context.Background()).
Preload("Warehouse").
First(&resolved, got[0].ProductWarehouseId).Error; err != nil {
t.Fatalf("failed to load resolved product warehouse: %v", err)
}
if resolved.ProductId != 8 {
t.Fatalf("expected product_id 8, got %d", resolved.ProductId)
}
if resolved.WarehouseId != 21 {
t.Fatalf("expected first farm warehouse id 21, got %d", resolved.WarehouseId)
}
if resolved.ProjectFlockKandangId != nil {
t.Fatalf("expected farm-level product warehouse to remain shared, got pfk %+v", resolved.ProjectFlockKandangId)
}
}
func TestResolveEggRequestsToFarmWarehousesFailsWhenFarmHasNoWarehouse(t *testing.T) {
db := setupRecordingServiceTestDB(t)
if err := db.Exec("DELETE FROM warehouses WHERE type = 'LOKASI'").Error; err != nil {
t.Fatalf("failed to remove farm warehouses: %v", err)
}
repo := repository.NewRecordingRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
svc := &recordingService{
Log: nil,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
}
pfk := &entity.ProjectFlockKandang{
Id: 10,
KandangId: 59,
ProjectFlock: entity.ProjectFlock{
LocationId: 16,
Location: entity.Location{Name: "Jamali"},
},
}
_, err := svc.resolveEggRequestsToFarmWarehouses(context.Background(), pfk, 9, []validation.Egg{
{ProductWarehouseId: 101, Qty: 120},
})
if err == nil {
t.Fatal("expected validation error when farm warehouse is missing")
}
if !strings.Contains(err.Error(), "Farm Jamali belum memiliki gudang") {
t.Fatalf("expected missing farm warehouse error, got %v", err)
}
}
func TestShouldNormalizeEggRequestsOnUpdatePreservesHistoricalKandangEggs(t *testing.T) {
db := setupRecordingServiceTestDB(t)
repo := repository.NewRecordingRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
svc := &recordingService{
Log: nil,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
}
shouldNormalize, err := svc.shouldNormalizeEggRequestsOnUpdate(context.Background(), []entity.RecordingEgg{
{ProductWarehouseId: 101, Qty: 120},
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if shouldNormalize {
t.Fatal("expected historical kandang-level egg rows to remain kandang-level on update")
}
}
func TestShouldNormalizeEggRequestsOnUpdateNormalizesFarmLevelEggs(t *testing.T) {
db := setupRecordingServiceTestDB(t)
if err := db.Exec(`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES (201, 8, 21, NULL, 300)`).Error; err != nil {
t.Fatalf("failed to insert farm-level egg warehouse: %v", err)
}
repo := repository.NewRecordingRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
svc := &recordingService{
Log: nil,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
}
shouldNormalize, err := svc.shouldNormalizeEggRequestsOnUpdate(context.Background(), []entity.RecordingEgg{
{ProductWarehouseId: 201, Qty: 120},
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !shouldNormalize {
t.Fatal("expected farm-level egg rows to keep using farm normalization on update")
}
}
func setupRecordingServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
if err != nil {
t.Fatalf("failed opening sqlite db: %v", err)
}
statements := []string{
`CREATE TABLE locations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)`,
`CREATE TABLE warehouses (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
location_id INTEGER NULL,
kandang_id INTEGER NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
project_flock_kandang_id INTEGER NULL,
qty NUMERIC NULL
)`,
`INSERT INTO locations (id, name) VALUES (16, 'Jamali')`,
`INSERT INTO warehouses (id, name, type, location_id, kandang_id, deleted_at) VALUES
(21, 'Gudang Farm Jamali A', 'LOKASI', 16, NULL, NULL),
(25, 'Gudang Farm Jamali B', 'LOKASI', 16, NULL, NULL),
(46, 'Gudang Jamali 1', 'KANDANG', 16, 59, NULL)`,
`INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES
(101, 8, 46, 10, 500)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}