Merge branch 'dev/teguh' into 'feat/BE/US-75/chick-in-doc'

FIX/BE][US#75] : adjust some param and body request with FE needs, and fix minor bugs on Master data

See merge request mbugroup/lti-api!46
This commit is contained in:
Hafizh A. Y.
2025-10-28 03:24:38 +00:00
42 changed files with 1450 additions and 180 deletions
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"fmt"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -32,3 +33,21 @@ func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeI
} }
return count > 0, nil return count > 0, nil
} }
func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) {
if field == "" {
return false, fmt.Errorf("field is required")
}
var count int64
q := db.WithContext(ctx).
Model(new(T)).
Where(fmt.Sprintf("%s = ?", field), value).
Where("deleted_at IS NULL")
if excludeID != nil {
q = q.Where("id <> ?", *excludeID)
}
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
@@ -0,0 +1,24 @@
BEGIN;
--? Child Indexes(optional, biar rapi tapi klo gada juga ilang pas di drop)
DROP INDEX IF EXISTS idx_recording_stocks_product;
DROP INDEX IF EXISTS idx_recording_stocks_recording;
DROP INDEX IF EXISTS idx_recording_depl_recording;
DROP INDEX IF EXISTS idx_recording_bws_recording;
--? Child Tables
DROP TABLE IF EXISTS recording_stocks;
DROP TABLE IF EXISTS recording_depletions;
DROP TABLE IF EXISTS recording_bws;
--? Parent Indexes ON recordings
DROP INDEX IF EXISTS uq_recordings_flock_record_date;
DROP INDEX IF EXISTS idx_recordings_flock_datetime;
--? Parent table
DROP TABLE IF EXISTS recordings;
COMMIT;
@@ -0,0 +1,150 @@
BEGIN;
--? RECORDINGS (tabel induk recording harian)
CREATE TABLE IF NOT EXISTS recordings (
id BIGSERIAL PRIMARY KEY,
project_flock_id BIGINT NOT NULL,
record_datetime TIMESTAMPTZ NOT NULL,
record_date DATE,
status INT NOT NULL DEFAULT 0, --? 0=draft,1=submitted,2=approved,3=rejected
ontime INT NOT NULL DEFAULT 0, --? 1=ontime,0=late (pakai INT/BOOLEAN sesuai preferensi)
day INT,
total_depletion INT,
cum_depletion_rate NUMERIC(7,3),
daily_gain NUMERIC(7,3),
avg_daily_gain NUMERIC(7,3),
cum_intake INT,
fcr_value NUMERIC(7,3),
total_chick BIGINT,
daily_depletion_rate NUMERIC(7,3),
cum_depletion INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT,
CONSTRAINT fk_recordings_project_flock
FOREIGN KEY (project_flock_id) REFERENCES project_flock_kandangs(id),
CONSTRAINT fk_recordings_created_by
FOREIGN KEY (created_by) REFERENCES users(id),
CONSTRAINT chk_recordings_status
CHECK (status IN (0,1,2,3)),
CONSTRAINT chk_recordings_ontime
CHECK (ontime IN (0,1)),
CONSTRAINT chk_recordings_day
CHECK (day IS NULL OR day >= 1),
CONSTRAINT chk_recordings_nonnegatives
CHECK (
(total_depletion IS NULL OR total_depletion >= 0) AND
(cum_depletion IS NULL OR cum_depletion >= 0) AND
(total_chick IS NULL OR total_chick >= 0) AND
(cum_intake IS NULL OR cum_intake >= 0) AND
(daily_gain IS NULL OR daily_gain >= 0) AND
(avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND
(fcr_value IS NULL OR fcr_value > 0) AND
(daily_depletion_rate IS NULL OR daily_depletion_rate >= 0) AND
(cum_depletion_rate IS NULL OR cum_depletion_rate >= 0)
)
);
--? Set record_date otomatis berdasarkan record_datetime (pakai zona Asia/Jakarta)
CREATE OR REPLACE FUNCTION trg_set_record_date() RETURNS trigger AS $$
BEGIN
NEW.record_date := (NEW.record_datetime AT TIME ZONE 'Asia/Jakarta')::date;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS recordings_set_record_date_trg ON recordings;
CREATE TRIGGER recordings_set_record_date_trg
BEFORE INSERT OR UPDATE OF record_datetime ON recordings
FOR EACH ROW EXECUTE FUNCTION trg_set_record_date();
CREATE INDEX IF NOT EXISTS idx_recordings_flock_datetime
ON recordings (project_flock_id, record_datetime);
--? Unique harian (1 recording per hari dan per flock)
CREATE UNIQUE INDEX IF NOT EXISTS uq_recordings_flock_record_date
ON recordings (project_flock_id, record_date)
WHERE deleted_at IS NULL;
--? RECORDING_BWS (BW per recording)
CREATE TABLE IF NOT EXISTS recording_bws (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT NOT NULL,
weight NUMERIC(8,2) NOT NULL, --? bobot per ekor/kelompok
qty INT NOT NULL DEFAULT 1, --? jumlah ekor pada bobot ini
notes VARCHAR,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_recording_bws_recording
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE,
CONSTRAINT chk_recording_bws_nonneg
CHECK (weight >= 0 AND qty >= 1)
);
CREATE INDEX IF NOT EXISTS idx_recording_bws_recording
ON recording_bws (recording_id);
--? RECORDING_DEPLETIONS
CREATE TABLE IF NOT EXISTS recording_depletions (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
total BIGINT NOT NULL,
notes VARCHAR,
CONSTRAINT fk_recording_depl_recording
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE,
CONSTRAINT fk_recording_depl_prodwh
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id),
CONSTRAINT chk_recording_depl_total
CHECK (total >= 0)
);
CREATE INDEX IF NOT EXISTS idx_recording_depl_recording
ON recording_depletions (recording_id);
--? RECORDING_STOCKS
CREATE TABLE IF NOT EXISTS recording_stocks (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
increase NUMERIC(10,3), --? penambahan (boleh NULL)
decrease NUMERIC(10,3), --? pengurangan (boleh NULL)
usage_amount BIGINT, --? pemakaian (opsional, jika konsep dipisah dari decrease)
notes VARCHAR,
CONSTRAINT fk_recording_stocks_recording
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE,
CONSTRAINT fk_recording_stocks_prodwh
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id),
CONSTRAINT chk_recording_stocks_nonneg
CHECK (
(increase IS NULL OR increase >= 0) AND
(decrease IS NULL OR decrease >= 0) AND
(usage_amount IS NULL OR usage_amount >= 0)
)
);
CREATE INDEX IF NOT EXISTS idx_recording_stocks_recording
ON recording_stocks (recording_id);
CREATE INDEX IF NOT EXISTS idx_recording_stocks_product
ON recording_stocks (product_warehouse_id);
COMMIT;
+24 -7
View File
@@ -7,12 +7,29 @@ import (
) )
type Recording struct { type Recording struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` ProjectFlockKandangId uint `gorm:"column:project_flock_id;not null;index"`
CreatedBy uint `gorm:"not null"` RecordDatetime time.Time `gorm:"column:record_datetime;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` RecordDate *time.Time `gorm:"column:record_date"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` Ontime int `gorm:"column:ontime;not null;default:0"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Day *int `gorm:"column:day"`
TotalDepletion *int `gorm:"column:total_depletion"`
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
DailyGain *float64 `gorm:"column:daily_gain"`
AvgDailyGain *float64 `gorm:"column:avg_daily_gain"`
CumIntake *int64 `gorm:"column:cum_intake"`
FcrValue *float64 `gorm:"column:fcr_value"`
TotalChick *int64 `gorm:"column:total_chick"`
DailyDepletionRate *float64 `gorm:"column:daily_depletion_rate"`
CumDepletion *int `gorm:"column:cum_depletion"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
BodyWeights []RecordingBW `gorm:"foreignKey:RecordingId;references:Id"`
Depletions []RecordingDepletion `gorm:"foreignKey:RecordingId;references:Id"`
Stocks []RecordingStock `gorm:"foreignKey:RecordingId;references:Id"`
} }
+16
View File
@@ -0,0 +1,16 @@
package entities
import "time"
type RecordingBW struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
Weight float64 `gorm:"column:weight;not null"`
Qty int `gorm:"column:qty;not null;default:1"`
Notes *string `gorm:"column:notes"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
}
+13
View File
@@ -0,0 +1,13 @@
package entities
type RecordingDepletion struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Total int64 `gorm:"column:total;not null"`
Notes *string `gorm:"column:notes"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
}
+14
View File
@@ -0,0 +1,14 @@
package entities
type RecordingStock struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Increase *float64 `gorm:"column:increase"`
Decrease *float64 `gorm:"column:decrease"`
UsageAmount *int64 `gorm:"column:usage_amount"`
Notes *string `gorm:"column:notes"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
}
@@ -28,6 +28,11 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
ProductId: uint(c.QueryInt("product_id", 0)), ProductId: uint(c.QueryInt("product_id", 0)),
WarehouseId: uint(c.QueryInt("warehouse_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)),
Flags: c.Query("flags", ""),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
} }
result, totalResults, err := u.ProductWarehouseService.GetAll(c, query) result, totalResults, err := u.ProductWarehouseService.GetAll(c, query)
@@ -71,5 +76,3 @@ func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error {
Data: dto.ToProductWarehouseListDTO(*result), Data: dto.ToProductWarehouseListDTO(*result),
}) })
} }
@@ -16,6 +16,7 @@ type ProductWarehouseRepository interface {
ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error)
ExistsByID(ctx context.Context, id uint) (bool, error) ExistsByID(ctx context.Context, id uint) (bool, error)
GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error)
GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error)
} }
type ProductWarehouseRepositoryImpl struct { type ProductWarehouseRepositoryImpl struct {
@@ -30,6 +31,17 @@ func NewProductWarehouseRepository(db *gorm.DB) ProductWarehouseRepository {
} }
} }
func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) {
return repository.Exists[entity.Product](ctx, r.db, productId)
}
func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) {
return repository.Exists[entity.Warehouse](ctx, r.db, warehouseId)
}
func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.ProductWarehouse](ctx, r.db, id)
}
func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Context, productId, warehouseId uint, excludeID *uint) (bool, error) { func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Context, productId, warehouseId uint, excludeID *uint) (bool, error) {
var count int64 var count int64
query := r.db.WithContext(ctx).Model(&entity.ProductWarehouse{}). query := r.db.WithContext(ctx).Model(&entity.ProductWarehouse{}).
@@ -43,17 +55,6 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Cont
return count > 0, nil return count > 0, nil
} }
func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) {
return repository.Exists[entity.Product](ctx, r.db, productId)
}
func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) {
return repository.Exists[entity.Warehouse](ctx, r.db, warehouseId)
}
func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.ProductWarehouse](ctx, r.db, id)
}
func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) { func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) {
var count int64 var count int64
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
@@ -72,3 +73,19 @@ func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehous
} }
return &productWarehouse, nil return &productWarehouse, nil
} }
func (r *ProductWarehouseRepositoryImpl) GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) {
var productWarehouses []entity.ProductWarehouse
err := r.db.WithContext(ctx).
Table("product_warehouses").
Select("product_warehouses.*").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN product_categories ON product_categories.id = products.product_category_id").
Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId).
Order("product_warehouses.created_at DESC").
Find(&productWarehouses).Error
if err != nil {
return nil, err
}
return productWarehouses, nil
}
@@ -49,8 +49,30 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
return nil, 0, err return nil, 0, err
} }
if params.ProductId > 0 {
isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId)
if err != nil {
return nil, 0, err
}
if !isProductExist {
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found")
}
}
if params.WarehouseId > 0 {
isWarehouseExist, err := s.Repository.IsWarehouseExist(c.Context(), params.WarehouseId)
if err != nil {
return nil, 0, err
}
if !isWarehouseExist {
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
}
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
cleanFlags := utils.ParseFlags(params.Flags)
productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
@@ -62,6 +84,12 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("warehouse_id = ?", params.WarehouseId) db = db.Where("warehouse_id = ?", params.WarehouseId)
} }
if len(cleanFlags) > 0 {
db = db.Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products").
Where("flags.name IN ?", cleanFlags)
}
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
@@ -13,8 +13,9 @@ 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"`
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"`
Flags string `query:"flags" validate:"omitempty"`
} }
@@ -29,6 +29,10 @@ func (u *AreaController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.AreaService.GetAll(c, query) result, totalResults, err := u.AreaService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -29,6 +29,10 @@ func (u *BankController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.BankService.GetAll(c, query) result, totalResults, err := u.BankService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -11,6 +11,7 @@ import (
type BankRepository interface { type BankRepository interface {
repository.BaseRepository[entity.Bank] repository.BaseRepository[entity.Bank]
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
AccountNumberExists(ctx context.Context, accountNumber string, excludeID *uint) (bool, error)
} }
type BankRepositoryImpl struct { type BankRepositoryImpl struct {
@@ -28,3 +29,7 @@ func NewBankRepository(db *gorm.DB) BankRepository {
func (r *BankRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { func (r *BankRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) {
return repository.ExistsByName[entity.Bank](ctx, r.db, name, excludeID) return repository.ExistsByName[entity.Bank](ctx, r.db, name, excludeID)
} }
func (r *BankRepositoryImpl) AccountNumberExists(ctx context.Context, accountNumber string, excludeID *uint) (bool, error) {
return repository.ExistsByField[entity.Bank](ctx, r.db, "account_number", accountNumber, excludeID)
}
@@ -87,6 +87,13 @@ func (s *bankService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.B
return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with name %s already exists", req.Name)) return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with name %s already exists", req.Name))
} }
if exists, err := s.Repository.AccountNumberExists(c.Context(), req.AccountNumber, nil); err != nil {
s.Log.Errorf("Failed to check bank account number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check bank account number")
} else if exists {
return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with account number %s already exists", req.AccountNumber))
}
createBody := &entity.Bank{ createBody := &entity.Bank{
Name: req.Name, Name: req.Name,
Alias: req.Alias, Alias: req.Alias,
@@ -29,6 +29,10 @@ func (u *CustomerController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.CustomerService.GetAll(c, query) result, totalResults, err := u.CustomerService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -29,6 +29,10 @@ func (u *FcrController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.FcrService.GetAll(c, query) result, totalResults, err := u.FcrService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -29,6 +29,10 @@ func (u *FlockController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.FlockService.GetAll(c, query) result, totalResults, err := u.FlockService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -31,6 +31,10 @@ func (u *KandangController) GetAll(c *fiber.Ctx) error {
PicId: c.QueryInt("pic_id", 0), PicId: c.QueryInt("pic_id", 0),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.KandangService.GetAll(c, query) result, totalResults, err := u.KandangService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -30,6 +30,10 @@ func (u *LocationController) GetAll(c *fiber.Ctx) error {
AreaId: c.QueryInt("area_id", 0), AreaId: c.QueryInt("area_id", 0),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.LocationService.GetAll(c, query) result, totalResults, err := u.LocationService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -29,6 +29,10 @@ func (u *NonstockController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.NonstockService.GetAll(c, query) result, totalResults, err := u.NonstockService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -29,6 +29,10 @@ func (u *ProductCategoryController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.ProductCategoryService.GetAll(c, query) result, totalResults, err := u.ProductCategoryService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -30,6 +30,10 @@ func (u *ProductController) GetAll(c *fiber.Ctx) error {
ProductCategoryID: c.QueryInt("product_category_id", 0), ProductCategoryID: c.QueryInt("product_category_id", 0),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.ProductService.GetAll(c, query) result, totalResults, err := u.ProductService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -29,6 +29,10 @@ func (u *SupplierController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.SupplierService.GetAll(c, query) result, totalResults, err := u.SupplierService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -11,7 +11,7 @@ import (
type SupplierRepository interface { type SupplierRepository interface {
repository.BaseRepository[entity.Supplier] repository.BaseRepository[entity.Supplier]
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error)
} }
type SupplierRepositoryImpl struct { type SupplierRepositoryImpl struct {
@@ -29,3 +29,7 @@ func NewSupplierRepository(db *gorm.DB) SupplierRepository {
func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) {
return repository.ExistsByName[entity.Supplier](ctx, r.db, name, excludeID) return repository.ExistsByName[entity.Supplier](ctx, r.db, name, excludeID)
} }
func (r *SupplierRepositoryImpl) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) {
return repository.ExistsByField[entity.Supplier](ctx, r.db, "alias", alias, excludeID)
}
@@ -88,6 +88,13 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti
return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with name %s already exists", req.Name)) return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with name %s already exists", req.Name))
} }
if exists, err := s.Repository.AliasExists(c.Context(), strings.TrimSpace(strings.ToUpper(req.Alias)), nil); err != nil {
s.Log.Errorf("Failed to check supplier alias: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier alias")
} else if exists {
return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with alias %s already exists", strings.TrimSpace(strings.ToUpper(req.Alias))))
}
typ := strings.ToUpper(req.Type) typ := strings.ToUpper(req.Type)
if !utils.IsValidCustomerSupplierType(typ) { if !utils.IsValidCustomerSupplierType(typ) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid supplier type") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid supplier type")
@@ -143,6 +150,12 @@ func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
} }
if req.Alias != nil { if req.Alias != nil {
if exists, err := s.Repository.AliasExists(c.Context(), strings.TrimSpace(strings.ToUpper(*req.Alias)), &id); err != nil {
s.Log.Errorf("Failed to check supplier alias: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier alias")
} else if exists {
return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with alias %s already exists", strings.TrimSpace(strings.ToUpper(*req.Alias))))
}
updateBody["alias"] = strings.TrimSpace(strings.ToUpper(*req.Alias)) updateBody["alias"] = strings.TrimSpace(strings.ToUpper(*req.Alias))
} }
@@ -29,6 +29,10 @@ func (u *UomController) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.UomService.GetAll(c, query) result, totalResults, err := u.UomService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -30,6 +30,10 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error {
AreaId: c.QueryInt("area_id", 0), AreaId: c.QueryInt("area_id", 0),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.WarehouseService.GetAll(c, query) result, totalResults, err := u.WarehouseService.GetAll(c, query)
if err != nil { if err != nil {
return err return err
@@ -121,14 +121,8 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
return nil, err return nil, err
} }
var productWarehouses []entity.ProductWarehouse // move complex DB query into repository for cleaner service
err = s.ProductWarehouseRepo.DB(). productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(c.Context(), "DOC", warehouse.Id)
WithContext(c.Context()).
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN product_categories ON product_categories.id = products.product_category_id").
Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id).
Order("created_at DESC").
Find(&productWarehouses).Error
if err != nil { if err != nil {
s.Log.Errorf("Failed to get product warehouses: %+v", err) s.Log.Errorf("Failed to get product warehouses: %+v", err)
return nil, err return nil, err
@@ -136,8 +130,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
if len(productWarehouses) == 0 { if len(productWarehouses) == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse")
} }
// Jumlahkan semua quantity DOC
totalQuantity := 0.0 totalQuantity := 0.0
for _, pw := range productWarehouses { for _, pw := range productWarehouses {
totalQuantity += pw.Quantity totalQuantity += pw.Quantity
@@ -147,7 +139,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient quantity in Product Warehouses") return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient quantity in Product Warehouses")
} }
// Buat satu chickin dengan total quantity
chickinDate, err := utils.ParseDateString(req.ChickInDate) chickinDate, err := utils.ParseDateString(req.ChickInDate)
if err != nil { if err != nil {
s.Log.Errorf("Failed to parse chickin date: %+v", err) s.Log.Errorf("Failed to parse chickin date: %+v", err)
@@ -157,7 +148,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
ProjectFlockKandangId: projectflockkandang.Id, ProjectFlockKandangId: projectflockkandang.Id,
ChickInDate: chickinDate, ChickInDate: chickinDate,
Quantity: totalQuantity, Quantity: totalQuantity,
Note: "", Note: req.Note,
CreatedBy: 1, //todo: ganti dengan user login CreatedBy: 1, //todo: ganti dengan user login
} }
err = s.Repository.CreateOne(c.Context(), newChickin, nil) err = s.Repository.CreateOne(c.Context(), newChickin, nil)
@@ -176,7 +167,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
return nil, err return nil, err
} }
// add ke detail chickin
newChickinDetail := &entity.ProjectChickinDetail{ newChickinDetail := &entity.ProjectChickinDetail{
ProjectChickinId: newChickin.Id, ProjectChickinId: newChickin.Id,
ProductWarehouseId: pw.Id, ProductWarehouseId: pw.Id,
@@ -232,6 +222,9 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if req.ChickInDate != "" { if req.ChickInDate != "" {
updateBody["chick_in_date"] = req.ChickInDate updateBody["chick_in_date"] = req.ChickInDate
} }
if req.Note != "" {
updateBody["note"] = req.Note
}
if len(updateBody) == 0 { if len(updateBody) == 0 {
return s.GetOne(c, id) return s.GetOne(c, id)
} }
@@ -293,7 +286,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
return rollback(err) return rollback(err)
} }
// helper: restore quantities from details; returns (restored bool, error)
restoreFromDetails := func() (bool, error) { restoreFromDetails := func() (bool, error) {
var details []entity.ProjectChickinDetail var details []entity.ProjectChickinDetail
if err := tx.WithContext(c.Context()).Where("project_chickin_id = ?", chickin.Id).Find(&details).Error; err != nil { if err := tx.WithContext(c.Context()).Where("project_chickin_id = ?", chickin.Id).Find(&details).Error; err != nil {
@@ -3,10 +3,12 @@ package validation
type Create struct { type Create struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"`
ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"`
Note string `json:"note" validate:"omitempty`
} }
type Update struct { type Update struct {
ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"`
Note string `json:"note" validate:"omitempty"`
} }
type Query struct { type Query struct {
@@ -246,17 +246,39 @@ func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error {
} }
func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
projectFlockIdStr := c.Query("project_flock_id", "") projectFlockId := c.QueryInt("project_flock_id", 0)
kandangIdStr := c.Query("kandang_id", "") kandangId := c.QueryInt("kandang_id", 0)
result, err := u.ProjectflockService.GetProjectFlockKandangByParams(c, "", projectFlockIdStr, kandangIdStr) if projectFlockId == 0 || kandangId == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id")
}
result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId))
if err != nil { if err != nil {
return err return err
} }
dtoResult := dto.ToProjectFlockKandangDTO(*result)
dtoResult.AvailableQuantity = float64(availableStock)
// populate available quantity for each kandang inside project_flock
if dtoResult.ProjectFlock != nil {
for i := range dtoResult.ProjectFlock.Kandangs {
kand := &dtoResult.ProjectFlock.Kandangs[i]
if kand.Id == 0 {
continue
}
if q, qerr := u.ProjectflockService.GetAvailableDocQuantity(c, kand.Id); qerr == nil {
kand.AvailableQuantity = q
}
}
// remove inner kandangs from project_flock to avoid duplication
dtoResult.ProjectFlock.Kandangs = nil
}
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{Code: fiber.StatusOK, JSON(response.Success{Code: fiber.StatusOK,
Status: "success", Status: "success",
Message: "Get projectflock kandang successfully", Message: "Get projectflock kandang successfully",
Data: dto.ToProjectFlockKandangDTO(*result)}) Data: dtoResult})
} }
@@ -10,10 +10,9 @@ import (
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
) )
// internal DTO used only for lookup response: project flock with kandangs carrying pivot ids
type KandangWithPivotDTO struct { type KandangWithPivotDTO struct {
kandangDTO.KandangBaseDTO kandangDTO.KandangBaseDTO
ProjectFlockKandangId *uint `json:"project_flock_kandang_id,omitempty"` AvailableQuantity float64 `json:"available_quantity"`
} }
type ProjectFlockWithPivotDTO struct { type ProjectFlockWithPivotDTO struct {
@@ -28,11 +27,13 @@ type ProjectFlockWithPivotDTO struct {
} }
type ProjectFlockKandangDTO struct { type ProjectFlockKandangDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
ProjectFlockId uint `json:"project_flock_id"` ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
KandangId uint `json:"kandang_id"` ProjectFlockId uint `json:"project_flock_id"`
Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` KandangId uint `json:"kandang_id"`
ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"`
ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"`
AvailableQuantity float64 `json:"available_quantity"`
} }
func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO {
@@ -44,7 +45,7 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD
var pf *ProjectFlockWithPivotDTO var pf *ProjectFlockWithPivotDTO
if e.ProjectFlock.Id != 0 { if e.ProjectFlock.Id != 0 {
// build project flock with kandangs that include pivot ids
pfLocal := ProjectFlockWithPivotDTO{ pfLocal := ProjectFlockWithPivotDTO{
ProjectFlockBaseDTO: ProjectFlockBaseDTO{ ProjectFlockBaseDTO: ProjectFlockBaseDTO{
Id: e.ProjectFlock.Id, Id: e.ProjectFlock.Id,
@@ -53,7 +54,6 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD
Category: e.ProjectFlock.Category, Category: e.ProjectFlock.Category,
} }
// fill related small summaries
if e.ProjectFlock.Flock.Id != 0 { if e.ProjectFlock.Flock.Id != 0 {
mapped := ToFlockSummaryDTO(e.ProjectFlock.Flock) mapped := ToFlockSummaryDTO(e.ProjectFlock.Flock)
pfLocal.Flock = &mapped pfLocal.Flock = &mapped
@@ -75,23 +75,16 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD
pfLocal.CreatedUser = &mapped pfLocal.CreatedUser = &mapped
} }
// build pivot map
pivotMap := make(map[uint]uint) pivotMap := make(map[uint]uint)
for _, ph := range e.ProjectFlock.KandangHistory { for _, ph := range e.ProjectFlock.KandangHistory {
pivotMap[ph.KandangId] = ph.Id pivotMap[ph.KandangId] = ph.Id
} }
// populate kandangs with pivot ids
for _, k := range e.ProjectFlock.Kandangs { for _, k := range e.ProjectFlock.Kandangs {
kb := kandangDTO.ToKandangBaseDTO(k) kb := kandangDTO.ToKandangBaseDTO(k)
var pid *uint
if v, ok := pivotMap[k.Id]; ok {
vv := v
pid = &vv
}
pfLocal.Kandangs = append(pfLocal.Kandangs, KandangWithPivotDTO{ pfLocal.Kandangs = append(pfLocal.Kandangs, KandangWithPivotDTO{
KandangBaseDTO: kb, KandangBaseDTO: kb,
ProjectFlockKandangId: pid, AvailableQuantity: 0,
}) })
} }
@@ -99,10 +92,12 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD
} }
return ProjectFlockKandangDTO{ return ProjectFlockKandangDTO{
Id: e.Id, Id: e.Id,
ProjectFlockId: e.ProjectFlockId, ProjectFlockKandangId: e.Id,
KandangId: e.KandangId, ProjectFlockId: e.ProjectFlockId,
Kandang: kandang, KandangId: e.KandangId,
ProjectFlock: pf, Kandang: kandang,
ProjectFlock: pf,
AvailableQuantity: 0,
} }
} }
@@ -9,8 +9,10 @@ import (
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gorm.io/gorm" "gorm.io/gorm"
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" rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories"
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
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" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services"
@@ -27,6 +29,8 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid
kandangRepo := rKandang.NewKandangRepository(db) kandangRepo := rKandang.NewKandangRepository(db)
projectflockRepo := rProjectflock.NewProjectflockRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db)
projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
@@ -35,7 +39,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid
panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err))
} }
projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, approvalService, validate) projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ProjectflockRoutes(router, userService, projectflockService) ProjectflockRoutes(router, userService, projectflockService)
@@ -4,14 +4,15 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strconv"
"strings" "strings"
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"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
productWarehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories"
kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
warehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils" utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -29,20 +30,23 @@ type ProjectflockService interface {
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error)
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, int, error)
GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error)
GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
} }
type projectflockService struct { type projectflockService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
Repository repository.ProjectflockRepository Repository repository.ProjectflockRepository
FlockRepo flockRepository.FlockRepository FlockRepo flockRepository.FlockRepository
KandangRepo kandangRepository.KandangRepository KandangRepo kandangRepository.KandangRepository
PivotRepo repository.ProjectFlockKandangRepository WarehouseRepo warehouseRepository.WarehouseRepository
ApprovalSvc commonSvc.ApprovalService ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository
approvalWorkflow approvalutils.ApprovalWorkflowKey ProjectFlockKandangRepo repository.ProjectFlockKandangRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
} }
type FlockPeriodSummary struct { type FlockPeriodSummary struct {
@@ -54,19 +58,23 @@ func NewProjectflockService(
repo repository.ProjectflockRepository, repo repository.ProjectflockRepository,
flockRepo flockRepository.FlockRepository, flockRepo flockRepository.FlockRepository,
kandangRepo kandangRepository.KandangRepository, kandangRepo kandangRepository.KandangRepository,
pivotRepo repository.ProjectFlockKandangRepository, ProjectFlockKandangRepo repository.ProjectFlockKandangRepository,
warehouseRepo warehouseRepository.WarehouseRepository,
productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
validate *validator.Validate, validate *validator.Validate,
) ProjectflockService { ) ProjectflockService {
return &projectflockService{ return &projectflockService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
FlockRepo: flockRepo, FlockRepo: flockRepo,
KandangRepo: kandangRepo, KandangRepo: kandangRepo,
PivotRepo: pivotRepo, WarehouseRepo: warehouseRepo,
ApprovalSvc: approvalSvc, ProductWarehouseRepo: productWarehouseRepo,
approvalWorkflow: utils.ApprovalWorkflowProjectFlock, ProjectFlockKandangRepo: ProjectFlockKandangRepo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowProjectFlock,
} }
} }
@@ -641,55 +649,48 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error {
return nil return nil
} }
func (s projectflockService) GetProjectFlockKandang(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, error) { func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, int, error) {
// keep for backward compatibility; delegate to new consolidated method
return s.GetProjectFlockKandangByParams(ctx, fmt.Sprintf("%d", id), "", "")
}
func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { availableStock, err := s.GetAvailableDocQuantity(ctx, kandangID)
if err != nil {
return nil, 0, err
}
pfk, err := s.PivotRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID) projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") return nil, 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found")
} }
return nil, err return nil, 0, err
} }
return pfk, nil
return projectFlockKandang, int(availableStock), nil
} }
func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) { func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) {
idStr = strings.TrimSpace(idStr)
projectFlockIdStr = strings.TrimSpace(projectFlockIdStr)
kandangIdStr = strings.TrimSpace(kandangIdStr)
if idStr != "" { wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID)
id, err := strconv.Atoi(idStr) if err != nil {
if err != nil || id <= 0 { return 0, err
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
pfk, err := s.PivotRepo.GetByID(ctx.Context(), uint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found")
}
return nil, err
}
return pfk, nil
} }
if projectFlockIdStr == "" || kandangIdStr == "" { var productWarehouses []entity.ProductWarehouse
return nil, fiber.NewError(fiber.StatusBadRequest, "Missing lookup parameters") err = s.ProductWarehouseRepo.DB().
WithContext(ctx.Context()).
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN product_categories ON product_categories.id = products.product_category_id").
Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", wh.Id).
Order("created_at DESC").
Find(&productWarehouses).Error
if err != nil {
return 0, err
} }
pfid, err := strconv.Atoi(projectFlockIdStr)
if err != nil || pfid <= 0 { total := 0.0
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") for _, pw := range productWarehouses {
total += pw.Quantity
} }
kid, err := strconv.Atoi(kandangIdStr) return total, nil
if err != nil || kid <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id")
}
return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid))
} }
func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) {
@@ -784,7 +785,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs")
} }
pivotRepo := s.pivotRepoWithTx(dbTransaction) ProjectFlockKandangRepo := s.ProjectFlockKandangRepoWithTx(dbTransaction)
records := make([]*entity.ProjectFlockKandang, len(kandangIDs)) records := make([]*entity.ProjectFlockKandang, len(kandangIDs))
for i, id := range kandangIDs { for i, id := range kandangIDs {
records[i] = &entity.ProjectFlockKandang{ records[i] = &entity.ProjectFlockKandang{
@@ -792,7 +793,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *
KandangId: id, KandangId: id,
} }
} }
if err := pivotRepo.CreateMany(ctx, records); err != nil { if err := ProjectFlockKandangRepo.CreateMany(ctx, records); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history")
} }
return nil return nil
@@ -814,15 +815,15 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs")
} }
if err := s.pivotRepoWithTx(dbTransaction).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { if err := s.ProjectFlockKandangRepoWithTx(dbTransaction).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history")
} }
return nil return nil
} }
func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { func (s projectflockService) ProjectFlockKandangRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository {
if s.PivotRepo == nil { if s.ProjectFlockKandangRepo == nil {
return repository.NewProjectFlockKandangRepository(dbTransaction) return repository.NewProjectFlockKandangRepository(dbTransaction)
} }
return s.PivotRepo.WithTx(dbTransaction) return s.ProjectFlockKandangRepo.WithTx(dbTransaction)
} }
@@ -23,10 +23,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)
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), }
if projectFlockID > 0 {
query.ProjectFlockKandangId = uint(projectFlockID)
} }
result, totalResults, err := u.RecordingService.GetAll(c, query) result, totalResults, err := u.RecordingService.GetAll(c, query)
@@ -67,7 +71,30 @@ func (u *RecordingController) GetOne(c *fiber.Ctx) error {
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
Message: "Get recording successfully", Message: "Get recording successfully",
Data: dto.ToRecordingListDTO(*result), Data: dto.ToRecordingDetailDTO(*result),
})
}
func (u *RecordingController) GetNextDay(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
if projectFlockID <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get next recording day successfully",
Data: fiber.Map{
"project_flock_kandang_id": projectFlockID,
"next_day": nextDay,
},
}) })
} }
@@ -88,7 +115,7 @@ func (u *RecordingController) CreateOne(c *fiber.Ctx) error {
Code: fiber.StatusCreated, Code: fiber.StatusCreated,
Status: "success", Status: "success",
Message: "Create recording successfully", Message: "Create recording successfully",
Data: dto.ToRecordingListDTO(*result), Data: dto.ToRecordingDetailDTO(*result),
}) })
} }
@@ -115,7 +142,7 @@ func (u *RecordingController) UpdateOne(c *fiber.Ctx) error {
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
Message: "Update recording successfully", Message: "Update recording successfully",
Data: dto.ToRecordingListDTO(*result), Data: dto.ToRecordingDetailDTO(*result),
}) })
} }
@@ -10,42 +10,102 @@ import (
// === DTO Structs === // === DTO Structs ===
type RecordingBaseDTO struct { type RecordingBaseDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
RecordDatetime time.Time `json:"record_datetime"`
RecordDate *time.Time `json:"record_date,omitempty"`
Ontime bool `json:"ontime"`
Day *int `json:"day,omitempty"`
TotalDepletion *int `json:"total_depletion,omitempty"`
CumDepletionRate *float64 `json:"cum_depletion_rate,omitempty"`
DailyGain *float64 `json:"daily_gain,omitempty"`
AvgDailyGain *float64 `json:"avg_daily_gain,omitempty"`
CumIntake *int64 `json:"cum_intake,omitempty"`
FcrValue *float64 `json:"fcr_value,omitempty"`
TotalChick *int64 `json:"total_chick,omitempty"`
DailyDepletionRate *float64 `json:"daily_depletion_rate,omitempty"`
CumDepletion *int `json:"cum_depletion,omitempty"`
} }
type RecordingListDTO struct { type RecordingListDTO struct {
RecordingBaseDTO RecordingBaseDTO
CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type RecordingDetailDTO struct { type RecordingDetailDTO struct {
RecordingListDTO RecordingListDTO
BodyWeights []RecordingBodyWeightDTO `json:"body_weights"`
Depletions []RecordingDepletionDTO `json:"depletions"`
Stocks []RecordingStockDTO `json:"stocks"`
}
type RecordingBodyWeightDTO struct {
Weight float64 `json:"weight"`
Qty int `json:"qty"`
Notes *string `json:"notes,omitempty"`
}
type RecordingDepletionDTO struct {
ProductWarehouseId uint `json:"product_warehouse_id"`
Total int64 `json:"total"`
Notes *string `json:"notes,omitempty"`
}
type RecordingStockDTO struct {
ProductWarehouseId uint `json:"product_warehouse_id"`
Increase *float64 `json:"increase,omitempty"`
Decrease *float64 `json:"decrease,omitempty"`
UsageAmount *int64 `json:"usage_amount,omitempty"`
Notes *string `json:"notes,omitempty"`
} }
// === Mapper Functions === // === Mapper Functions ===
func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO {
recordDate := e.RecordDate
if recordDate == nil {
rd := time.Date(
e.RecordDatetime.Year(),
e.RecordDatetime.Month(),
e.RecordDatetime.Day(),
0, 0, 0, 0,
e.RecordDatetime.Location(),
)
recordDate = &rd
}
return RecordingBaseDTO{ return RecordingBaseDTO{
Id: e.Id, Id: e.Id,
Name: e.Name, ProjectFlockKandangId: e.ProjectFlockKandangId,
RecordDatetime: e.RecordDatetime,
RecordDate: recordDate,
Ontime: e.Ontime == 1,
Day: e.Day,
TotalDepletion: e.TotalDepletion,
CumDepletionRate: e.CumDepletionRate,
DailyGain: e.DailyGain,
AvgDailyGain: e.AvgDailyGain,
CumIntake: e.CumIntake,
FcrValue: e.FcrValue,
TotalChick: e.TotalChick,
DailyDepletionRate: e.DailyDepletionRate,
CumDepletion: e.CumDepletion,
} }
} }
func ToRecordingListDTO(e entity.Recording) RecordingListDTO { func ToRecordingListDTO(e entity.Recording) RecordingListDTO {
var createdUser *userDTO.UserBaseDTO var createdUser *userDTO.UserBaseDTO
if e.CreatedUser.Id != 0 { if e.CreatedUser != nil && e.CreatedUser.Id != 0 {
mapped := userDTO.ToUserBaseDTO(e.CreatedUser) mapped := userDTO.ToUserBaseDTO(*e.CreatedUser)
createdUser = &mapped createdUser = &mapped
} }
return RecordingListDTO{ return RecordingListDTO{
RecordingBaseDTO: ToRecordingBaseDTO(e), RecordingBaseDTO: ToRecordingBaseDTO(e),
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser, CreatedUser: createdUser,
} }
} }
@@ -60,5 +120,46 @@ func ToRecordingListDTOs(e []entity.Recording) []RecordingListDTO {
func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO {
return RecordingDetailDTO{ return RecordingDetailDTO{
RecordingListDTO: ToRecordingListDTO(e), RecordingListDTO: ToRecordingListDTO(e),
BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights),
Depletions: ToRecordingDepletionDTOs(e.Depletions),
Stocks: ToRecordingStockDTOs(e.Stocks),
} }
} }
func ToRecordingBodyWeightDTOs(bodyWeights []entity.RecordingBW) []RecordingBodyWeightDTO {
result := make([]RecordingBodyWeightDTO, len(bodyWeights))
for i, bw := range bodyWeights {
result[i] = RecordingBodyWeightDTO{
Weight: bw.Weight,
Qty: bw.Qty,
Notes: bw.Notes,
}
}
return result
}
func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []RecordingDepletionDTO {
result := make([]RecordingDepletionDTO, len(depletions))
for i, d := range depletions {
result[i] = RecordingDepletionDTO{
ProductWarehouseId: d.ProductWarehouseId,
Total: d.Total,
Notes: d.Notes,
}
}
return result
}
func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO {
result := make([]RecordingStockDTO, len(stocks))
for i, s := range stocks {
result[i] = RecordingStockDTO{
ProductWarehouseId: s.ProductWarehouseId,
Increase: s.Increase,
Decrease: s.Decrease,
UsageAmount: s.UsageAmount,
Notes: s.Notes,
}
}
return result
}
@@ -5,6 +5,8 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
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"
@@ -16,11 +18,12 @@ 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)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
recordingService := sRecording.NewRecordingService(recordingRepo, validate) recordingService := sRecording.NewRecordingService(recordingRepo, projectFlockKandangRepo, productWarehouseRepo, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
RecordingRoutes(router, userService, recordingService) RecordingRoutes(router, userService, recordingService)
} }
@@ -21,6 +21,7 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS
// route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne)
route.Get("/", ctrl.GetAll) route.Get("/", ctrl.GetAll)
route.Get("/next-day", ctrl.GetNextDay)
route.Post("/", ctrl.CreateOne) route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne) route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne) route.Patch("/:id", ctrl.UpdateOne)
@@ -2,8 +2,15 @@ package service
import ( import (
"errors" "errors"
"fmt"
"math"
"sort"
"strings"
"time"
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"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/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"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -17,27 +24,43 @@ import (
type RecordingService interface { type RecordingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error)
GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint) (int, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error)
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
} }
type recordingService struct { type recordingService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
Repository repository.RecordingRepository Repository repository.RecordingRepository
ProjectFlockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
} }
func NewRecordingService(repo repository.RecordingRepository, validate *validator.Validate) RecordingService { func NewRecordingService(
repo repository.RecordingRepository,
projectFlockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
validate *validator.Validate,
) RecordingService {
return &recordingService{ return &recordingService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
ProductWarehouseRepo: productWarehouseRepo,
} }
} }
func (s recordingService) withRelations(db *gorm.DB) *gorm.DB { func (s recordingService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser") return db.
Preload("CreatedUser").
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("BodyWeights").
Preload("Depletions").
Preload("Stocks")
} }
func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) { func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) {
@@ -45,14 +68,22 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
return nil, 0, err return nil, 0, err
} }
offset := (params.Page - 1) * params.Limit limit := params.Limit
if limit == 0 {
limit = 10
}
page := params.Page
if page == 0 {
page = 1
}
offset := (page - 1) * limit
recordings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
if params.Search != "" { if params.ProjectFlockKandangId != 0 {
return db.Where("name LIKE ?", "%"+params.Search+"%") db = db.Where("project_flock_id = ?", params.ProjectFlockKandangId)
} }
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("record_datetime DESC").Order("created_at DESC")
}) })
if err != nil { if err != nil {
@@ -74,21 +105,111 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro
return recording, nil return recording, nil
} }
func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) (int, error) {
if projectFlockKandangId == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
db := s.Repository.DB().WithContext(c.Context())
next, err := s.generateNextDay(db, projectFlockKandangId)
if err != nil {
s.Log.Errorf("Failed to compute next recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err)
return 0, err
}
return next, nil
}
func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
createBody := &entity.Recording{ if _, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId); err != nil {
Name: req.Name, if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found")
}
s.Log.Errorf("Failed to get project flock kandang: %+v", err)
return nil, err
} }
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions); err != nil {
return nil, err
}
tx := s.Repository.DB().WithContext(c.Context()).Begin()
if tx.Error != nil {
s.Log.Errorf("Failed to start recording transaction: %+v", tx.Error)
return nil, tx.Error
}
defer func() {
if r := recover(); r != nil {
_ = tx.Rollback()
panic(r)
}
}()
nextDay, err := s.generateNextDay(tx, req.ProjectFlockKandangId)
if err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to determine recording day: %+v", err)
return nil, err
}
currentTime := time.Now().UTC()
recordTime := currentTime
recordDate := time.Date(
recordTime.Year(),
recordTime.Month(),
recordTime.Day(),
0, 0, 0, 0,
recordTime.Location(),
)
ontimeFlag := computeOntime(recordTime, currentTime)
recording := &entity.Recording{
ProjectFlockKandangId: req.ProjectFlockKandangId,
RecordDatetime: recordTime,
RecordDate: &recordDate,
Ontime: boolToInt(ontimeFlag),
Day: &nextDay,
CreatedBy: 1, // TODO: replace with authenticated user
}
if err := tx.Create(recording).Error; err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to create recording: %+v", err) s.Log.Errorf("Failed to create recording: %+v", err)
return nil, err return nil, err
} }
return s.GetOne(c, createBody.Id) if err := s.persistBodyWeights(tx, recording.Id, req.BodyWeights); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to persist body weights: %+v", err)
return nil, err
}
if err := s.persistStocks(tx, recording.Id, req.Stocks); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to persist stocks: %+v", err)
return nil, err
}
if err := s.persistDepletions(tx, recording.Id, req.Depletions); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to persist depletions: %+v", err)
return nil, err
}
if err := s.computeAndUpdateMetrics(tx, recording); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to compute recording metrics: %+v", err)
return nil, err
}
if err := tx.Commit().Error; err != nil {
s.Log.Errorf("Failed to commit recording transaction: %+v", err)
return nil, err
}
return s.GetOne(c, recording.Id)
} }
func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) { func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) {
@@ -96,21 +217,74 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return nil, err return nil, err
} }
updateBody := make(map[string]any) tx := s.Repository.DB().WithContext(c.Context()).Begin()
if tx.Error != nil {
if req.Name != nil { s.Log.Errorf("Failed to start recording transaction: %+v", tx.Error)
updateBody["name"] = *req.Name return nil, tx.Error
} }
defer func() {
if r := recover(); r != nil {
_ = tx.Rollback()
panic(r)
}
}()
if len(updateBody) == 0 { var recording entity.Recording
return s.GetOne(c, id) if err := tx.First(&recording, id).Error; err != nil {
} _ = tx.Rollback()
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found")
} }
s.Log.Errorf("Failed to update recording: %+v", err) s.Log.Errorf("Failed to find recording: %+v", err)
return nil, err
}
ontimeValue := boolToInt(computeOntime(recording.RecordDatetime, time.Now().UTC()))
if err := tx.Model(&entity.Recording{}).Where("id = ?", id).Update("ontime", ontimeValue).Error; err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to refresh ontime flag: %+v", err)
return nil, err
}
recording.Ontime = ontimeValue
if req.BodyWeights != nil {
if err := s.replaceBodyWeights(tx, recording.Id, req.BodyWeights); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to update body weights: %+v", err)
return nil, err
}
}
if req.Stocks != nil {
if err := s.ensureProductWarehousesExist(c, req.Stocks, nil); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := s.replaceStocks(tx, recording.Id, req.Stocks); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to update stocks: %+v", err)
return nil, err
}
}
if req.Depletions != nil {
if err := s.ensureProductWarehousesExist(c, nil, req.Depletions); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := s.replaceDepletions(tx, recording.Id, req.Depletions); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to update depletions: %+v", err)
return nil, err
}
}
if err := s.computeAndUpdateMetrics(tx, &recording); err != nil {
_ = tx.Rollback()
s.Log.Errorf("Failed to recompute recording metrics: %+v", err)
return nil, err
}
if err := tx.Commit().Error; err != nil {
s.Log.Errorf("Failed to commit recording transaction: %+v", err)
return nil, err return nil, err
} }
@@ -127,3 +301,501 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
} }
return nil return nil
} }
// === Persistence Helpers ===
func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion) error {
idSet := make(map[uint]struct{})
for _, stock := range stocks {
if stock.ProductWarehouseId != 0 {
idSet[stock.ProductWarehouseId] = struct{}{}
}
}
for _, dep := range depletions {
if dep.ProductWarehouseId != 0 {
idSet[dep.ProductWarehouseId] = struct{}{}
}
}
if len(idSet) == 0 {
return nil
}
for id := range idSet {
ok, err := s.ProductWarehouseRepo.ExistsByID(c.Context(), id)
if err != nil {
s.Log.Errorf("Failed to validate product warehouse %d: %+v", id, err)
return err
}
if !ok {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d not found", id))
}
}
return nil
}
func (s *recordingService) generateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) {
var days []int
if err := tx.Model(&entity.Recording{}).
Where("project_flock_id = ?", projectFlockKandangId).
Where("day IS NOT NULL").
Pluck("day", &days).Error; err != nil {
return 0, err
}
return nextRecordingDay(days), nil
}
func nextRecordingDay(days []int) int {
if len(days) == 0 {
return 1
}
unique := make(map[int]struct{}, len(days))
for _, day := range days {
if day > 0 {
unique[day] = struct{}{}
}
}
normalized := make([]int, 0, len(unique))
for day := range unique {
normalized = append(normalized, day)
}
sort.Ints(normalized)
for idx, day := range normalized {
expected := idx + 1
if day != expected {
return expected
}
}
return len(normalized) + 1
}
func computeOntime(recordDatetime, reference time.Time) bool {
return !recordDatetime.Before(reference)
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
func (s *recordingService) persistBodyWeights(tx *gorm.DB, recordingID uint, payload []validation.BodyWeight) error {
if len(payload) == 0 {
return nil
}
bodyWeights := make([]entity.RecordingBW, len(payload))
for i, bw := range payload {
bodyWeights[i] = entity.RecordingBW{
RecordingId: recordingID,
Weight: bw.Weight,
Qty: bw.Qty,
Notes: bw.Notes,
}
}
return tx.Create(&bodyWeights).Error
}
func (s *recordingService) persistStocks(tx *gorm.DB, recordingID uint, payload []validation.Stock) error {
if len(payload) == 0 {
return nil
}
stocks := make([]entity.RecordingStock, len(payload))
for i, stock := range payload {
stocks[i] = entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: stock.ProductWarehouseId,
Notes: stock.Notes,
}
if stock.Increase != nil {
val := *stock.Increase
stocks[i].Increase = &val
}
if stock.Decrease != nil {
val := *stock.Decrease
stocks[i].Decrease = &val
}
if stock.UsageAmount != nil {
val := *stock.UsageAmount
stocks[i].UsageAmount = &val
}
}
return tx.Create(&stocks).Error
}
func (s *recordingService) persistDepletions(tx *gorm.DB, recordingID uint, payload []validation.Depletion) error {
if len(payload) == 0 {
return nil
}
depletions := make([]entity.RecordingDepletion, len(payload))
for i, depl := range payload {
total := depl.Total
depletions[i] = entity.RecordingDepletion{
RecordingId: recordingID,
ProductWarehouseId: depl.ProductWarehouseId,
Total: total,
Notes: depl.Notes,
}
}
return tx.Create(&depletions).Error
}
func (s *recordingService) replaceBodyWeights(tx *gorm.DB, recordingID uint, payload []validation.BodyWeight) error {
if err := tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingBW{}).Error; err != nil {
return err
}
return s.persistBodyWeights(tx, recordingID, payload)
}
func (s *recordingService) replaceStocks(tx *gorm.DB, recordingID uint, payload []validation.Stock) error {
if err := tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error; err != nil {
return err
}
return s.persistStocks(tx, recordingID, payload)
}
func (s *recordingService) replaceDepletions(tx *gorm.DB, recordingID uint, payload []validation.Depletion) error {
if err := tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingDepletion{}).Error; err != nil {
return err
}
return s.persistDepletions(tx, recordingID, payload)
}
// === Metrics Calculation ===
func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entity.Recording) error {
day := 0
if recording.Day != nil {
day = *recording.Day
}
totalDepletion, err := s.sumRecordingDepletions(tx, recording.Id)
if err != nil {
return fmt.Errorf("sumRecordingDepletions: %w", err)
}
prevRecording, err := s.getPreviousRecording(tx, recording.ProjectFlockKandangId, day)
if err != nil {
return fmt.Errorf("getPreviousRecording: %w", err)
}
var prevCumDepletion int64
var prevCumIntake float64
var prevAvgWeight float64
if prevRecording != nil {
if prevRecording.CumDepletion != nil {
prevCumDepletion = int64(*prevRecording.CumDepletion)
}
if prevRecording.CumIntake != nil {
prevCumIntake = float64(*prevRecording.CumIntake)
}
prevAvgWeight, err = s.getAverageBodyWeight(tx, prevRecording.Id)
if err != nil {
return fmt.Errorf("getAverageBodyWeight(prev): %w", err)
}
}
totalChick, err := s.getTotalChick(tx, recording.ProjectFlockKandangId)
if err != nil {
return fmt.Errorf("getTotalChick: %w", err)
}
currentAvgWeight, err := s.getAverageBodyWeight(tx, recording.Id)
if err != nil {
return fmt.Errorf("getAverageBodyWeight(current): %w", err)
}
usageInGrams, err := s.getFeedUsageInGrams(tx, recording.Id)
if err != nil {
return fmt.Errorf("getFeedUsageInGrams: %w", err)
}
fcrId, err := s.getFcrID(tx, recording.ProjectFlockKandangId)
if err != nil {
return fmt.Errorf("getFcrID: %w", err)
}
currentAvgGrams := toGrams(currentAvgWeight)
currentAvgKg := gramsToKg(currentAvgGrams)
prevAvgGrams := toGrams(prevAvgWeight)
totalDepletionInt := int(totalDepletion)
cumDepletion := prevCumDepletion + totalDepletion
cumDepletionInt := int(cumDepletion)
updates := map[string]any{
"total_depletion": totalDepletionInt,
"cum_depletion": cumDepletionInt,
}
recording.TotalDepletion = &totalDepletionInt
recording.CumDepletion = &cumDepletionInt
if totalChick > 0 {
updates["total_chick"] = totalChick
recording.TotalChick = &totalChick
cumRate := (float64(cumDepletion) / float64(totalChick)) * 100
updates["cum_depletion_rate"] = cumRate
recording.CumDepletionRate = &cumRate
remainingAfter := totalChick - cumDepletion
if remainingAfter <= 0 {
remainingAfter = 1
}
dailyRate := (float64(totalDepletion) / float64(remainingAfter)) * 100
updates["daily_depletion_rate"] = dailyRate
recording.DailyDepletionRate = &dailyRate
} else {
updates["total_chick"] = gorm.Expr("NULL")
updates["cum_depletion_rate"] = gorm.Expr("NULL")
updates["daily_depletion_rate"] = gorm.Expr("NULL")
recording.TotalChick = nil
recording.CumDepletionRate = nil
recording.DailyDepletionRate = nil
}
if currentAvgGrams > 0 && prevAvgGrams > 0 {
dailyGainKg := (currentAvgGrams - prevAvgGrams) / 1000
updates["daily_gain"] = dailyGainKg
recording.DailyGain = &dailyGainKg
} else {
updates["daily_gain"] = gorm.Expr("NULL")
recording.DailyGain = nil
}
if fcrId != 0 && currentAvgKg > 0 && day > 0 {
if fcrWeightKg, ok, err := s.getFcrStandardWeightKg(tx, fcrId, currentAvgKg); err != nil {
return fmt.Errorf("getFcrStandardWeightKg: %w", err)
} else if ok {
avgDailyGain := (currentAvgKg - fcrWeightKg) / float64(day)
updates["avg_daily_gain"] = avgDailyGain
recording.AvgDailyGain = &avgDailyGain
} else {
updates["avg_daily_gain"] = gorm.Expr("NULL")
recording.AvgDailyGain = nil
}
} else {
updates["avg_daily_gain"] = gorm.Expr("NULL")
recording.AvgDailyGain = nil
}
if usageInGrams > 0 && totalChick > 0 {
var cumIntakeValue float64
if prevRecording == nil || prevRecording.CumIntake == nil {
cumIntakeValue = usageInGrams / float64(totalChick)
} else {
remaining := float64(totalChick - cumDepletion)
if remaining <= 0 {
remaining = float64(totalChick)
}
cumIntakeValue = prevCumIntake + (usageInGrams / remaining)
}
cumIntakeRounded := int64(math.Round(cumIntakeValue))
updates["cum_intake"] = cumIntakeRounded
recording.CumIntake = &cumIntakeRounded
} else if prevRecording != nil && prevRecording.CumIntake != nil {
// Keep previous cumulative intake if no additional feed usage provided
updates["cum_intake"] = *prevRecording.CumIntake
recording.CumIntake = prevRecording.CumIntake
} else {
updates["cum_intake"] = gorm.Expr("NULL")
recording.CumIntake = nil
}
if usageInGrams > 0 && currentAvgKg > 0 {
feedUsageKg := usageInGrams / 1000
fcrValue := feedUsageKg / currentAvgKg
updates["fcr_value"] = fcrValue
recording.FcrValue = &fcrValue
} else {
updates["fcr_value"] = gorm.Expr("NULL")
recording.FcrValue = nil
}
if err := tx.Model(&entity.Recording{}).
Where("id = ?", recording.Id).
Updates(updates).Error; err != nil {
return err
}
return nil
}
// === Query Helpers ===
func (s *recordingService) sumRecordingDepletions(tx *gorm.DB, recordingID uint) (int64, error) {
var result int64
if err := tx.Model(&entity.RecordingDepletion{}).
Where("recording_id = ?", recordingID).
Select("COALESCE(SUM(total), 0)").
Scan(&result).Error; err != nil {
return 0, err
}
return result, nil
}
func (s *recordingService) getPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) {
if currentDay <= 1 {
return nil, nil
}
var prev entity.Recording
err := tx.
Where("project_flock_id = ? AND day < ?", projectFlockKandangId, currentDay).
Where("day IS NOT NULL").
Order("day DESC").
Limit(1).
Find(&prev).Error
if errors.Is(err, gorm.ErrRecordNotFound) || prev.Id == 0 {
return nil, nil
}
if err != nil {
return nil, err
}
return &prev, nil
}
func (s *recordingService) getTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) {
var population entity.ProjectFlockPopulation
err := tx.
Where("project_flock_kandang_id = ?", projectFlockKandangId).
Order("created_at DESC").
First(&population).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return int64(math.Round(population.InitialQuantity)), nil
}
func (s *recordingService) getAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) {
var result struct {
TotalWeight float64
TotalQty float64
}
if err := tx.Model(&entity.RecordingBW{}).
Select("COALESCE(SUM(weight * qty), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty").
Where("recording_id = ?", recordingID).
Scan(&result).Error; err != nil {
return 0, err
}
if result.TotalQty == 0 {
return 0, nil
}
return result.TotalWeight / result.TotalQty, nil
}
func (s *recordingService) getFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) {
var rows []struct {
UsageAmount float64
UomName string
}
if err := tx.
Table("recording_stocks").
Select("COALESCE(recording_stocks.usage_amount, 0) AS usage_amount, LOWER(uoms.name) AS uom_name").
Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN uoms ON uoms.id = products.uom_id").
Where("recording_stocks.recording_id = ?", recordingID).
Scan(&rows).Error; err != nil {
return 0, err
}
var total float64
for _, row := range rows {
if row.UsageAmount <= 0 {
continue
}
switch strings.TrimSpace(row.UomName) {
case "kilogram", "kg", "kilograms", "kilo":
total += row.UsageAmount * 1000
case "gram", "g", "grams":
total += row.UsageAmount
default:
total += row.UsageAmount
}
}
return total, nil
}
func (s *recordingService) getFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) {
var result struct {
FcrID uint
}
if err := tx.Table("project_flock_kandangs").
Select("project_flocks.fcr_id AS fcr_id").
Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id").
Where("project_flock_kandangs.id = ?", projectFlockKandangId).
Scan(&result).Error; err != nil {
return 0, err
}
return result.FcrID, nil
}
func (s *recordingService) getFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) {
if fcrId == 0 {
return 0, false, nil
}
var standard entity.FcrStandard
err := tx.
Where("fcr_id = ? AND weight >= ?", fcrId, currentWeightKg).
Order("weight ASC").
First(&standard).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = tx.
Where("fcr_id = ?", fcrId).
Order("weight DESC").
First(&standard).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, false, nil
}
}
if err != nil {
return 0, false, err
}
weight := standard.Weight
if weight > 10 {
// assume already in grams
return weight / 1000, true, nil
}
return weight, true, nil
}
// === Unit Helpers ===
func toGrams(weight float64) float64 {
if weight <= 0 {
return 0
}
if weight > 10 {
return weight
}
return weight * 1000
}
func gramsToKg(value float64) float64 {
if value <= 0 {
return 0
}
return value / 1000
}
@@ -1,15 +1,42 @@
package validation package validation
type (
BodyWeight struct {
Weight float64 `json:"weight" validate:"required"`
Qty int `json:"qty" validate:"required,number,min=1"`
Notes *string `json:"notes,omitempty" validate:"omitempty"`
}
Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Increase *float64 `json:"increase,omitempty" validate:"omitempty"`
Decrease *float64 `json:"decrease,omitempty" validate:"omitempty"`
UsageAmount *int64 `json:"usage_amount,omitempty" validate:"omitempty,min=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty"`
}
Depletion struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Total int64 `json:"total" validate:"required,number,min=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty"`
}
)
type Create struct { type Create struct {
Name string `json:"name" validate:"required_strict,min=3"` ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"`
BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
} }
type Update struct { type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"` BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
} }
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"`
Search string `query:"search" validate:"omitempty,max=50"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
} }
+37 -1
View File
@@ -1,6 +1,9 @@
package utils package utils
import "strings" import (
"sort"
"strings"
)
// NormalizeTrim returns the input string without leading/trailing whitespace. // NormalizeTrim returns the input string without leading/trailing whitespace.
func NormalizeTrim(value string) string { func NormalizeTrim(value string) string {
@@ -11,3 +14,36 @@ func NormalizeTrim(value string) string {
func NormalizeUpper(value string) string { func NormalizeUpper(value string) string {
return strings.ToUpper(NormalizeTrim(value)) return strings.ToUpper(NormalizeTrim(value))
} }
// NormalizeFlag trims whitespace, removes surrounding brackets/quotes and returns upper-case flag
func NormalizeFlag(value string) string {
v := NormalizeTrim(value)
v = strings.Trim(v, "[]\"'")
return strings.ToUpper(v)
}
// ParseFlags parses a raw flags string like "[DOC, PAKAN]" or "DOC,PAKAN"
// and returns a deduplicated, sorted slice of normalized flags (upper-case, trimmed).
func ParseFlags(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
set := make(map[string]struct{}, len(parts))
for _, p := range parts {
f := NormalizeFlag(p)
if f == "" {
continue
}
set[f] = struct{}{}
}
if len(set) == 0 {
return nil
}
res := make([]string, 0, len(set))
for k := range set {
res = append(res, k)
}
sort.Strings(res)
return res
}
+4
View File
@@ -29,6 +29,10 @@ func (u *{{Pascal .Entity}}Controller) GetAll(c *fiber.Ctx) error {
Search: c.Query("search", ""), Search: c.Query("search", ""),
} }
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.{{Pascal .Entity}}Service.GetAll(c, query) result, totalResults, err := u.{{Pascal .Entity}}Service.GetAll(c, query)
if err != nil { if err != nil {
return err return err