Merge branch 'codex/sales-at-farm-level' into 'development'

codex/fix: purchase receivement error and recording doesn't show depletion/egg

See merge request mbugroup/lti-api!382
This commit is contained in:
Adnan Zahir
2026-04-01 11:10:19 +07:00
5 changed files with 308 additions and 6 deletions
@@ -53,6 +53,24 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.Chickins")
}
func applyWarehouseSelectionFilter(db *gorm.DB, kandangID, locationID uint) *gorm.DB {
switch {
case kandangID != 0 && locationID != 0:
return db.Where(
"w_scope.location_id = ? AND (w_scope.type = ? OR w_scope.kandang_id = ?)",
locationID,
"LOKASI",
kandangID,
)
case kandangID != 0:
return db.Where("w_scope.kandang_id = ?", kandangID)
case locationID != 0:
return db.Where("w_scope.location_id = ?", locationID)
default:
return db
}
}
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -133,10 +151,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("product_id = ?", params.ProductId)
}
if params.KandangId != 0 {
db = db.Joins("JOIN warehouses ON product_warehouses.warehouse_id = warehouses.id").
Where("warehouses.kandang_id = ?", params.KandangId)
}
db = applyWarehouseSelectionFilter(db, params.KandangId, params.LocationId)
if params.WarehouseId != 0 {
db = db.Where("warehouse_id = ?", params.WarehouseId)
@@ -0,0 +1,111 @@
package service
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestApplyWarehouseSelectionFilterIncludesFarmAndSelectedKandangInLocation(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 11, 101).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1, 2})
}
func TestApplyWarehouseSelectionFilterPreservesKandangOnlyBehavior(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 11, 0).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1})
}
func TestApplyWarehouseSelectionFilterSupportsLocationOnlyQuery(t *testing.T) {
db := setupProductWarehouseServiceTestDB(t)
var ids []uint
err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 0, 101).
Order("product_warehouses.id").
Pluck("product_warehouses.id", &ids).Error
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertUintIDs(t, ids, []uint{1, 2, 3})
}
func setupProductWarehouseServiceTestDB(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 warehouses (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
location_id INTEGER NULL,
kandang_id INTEGER NULL,
deleted_at TIMESTAMP NULL
)`,
`CREATE TABLE product_warehouses (
id INTEGER PRIMARY KEY,
warehouse_id INTEGER NOT NULL
)`,
`INSERT INTO warehouses (id, type, location_id, kandang_id, deleted_at) VALUES
(1, 'KANDANG', 101, 11, NULL),
(2, 'LOKASI', 101, NULL, NULL),
(3, 'KANDANG', 101, 12, NULL),
(4, 'LOKASI', 102, NULL, NULL)`,
`INSERT INTO product_warehouses (id, warehouse_id) VALUES
(1, 1),
(2, 2),
(3, 3),
(4, 4)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}
func baseProductWarehouseSelectionQuery(db *gorm.DB) *gorm.DB {
return db.Table("product_warehouses").
Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id").
Where("w_scope.deleted_at IS NULL")
}
func assertUintIDs(t *testing.T, got []uint, want []uint) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("expected ids %v, got %v", want, got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("expected ids %v, got %v", want, got)
}
}
}
@@ -17,6 +17,7 @@ type Query struct {
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"`
LocationId uint `query:"location_id" validate:"omitempty,number,min=1"`
Flags string `query:"flags" validate:"omitempty"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
@@ -143,6 +143,17 @@ func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uin
return r.DB().WithContext(ctx).Create(&items).Error
}
func (r *PurchaseRepositoryImpl) purchaseItemExists(ctx context.Context, purchaseID uint, itemID uint) (bool, error) {
var count int64
if err := r.DB().WithContext(ctx).
Model(&entity.PurchaseItem{}).
Where("purchase_id = ? AND id = ?", purchaseID, itemID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
type PurchasePricingUpdate struct {
ItemID uint
ProductID *uint
@@ -197,7 +208,13 @@ func (r *PurchaseRepositoryImpl) UpdatePricing(
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID)
if err != nil {
return err
}
if !exists {
return gorm.ErrRecordNotFound
}
}
}
@@ -251,7 +268,13 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails(
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID)
if err != nil {
return err
}
if !exists {
return gorm.ErrRecordNotFound
}
}
}
@@ -0,0 +1,152 @@
package repositories
import (
"context"
"errors"
"testing"
"time"
"github.com/glebarez/sqlite"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
func TestUpdateReceivingDetailsAllowsNoOpUpdatesOnExistingItem(t *testing.T) {
db := setupPurchaseRepositoryTestDB(t)
repo := NewPurchaseRepository(db)
ctx := context.Background()
receivedAt := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
travelNumber := "SJ-001"
vehicleNumber := "B 1234 CD"
if err := db.WithContext(ctx).Create(&entity.PurchaseItem{
Id: 10,
PurchaseId: 1,
ProductId: 2,
WarehouseId: 3,
SubQty: 10,
TotalQty: 10,
Price: 15000,
TotalPrice: 150000,
ReceivedDate: &receivedAt,
TravelNumber: &travelNumber,
VehicleNumber: &vehicleNumber,
}).Error; err != nil {
t.Fatalf("failed seeding purchase item: %v", err)
}
pwID := uint(99)
if err := repo.UpdateReceivingDetails(ctx, 1, []PurchaseReceivingUpdate{
{
ItemID: 10,
ReceivedDate: &receivedAt,
TravelNumber: &travelNumber,
VehicleNumber: &vehicleNumber,
ProductWarehouseID: &pwID,
},
}); err != nil {
t.Fatalf("expected no-op receive update to succeed, got %v", err)
}
}
func TestUpdateReceivingDetailsReturnsNotFoundForMissingItem(t *testing.T) {
db := setupPurchaseRepositoryTestDB(t)
repo := NewPurchaseRepository(db)
ctx := context.Background()
receivedAt := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
err := repo.UpdateReceivingDetails(ctx, 1, []PurchaseReceivingUpdate{
{
ItemID: 999,
ReceivedDate: &receivedAt,
},
})
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("expected gorm.ErrRecordNotFound, got %v", err)
}
}
func TestUpdatePricingAllowsNoOpUpdatesOnExistingItem(t *testing.T) {
db := setupPurchaseRepositoryTestDB(t)
repo := NewPurchaseRepository(db)
ctx := context.Background()
if err := db.WithContext(ctx).Create(&entity.PurchaseItem{
Id: 20,
PurchaseId: 2,
ProductId: 5,
WarehouseId: 6,
SubQty: 5,
TotalQty: 5,
Price: 10000,
TotalPrice: 50000,
}).Error; err != nil {
t.Fatalf("failed seeding purchase item: %v", err)
}
if err := repo.UpdatePricing(ctx, 2, []PurchasePricingUpdate{
{
ItemID: 20,
Price: 10000,
TotalPrice: 50000,
},
}); err != nil {
t.Fatalf("expected no-op pricing update to succeed, got %v", err)
}
}
func TestUpdatePricingReturnsNotFoundForMissingItem(t *testing.T) {
db := setupPurchaseRepositoryTestDB(t)
repo := NewPurchaseRepository(db)
ctx := context.Background()
err := repo.UpdatePricing(ctx, 2, []PurchasePricingUpdate{
{
ItemID: 777,
Price: 10000,
TotalPrice: 50000,
},
})
if !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatalf("expected gorm.ErrRecordNotFound, got %v", err)
}
}
func setupPurchaseRepositoryTestDB(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 purchase_items (
id INTEGER PRIMARY KEY,
purchase_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
product_warehouse_id INTEGER NULL,
project_flock_kandang_id INTEGER NULL,
received_date TIMESTAMP NULL,
travel_number TEXT NULL,
travel_number_docs TEXT NULL,
vehicle_number TEXT NULL,
sub_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
total_qty NUMERIC(15,3) NOT NULL DEFAULT 0,
total_used NUMERIC(15,3) NOT NULL DEFAULT 0,
price NUMERIC(15,3) NOT NULL DEFAULT 0,
total_price NUMERIC(15,3) NOT NULL DEFAULT 0,
expense_nonstock_id INTEGER NULL
)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("failed preparing schema: %v", err)
}
}
return db
}