Merge branch 'development-before-sso' of https://gitlab.com/mbugroup/lti-api into refactor-to-serve/with-middleware

This commit is contained in:
ragilap
2025-11-03 16:57:10 +07:00
70 changed files with 3089 additions and 1065 deletions
Vendored
BIN
View File
Binary file not shown.
+27 -2
View File
@@ -62,9 +62,34 @@ func setupRedis() *redis.Client {
} }
func setupSSO(ctx context.Context, rdb *redis.Client) { func setupSSO(ctx context.Context, rdb *redis.Client) {
if err := sso.Init(ctx, config.SSOJWKSURL, config.SSOIssuer, config.SSOAllowedAudiences); err != nil { const (
utils.Log.Fatalf("SSO initialization failed: %v", err) maxAttempts = 12
retryDelay = 5 * time.Second
)
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := sso.Init(ctx, config.SSOJWKSURL, config.SSOIssuer, config.SSOAllowedAudiences); err != nil {
lastErr = err
utils.Log.WithError(err).Warnf("SSO initialization attempt %d/%d failed", attempt, maxAttempts)
select {
case <-ctx.Done():
utils.Log.Fatalf("SSO initialization aborted: %v", ctx.Err())
case <-time.After(retryDelay):
}
continue
}
lastErr = nil
if attempt > 1 {
utils.Log.Infof("SSO initialization succeeded after %d attempts", attempt)
}
break
} }
if lastErr != nil {
utils.Log.Fatalf("SSO initialization failed: %v", lastErr)
}
if rdb != nil { if rdb != nil {
session.SetRevocationStore(session.NewRevocationStore(rdb, config.SSOTokenBlacklistPrefix)) session.SetRevocationStore(session.NewRevocationStore(rdb, config.SSOTokenBlacklistPrefix))
} else { } else {
+44
View File
@@ -0,0 +1,44 @@
package capabilities
import (
"strings"
recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings"
)
// FromPermissions returns a filtered map of capabilities that the frontend can use
// to toggle features. Only permissions recognized by the application are exposed.
func FromPermissions(perms []string) map[string]bool {
if len(perms) == 0 {
return nil
}
out := make(map[string]bool)
for _, perm := range perms {
if key, ok := normalizeAndAllow(perm); ok {
out[key] = true
}
}
if len(out) == 0 {
return nil
}
return out
}
func normalizeAndAllow(perm string) (string, bool) {
perm = strings.ToLower(strings.TrimSpace(perm))
if perm == "" {
return "", false
}
if _, ok := allowed[perm]; !ok {
return "", false
}
return perm, true
}
var allowed = map[string]struct{}{
recordings.PermissionRecordingRead: {},
recordings.PermissionRecordingCreate: {},
recordings.PermissionRecordingUpdate: {},
recordings.PermissionRecordingDelete: {},
}
@@ -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
}
+11 -3
View File
@@ -12,9 +12,9 @@ import (
) )
type SSOClientConfig struct { type SSOClientConfig struct {
PublicID string `json:"public_id"` PublicID string `json:"public_id"`
RedirectURI string `json:"redirect_uri"` RedirectURI string `json:"redirect_uri"`
Scope string `json:"scope"` Scope string `json:"scope"`
// Prompt string `json:"prompt"` // Prompt string `json:"prompt"`
DefaultReturnURI string `json:"default_return_uri"` DefaultReturnURI string `json:"default_return_uri"`
AllowedReturnOrigins []string `json:"allowed_return_origins"` AllowedReturnOrigins []string `json:"allowed_return_origins"`
@@ -32,6 +32,10 @@ var (
DBPassword string DBPassword string
DBName string DBName string
DBPort int DBPort int
DBSSLMode string
DBSSLRootCert string
DBSSLCert string
DBSSLKey string
JWTSecret string JWTSecret string
JWTAccessExp int JWTAccessExp int
JWTRefreshExp int JWTRefreshExp int
@@ -79,6 +83,10 @@ func init() {
DBPassword = viper.GetString("DB_PASSWORD") DBPassword = viper.GetString("DB_PASSWORD")
DBName = viper.GetString("DB_NAME") DBName = viper.GetString("DB_NAME")
DBPort = viper.GetInt("DB_PORT") DBPort = viper.GetInt("DB_PORT")
DBSSLMode = defaultString(viper.GetString("DB_SSLMODE"), "disable")
DBSSLRootCert = strings.TrimSpace(viper.GetString("DB_SSLROOTCERT"))
DBSSLCert = strings.TrimSpace(viper.GetString("DB_SSLCERT"))
DBSSLKey = strings.TrimSpace(viper.GetString("DB_SSLKEY"))
// jwt configuration // jwt configuration
JWTSecret = viper.GetString("JWT_SECRET") JWTSecret = viper.GetString("JWT_SECRET")
+20 -4
View File
@@ -2,6 +2,7 @@ package database
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/config"
@@ -13,10 +14,25 @@ import (
) )
func Connect(dbHost, dbName string) *gorm.DB { func Connect(dbHost, dbName string) *gorm.DB {
dsn := fmt.Sprintf( parts := []string{
"host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=Asia/Shanghai", fmt.Sprintf("host=%s", dbHost),
dbHost, config.DBUser, config.DBPassword, dbName, config.DBPort, fmt.Sprintf("user=%s", config.DBUser),
) fmt.Sprintf("password=%s", config.DBPassword),
fmt.Sprintf("dbname=%s", dbName),
fmt.Sprintf("port=%d", config.DBPort),
fmt.Sprintf("sslmode=%s", config.DBSSLMode),
"TimeZone=Asia/Shanghai",
}
if config.DBSSLRootCert != "" {
parts = append(parts, fmt.Sprintf("sslrootcert=%s", config.DBSSLRootCert))
}
if config.DBSSLCert != "" {
parts = append(parts, fmt.Sprintf("sslcert=%s", config.DBSSLCert))
}
if config.DBSSLKey != "" {
parts = append(parts, fmt.Sprintf("sslkey=%s", config.DBSSLKey))
}
dsn := strings.Join(parts, " ")
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), Logger: logger.Default.LogMode(logger.Info),
@@ -0,0 +1,98 @@
BEGIN;
DROP INDEX IF EXISTS project_flocks_base_period_unique;
ALTER TABLE project_flocks
ADD COLUMN IF NOT EXISTS flock_id BIGINT;
WITH normalized AS (
SELECT
pf.id,
COALESCE(
NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''),
CONCAT('Project Flock ', pf.id)
) AS normalized_name,
COALESCE(NULLIF(pf.created_by, 0), 1) AS created_by
FROM project_flocks pf
),
seed_flocks AS (
SELECT DISTINCT
n.normalized_name,
MIN(n.created_by) AS created_by
FROM normalized n
GROUP BY n.normalized_name
)
INSERT INTO flocks (name, created_by, created_at, updated_at)
SELECT sf.normalized_name, sf.created_by, NOW(), NOW()
FROM seed_flocks sf
ON CONFLICT DO NOTHING;
WITH normalized AS (
SELECT
pf.id,
COALESCE(
NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''),
CONCAT('Project Flock ', pf.id)
) AS normalized_name
FROM project_flocks pf
),
resolved AS (
SELECT
n.id,
f.id AS flock_id
FROM normalized n
JOIN flocks f ON LOWER(f.name) = LOWER(n.normalized_name)
)
UPDATE project_flocks pf
SET flock_id = resolved.flock_id
FROM resolved
WHERE pf.id = resolved.id;
WITH missing AS (
SELECT
pf.id,
COALESCE(
NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''),
CONCAT('Project Flock ', pf.id)
) AS normalized_name,
COALESCE(NULLIF(pf.created_by, 0), 1) AS created_by
FROM project_flocks pf
WHERE pf.flock_id IS NULL
),
seed_missing AS (
SELECT DISTINCT normalized_name, created_by FROM missing
)
INSERT INTO flocks (name, created_by, created_at, updated_at)
SELECT sm.normalized_name, sm.created_by, NOW(), NOW()
FROM seed_missing sm
ON CONFLICT DO NOTHING;
WITH missing AS (
SELECT
pf.id,
COALESCE(
NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''),
CONCAT('Project Flock ', pf.id)
) AS normalized_name
FROM project_flocks pf
WHERE pf.flock_id IS NULL
)
UPDATE project_flocks pf
SET flock_id = f.id
FROM missing m
JOIN flocks f ON LOWER(f.name) = LOWER(m.normalized_name)
WHERE pf.id = m.id;
ALTER TABLE project_flocks
ALTER COLUMN flock_id SET NOT NULL;
DROP INDEX IF EXISTS project_flocks_flock_name_unique;
ALTER TABLE project_flocks
DROP COLUMN IF EXISTS flock_name;
CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_flock_period_unique
ON project_flocks (flock_id, period)
WHERE deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,55 @@
BEGIN;
ALTER TABLE project_flocks
ADD COLUMN IF NOT EXISTS flock_name VARCHAR(255);
WITH generated_names AS (
SELECT
pf.id,
COALESCE(f.name, CONCAT('Project Flock ', pf.id)) AS base_name,
pf.period,
ROW_NUMBER() OVER (PARTITION BY COALESCE(f.name, CONCAT('Project Flock ', pf.id)) ORDER BY pf.id) AS rn
FROM project_flocks pf
LEFT JOIN flocks f ON f.id = pf.flock_id
)
UPDATE project_flocks pf
SET flock_name = CASE
WHEN gn.period IS NOT NULL THEN
CASE
WHEN gn.rn = 1 THEN CONCAT(gn.base_name, ' ', gn.period)
ELSE CONCAT(gn.base_name, ' ', gn.period, ' ', gn.rn)
END
ELSE
CASE
WHEN gn.rn = 1 THEN gn.base_name
ELSE CONCAT(gn.base_name, ' ', gn.rn)
END
END
FROM generated_names gn
WHERE pf.id = gn.id
AND (pf.flock_name IS NULL OR pf.flock_name = '');
UPDATE project_flocks
SET flock_name = CONCAT('Project Flock ', id)
WHERE flock_name IS NULL OR flock_name = '';
ALTER TABLE project_flocks
ALTER COLUMN flock_name SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_flock_name_unique
ON project_flocks (flock_name)
WHERE deleted_at IS NULL;
DROP INDEX IF EXISTS project_flocks_flock_period_unique;
CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_base_period_unique
ON project_flocks (
LOWER(TRIM(regexp_replace(flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g'))),
period
)
WHERE deleted_at IS NULL;
ALTER TABLE project_flocks
DROP COLUMN IF EXISTS flock_id;
COMMIT;
@@ -0,0 +1,143 @@
BEGIN;
-- Drop newly introduced egg tables
DROP TABLE IF EXISTS grading_eggs;
DROP TABLE IF EXISTS recording_eggs;
-- Revert recording_stocks structure
ALTER TABLE recording_stocks
DROP CONSTRAINT IF EXISTS chk_recording_stocks_nonneg;
ALTER TABLE recording_stocks
DROP COLUMN IF EXISTS usage_qty,
DROP COLUMN IF EXISTS pending_qty;
ALTER TABLE recording_stocks
ADD COLUMN increase NUMERIC(10,3),
ADD COLUMN decrease NUMERIC(10,3),
ADD COLUMN usage_amount BIGINT,
ADD COLUMN notes VARCHAR;
ALTER TABLE recording_stocks
ADD 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)
);
-- Revert recording_depletions structure
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS chk_recording_depl_qty;
ALTER TABLE recording_depletions
ALTER COLUMN qty TYPE BIGINT USING COALESCE(qty, 0)::BIGINT;
ALTER TABLE recording_depletions
RENAME COLUMN qty TO total;
ALTER TABLE recording_depletions
ADD COLUMN notes VARCHAR;
ALTER TABLE recording_depletions
ADD CONSTRAINT chk_recording_depl_total CHECK (total >= 0);
-- Revert recording_bws structure
ALTER TABLE recording_bws
DROP CONSTRAINT IF EXISTS chk_recording_bws_nonneg;
ALTER TABLE recording_bws
ALTER COLUMN qty TYPE INT USING COALESCE(qty, 0)::INT;
ALTER TABLE recording_bws
DROP COLUMN IF EXISTS total_weight;
ALTER TABLE recording_bws
ALTER COLUMN avg_weight TYPE NUMERIC(8,2) USING COALESCE(avg_weight, 0)::NUMERIC(8,2);
ALTER TABLE recording_bws
RENAME COLUMN avg_weight TO weight;
ALTER TABLE recording_bws
ADD COLUMN notes VARCHAR;
UPDATE recording_bws
SET qty = GREATEST(qty, 1);
ALTER TABLE recording_bws
ADD CONSTRAINT chk_recording_bws_nonneg CHECK (weight >= 0 AND qty >= 1);
-- Revert recordings header
DROP INDEX IF EXISTS idx_recordings_flock_datetime;
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS fk_recordings_project_flock_kandang,
DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v2;
ALTER TABLE recordings
ALTER COLUMN total_depletion_qty TYPE INT USING COALESCE(total_depletion_qty, 0)::INT,
ALTER COLUMN total_chick_qty TYPE BIGINT USING COALESCE(total_chick_qty, 0)::BIGINT;
ALTER TABLE recordings
RENAME COLUMN total_depletion_qty TO total_depletion;
ALTER TABLE recordings
RENAME COLUMN total_chick_qty TO total_chick;
ALTER TABLE recordings
ADD COLUMN record_date DATE,
ADD COLUMN status INT NOT NULL DEFAULT 0,
ADD COLUMN ontime INT NOT NULL DEFAULT 0,
ADD COLUMN daily_depletion_rate NUMERIC(7,3),
ADD COLUMN cum_depletion INT;
ALTER TABLE recordings
RENAME COLUMN project_flock_kandangs_id TO project_flock_id;
ALTER TABLE recordings
ADD CONSTRAINT fk_recordings_project_flock
FOREIGN KEY (project_flock_id) REFERENCES project_flock_kandangs(id);
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_status CHECK (status IN (0,1,2,3));
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_ontime CHECK (ontime IN (0,1));
ALTER TABLE recordings
ADD 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)
);
-- Ensure new columns carry derived data
UPDATE recordings
SET record_date = (record_datetime AT TIME ZONE 'Asia/Jakarta')::date
WHERE record_date IS NULL;
-- Restore helper trigger/function and indexes
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;
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 idx_recordings_flock_datetime
ON recordings (project_flock_id, record_datetime);
CREATE UNIQUE INDEX uq_recordings_flock_record_date
ON recordings (project_flock_id, record_date)
WHERE deleted_at IS NULL;
COMMIT;
@@ -0,0 +1,168 @@
BEGIN;
-- Drop trigger & helper function tied to record_date before removing the column
DROP TRIGGER IF EXISTS recordings_set_record_date_trg ON recordings;
DROP FUNCTION IF EXISTS trg_set_record_date();
-- Drop indexes and constraints that reference legacy columns
DROP INDEX IF EXISTS uq_recordings_flock_record_date;
DROP INDEX IF EXISTS idx_recordings_flock_datetime;
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS fk_recordings_project_flock,
DROP CONSTRAINT IF EXISTS chk_recordings_status,
DROP CONSTRAINT IF EXISTS chk_recordings_ontime,
DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives;
-- Align recordings header with the new schema
ALTER TABLE recordings
RENAME COLUMN project_flock_id TO project_flock_kandangs_id;
ALTER TABLE recordings
DROP COLUMN IF EXISTS record_date,
DROP COLUMN IF EXISTS status,
DROP COLUMN IF EXISTS ontime,
DROP COLUMN IF EXISTS daily_depletion_rate,
DROP COLUMN IF EXISTS cum_depletion;
ALTER TABLE recordings
RENAME COLUMN total_depletion TO total_depletion_qty;
ALTER TABLE recordings
RENAME COLUMN total_chick TO total_chick_qty;
ALTER TABLE recordings
ALTER COLUMN total_depletion_qty TYPE NUMERIC(15,3) USING COALESCE(total_depletion_qty, 0)::NUMERIC(15,3),
ALTER COLUMN total_chick_qty TYPE NUMERIC(15,3) USING COALESCE(total_chick_qty, 0)::NUMERIC(15,3),
ALTER COLUMN cum_intake TYPE INT USING COALESCE(cum_intake, 0)::INT;
ALTER TABLE recordings
ADD CONSTRAINT fk_recordings_project_flock_kandang
FOREIGN KEY (project_flock_kandangs_id) REFERENCES project_flock_kandangs(id);
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_nonnegatives_v2 CHECK (
(total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND
(cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND
(daily_gain IS NULL OR daily_gain >= 0) AND
(avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND
(cum_intake IS NULL OR cum_intake >= 0) AND
(fcr_value IS NULL OR fcr_value >= 0) AND
(total_chick_qty IS NULL OR total_chick_qty >= 0)
);
CREATE INDEX idx_recordings_flock_datetime
ON recordings (project_flock_kandangs_id, record_datetime);
-- recording_bws reshape
ALTER TABLE recording_bws
RENAME COLUMN weight TO avg_weight;
ALTER TABLE recording_bws
ALTER COLUMN avg_weight TYPE NUMERIC(8,2) USING COALESCE(avg_weight, 0)::NUMERIC(8,2);
ALTER TABLE recording_bws
ADD COLUMN total_weight NUMERIC(10,3);
UPDATE recording_bws
SET total_weight = COALESCE(avg_weight, 0) * COALESCE(qty, 0);
ALTER TABLE recording_bws
ALTER COLUMN total_weight SET NOT NULL;
ALTER TABLE recording_bws
ALTER COLUMN qty TYPE NUMERIC(15,3) USING COALESCE(qty, 0)::NUMERIC(15,3);
ALTER TABLE recording_bws
DROP COLUMN IF EXISTS notes;
ALTER TABLE recording_bws
DROP CONSTRAINT IF EXISTS chk_recording_bws_nonneg;
ALTER TABLE recording_bws
ADD CONSTRAINT chk_recording_bws_nonneg CHECK (
avg_weight >= 0 AND qty >= 0 AND total_weight >= 0
);
-- recording_depletions reshape
ALTER TABLE recording_depletions
RENAME COLUMN total TO qty;
ALTER TABLE recording_depletions
ALTER COLUMN qty TYPE NUMERIC(15,3) USING COALESCE(qty, 0)::NUMERIC(15,3);
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS notes;
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS chk_recording_depl_total;
ALTER TABLE recording_depletions
ADD CONSTRAINT chk_recording_depl_qty CHECK (qty >= 0);
-- recording_stocks reshape
ALTER TABLE recording_stocks
DROP CONSTRAINT IF EXISTS chk_recording_stocks_nonneg;
ALTER TABLE recording_stocks
DROP COLUMN IF EXISTS increase,
DROP COLUMN IF EXISTS decrease,
DROP COLUMN IF EXISTS usage_amount,
DROP COLUMN IF EXISTS notes;
ALTER TABLE recording_stocks
ADD COLUMN usage_qty NUMERIC(15,3),
ADD COLUMN pending_qty NUMERIC(15,3);
ALTER TABLE recording_stocks
ADD CONSTRAINT chk_recording_stocks_nonneg CHECK (
(usage_qty IS NULL OR usage_qty >= 0) AND
(pending_qty IS NULL OR pending_qty >= 0)
);
-- recording_eggs table
CREATE TABLE recording_eggs (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
qty INT NOT NULL,
created_by BIGINT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_recording_eggs_recording
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE,
CONSTRAINT fk_recording_eggs_product_warehouse
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id),
CONSTRAINT fk_recording_eggs_created_by
FOREIGN KEY (created_by) REFERENCES users(id),
CONSTRAINT chk_recording_eggs_qty CHECK (qty >= 0)
);
CREATE INDEX idx_recording_eggs_recording
ON recording_eggs (recording_id);
CREATE INDEX idx_recording_eggs_product
ON recording_eggs (product_warehouse_id);
-- grading_eggs table
CREATE TABLE grading_eggs (
id BIGSERIAL PRIMARY KEY,
recording_egg_id BIGINT NOT NULL,
qty NUMERIC(15,3) NOT NULL,
grade VARCHAR,
created_by BIGINT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_grading_eggs_recording_egg
FOREIGN KEY (recording_egg_id) REFERENCES recording_eggs(id) ON DELETE CASCADE,
CONSTRAINT fk_grading_eggs_created_by
FOREIGN KEY (created_by) REFERENCES users(id),
CONSTRAINT chk_grading_eggs_qty CHECK (qty >= 0)
);
CREATE INDEX idx_grading_eggs_recording_egg
ON grading_eggs (recording_egg_id);
COMMIT;
+97 -274
View File
@@ -8,7 +8,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -41,21 +40,14 @@ func Run(db *gorm.DB) error {
return err return err
} }
flocks, err := seedFlocks(tx, adminID) if _, err := seedFlocks(tx, adminID); err != nil {
if err != nil {
return err return err
} }
fcrs, err := seedFcr(tx, adminID) if _, err := seedFcr(tx, adminID); err != nil {
if err != nil {
return err return err
} }
if err := seedProjectFlocks(tx, adminID, flocks, areas, fcrs, locations); err != nil {
return err
}
kandangs, err := seedKandangs(tx, adminID, locations, users) kandangs, err := seedKandangs(tx, adminID, locations, users)
if err != nil { if err != nil {
return err return err
@@ -93,10 +85,6 @@ func Run(db *gorm.DB) error {
if err := seedTransferStock(tx, adminID); err != nil { if err := seedTransferStock(tx, adminID); err != nil {
return err return err
} }
if err := seedChickin(tx, adminID); err != nil {
return err
}
fmt.Println("✅ Master data seeding completed") fmt.Println("✅ Master data seeding completed")
return nil return nil
}) })
@@ -243,158 +231,12 @@ func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) {
return result, nil return result, nil
} }
func seedProjectFlocks(
tx *gorm.DB,
createdBy uint,
flocks, areas, fcrs, locations map[string]uint,
) error {
seeds := []struct {
Key string
Flock string
Area string
Category utils.ProjectFlockCategory
Fcr string
Location string
Period int
}{
{
Key: "Singaparna Period 1",
Flock: "Flock Priangan",
Area: "Priangan",
Category: utils.ProjectFlockCategoryGrowing,
Fcr: "FCR Layer",
Location: "Singaparna",
Period: 1,
},
{
Key: "Cikaum Period 1",
Flock: "Flock Banten",
Area: "Banten",
Category: utils.ProjectFlockCategoryGrowing,
Fcr: "FCR Layer",
Location: "Cikaum",
Period: 1,
},
}
for _, seed := range seeds {
flockID, ok := flocks[seed.Flock]
if !ok {
return fmt.Errorf("floc %s not seeded", seed.Flock)
}
areaID, ok := areas[seed.Area]
if !ok {
return fmt.Errorf("area %s not seeded", seed.Area)
}
fcrID, ok := fcrs[seed.Fcr]
if !ok {
return fmt.Errorf("fcr %s not seeded", seed.Fcr)
}
locationID, ok := locations[seed.Location]
if !ok {
return fmt.Errorf("location %s not seeded", seed.Location)
}
var projectFlock entity.ProjectFlock
err := tx.Where(
"flock_id = ? AND area_id = ? AND category = ? AND fcr_id = ? AND location_id = ? AND period = ?",
flockID, areaID, seed.Category, fcrID, locationID, seed.Period,
).First(&projectFlock).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
projectFlock = entity.ProjectFlock{
FlockId: flockID,
AreaId: areaID,
Category: string(seed.Category),
FcrId: fcrID,
LocationId: locationID,
Period: seed.Period,
CreatedBy: createdBy,
}
if err := tx.Create(&projectFlock).Error; err != nil {
return err
}
} else if err != nil {
return err
} else {
if err := tx.Model(&entity.ProjectFlock{}).Where("id = ?", projectFlock.Id).Updates(map[string]any{
"flock_id": flockID,
"area_id": areaID,
"category": string(seed.Category),
"fcr_id": fcrID,
"location_id": locationID,
"period": seed.Period,
}).Error; err != nil {
return err
}
}
if err := ensureProjectFlockApprovals(tx, projectFlock.Id, createdBy); err != nil {
return err
}
}
return nil
}
func ensureProjectFlockApprovals(tx *gorm.DB, projectFlockID uint, actorID uint) error {
if projectFlockID == 0 || actorID == 0 {
return nil
}
workflow := utils.ApprovalWorkflowProjectFlock.String()
steps := []struct {
step approvalutils.ApprovalStep
action entity.ApprovalAction
}{
{step: utils.ProjectFlockStepPengajuan, action: entity.ApprovalActionCreated},
{step: utils.ProjectFlockStepAktif, action: entity.ApprovalActionApproved},
}
for _, cfg := range steps {
var count int64
if err := tx.Model(&entity.Approval{}).
Where("approvable_type = ? AND approvable_id = ? AND step_number = ?", workflow, projectFlockID, uint16(cfg.step)).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
continue
}
stepName, ok := utils.ProjectFlockApprovalSteps[cfg.step]
if !ok || strings.TrimSpace(stepName) == "" {
stepName = fmt.Sprintf("Step %d", cfg.step)
}
var actionPtr *entity.ApprovalAction
action := cfg.action
actionPtr = &action
record := entity.Approval{
ApprovableType: workflow,
ApprovableId: projectFlockID,
StepNumber: uint16(cfg.step),
StepName: stepName,
Action: actionPtr,
ActionBy: uintPtr(actorID),
}
if err := tx.Create(&record).Error; err != nil {
return err
}
}
return nil
}
func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) {
seeds := []struct { seeds := []struct {
Name string Name string
Status utils.KandangStatus Status utils.KandangStatus
Location string Location string
PicKey string PicKey string
}{ }{
{Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"},
{Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"},
@@ -414,16 +256,15 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users
return nil, fmt.Errorf("user %s not seeded", seed.PicKey) return nil, fmt.Errorf("user %s not seeded", seed.PicKey)
} }
var kandang entity.Kandang var kandang entity.Kandang
err := tx.Where("name = ?", seed.Name).First(&kandang).Error err := tx.Where("name = ?", seed.Name).First(&kandang).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
kandang = entity.Kandang{ kandang = entity.Kandang{
Name: seed.Name, Name: seed.Name,
Status: string(seed.Status), Status: string(seed.Status),
LocationId: locID, LocationId: locID,
PicId: picID, PicId: picID,
CreatedBy: createdBy, CreatedBy: createdBy,
} }
if err := tx.Create(&kandang).Error; err != nil { if err := tx.Create(&kandang).Error; err != nil {
return nil, err return nil, err
@@ -446,7 +287,6 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users
return result, nil return result, nil
} }
func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error {
seeds := []struct { seeds := []struct {
Name string Name string
@@ -523,8 +363,10 @@ func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error)
Name string Name string
Code string Code string
}{ }{
{"Pullet", "PLT"},
{"Bahan Baku", "RAW"}, {"Bahan Baku", "RAW"},
{"Day Old Chick", "DOC"}, {"Day Old Chick", "DOC"},
{"Telur", "EGG"},
} }
result := make(map[string]uint, len(seeds)) result := make(map[string]uint, len(seeds))
@@ -728,6 +570,54 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"},
Flags: []utils.FlagType{utils.FlagDOC}, Flags: []utils.FlagType{utils.FlagDOC},
}, },
{
Name: "Ayam Afkir",
Brand: "-",
Sku: "1",
Uom: "Ekor",
Category: "Day Old Chick",
Price: 1,
},
{
Name: "Ayam Mati",
Brand: "-",
Sku: "2",
Uom: "Ekor",
Category: "Day Old Chick",
Price: 1,
},
{
Name: "Ayam Culling",
Brand: "-",
Sku: "3",
Uom: "Ekor",
Category: "Day Old Chick",
Price: 1,
},
{
Name: "Telur Konsumsi Baik",
Brand: "-",
Sku: "4",
Uom: "Unit",
Category: "Telur",
Price: 1,
},
{
Name: "Telur Pecah",
Brand: "-",
Sku: "5",
Uom: "Unit",
Category: "Telur",
Price: 1,
},
{ {
Name: "281 SPECIAL STARTER", Name: "281 SPECIAL STARTER",
Brand: "281 STARTER", Brand: "281 STARTER",
@@ -978,25 +868,44 @@ func seedBanks(tx *gorm.DB, createdBy uint) error {
} }
func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { func seedProductWarehouse(tx *gorm.DB, createdBy uint) error {
seeds := []struct { seeds := []struct {
ProductID uint ProductName string
WarehouseID uint WarehouseName string
Quantity float64 Quantity float64
}{ }{
{ProductID: 1, WarehouseID: 1, Quantity: 100}, {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 100},
{ProductID: 2, WarehouseID: 2, Quantity: 200}, {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 200},
{ProductID: 2, WarehouseID: 1, Quantity: 300}, {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 300},
{ProductID: 1, WarehouseID: 3, Quantity: 5000}, {ProductName: "DOC Broiler", WarehouseName: "Gudang Singaparna 1", Quantity: 5000},
{ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Singaparna 1", Quantity: 600},
{ProductName: "Telur Pecah", WarehouseName: "Gudang Singaparna 1", Quantity: 80},
{ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Cikaum 1", Quantity: 450},
{ProductName: "Telur Pecah", WarehouseName: "Gudang Cikaum 1", Quantity: 60},
} }
for _, seed := range seeds { for _, seed := range seeds {
var product entity.Product
if err := tx.Where("name = ?", seed.ProductName).First(&product).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("product %q not found for product warehouse seeding", seed.ProductName)
}
return err
}
var warehouse entity.Warehouse
if err := tx.Where("name = ?", seed.WarehouseName).First(&warehouse).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("warehouse %q not found for product warehouse seeding", seed.WarehouseName)
}
return err
}
var productWarehouse entity.ProductWarehouse var productWarehouse entity.ProductWarehouse
err := tx.Where("product_id = ? AND warehouse_id = ?", seed.ProductID, seed.WarehouseID).First(&productWarehouse).Error err := tx.Where("product_id = ? AND warehouse_id = ?", product.Id, warehouse.Id).First(&productWarehouse).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
productWarehouse = entity.ProductWarehouse{ productWarehouse = entity.ProductWarehouse{
ProductId: seed.ProductID, ProductId: product.Id,
WarehouseId: seed.WarehouseID, WarehouseId: warehouse.Id,
Quantity: seed.Quantity, Quantity: seed.Quantity,
CreatedBy: createdBy, CreatedBy: createdBy,
} }
@@ -1005,6 +914,12 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error {
} }
} else if err != nil { } else if err != nil {
return err return err
} else {
if err := tx.Model(&productWarehouse).Updates(map[string]any{
"quantity": seed.Quantity,
}).Error; err != nil {
return err
}
} }
} }
@@ -1085,71 +1000,6 @@ func seedTransferStock(tx *gorm.DB, createdBy uint) error {
return nil return nil
} }
func seedChickin(tx *gorm.DB, createdBy uint) error {
// gunakan identitas yang stabil, bukan ID pivot
seeds := []struct {
KandangName string
LocationName string
Period int
ChickInDate string
Quantity float64
Note string
}{
{"Singaparna 1", "Singaparna", 1, "2025-10-20", 100, "Seeder chickin 1"},
{"Cikaum 1", "Cikaum", 1, "2025-10-21", 200, "Seeder chickin 2"},
}
for _, s := range seeds {
pfkID, err := ensurePFK(tx, s.KandangName, s.LocationName, s.Period)
if err != nil { return err }
date, err := time.Parse("2006-01-02", s.ChickInDate)
if err != nil { return err }
// upsert project_chickin (idempotent)
var chickin entity.ProjectChickin
err = tx.Where("project_flock_kandang_id = ? AND chick_in_date = ?", pfkID, date).First(&chickin).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
chickin = entity.ProjectChickin{
ProjectFlockKandangId: pfkID,
ChickInDate: date,
Quantity: s.Quantity,
Note: s.Note,
CreatedBy: createdBy,
}
if err := tx.Create(&chickin).Error; err != nil { return err }
} else if err != nil {
return err
}
// upsert population
var pop entity.ProjectFlockPopulation
err = tx.Where("project_flock_kandang_id = ?", pfkID).First(&pop).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
pop = entity.ProjectFlockPopulation{
ProjectFlockKandangId: pfkID,
InitialQuantity: s.Quantity,
CurrentQuantity: s.Quantity,
ReservedQuantity: 0,
CreatedBy: createdBy,
}
if err := tx.Create(&pop).Error; err != nil { return err }
} else if err != nil {
return err
} else {
if err := tx.Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", pop.Id).
Updates(map[string]any{
"initial_quantity": pop.InitialQuantity + s.Quantity,
"current_quantity": pop.CurrentQuantity + s.Quantity,
"reserved_quantity": 0,
}).Error; err != nil { return err }
}
}
return nil
}
func ptr[T any](v T) *T { func ptr[T any](v T) *T {
return &v return &v
} }
@@ -1165,30 +1015,3 @@ func intPtr(v int) *int {
func uintPtr(v uint) *uint { func uintPtr(v uint) *uint {
return &v return &v
} }
func ensurePFK(tx *gorm.DB, kandangName, locationName string, period int) (uint, error) {
var kandang entity.Kandang
if err := tx.Where("name = ?", kandangName).First(&kandang).Error; err != nil {
return 0, fmt.Errorf("kandang %q not found: %w", kandangName, err)
}
var loc entity.Location
if err := tx.Where("name = ?", locationName).First(&loc).Error; err != nil {
return 0, fmt.Errorf("location %q not found: %w", locationName, err)
}
var pf entity.ProjectFlock
if err := tx.Where("location_id = ? AND period = ?", loc.Id, period).First(&pf).Error; err != nil {
return 0, fmt.Errorf("project_flock for %s period %d not found: %w", locationName, period, err)
}
var pfk entity.ProjectFlockKandang
if err := tx.Where("project_flock_id = ? AND kandang_id = ?", pf.Id, kandang.Id).First(&pfk).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
pfk = entity.ProjectFlockKandang{ ProjectFlockId: pf.Id, KandangId: kandang.Id }
if err := tx.Create(&pfk).Error; err != nil {
return 0, fmt.Errorf("create pivot pfk(%d,%d) failed: %w", pf.Id, kandang.Id, err)
}
} else {
return 0, err
}
}
return pfk.Id, nil
}
+2 -3
View File
@@ -8,18 +8,17 @@ import (
type ProjectFlock struct { type ProjectFlock struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
FlockId uint `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"`
AreaId uint `gorm:"not null"` AreaId uint `gorm:"not null"`
Category string `gorm:"type:varchar(20);not null"` Category string `gorm:"type:varchar(20);not null"`
FcrId uint `gorm:"not null"` FcrId uint `gorm:"not null"`
LocationId uint `gorm:"not null"` LocationId uint `gorm:"not null"`
Period int `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` Period int `gorm:"not null"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Flock Flock `gorm:"foreignKey:FlockId;references:Id"`
Area Area `gorm:"foreignKey:AreaId;references:Id"` Area Area `gorm:"foreignKey:AreaId;references:Id"`
Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"`
+7 -8
View File
@@ -8,20 +8,16 @@ import (
type Recording struct { type Recording struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
ProjectFlockKandangId uint `gorm:"column:project_flock_id;not null;index"` ProjectFlockKandangId uint `gorm:"column:project_flock_kandangs_id;not null;index"`
RecordDatetime time.Time `gorm:"column:record_datetime;not null"` RecordDatetime time.Time `gorm:"column:record_datetime;not null"`
RecordDate *time.Time `gorm:"column:record_date"`
Ontime int `gorm:"column:ontime;not null;default:0"`
Day *int `gorm:"column:day"` Day *int `gorm:"column:day"`
TotalDepletion *int `gorm:"column:total_depletion"` TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"`
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
DailyGain *float64 `gorm:"column:daily_gain"` DailyGain *float64 `gorm:"column:daily_gain"`
AvgDailyGain *float64 `gorm:"column:avg_daily_gain"` AvgDailyGain *float64 `gorm:"column:avg_daily_gain"`
CumIntake *int64 `gorm:"column:cum_intake"` CumIntake *int `gorm:"column:cum_intake"`
FcrValue *float64 `gorm:"column:fcr_value"` FcrValue *float64 `gorm:"column:fcr_value"`
TotalChick *int64 `gorm:"column:total_chick"` TotalChickQty *float64 `gorm:"column:total_chick_qty"`
DailyDepletionRate *float64 `gorm:"column:daily_depletion_rate"`
CumDepletion *int `gorm:"column:cum_depletion"`
CreatedBy uint `gorm:"column:created_by"` CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -32,4 +28,7 @@ type Recording struct {
BodyWeights []RecordingBW `gorm:"foreignKey:RecordingId;references:Id"` BodyWeights []RecordingBW `gorm:"foreignKey:RecordingId;references:Id"`
Depletions []RecordingDepletion `gorm:"foreignKey:RecordingId;references:Id"` Depletions []RecordingDepletion `gorm:"foreignKey:RecordingId;references:Id"`
Stocks []RecordingStock `gorm:"foreignKey:RecordingId;references:Id"` Stocks []RecordingStock `gorm:"foreignKey:RecordingId;references:Id"`
Eggs []RecordingEgg `gorm:"foreignKey:RecordingId;references:Id"`
LatestApproval *Approval `gorm:"-" json:"-"`
} }
+8 -9
View File
@@ -1,16 +1,15 @@
package entities package entities
import "time" import "time"
type RecordingBW struct { type RecordingBW struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"` RecordingId uint `gorm:"column:recording_id;not null;index"`
Weight float64 `gorm:"column:weight;not null"` AvgWeight float64 `gorm:"column:avg_weight;not null"`
Qty int `gorm:"column:qty;not null;default:1"` Qty float64 `gorm:"column:qty;not null"`
Notes *string `gorm:"column:notes"` TotalWeight float64 `gorm:"column:total_weight;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
} }
+6 -8
View File
@@ -1,13 +1,11 @@
package entities package entities
type RecordingDepletion struct { type RecordingDepletion struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"` RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Total int64 `gorm:"column:total;not null"` Qty float64 `gorm:"column:qty;not null"`
Notes *string `gorm:"column:notes"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
} }
+30
View File
@@ -0,0 +1,30 @@
package entities
import "time"
type RecordingEgg struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Qty int `gorm:"column:qty;not null"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
GradingEggs []GradingEgg `gorm:"foreignKey:RecordingEggId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
}
type GradingEgg struct {
Id uint `gorm:"primaryKey"`
RecordingEggId uint `gorm:"column:recording_egg_id;not null;index"`
Qty float64 `gorm:"column:qty;not null"`
Grade string `gorm:"column:grade;type:varchar(50)"`
CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
RecordingEgg RecordingEgg `gorm:"foreignKey:RecordingEggId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
}
+7 -9
View File
@@ -1,14 +1,12 @@
package entities package entities
type RecordingStock struct { type RecordingStock struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"` RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Increase *float64 `gorm:"column:increase"` UsageQty *float64 `gorm:"column:usage_qty"`
Decrease *float64 `gorm:"column:decrease"` PendingQty *float64 `gorm:"column:pending_qty"`
UsageAmount *int64 `gorm:"column:usage_amount"`
Notes *string `gorm:"column:notes"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
} }
@@ -202,21 +202,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
if query.TransactionType != "" { if query.TransactionType != "" {
db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType))
} }
if query.ProductID > 0 { db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID))
db = db.Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id").
Where("product_warehouses.product_id = ?", query.ProductID)
}
if query.WarehouseID > 0 {
if query.ProductID > 0 {
db = db.Where("product_warehouses.warehouse_id = ?", query.WarehouseID)
} else {
db = db.Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id").
Where("product_warehouses.warehouse_id = ?", query.WarehouseID)
}
}
return db.Order("created_at DESC") return db.Order("created_at DESC")
}) })
@@ -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)
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"fmt"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -16,23 +17,36 @@ 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)
GetLatestByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint, db *gorm.DB) (*entity.ProductWarehouse, error)
ApplyFlagsFilter(db *gorm.DB, flags []string) *gorm.DB
AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error
} }
type ProductWarehouseRepositoryImpl struct { type ProductWarehouseRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.ProductWarehouse] *repository.BaseRepositoryImpl[entity.ProductWarehouse]
db *gorm.DB
} }
func NewProductWarehouseRepository(db *gorm.DB) ProductWarehouseRepository { func NewProductWarehouseRepository(db *gorm.DB) ProductWarehouseRepository {
return &ProductWarehouseRepositoryImpl{ return &ProductWarehouseRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductWarehouse](db), BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductWarehouse](db),
db: db,
} }
} }
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{}).
Where("product_id = ? AND warehouse_id = ?", productId, warehouseId) Where("product_id = ? AND warehouse_id = ?", productId, warehouseId)
if excludeID != nil { if excludeID != nil {
query = query.Where("id != ?", *excludeID) query = query.Where("id != ?", *excludeID)
@@ -43,20 +57,9 @@ 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).
Model(&entity.ProductWarehouse{}). Model(&entity.ProductWarehouse{}).
Where("product_id = ? AND warehouse_id = ?", productId, warehouseId). Where("product_id = ? AND warehouse_id = ?", productId, warehouseId).
Count(&count).Error; err != nil { Count(&count).Error; err != nil {
@@ -72,3 +75,74 @@ 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
}
func (r *ProductWarehouseRepositoryImpl) GetLatestByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint, db *gorm.DB) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
query := r.DB()
if db != nil {
query = db
}
fmt.Println(warehouseId)
err := query.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").
First(&productWarehouse).Error
if err != nil {
return nil, err
}
return &productWarehouse, nil
}
func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []string) *gorm.DB {
if len(flags) == 0 {
return db
}
return 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 ?", flags)
}
func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error {
if len(deltas) == 0 {
return nil
}
base := r.DB().WithContext(ctx)
if modifier != nil {
base = modifier(base)
}
for id, delta := range deltas {
if delta == 0 {
continue
}
if err := base.Model(&entity.ProductWarehouse{}).
Where("id = ?", id).
Update("quantity", gorm.Expr("COALESCE(quantity,0) + ?", delta)).Error; err != nil {
return err
}
}
return 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,8 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("warehouse_id = ?", params.WarehouseId) db = db.Where("warehouse_id = ?", params.WarehouseId)
} }
db = s.Repository.ApplyFlagsFilter(db, 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
@@ -11,6 +11,7 @@ import (
type FlockRepository interface { type FlockRepository interface {
repository.BaseRepository[entity.Flock] repository.BaseRepository[entity.Flock]
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
GetByName(ctx context.Context, name string) (*entity.Flock, error)
} }
type FlockRepositoryImpl struct { type FlockRepositoryImpl struct {
@@ -28,3 +29,15 @@ func NewFlockRepository(db *gorm.DB) FlockRepository {
func (r *FlockRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { func (r *FlockRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) {
return repository.ExistsByName[entity.Flock](ctx, r.db, name, excludeID) return repository.ExistsByName[entity.Flock](ctx, r.db, name, excludeID)
} }
func (r *FlockRepositoryImpl) GetByName(ctx context.Context, name string) (*entity.Flock, error) {
var flock entity.Flock
err := r.db.WithContext(ctx).
Where("LOWER(name) = LOWER(?)", name).
Where("deleted_at IS NULL").
First(&flock).Error
if err != nil {
return nil, err
}
return &flock, nil
}
@@ -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
@@ -20,6 +20,7 @@ type KandangRepository interface {
HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error)
UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error
UpsertProjectFlockKandang(ctx context.Context, projectFlockID, kandangID uint) error UpsertProjectFlockKandang(ctx context.Context, projectFlockID, kandangID uint) error
UpdateStatusByIDs(ctx context.Context, kandangIDs []uint, status utils.KandangStatus) error
} }
type KandangRepositoryImpl struct { type KandangRepositoryImpl struct {
@@ -122,3 +123,14 @@ func (r *KandangRepositoryImpl) UpsertProjectFlockKandang(ctx context.Context, p
} }
return err return err
} }
func (r *KandangRepositoryImpl) UpdateStatusByIDs(ctx context.Context, kandangIDs []uint, status utils.KandangStatus) error {
if len(kandangIDs) == 0 {
return nil
}
return r.db.WithContext(ctx).
Model(&entity.Kandang{}).
Where("id IN ?", kandangIDs).
Where("deleted_at IS NULL").
Update("status", string(status)).Error
}
@@ -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,6 +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 {
@@ -28,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
@@ -9,6 +9,7 @@ import (
flockBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto"
kandangBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" kandangBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" locationBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
userBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
) )
@@ -88,9 +89,9 @@ func ToUserBaseDTO(e entity.User) userBaseDTO.UserBaseDTO {
func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO {
var flock *flockBaseDTO.FlockBaseDTO var flock *flockBaseDTO.FlockBaseDTO
if e.Flock.Id != 0 { if base := pfutils.DeriveBaseName(e.FlockName); base != "" {
mapped := flockBaseDTO.ToFlockBaseDTO(e.Flock) summary := flockBaseDTO.FlockBaseDTO{Id: 0, Name: base}
flock = &mapped flock = &summary
} }
var area *areaBaseDTO.AreaBaseDTO var area *areaBaseDTO.AreaBaseDTO
if e.Area.Id != 0 { if e.Area.Id != 0 {
@@ -63,7 +63,6 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProjectFlockKandang.Kandang.Location.Area"). Preload("ProjectFlockKandang.Kandang.Location.Area").
Preload("ProjectFlockKandang.Kandang.Pic"). Preload("ProjectFlockKandang.Kandang.Pic").
Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.Flock").
Preload("ProjectFlockKandang.ProjectFlock.Area"). Preload("ProjectFlockKandang.ProjectFlock.Area").
Preload("ProjectFlockKandang.ProjectFlock.Fcr"). Preload("ProjectFlockKandang.ProjectFlock.Fcr").
Preload("ProjectFlockKandang.ProjectFlock.Location"). Preload("ProjectFlockKandang.ProjectFlock.Location").
@@ -121,14 +120,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 +129,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 +138,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 +147,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 +166,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 +221,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 +285,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 {
@@ -348,15 +339,12 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
return rollback(err) return rollback(err)
} }
var productWarehouse entity.ProductWarehouse productWarehouse, err := s.ProductWarehouseRepo.GetLatestByCategoryCodeAndWarehouseID(
err = tx.WithContext(c.Context()).Table("product_warehouses"). c.Context(),
Select("product_warehouses.*"). "DOC",
Joins("JOIN products ON products.id = product_warehouses.product_id"). warehouse.Id,
Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). tx,
Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). )
Order("product_warehouses.created_at DESC").
First(&productWarehouse).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return rollback(fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse")) return rollback(fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse"))
@@ -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 {
@@ -222,11 +222,11 @@ func (u *ProjectflockController) Approval(c *fiber.Ctx) error {
} }
func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error {
param := c.Params("flock_id") param := c.Params("project_flock_kandang_id")
id, err := strconv.Atoi(param) id, err := strconv.Atoi(param)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Flock Id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
} }
summary, err := u.ProjectflockService.GetFlockPeriodSummary(c, uint(id)) summary, err := u.ProjectflockService.GetFlockPeriodSummary(c, uint(id))
@@ -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,19 +10,21 @@ import (
flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
// pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
) )
type ProjectFlockBaseDTO struct { type ProjectFlockBaseDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Period int `json:"period"` Period int `json:"period"`
FlockName string `json:"flock_name"`
} }
type ProjectFlockListDTO struct { type ProjectFlockListDTO struct {
ProjectFlockBaseDTO ProjectFlockBaseDTO
Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` // Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"`
Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` Area *areaDTO.AreaBaseDTO `json:"area,omitempty"`
Category string `json:"category"` Category string `json:"category"`
Fcr *fcrDTO.FcrBaseDTO `json:"fcr,omitempty"` Fcr *fcrDTO.FcrBaseDTO `json:"fcr,omitempty"`
@@ -58,11 +60,11 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO {
} }
} }
var flockSummary *flockDTO.FlockBaseDTO // var flockSummary *flockDTO.FlockBaseDTO
if e.Flock.Id != 0 { // if baseName := pfutils.DeriveBaseName(e.FlockName); baseName != "" {
mapped := flockDTO.ToFlockBaseDTO(e.Flock) // summary := flockDTO.FlockBaseDTO{Id: 0, Name: baseName}
flockSummary = &mapped // flockSummary = &summary
} // }
var areaSummary *areaDTO.AreaBaseDTO var areaSummary *areaDTO.AreaBaseDTO
if e.Area.Id != 0 { if e.Area.Id != 0 {
@@ -90,7 +92,7 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO {
return ProjectFlockListDTO{ return ProjectFlockListDTO{
ProjectFlockBaseDTO: createProjectFlockBaseDTO(e), ProjectFlockBaseDTO: createProjectFlockBaseDTO(e),
Flock: flockSummary, // Flock: flockSummary,
Area: areaSummary, Area: areaSummary,
Kandangs: kandangSummaries, Kandangs: kandangSummaries,
Category: e.Category, Category: e.Category,
@@ -144,8 +146,9 @@ func defaultProjectFlockLatestApproval(e entity.ProjectFlock) approvalDTO.Approv
func createProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { func createProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO {
return ProjectFlockBaseDTO{ return ProjectFlockBaseDTO{
Id: e.Id, Id: e.Id,
Period: e.Period, Period: e.Period,
FlockName: e.FlockName,
} }
} }
@@ -7,13 +7,13 @@ import (
flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto"
pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
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 +28,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,19 +46,19 @@ 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,
Period: e.ProjectFlock.Period, Period: e.ProjectFlock.Period,
FlockName: e.ProjectFlock.FlockName,
}, },
Category: e.ProjectFlock.Category, Category: e.ProjectFlock.Category,
} }
// fill related small summaries if base := pfutils.DeriveBaseName(e.ProjectFlock.FlockName); base != "" {
if e.ProjectFlock.Flock.Id != 0 { summary := flockDTO.FlockBaseDTO{Id: 0, Name: base}
mapped := ToFlockSummaryDTO(e.ProjectFlock.Flock) pfLocal.Flock = &summary
pfLocal.Flock = &mapped
} }
if e.ProjectFlock.Area.Id != 0 { if e.ProjectFlock.Area.Id != 0 {
mapped := areaDTO.ToAreaBaseDTO(e.ProjectFlock.Area) mapped := areaDTO.ToAreaBaseDTO(e.ProjectFlock.Area)
@@ -75,23 +77,11 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD
pfLocal.CreatedUser = &mapped pfLocal.CreatedUser = &mapped
} }
// build pivot map
pivotMap := make(map[uint]uint)
for _, ph := range e.ProjectFlock.KandangHistory {
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 +89,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)
@@ -3,19 +3,30 @@ package repository
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
const baseNameExpression = "LOWER(TRIM(regexp_replace(flock_name, '\\\\s+\\\\d+(\\\\s+\\\\d+)*$', '', 'g')))"
type ProjectflockRepository interface { type ProjectflockRepository interface {
repository.BaseRepository[entity.ProjectFlock] repository.BaseRepository[entity.ProjectFlock]
GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) GetAllByBaseName(ctx context.Context, baseName string) ([]entity.ProjectFlock, error)
GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) GetActiveByBaseName(ctx context.Context, baseName string) (*entity.ProjectFlock, error)
GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) GetMaxPeriodByBaseName(ctx context.Context, baseName string) (int, error)
GetNextPeriodForFlock(ctx context.Context, flockID uint) (int, error) GetNextSequenceForBase(ctx context.Context, baseName string) (int, error)
GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error)
WithDefaultRelations() func(*gorm.DB) *gorm.DB
ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error)
AreaExists(ctx context.Context, id uint) (bool, error)
FcrExists(ctx context.Context, id uint) (bool, error)
LocationExists(ctx context.Context, id uint) (bool, error)
} }
type ProjectflockRepositoryImpl struct { type ProjectflockRepositoryImpl struct {
@@ -28,11 +39,11 @@ func NewProjectflockRepository(db *gorm.DB) ProjectflockRepository {
} }
} }
func (r *ProjectflockRepositoryImpl) GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) { func (r *ProjectflockRepositoryImpl) GetAllByBaseName(ctx context.Context, baseName string) ([]entity.ProjectFlock, error) {
var records []entity.ProjectFlock var records []entity.ProjectFlock
if err := r.DB().WithContext(ctx). if err := r.DB().WithContext(ctx).
Unscoped(). Unscoped().
Where("flock_id = ?", flockID). Where(baseNameExpression+" = LOWER(?)", baseName).
Order("period ASC"). Order("period ASC").
Find(&records).Error; err != nil { Find(&records).Error; err != nil {
return nil, err return nil, err
@@ -40,10 +51,10 @@ func (r *ProjectflockRepositoryImpl) GetAllByFlock(ctx context.Context, flockID
return records, nil return records, nil
} }
func (r *ProjectflockRepositoryImpl) GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) { func (r *ProjectflockRepositoryImpl) GetActiveByBaseName(ctx context.Context, baseName string) (*entity.ProjectFlock, error) {
var record entity.ProjectFlock var record entity.ProjectFlock
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Where("flock_id = ?", flockID). Where(baseNameExpression+" = LOWER(?)", baseName).
Order("period DESC"). Order("period DESC").
First(&record).Error First(&record).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -55,11 +66,11 @@ func (r *ProjectflockRepositoryImpl) GetActiveByFlock(ctx context.Context, flock
return &record, nil return &record, nil
} }
func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) { func (r *ProjectflockRepositoryImpl) GetMaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) {
var max int var max int
if err := r.DB().WithContext(ctx). if err := r.DB().WithContext(ctx).
Model(&entity.ProjectFlock{}). Model(&entity.ProjectFlock{}).
Where("flock_id = ?", flockID). Where(baseNameExpression+" = LOWER(?)", baseName).
Select("COALESCE(MAX(period), 0)"). Select("COALESCE(MAX(period), 0)").
Scan(&max).Error; err != nil { Scan(&max).Error; err != nil {
return 0, err return 0, err
@@ -67,13 +78,13 @@ func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, fl
return max, nil return max, nil
} }
func (r *ProjectflockRepositoryImpl) GetNextPeriodForFlock(ctx context.Context, flockID uint) (int, error) { func (r *ProjectflockRepositoryImpl) GetNextSequenceForBase(ctx context.Context, baseName string) (int, error) {
var payload struct { var payload struct {
Period int Period int
} }
if err := r.DB().WithContext(ctx). if err := r.DB().WithContext(ctx).
Model(&entity.ProjectFlock{}). Model(&entity.ProjectFlock{}).
Where("flock_id = ?", flockID). Where(baseNameExpression+" = LOWER(?)", baseName).
Clauses(clause.Locking{Strength: "UPDATE"}). Clauses(clause.Locking{Strength: "UPDATE"}).
Order("period DESC"). Order("period DESC").
Limit(1). Limit(1).
@@ -86,3 +97,164 @@ func (r *ProjectflockRepositoryImpl) GetNextPeriodForFlock(ctx context.Context,
} }
return payload.Period + 1, nil return payload.Period + 1, nil
} }
func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) {
return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB {
db = r.withDefaultRelations(db)
return r.applyQueryFilters(db, params)
})
}
func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return r.withDefaultRelations(db)
}
}
func (r *ProjectflockRepositoryImpl) withDefaultRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("Area").
Preload("Fcr").
Preload("Location").
Preload("Kandangs")
}
func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *validation.Query) *gorm.DB {
if params == nil {
return db
}
if params.AreaId > 0 {
db = db.Where("project_flocks.area_id = ?", params.AreaId)
}
if params.LocationId > 0 {
db = db.Where("project_flocks.location_id = ?", params.LocationId)
}
if params.Period > 0 {
db = db.Where("project_flocks.period = ?", params.Period)
}
if len(params.KandangIds) > 0 {
db = db.Where(`
EXISTS (
SELECT 1
FROM project_flock_kandangs pfk
WHERE pfk.project_flock_id = project_flocks.id
AND pfk.kandang_id IN ?
)`, params.KandangIds)
}
db = r.applySearchFilters(db, params.Search)
for _, expr := range r.buildOrderExpressions(params.SortBy, params.SortOrder) {
db = db.Order(expr)
}
return db
}
func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB {
if rawSearch == "" {
return db
}
normalized := strings.ToLower(strings.TrimSpace(rawSearch))
if normalized == "" {
return db
}
likeQuery := "%" + normalized + "%"
return db.
Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id").
Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id").
Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id").
Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by").
Where(`
LOWER(areas.name) LIKE ?
OR LOWER(project_flocks.category) LIKE ?
OR LOWER(fcrs.name) LIKE ?
OR LOWER(locations.name) LIKE ?
OR LOWER(locations.address) LIKE ?
OR LOWER(created_users.name) LIKE ?
OR LOWER(created_users.email) LIKE ?
OR LOWER(project_flocks.flock_name) LIKE ?
OR LOWER(TRIM(regexp_replace(project_flocks.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g'))) LIKE ?
OR LOWER(CAST(project_flocks.period AS TEXT)) LIKE ?
OR EXISTS (
SELECT 1 FROM kandangs
WHERE kandangs.project_flock_id = project_flocks.id
AND LOWER(kandangs.name) LIKE ?
)
`,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
)
}
func (r *ProjectflockRepositoryImpl) AreaExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.Area](ctx, r.DB(), id)
}
func (r *ProjectflockRepositoryImpl) FcrExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.Fcr](ctx, r.DB(), id)
}
func (r *ProjectflockRepositoryImpl) LocationExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.Location](ctx, r.DB(), id)
}
func (r *ProjectflockRepositoryImpl) buildOrderExpressions(sortBy, sortOrder string) []string {
direction := "ASC"
if strings.ToLower(sortOrder) == "desc" {
direction = "DESC"
}
switch sortBy {
case "area":
return []string{
fmt.Sprintf("(SELECT name FROM areas WHERE areas.id = project_flocks.area_id) %s", direction),
fmt.Sprintf("project_flocks.id %s", direction),
}
case "location":
return []string{
fmt.Sprintf("(SELECT name FROM locations WHERE locations.id = project_flocks.location_id) %s", direction),
fmt.Sprintf("project_flocks.id %s", direction),
}
case "kandangs":
return []string{
fmt.Sprintf("(SELECT COUNT(*) FROM project_flock_kandangs pfk WHERE pfk.project_flock_id = project_flocks.id) %s", direction),
fmt.Sprintf("project_flocks.id %s", direction),
}
case "period":
return []string{
fmt.Sprintf("project_flocks.period %s", direction),
fmt.Sprintf("project_flocks.id %s", direction),
}
default:
return []string{
"project_flocks.created_at DESC",
"project_flocks.updated_at DESC",
}
}
}
func (r *ProjectflockRepositoryImpl) ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) {
var count int64
q := r.DB().WithContext(ctx).Model(&entity.ProjectFlock{}).Where("flock_name = ?", flockName)
if excludeID != nil && *excludeID != 0 {
q = q.Where("id <> ?", *excludeID)
}
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm" "gorm.io/gorm"
@@ -13,6 +14,10 @@ type ProjectFlockKandangRepository interface {
CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error
DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error
GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error)
ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error)
HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error)
FindKandangsWithRecordings(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]entity.Kandang, error)
MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error)
WithTx(tx *gorm.DB) ProjectFlockKandangRepository WithTx(tx *gorm.DB) ProjectFlockKandangRepository
DB() *gorm.DB DB() *gorm.DB
} }
@@ -21,6 +26,8 @@ type projectFlockKandangRepositoryImpl struct {
db *gorm.DB db *gorm.DB
} }
const flockBaseNameExpression = "LOWER(TRIM(regexp_replace(project_flocks.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')))"
func NewProjectFlockKandangRepository(db *gorm.DB) ProjectFlockKandangRepository { func NewProjectFlockKandangRepository(db *gorm.DB) ProjectFlockKandangRepository {
return &projectFlockKandangRepositoryImpl{db: db} return &projectFlockKandangRepositoryImpl{db: db}
} }
@@ -45,7 +52,6 @@ func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entit
var records []entity.ProjectFlockKandang var records []entity.ProjectFlockKandang
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Preload("ProjectFlock"). Preload("ProjectFlock").
Preload("ProjectFlock.Flock").
Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area"). Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location"). Preload("ProjectFlock.Location").
@@ -72,7 +78,6 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint
record := new(entity.ProjectFlockKandang) record := new(entity.ProjectFlockKandang)
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Preload("ProjectFlock"). Preload("ProjectFlock").
Preload("ProjectFlock.Flock").
Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area"). Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location"). Preload("ProjectFlock.Location").
@@ -91,7 +96,6 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID).
Preload("ProjectFlock"). Preload("ProjectFlock").
Preload("ProjectFlock.Flock").
Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Fcr").
Preload("ProjectFlock.Area"). Preload("ProjectFlock.Area").
Preload("ProjectFlock.Location"). Preload("ProjectFlock.Location").
@@ -104,3 +108,62 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont
} }
return record, nil return record, nil
} }
func (r *projectFlockKandangRepositoryImpl) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) {
if len(kandangIDs) == 0 {
return nil, nil
}
var existing []uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs).
Pluck("kandang_id", &existing).Error
return existing, err
}
func (r *projectFlockKandangRepositoryImpl) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) {
if len(kandangIDs) == 0 {
return false, nil
}
q := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Where("kandang_id IN ?", kandangIDs)
if exceptProjectID != nil {
q = q.Where("project_flock_id <> ?", *exceptProjectID)
}
var count int64
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *projectFlockKandangRepositoryImpl) FindKandangsWithRecordings(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]entity.Kandang, error) {
if len(kandangIDs) == 0 {
return nil, nil
}
var kandangs []entity.Kandang
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("pfk.kandang_id AS id, COALESCE(k.name, '') AS name").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("LEFT JOIN kandangs AS k ON k.id = pfk.kandang_id").
Where("pfk.project_flock_id = ? AND pfk.kandang_id IN ?", projectFlockID, kandangIDs).
Group("pfk.kandang_id, k.name").
Scan(&kandangs).Error
return kandangs, err
}
func (r *projectFlockKandangRepositoryImpl) MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) {
if strings.TrimSpace(baseName) == "" {
return 0, nil
}
var max int
err := r.db.WithContext(ctx).
Table("project_flock_kandangs pfk").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where(flockBaseNameExpression+" = LOWER(?)", baseName).
Select("COALESCE(MAX(pf.period), 0)").
Scan(&max).Error
return max, err
}
@@ -22,6 +22,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj
route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", ctrl.DeleteOne)
route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang)
route.Post("/approvals", ctrl.Approval) route.Post("/approvals", ctrl.Approval)
route.Get("/flocks/:flock_id/periods", ctrl.GetFlockPeriodSummary) route.Get("/kandangs/:project_flock_kandang_id/periods", ctrl.GetFlockPeriodSummary)
} }
@@ -10,10 +10,13 @@ import (
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
auth "gitlab.com/mbugroup/lti-api.git/internal/middleware" authmiddleware "gitlab.com/mbugroup/lti-api.git/internal/middleware"
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"
pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
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"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
@@ -29,21 +32,24 @@ type ProjectflockService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error)
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)
GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, 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, 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 PivotRepo repository.ProjectFlockKandangRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
} }
type FlockPeriodSummary struct { type FlockPeriodSummary struct {
@@ -56,31 +62,25 @@ func NewProjectflockService(
flockRepo flockRepository.FlockRepository, flockRepo flockRepository.FlockRepository,
kandangRepo kandangRepository.KandangRepository, kandangRepo kandangRepository.KandangRepository,
pivotRepo repository.ProjectFlockKandangRepository, pivotRepo 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, PivotRepo: pivotRepo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowProjectFlock,
} }
} }
func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("CreatedUser").
Preload("Flock").
Preload("Area").
Preload("Fcr").
Preload("Location").
Preload("Kandangs")
}
func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, err return nil, 0, err
@@ -95,79 +95,11 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
projectflocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { projectflocks, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params)
db = s.withRelations(db)
if params.AreaId > 0 {
db = db.Where("project_flocks.area_id = ?", params.AreaId)
}
if params.LocationId > 0 {
db = db.Where("project_flocks.location_id = ?", params.LocationId)
}
if params.Period > 0 {
db = db.Where("project_flocks.period = ?", params.Period)
}
if len(params.KandangIds) > 0 {
db = db.Where(`
EXISTS (
SELECT 1
FROM project_flock_kandangs pfk
WHERE pfk.project_flock_id = project_flocks.id
AND pfk.kandang_id IN ?
)`, params.KandangIds)
}
if params.Search != "" {
normalizedSearch := strings.ToLower(strings.TrimSpace(params.Search))
if normalizedSearch == "" {
for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) {
db = db.Order(expr)
}
return db
}
likeQuery := "%" + normalizedSearch + "%"
db = db.
Joins("LEFT JOIN flocks ON flocks.id = project_flocks.flock_id").
Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id").
Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id").
Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id").
Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by").
Where(`
LOWER(flocks.name) LIKE ?
OR LOWER(areas.name) LIKE ?
OR LOWER(project_flocks.category) LIKE ?
OR LOWER(fcrs.name) LIKE ?
OR LOWER(locations.name) LIKE ?
OR LOWER(locations.address) LIKE ?
OR LOWER(created_users.name) LIKE ?
OR LOWER(created_users.email) LIKE ?
OR LOWER(CAST(project_flocks.period AS TEXT)) LIKE ?
OR EXISTS (
SELECT 1 FROM kandangs
WHERE kandangs.project_flock_id = project_flocks.id
AND LOWER(kandangs.name) LIKE ?
)
`,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
likeQuery,
)
}
for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) {
db = db.Order(expr)
}
return db
})
if err != nil { if err != nil {
s.Log.Errorf("Failed to get projectflocks: %+v", err) s.Log.Errorf("Failed to get projectflocks: %+v", err)
return nil, 0, err return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flocks")
} }
if s.ApprovalSvc != nil && len(projectflocks) > 0 { if s.ApprovalSvc != nil && len(projectflocks) > 0 {
@@ -194,13 +126,13 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
} }
func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) {
projectflock, err := s.Repository.GetByID(c.Context(), id, s.withRelations) projectflock, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations())
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found")
} }
if err != nil { if err != nil {
s.Log.Errorf("Failed get projectflock by id: %+v", err) s.Log.Errorf("Failed get projectflock by id: %+v", err)
return nil, err return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
} }
if s.ApprovalSvc != nil { if s.ApprovalSvc != nil {
@@ -227,6 +159,11 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
return nil, err return nil, err
} }
actorID, err := actorIDFromContext(c)
if err != nil {
return nil, err
}
cat := strings.ToUpper(req.Category) cat := strings.ToUpper(req.Category)
if !utils.IsValidProjectFlockCategory(cat) { if !utils.IsValidProjectFlockCategory(cat) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category")
@@ -236,15 +173,28 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required") return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required")
} }
baseName := strings.TrimSpace(req.FlockName)
if baseName == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty")
}
if err := commonSvc.EnsureRelations(c.Context(), if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists},
commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: s.Repository.FcrExists},
commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists},
commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB())},
); err != nil { ); err != nil {
return nil, err return nil, err
} }
canonicalBase := baseName
if s.FlockRepo != nil {
baseFlock, err := s.ensureFlockByName(c.Context(), actorID, baseName)
if err != nil {
return nil, err
}
canonicalBase = baseFlock.Name
}
kandangIDs := uniqueUintSlice(req.KandangIds) kandangIDs := uniqueUintSlice(req.KandangIds)
kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil) kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil)
if err != nil { if err != nil {
@@ -257,19 +207,13 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found")
} }
// larang kalau ada yg sudah terikat ke project lain // larang kalau ada yg sudah terikat ke project lain
if linked, err := s.anyKandangLinkedToOtherProject(c.Context(), s.Repository.DB(), kandangIDs, nil); err != nil { if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), kandangIDs, nil); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage")
} else if linked { } else if linked {
return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain")
} }
actorID, err := actorIDFromContext(c)
if err != nil {
return nil, err
}
createBody := &entity.ProjectFlock{ createBody := &entity.ProjectFlock{
FlockId: req.FlockId,
AreaId: req.AreaId, AreaId: req.AreaId,
Category: cat, Category: cat,
FcrId: req.FcrId, FcrId: req.FcrId,
@@ -280,11 +224,16 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
projectRepo := repository.NewProjectflockRepository(dbTransaction) projectRepo := repository.NewProjectflockRepository(dbTransaction)
period, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) nextSeq, err := projectRepo.GetNextSequenceForBase(c.Context(), canonicalBase)
if err != nil { if err != nil {
return err return err
} }
createBody.Period = period generatedName, seq, err := s.generateSequentialFlockName(c.Context(), projectRepo, canonicalBase, nextSeq, nil)
if err != nil {
return err
}
createBody.FlockName = generatedName
createBody.Period = seq
if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil {
return err return err
@@ -309,11 +258,14 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*
}) })
if err != nil { if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists")
} }
s.Log.Errorf("Failed to create projectflock: %+v", err) s.Log.Errorf("Failed to create projectflock: %+v", err)
return nil, err return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create project flock")
} }
return s.GetOne(c, createBody.Id) return s.GetOne(c, createBody.Id)
@@ -324,7 +276,12 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id
return nil, err return nil, err
} }
existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) actorID, err := actorIDFromContext(c)
if err != nil {
return nil, err
}
existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations())
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found")
} }
@@ -335,15 +292,28 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id
updateBody := make(map[string]any) updateBody := make(map[string]any)
hasBodyChanges := false hasBodyChanges := false
var relationChecks []commonSvc.RelationCheck var relationChecks []commonSvc.RelationCheck
existingBase := pfutils.DeriveBaseName(existing.FlockName)
targetBaseName := existingBase
needFlockNameRegenerate := false
if req.FlockId != nil { if req.FlockName != nil {
updateBody["flock_id"] = *req.FlockId trimmed := strings.TrimSpace(*req.FlockName)
hasBodyChanges = true if trimmed == "" {
relationChecks = append(relationChecks, commonSvc.RelationCheck{ return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty")
Name: "Flock", }
ID: req.FlockId, canonicalBase := trimmed
Exists: relationExistsChecker[entity.Flock](s.Repository.DB()), if s.FlockRepo != nil {
}) flockEntity, err := s.ensureFlockByName(c.Context(), actorID, trimmed)
if err != nil {
return nil, err
}
canonicalBase = flockEntity.Name
}
if !strings.EqualFold(canonicalBase, existingBase) {
needFlockNameRegenerate = true
targetBaseName = canonicalBase
hasBodyChanges = true
}
} }
if req.AreaId != nil { if req.AreaId != nil {
updateBody["area_id"] = *req.AreaId updateBody["area_id"] = *req.AreaId
@@ -351,7 +321,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id
relationChecks = append(relationChecks, commonSvc.RelationCheck{ relationChecks = append(relationChecks, commonSvc.RelationCheck{
Name: "Area", Name: "Area",
ID: req.AreaId, ID: req.AreaId,
Exists: relationExistsChecker[entity.Area](s.Repository.DB()), Exists: s.Repository.AreaExists,
}) })
} }
if req.Category != nil { if req.Category != nil {
@@ -368,7 +338,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id
relationChecks = append(relationChecks, commonSvc.RelationCheck{ relationChecks = append(relationChecks, commonSvc.RelationCheck{
Name: "FCR", Name: "FCR",
ID: req.FcrId, ID: req.FcrId,
Exists: relationExistsChecker[entity.Fcr](s.Repository.DB()), Exists: s.Repository.FcrExists,
}) })
} }
if req.LocationId != nil { if req.LocationId != nil {
@@ -377,7 +347,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id
relationChecks = append(relationChecks, commonSvc.RelationCheck{ relationChecks = append(relationChecks, commonSvc.RelationCheck{
Name: "Location", Name: "Location",
ID: req.LocationId, ID: req.LocationId,
Exists: relationExistsChecker[entity.Location](s.Repository.DB()), Exists: s.Repository.LocationExists,
}) })
} }
@@ -405,7 +375,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id
if len(kandangs) != len(newKandangIDs) { if len(kandangs) != len(newKandangIDs) {
return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found")
} }
if linked, err := s.anyKandangLinkedToOtherProject(c.Context(), s.Repository.DB(), newKandangIDs, &id); err != nil { if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), newKandangIDs, &id); err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage")
} else if linked { } else if linked {
return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain")
@@ -418,14 +388,32 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id
return s.GetOne(c, id) return s.GetOne(c, id)
} }
actorID, authErr := actorIDFromContext(c)
if authErr != nil {
return nil, authErr
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
projectRepo := repository.NewProjectflockRepository(dbTransaction) projectRepo := repository.NewProjectflockRepository(dbTransaction)
baseForGeneration := targetBaseName
if strings.TrimSpace(baseForGeneration) == "" {
baseForGeneration = existingBase
}
if strings.TrimSpace(baseForGeneration) == "" {
baseForGeneration = strings.TrimSpace(existing.FlockName)
}
if needFlockNameRegenerate {
nextSeq, err := projectRepo.GetNextSequenceForBase(c.Context(), baseForGeneration)
if err != nil {
return err
}
newName, seq, err := s.generateSequentialFlockName(c.Context(), projectRepo, baseForGeneration, nextSeq, &id)
if err != nil {
return err
}
updateBody["flock_name"] = newName
if seq != existing.Period {
updateBody["period"] = seq
}
}
if len(updateBody) > 0 { if len(updateBody) > 0 {
if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil {
return err return err
@@ -513,7 +501,10 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id
return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found")
} }
s.Log.Errorf("Failed to update projectflock %d: %+v", id, err) s.Log.Errorf("Failed to update projectflock %d: %+v", id, err)
return nil, err if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock")
} }
return s.GetOne(c, id) return s.GetOne(c, id)
@@ -621,7 +612,7 @@ func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([]
} }
func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error {
existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations())
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") return fiber.NewError(fiber.StatusNotFound, "Projectflock not found")
} }
@@ -655,38 +646,32 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error {
return fiberErr return fiberErr
} }
s.Log.Errorf("Failed to delete projectflock %d: %+v", id, err) s.Log.Errorf("Failed to delete projectflock %d: %+v", id, err)
return err return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete project flock")
} }
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, float64, error) {
// keep for backward compatibility; delegate to new consolidated method
return s.GetProjectFlockKandangByParams(ctx, fmt.Sprintf("%d", id), "", "")
}
func actorIDFromContext(c *fiber.Ctx) (uint, error) {
user, ok := auth.AuthenticatedUser(c)
if !ok || user == nil || user.Id == 0 {
return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
return user.Id, nil
}
func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) {
pfk, err := s.PivotRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID) pfk, err := s.PivotRepo.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 s.Log.Errorf("Failed to fetch project_flock_kandang by project %d and kandang %d: %+v", projectFlockID, kandangID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang")
} }
return pfk, nil
availableQuantity, err := s.GetAvailableDocQuantity(ctx, pfk.KandangId)
if err != nil {
return nil, 0, err
}
return pfk, availableQuantity, nil
} }
func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) { func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) {
idStr = strings.TrimSpace(idStr) idStr = strings.TrimSpace(idStr)
projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) projectFlockIdStr = strings.TrimSpace(projectFlockIdStr)
kandangIdStr = strings.TrimSpace(kandangIdStr) kandangIdStr = strings.TrimSpace(kandangIdStr)
@@ -694,52 +679,107 @@ func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idSt
if idStr != "" { if idStr != "" {
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 { if err != nil || id <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
} }
pfk, err := s.PivotRepo.GetByID(ctx.Context(), uint(id)) pfk, err := s.PivotRepo.GetByID(ctx.Context(), uint(id))
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 s.Log.Errorf("Failed to fetch project_flock_kandang %d: %+v", id, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang")
} }
return pfk, nil
availableQuantity, err := s.GetAvailableDocQuantity(ctx, pfk.KandangId)
if err != nil {
return nil, 0, err
}
return pfk, availableQuantity, nil
} }
if projectFlockIdStr == "" || kandangIdStr == "" { if projectFlockIdStr == "" || kandangIdStr == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Missing lookup parameters") return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Missing lookup parameters")
} }
pfid, err := strconv.Atoi(projectFlockIdStr) pfid, err := strconv.Atoi(projectFlockIdStr)
if err != nil || pfid <= 0 { if err != nil || pfid <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
} }
kid, err := strconv.Atoi(kandangIdStr) kid, err := strconv.Atoi(kandangIdStr)
if err != nil || kid <= 0 { if err != nil || kid <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id")
} }
return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid))
} }
func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) {
flock, err := s.FlockRepo.GetByID(c.Context(), flockID, func(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser") wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID)
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Flock not found")
}
if err != nil { if err != nil {
s.Log.Errorf("Failed get flock %d for period summary: %+v", flockID, err) return 0, err
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock")
} }
maxPeriod, err := s.Repository.GetMaxPeriodByFlock(c.Context(), flockID) productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(ctx.Context(), "DOC", wh.Id)
if err != nil { if err != nil {
s.Log.Errorf("Failed to compute next period for flock %d: %+v", flockID, err) return 0, err
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to compute next period") }
total := 0.0
for _, pw := range productWarehouses {
total += pw.Quantity
}
return total, nil
}
func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, projectFlockKandangID uint) (*FlockPeriodSummary, error) {
if projectFlockKandangID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
pivot, err := s.pivotRepo().GetByID(c.Context(), projectFlockKandangID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found")
}
if err != nil {
s.Log.Errorf("Failed to fetch project_flock_kandang %d: %+v", projectFlockKandangID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang")
}
var baseName string
var referenceFlock *entity.Flock
if pivot.ProjectFlock.Id != 0 {
baseName = pfutils.DeriveBaseName(pivot.ProjectFlock.FlockName)
}
if strings.TrimSpace(baseName) != "" {
referenceFlock, err = s.FlockRepo.GetByName(c.Context(), baseName)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch flock %q: %+v", baseName, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock")
}
}
if referenceFlock == nil {
referenceFlock = &entity.Flock{Name: pivot.ProjectFlock.FlockName}
}
maxPeriod := pivot.ProjectFlock.Period
if strings.TrimSpace(baseName) != "" {
if headerMax, err := s.Repository.GetMaxPeriodByBaseName(c.Context(), baseName); err != nil {
s.Log.Warnf("Unable to compute header period for base %q: %+v", baseName, err)
} else if headerMax > maxPeriod {
maxPeriod = headerMax
}
if pivotMax, err := s.pivotRepo().MaxPeriodByBaseName(c.Context(), baseName); err != nil {
s.Log.Warnf("Unable to compute pivot period for base %q: %+v", baseName, err)
} else if pivotMax > maxPeriod {
maxPeriod = pivotMax
}
} }
return &FlockPeriodSummary{ return &FlockPeriodSummary{
Flock: *flock, Flock: *referenceFlock,
NextPeriod: maxPeriod + 1, NextPeriod: maxPeriod + 1,
}, nil }, nil
} }
@@ -757,45 +797,64 @@ func uniqueUintSlice(values []uint) []uint {
return result return result
} }
func relationExistsChecker[T any](db *gorm.DB) func(context.Context, uint) (bool, error) { func (s projectflockService) generateSequentialFlockName(ctx context.Context, repo repository.ProjectflockRepository, baseName string, startNumber int, excludeID *uint) (string, int, error) {
return func(ctx context.Context, id uint) (bool, error) { name := strings.TrimSpace(baseName)
return commonRepo.Exists[T](ctx, db, id) if name == "" {
return "", 0, fiber.NewError(fiber.StatusBadRequest, "Base flock name cannot be empty")
}
number := startNumber
if number <= 0 {
number = 1
}
attempts := 0
for {
candidate := fmt.Sprintf("%s %03d", name, number)
exists, err := repo.ExistsByFlockName(ctx, candidate, excludeID)
if err != nil {
s.Log.Errorf("Failed checking project flock name uniqueness for %q: %+v", candidate, err)
return "", 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate flock name")
}
if !exists {
return candidate, number, nil
}
number++
attempts++
if attempts > 9999 {
return "", 0, fiber.NewError(fiber.StatusInternalServerError, "Unable to generate unique flock name")
}
} }
} }
func (s projectflockService) buildOrderExpressions(sortBy, sortOrder string) []string { func (s projectflockService) ensureFlockByName(ctx context.Context, actorID uint, name string) (*entity.Flock, error) {
direction := "ASC" trimmed := strings.TrimSpace(name)
if strings.ToLower(sortOrder) == "desc" { if trimmed == "" {
direction = "DESC" return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty")
} }
switch sortBy { flock, err := s.FlockRepo.GetByName(ctx, trimmed)
case "area": if err == nil {
return []string{ return flock, nil
fmt.Sprintf("(SELECT name FROM areas WHERE areas.id = project_flocks.area_id) %s", direction),
fmt.Sprintf("project_flocks.id %s", direction),
}
case "location":
return []string{
fmt.Sprintf("(SELECT name FROM locations WHERE locations.id = project_flocks.location_id) %s", direction),
fmt.Sprintf("project_flocks.id %s", direction),
}
case "kandangs":
return []string{
fmt.Sprintf("(SELECT COUNT(*) FROM project_flock_kandangs pfk WHERE pfk.project_flock_id = project_flocks.id) %s", direction),
fmt.Sprintf("project_flocks.id %s", direction),
}
case "period":
return []string{
fmt.Sprintf("project_flocks.period %s", direction),
fmt.Sprintf("project_flocks.id %s", direction),
}
default:
return []string{
"project_flocks.created_at DESC",
"project_flocks.updated_at DESC",
}
} }
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch flock by name %q: %+v", trimmed, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare flock data")
}
newFlock := &entity.Flock{
Name: trimmed,
CreatedBy: actorID,
}
if err := s.FlockRepo.CreateOne(ctx, newFlock, nil); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return s.FlockRepo.GetByName(ctx, trimmed)
}
s.Log.Errorf("Failed to create flock %q: %+v", trimmed, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare flock data")
}
return newFlock, nil
} }
func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint) error { func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint) error {
@@ -803,20 +862,12 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *
return nil return nil
} }
if err := dbTransaction. if err := s.kandangRepoWithTx(dbTransaction).UpdateStatusByIDs(ctx, kandangIDs, utils.KandangStatusPengajuan); err != nil {
Model(&entity.Kandang{}).
Where("id IN ?", kandangIDs).
Updates(map[string]any{
"status": string(utils.KandangStatusPengajuan),
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status")
} }
var already []uint already, err := s.pivotRepoWithTx(dbTransaction).ListExistingKandangIDs(ctx, projectFlockID, kandangIDs)
if err := dbTransaction. if err != nil {
Table("project_flock_kandangs").
Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs).
Pluck("kandang_id", &already).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing pivot") return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing pivot")
} }
exists := make(map[uint]struct{}, len(already)) exists := make(map[uint]struct{}, len(already))
@@ -847,6 +898,9 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *
}) })
} }
if err := s.pivotRepoWithTx(dbTransaction).CreateMany(ctx, records); err != nil { if err := s.pivotRepoWithTx(dbTransaction).CreateMany(ctx, records); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terhubung dengan project flock ini")
}
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
@@ -857,13 +911,25 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *
return nil return nil
} }
blocked, err := s.pivotRepoWithTx(dbTransaction).FindKandangsWithRecordings(ctx, projectFlockID, kandangIDs)
if err != nil {
s.Log.Errorf("Failed to check recordings before detaching kandangs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandang detachment")
}
if len(blocked) > 0 {
names := make([]string, 0, len(blocked))
for _, item := range blocked {
label := fmt.Sprintf("ID %d", item.Id)
if strings.TrimSpace(item.Name) != "" {
label = fmt.Sprintf("%s (%s)", label, item.Name)
}
names = append(names, label)
}
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak dapat melepas kandang karena sudah memiliki recording: %s", strings.Join(names, ", ")))
}
if resetStatus { if resetStatus {
if err := dbTransaction. if err := s.kandangRepoWithTx(dbTransaction).UpdateStatusByIDs(ctx, kandangIDs, utils.KandangStatusNonActive); err != nil {
Model(&entity.Kandang{}).
Where("id IN ?", kandangIDs).
Updates(map[string]any{
"status": string(utils.KandangStatusNonActive),
}).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status")
} }
} }
@@ -875,22 +941,33 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *
} }
func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository {
if s.PivotRepo == nil { if dbTransaction == nil {
return repository.NewProjectFlockKandangRepository(dbTransaction) return s.pivotRepo()
} }
return s.PivotRepo.WithTx(dbTransaction) return s.pivotRepo().WithTx(dbTransaction)
} }
func (s projectflockService) anyKandangLinkedToOtherProject(ctx context.Context, db *gorm.DB, kandangIDs []uint, exceptProjectID *uint) (bool, error) { func (s projectflockService) pivotRepo() repository.ProjectFlockKandangRepository {
q := db.WithContext(ctx). if s.PivotRepo != nil {
Table("project_flock_kandangs"). return s.PivotRepo
Where("kandang_id IN ?", kandangIDs)
if exceptProjectID != nil {
q = q.Where("project_flock_id <> ?", *exceptProjectID)
} }
var count int64 return repository.NewProjectFlockKandangRepository(s.Repository.DB())
if err := q.Count(&count).Error; err != nil { }
return false, err
} func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.KandangRepository {
return count > 0, nil if tx != nil {
return kandangRepository.NewKandangRepository(tx)
}
if s.KandangRepo != nil {
return s.KandangRepo
}
return kandangRepository.NewKandangRepository(s.Repository.DB())
}
func actorIDFromContext(c *fiber.Ctx) (uint, error) {
user, ok := authmiddleware.AuthenticatedUser(c)
if !ok || user == nil || user.Id == 0 {
return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
return user.Id, nil
} }
@@ -0,0 +1,25 @@
package utils
import (
"strconv"
"strings"
)
// DeriveBaseName removes trailing numeric tokens from the flock name.
func DeriveBaseName(name string) string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return ""
}
parts := strings.Fields(trimmed)
for len(parts) > 0 {
if _, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
parts = parts[:len(parts)-1]
continue
}
break
}
return strings.TrimSpace(strings.Join(parts, " "))
}
@@ -1,7 +1,7 @@
package validation package validation
type Create struct { type Create struct {
FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"` FlockName string `json:"flock_name" validate:"required_strict"`
AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"`
Category string `json:"category" validate:"required_strict"` Category string `json:"category" validate:"required_strict"`
FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"`
@@ -10,7 +10,7 @@ type Create struct {
} }
type Update struct { type Update struct {
FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"` FlockName *string `json:"flock_name,omitempty" validate:"omitempty"`
AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"`
Category *string `json:"category,omitempty" validate:"omitempty"` Category *string `json:"category,omitempty" validate:"omitempty"`
FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"`
@@ -146,6 +146,60 @@ func (u *RecordingController) UpdateOne(c *fiber.Ctx) error {
}) })
} }
func (u *RecordingController) SubmitGrading(c *fiber.Ctx) error {
req := new(validation.SubmitGrading)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.RecordingService.SubmitGrading(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Submit grading eggs successfully",
Data: dto.ToRecordingDetailDTO(*result),
})
}
func (u *RecordingController) Approve(c *fiber.Ctx) error {
req := new(validation.Approve)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
results, err := u.RecordingService.Approval(c, req)
if err != nil {
return err
}
var (
data interface{}
message = "Submit recording approvals successfully"
)
if len(results) == 1 {
message = "Submit recording approval successfully"
data = dto.ToRecordingDetailDTO(results[0])
} else {
data = dto.ToRecordingListDTOs(results)
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: message,
Data: data,
})
}
func (u *RecordingController) DeleteOne(c *fiber.Ctx) error { func (u *RecordingController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id") param := c.Params("id")
@@ -1,30 +1,35 @@
package dto package dto
import ( import (
"math"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
) )
// === DTO Structs === // === DTO Structs ===
type RecordingBaseDTO struct { type RecordingBaseDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
ProjectFlockKandangId uint `json:"project_flock_kandang_id"` ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
RecordDatetime time.Time `json:"record_datetime"` RecordDatetime time.Time `json:"record_datetime"`
RecordDate *time.Time `json:"record_date,omitempty"` Day *int `json:"day,omitempty"`
Ontime bool `json:"ontime"` ProjectFlockCategory *string `json:"project_flock_category,omitempty"`
Day *int `json:"day,omitempty"` TotalDepletionQty *float64 `json:"total_depletion_qty,omitempty"`
TotalDepletion *int `json:"total_depletion,omitempty"` CumDepletionRate *float64 `json:"cum_depletion_rate,omitempty"`
CumDepletionRate *float64 `json:"cum_depletion_rate,omitempty"` DailyGain *float64 `json:"daily_gain,omitempty"`
DailyGain *float64 `json:"daily_gain,omitempty"` AvgDailyGain *float64 `json:"avg_daily_gain,omitempty"`
AvgDailyGain *float64 `json:"avg_daily_gain,omitempty"` CumIntake *int `json:"cum_intake,omitempty"`
CumIntake *int64 `json:"cum_intake,omitempty"` FcrValue *float64 `json:"fcr_value,omitempty"`
FcrValue *float64 `json:"fcr_value,omitempty"` TotalChickQty *float64 `json:"total_chick_qty,omitempty"`
TotalChick *int64 `json:"total_chick,omitempty"` Approval approvalDTO.ApprovalBaseDTO `json:"approval"`
DailyDepletionRate *float64 `json:"daily_depletion_rate,omitempty"` EggGradingStatus *string `json:"egg_grading_status,omitempty"`
CumDepletion *int `json:"cum_depletion,omitempty"` EggGradingPendingQty *int `json:"egg_grading_pending_qty,omitempty"`
EggGradingCompletedQty *int `json:"egg_grading_completed_qty,omitempty"`
} }
type RecordingListDTO struct { type RecordingListDTO struct {
@@ -39,30 +44,35 @@ type RecordingDetailDTO struct {
BodyWeights []RecordingBodyWeightDTO `json:"body_weights"` BodyWeights []RecordingBodyWeightDTO `json:"body_weights"`
Depletions []RecordingDepletionDTO `json:"depletions"` Depletions []RecordingDepletionDTO `json:"depletions"`
Stocks []RecordingStockDTO `json:"stocks"` Stocks []RecordingStockDTO `json:"stocks"`
Eggs []RecordingEggDTO `json:"eggs"`
} }
type RecordingBodyWeightDTO struct { type RecordingBodyWeightDTO struct {
Weight float64 `json:"weight"` AvgWeight float64 `json:"avg_weight"`
Qty int `json:"qty"` Qty float64 `json:"qty"`
Notes *string `json:"notes,omitempty"` TotalWeight float64 `json:"total_weight"`
} }
type RecordingDepletionDTO struct { type RecordingDepletionDTO struct {
ProductWarehouseId uint `json:"product_warehouse_id"` ProductWarehouseId uint `json:"product_warehouse_id"`
Total int64 `json:"total"` Qty float64 `json:"qty"`
Notes *string `json:"notes,omitempty"`
ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"` ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"`
} }
type RecordingStockDTO struct { type RecordingStockDTO struct {
ProductWarehouseId uint `json:"product_warehouse_id"` ProductWarehouseId uint `json:"product_warehouse_id"`
Increase *float64 `json:"increase,omitempty"` UsageAmount *float64 `json:"usage_amount,omitempty"`
Decrease *float64 `json:"decrease,omitempty"` PendingQty *float64 `json:"pending_qty,omitempty"`
UsageAmount *int64 `json:"usage_amount,omitempty"`
Notes *string `json:"notes,omitempty"`
ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"` ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"`
} }
type RecordingEggDTO struct {
ProductWarehouseId uint `json:"product_warehouse_id"`
Qty int `json:"qty"`
ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"`
Gradings []RecordingEggGradingDTO `json:"gradings,omitempty"`
}
type RecordingProductWarehouseDTO struct { type RecordingProductWarehouseDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
ProductId uint `json:"product_id"` ProductId uint `json:"product_id"`
@@ -71,36 +81,47 @@ type RecordingProductWarehouseDTO struct {
WarehouseName string `json:"warehouse_name"` WarehouseName string `json:"warehouse_name"`
} }
type RecordingEggGradingDTO struct {
Grade string `json:"grade,omitempty"`
Qty float64 `json:"qty"`
}
// === Mapper Functions === // === Mapper Functions ===
func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO {
recordDate := e.RecordDate var projectFlockCategory *string
if recordDate == nil { if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 {
rd := time.Date( category := e.ProjectFlockKandang.ProjectFlock.Category
e.RecordDatetime.Year(), if category != "" {
e.RecordDatetime.Month(), projectFlockCategory = &category
e.RecordDatetime.Day(), }
0, 0, 0, 0,
e.RecordDatetime.Location(),
)
recordDate = &rd
} }
latestApproval := defaultRecordingLatestApproval(e)
if e.LatestApproval != nil {
snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval)
latestApproval = snapshot
}
gradingStatus, gradingPending, gradingCompleted := computeEggGradingStatus(e)
return RecordingBaseDTO{ return RecordingBaseDTO{
Id: e.Id, Id: e.Id,
ProjectFlockKandangId: e.ProjectFlockKandangId, ProjectFlockKandangId: e.ProjectFlockKandangId,
RecordDatetime: e.RecordDatetime, RecordDatetime: e.RecordDatetime,
RecordDate: recordDate, Day: e.Day,
Ontime: e.Ontime == 1, ProjectFlockCategory: projectFlockCategory,
Day: e.Day, TotalDepletionQty: e.TotalDepletionQty,
TotalDepletion: e.TotalDepletion, CumDepletionRate: e.CumDepletionRate,
CumDepletionRate: e.CumDepletionRate, DailyGain: e.DailyGain,
DailyGain: e.DailyGain, AvgDailyGain: e.AvgDailyGain,
AvgDailyGain: e.AvgDailyGain, CumIntake: e.CumIntake,
CumIntake: e.CumIntake, FcrValue: e.FcrValue,
FcrValue: e.FcrValue, TotalChickQty: e.TotalChickQty,
TotalChick: e.TotalChick, Approval: latestApproval,
DailyDepletionRate: e.DailyDepletionRate, EggGradingStatus: gradingStatus,
CumDepletion: e.CumDepletion, EggGradingPendingQty: gradingPending,
EggGradingCompletedQty: gradingCompleted,
} }
} }
@@ -133,6 +154,7 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO {
BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights), BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights),
Depletions: ToRecordingDepletionDTOs(e.Depletions), Depletions: ToRecordingDepletionDTOs(e.Depletions),
Stocks: ToRecordingStockDTOs(e.Stocks), Stocks: ToRecordingStockDTOs(e.Stocks),
Eggs: ToRecordingEggDTOs(e.Eggs),
} }
} }
@@ -140,9 +162,9 @@ func ToRecordingBodyWeightDTOs(bodyWeights []entity.RecordingBW) []RecordingBody
result := make([]RecordingBodyWeightDTO, len(bodyWeights)) result := make([]RecordingBodyWeightDTO, len(bodyWeights))
for i, bw := range bodyWeights { for i, bw := range bodyWeights {
result[i] = RecordingBodyWeightDTO{ result[i] = RecordingBodyWeightDTO{
Weight: bw.Weight, AvgWeight: bw.AvgWeight,
Qty: bw.Qty, Qty: bw.Qty,
Notes: bw.Notes, TotalWeight: bw.TotalWeight,
} }
} }
return result return result
@@ -153,8 +175,7 @@ func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []Recordin
for i, d := range depletions { for i, d := range depletions {
result[i] = RecordingDepletionDTO{ result[i] = RecordingDepletionDTO{
ProductWarehouseId: d.ProductWarehouseId, ProductWarehouseId: d.ProductWarehouseId,
Total: d.Total, Qty: d.Qty,
Notes: d.Notes,
ProductWarehouse: toRecordingProductWarehouseDTO(&d.ProductWarehouse), ProductWarehouse: toRecordingProductWarehouseDTO(&d.ProductWarehouse),
} }
} }
@@ -166,16 +187,43 @@ func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO {
for i, s := range stocks { for i, s := range stocks {
result[i] = RecordingStockDTO{ result[i] = RecordingStockDTO{
ProductWarehouseId: s.ProductWarehouseId, ProductWarehouseId: s.ProductWarehouseId,
Increase: s.Increase, UsageAmount: s.UsageQty,
Decrease: s.Decrease, PendingQty: s.PendingQty,
UsageAmount: s.UsageAmount,
Notes: s.Notes,
ProductWarehouse: toRecordingProductWarehouseDTO(&s.ProductWarehouse), ProductWarehouse: toRecordingProductWarehouseDTO(&s.ProductWarehouse),
} }
} }
return result return result
} }
func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO {
result := make([]RecordingEggDTO, len(eggs))
for i, egg := range eggs {
result[i] = RecordingEggDTO{
ProductWarehouseId: egg.ProductWarehouseId,
Qty: egg.Qty,
ProductWarehouse: toRecordingProductWarehouseDTO(&egg.ProductWarehouse),
Gradings: ToRecordingEggGradingDTOs(egg.GradingEggs),
}
}
return result
}
func ToRecordingEggGradingDTOs(gradings []entity.GradingEgg) []RecordingEggGradingDTO {
if len(gradings) == 0 {
return nil
}
result := make([]RecordingEggGradingDTO, len(gradings))
for i, grading := range gradings {
result[i] = RecordingEggGradingDTO{
Grade: grading.Grade,
Qty: grading.Qty,
}
}
return result
}
func toRecordingProductWarehouseDTO(pw *entity.ProductWarehouse) *RecordingProductWarehouseDTO { func toRecordingProductWarehouseDTO(pw *entity.ProductWarehouse) *RecordingProductWarehouseDTO {
if pw == nil || pw.Id == 0 { if pw == nil || pw.Id == 0 {
return nil return nil
@@ -196,3 +244,81 @@ func toRecordingProductWarehouseDTO(pw *entity.ProductWarehouse) *RecordingProdu
return &dto return &dto
} }
const goodEggProductWarehouseID uint = 5
func computeEggGradingStatus(e entity.Recording) (*string, *int, *int) {
goodEggs := filterGoodEggs(e.Eggs)
if len(goodEggs) == 0 {
return nil, nil, nil
}
totalEggs := 0
totalGraded := 0.0
for _, egg := range goodEggs {
totalEggs += egg.Qty
for _, grading := range egg.GradingEggs {
totalGraded += grading.Qty
}
}
if totalEggs == 0 {
return nil, nil, nil
}
pendingFloat := float64(totalEggs) - totalGraded
if pendingFloat < 0 {
pendingFloat = 0
}
pendingInt := int(math.Round(pendingFloat))
completedInt := int(math.Round(totalGraded))
if completedInt < 0 {
completedInt = 0
}
if pendingInt > 0 {
status := "GRADING_TELUR"
return &status, &pendingInt, &completedInt
}
status := "GRADING_SELESAI"
zero := 0
return &status, &zero, &completedInt
}
func filterGoodEggs(eggs []entity.RecordingEgg) []entity.RecordingEgg {
if len(eggs) == 0 {
return nil
}
result := make([]entity.RecordingEgg, 0, len(eggs))
for _, egg := range eggs {
if egg.ProductWarehouseId == goodEggProductWarehouseID {
result = append(result, egg)
}
}
return result
}
func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalBaseDTO {
result := approvalDTO.ApprovalBaseDTO{}
step := utils.RecordingStepPengajuan
result.StepNumber = uint16(step)
if label, ok := approvalutils.ApprovalStepName(utils.ApprovalWorkflowRecording, step); ok {
result.StepName = label
} else if label, ok := utils.RecordingApprovalSteps[step]; ok {
result.StepName = label
}
if e.CreatedUser != nil && e.CreatedUser.Id != 0 {
result.ActionBy = userDTO.ToUserBaseDTO(*e.CreatedUser)
} else if e.CreatedBy != 0 {
result.ActionBy = userDTO.UserBaseDTO{
Id: e.CreatedBy,
IdUser: int64(e.CreatedBy),
}
}
return result
}
@@ -1,14 +1,19 @@
package recordings package recordings
import ( import (
"fmt"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
rProjectFlockKandang "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"
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"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
@@ -18,11 +23,26 @@ 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) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register recording approval workflow: %v", err))
}
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
recordingService := sRecording.NewRecordingService(recordingRepo, projectFlockKandangRepo, productWarehouseRepo, validate) recordingService := sRecording.NewRecordingService(
recordingRepo,
projectFlockKandangRepo,
productWarehouseRepo,
projectFlockPopulationRepo,
approvalRepo,
approvalService,
validate,
)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
RecordingRoutes(router, userService, recordingService) RecordingRoutes(router, userService, recordingService)
@@ -0,0 +1,8 @@
package recordings
const (
PermissionRecordingRead = "recording.read"
PermissionRecordingCreate = "recording.write"
PermissionRecordingUpdate = "recording.update"
PermissionRecordingDelete = "recording.delete"
)
@@ -1,10 +1,12 @@
package repository package repository
import ( import (
"context"
"errors" "errors"
"math" "math"
"sort" "sort"
"strings" "strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -22,11 +24,22 @@ type RecordingRepository interface {
CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error
DeleteStocks(tx *gorm.DB, recordingID uint) error DeleteStocks(tx *gorm.DB, recordingID uint) error
ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error)
CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error
DeleteDepletions(tx *gorm.DB, recordingID uint) error DeleteDepletions(tx *gorm.DB, recordingID uint) error
ListDepletions(tx *gorm.DB, recordingID uint) ([]entity.RecordingDepletion, error)
SumRecordingDepletions(tx *gorm.DB, recordingID uint) (int64, error) CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error
DeleteEggs(tx *gorm.DB, recordingID uint) error
ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error)
GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error)
CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error
DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error
ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error)
SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error)
FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error)
GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error)
GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error)
@@ -58,13 +71,18 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("Stocks"). Preload("Stocks").
Preload("Stocks.ProductWarehouse"). Preload("Stocks.ProductWarehouse").
Preload("Stocks.ProductWarehouse.Product"). Preload("Stocks.ProductWarehouse.Product").
Preload("Stocks.ProductWarehouse.Warehouse") Preload("Stocks.ProductWarehouse.Warehouse").
Preload("Eggs").
Preload("Eggs.ProductWarehouse").
Preload("Eggs.ProductWarehouse.Product").
Preload("Eggs.ProductWarehouse.Warehouse").
Preload("Eggs.GradingEggs")
} }
func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) {
var days []int var days []int
if err := tx.Model(&entity.Recording{}). if err := tx.Model(&entity.Recording{}).
Where("project_flock_id = ?", projectFlockKandangId). Where("project_flock_kandangs_id = ?", projectFlockKandangId).
Where("day IS NOT NULL"). Where("day IS NOT NULL").
Pluck("day", &days).Error; err != nil { Pluck("day", &days).Error; err != nil {
return 0, err return 0, err
@@ -94,6 +112,14 @@ func (r *RecordingRepositoryImpl) DeleteStocks(tx *gorm.DB, recordingID uint) er
return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error
} }
func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) {
var items []entity.RecordingStock
if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error {
if len(depletions) == 0 { if len(depletions) == 0 {
return nil return nil
@@ -105,11 +131,100 @@ func (r *RecordingRepositoryImpl) DeleteDepletions(tx *gorm.DB, recordingID uint
return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingDepletion{}).Error return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingDepletion{}).Error
} }
func (r *RecordingRepositoryImpl) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (int64, error) { func (r *RecordingRepositoryImpl) ListDepletions(tx *gorm.DB, recordingID uint) ([]entity.RecordingDepletion, error) {
var result int64 var items []entity.RecordingDepletion
if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *RecordingRepositoryImpl) CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error {
if len(eggs) == 0 {
return nil
}
return tx.Create(&eggs).Error
}
func (r *RecordingRepositoryImpl) DeleteEggs(tx *gorm.DB, recordingID uint) error {
return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingEgg{}).Error
}
func (r *RecordingRepositoryImpl) ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) {
var items []entity.RecordingEgg
if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *RecordingRepositoryImpl) GetRecordingEggByID(
ctx context.Context,
id uint,
modifier func(*gorm.DB) *gorm.DB,
) (*entity.RecordingEgg, error) {
if id == 0 {
return nil, gorm.ErrRecordNotFound
}
db := r.DB()
if modifier != nil {
db = modifier(db)
}
var egg entity.RecordingEgg
query := db.WithContext(ctx).
Preload("Recording").
Preload("Recording.ProjectFlockKandang").
Preload("Recording.ProjectFlockKandang.ProjectFlock").
Preload("ProductWarehouse").
Preload("GradingEggs").
Where("id = ?", id)
if err := query.First(&egg).Error; err != nil {
return nil, err
}
return &egg, nil
}
func (r *RecordingRepositoryImpl) CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error {
if len(gradings) == 0 {
return nil
}
return tx.Create(&gradings).Error
}
func (r *RecordingRepositoryImpl) DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error {
return tx.Where("recording_egg_id = ?", recordingEggID).Delete(&entity.GradingEgg{}).Error
}
func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) {
if projectFlockKandangId == 0 {
return false, nil
}
ref := recordTime.In(time.UTC)
startOfDay := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC)
endOfDay := startOfDay.Add(24 * time.Hour)
var count int64
err := r.DB().
WithContext(ctx).
Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangId).
Where("record_datetime >= ? AND record_datetime < ?", startOfDay, endOfDay).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *RecordingRepositoryImpl) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) {
var result float64
if err := tx.Model(&entity.RecordingDepletion{}). if err := tx.Model(&entity.RecordingDepletion{}).
Where("recording_id = ?", recordingID). Where("recording_id = ?", recordingID).
Select("COALESCE(SUM(total), 0)"). Select("COALESCE(SUM(qty), 0)").
Scan(&result).Error; err != nil { Scan(&result).Error; err != nil {
return 0, err return 0, err
} }
@@ -123,7 +238,7 @@ func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFloc
var prev entity.Recording var prev entity.Recording
err := tx. err := tx.
Where("project_flock_id = ? AND day < ?", projectFlockKandangId, currentDay). Where("project_flock_kandangs_id = ? AND day < ?", projectFlockKandangId, currentDay).
Where("day IS NOT NULL"). Where("day IS NOT NULL").
Order("day DESC"). Order("day DESC").
Limit(1). Limit(1).
@@ -159,7 +274,7 @@ func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID
TotalQty float64 TotalQty float64
} }
if err := tx.Model(&entity.RecordingBW{}). if err := tx.Model(&entity.RecordingBW{}).
Select("COALESCE(SUM(weight * qty), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty"). Select("COALESCE(SUM(total_weight), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty").
Where("recording_id = ?", recordingID). Where("recording_id = ?", recordingID).
Scan(&result).Error; err != nil { Scan(&result).Error; err != nil {
return 0, err return 0, err
@@ -172,13 +287,13 @@ func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID
func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) {
var rows []struct { var rows []struct {
UsageAmount float64 UsageQty float64
UomName string UomName string
} }
if err := tx. if err := tx.
Table("recording_stocks"). Table("recording_stocks").
Select("COALESCE(recording_stocks.usage_amount, 0) AS usage_amount, LOWER(uoms.name) AS uom_name"). Select("COALESCE(recording_stocks.usage_qty, 0) AS usage_qty, LOWER(uoms.name) AS uom_name").
Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id"). 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 products ON products.id = product_warehouses.product_id").
Joins("JOIN uoms ON uoms.id = products.uom_id"). Joins("JOIN uoms ON uoms.id = products.uom_id").
@@ -189,16 +304,16 @@ func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID u
var total float64 var total float64
for _, row := range rows { for _, row := range rows {
if row.UsageAmount <= 0 { if row.UsageQty <= 0 {
continue continue
} }
switch strings.TrimSpace(row.UomName) { switch strings.TrimSpace(row.UomName) {
case "kilogram", "kg", "kilograms", "kilo": case "kilogram", "kg", "kilograms", "kilo":
total += row.UsageAmount * 1000 total += row.UsageQty * 1000
case "gram", "g", "grams": case "gram", "g", "grams":
total += row.UsageAmount total += row.UsageQty
default: default:
total += row.UsageAmount total += row.UsageQty
} }
} }
return total, nil return total, nil
@@ -18,7 +18,9 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS
route.Get("/", ctrl.GetAll) route.Get("/", ctrl.GetAll)
route.Get("/next-day", ctrl.GetNextDay) route.Get("/next-day", ctrl.GetNextDay)
route.Post("/", ctrl.CreateOne) route.Post("/", ctrl.CreateOne)
route.Post("/gradings", ctrl.SubmitGrading)
route.Get("/:id", ctrl.GetOne) route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne) route.Patch("/:id", ctrl.UpdateOne)
route.Post("/approvals", ctrl.Approve)
route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", ctrl.DeleteOne)
} }
File diff suppressed because it is too large Load Diff
@@ -2,23 +2,25 @@ package validation
type ( type (
BodyWeight struct { BodyWeight struct {
Weight float64 `json:"weight" validate:"required"` AvgWeight float64 `json:"avg_weight" validate:"required"`
Qty int `json:"qty" validate:"required,number,min=1"` Qty float64 `json:"qty" validate:"required,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty"` TotalWeight *float64 `json:"total_weight,omitempty" validate:"omitempty,gt=0"`
} }
Stock struct { Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Increase *float64 `json:"increase,omitempty" validate:"omitempty"` Qty *float64 `json:"qty,omitempty" validate:"required_without=UsageAmount,gte=0"`
Decrease *float64 `json:"decrease,omitempty" validate:"omitempty"` PendingQty *float64 `json:"pending_qty,omitempty" validate:"omitempty,gte=0"`
UsageAmount *int64 `json:"usage_amount,omitempty" validate:"omitempty,min=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty"`
} }
Depletion struct { Depletion struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Total int64 `json:"total" validate:"required,number,min=0"` Qty float64 `json:"qty" validate:"required,gte=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty"` }
Egg struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Qty int `json:"qty" validate:"required,number,min=0"`
} }
) )
@@ -27,12 +29,14 @@ type Create struct {
BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"` BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"`
} }
type Update struct { type Update struct {
BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"` BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"`
} }
type Query struct { type Query struct {
@@ -40,3 +44,19 @@ type Query struct {
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
} }
type EggGrading struct {
RecordingEggId uint `json:"recording_egg_id" validate:"required,number,min=1"`
Grade string `json:"grade" validate:"required"`
Qty float64 `json:"qty" validate:"required,gte=0"`
}
type SubmitGrading struct {
EggsGrading []EggGrading `json:"eggs_grading" validate:"required,dive"`
}
type Approve struct {
Action string `json:"action" validate:"required_strict"`
ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
@@ -13,6 +13,7 @@ type StockLogRepository interface {
GetByFlaggable(ctx context.Context, logType string, logId uint) ([]*entity.StockLog, error) GetByFlaggable(ctx context.Context, logType string, logId uint) ([]*entity.StockLog, error)
GetByProductWarehouse(ctx context.Context, productWarehouseId uint, limit int) ([]*entity.StockLog, error) GetByProductWarehouse(ctx context.Context, productWarehouseId uint, limit int) ([]*entity.StockLog, error)
GetByTransactionType(ctx context.Context, transactionType string, limit int) ([]*entity.StockLog, error) GetByTransactionType(ctx context.Context, transactionType string, limit int) ([]*entity.StockLog, error)
ApplyProductWarehouseFilters(db *gorm.DB, productID, warehouseID uint) *gorm.DB
} }
type StockLogRepositoryImpl struct { type StockLogRepositoryImpl struct {
@@ -86,3 +87,20 @@ func (r *StockLogRepositoryImpl) GetByTransactionType(ctx context.Context, trans
return stockLogs, nil return stockLogs, nil
} }
func (r *StockLogRepositoryImpl) ApplyProductWarehouseFilters(db *gorm.DB, productID, warehouseID uint) *gorm.DB {
if productID == 0 && warehouseID == 0 {
return db
}
db = db.Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id")
if productID > 0 {
db = db.Where("product_warehouses.product_id = ?", productID)
}
if warehouseID > 0 {
db = db.Where("product_warehouses.warehouse_id = ?", warehouseID)
}
return db
}
@@ -211,7 +211,6 @@ func (h *Controller) Callback(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadGateway, "missing access token") return fiber.NewError(fiber.StatusBadGateway, "missing access token")
} }
fmt.Println(tokenResp.AccessToken)
verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) verification, err := sso.VerifyAccessToken(tokenResp.AccessToken)
if err != nil { if err != nil {
utils.Log.Errorf("access token verification failed: %v", err) utils.Log.Errorf("access token verification failed: %v", err)
@@ -308,6 +307,13 @@ func (h *Controller) UserInfo(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response") return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response")
} }
// if sanitized, perms, ok := sanitizeUserInfoPayload(body); ok {
// if caps := capabilities.FromPermissions(perms); len(caps) > 0 {
// injectCapabilities(sanitized, caps)
// }
// return c.Status(resp.StatusCode).JSON(sanitized)
// }
if ct := resp.Header.Get("Content-Type"); ct != "" { if ct := resp.Header.Get("Content-Type"); ct != "" {
c.Set("Content-Type", ct) c.Set("Content-Type", ct)
} else { } else {
@@ -545,6 +551,99 @@ func normalizeClientParam(raw string) string {
return strings.ToLower(value) return strings.ToLower(value)
} }
func sanitizeUserInfoPayload(body []byte) (map[string]any, []string, bool) {
if len(body) == 0 {
return map[string]any{}, nil, true
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, nil, false
}
perms := collectPermissionNames(payload)
sensitive := map[string]struct{}{
"roles": {},
"permissions": {},
}
payload = scrubSensitiveKeys(payload, sensitive)
sanitized, ok := payload.(map[string]any)
if !ok {
sanitized = map[string]any{"data": payload}
}
return sanitized, perms, true
}
func scrubSensitiveKeys(value any, sensitive map[string]struct{}) any {
switch v := value.(type) {
case map[string]any:
for key, val := range v {
if _, ok := sensitive[strings.ToLower(key)]; ok {
delete(v, key)
continue
}
v[key] = scrubSensitiveKeys(val, sensitive)
}
return v
case []any:
for i, item := range v {
v[i] = scrubSensitiveKeys(item, sensitive)
}
return v
default:
return value
}
}
func collectPermissionNames(value any) []string {
names := make(map[string]struct{})
collectPermissionRec(value, names)
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
return out
}
func collectPermissionRec(value any, acc map[string]struct{}) {
switch v := value.(type) {
case map[string]any:
for key, val := range v {
if strings.EqualFold(key, "permissions") {
if arr, ok := val.([]any); ok {
for _, item := range arr {
if perm, ok := item.(map[string]any); ok {
if name, ok := perm["name"].(string); ok && strings.TrimSpace(name) != "" {
acc[strings.ToLower(strings.TrimSpace(name))] = struct{}{}
}
}
}
}
} else {
collectPermissionRec(val, acc)
}
}
case []any:
for _, item := range v {
collectPermissionRec(item, acc)
}
}
}
func injectCapabilities(payload map[string]any, caps map[string]bool) {
if len(caps) == 0 {
return
}
if data, ok := payload["data"].(map[string]any); ok {
data["capabilities"] = caps
return
}
payload["capabilities"] = caps
}
func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) { func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) {
if requestedAlias == "" { if requestedAlias == "" {
return "", config.SSOClientConfig{}, false return "", config.SSOClientConfig{}, false
+3
View File
@@ -125,6 +125,9 @@ func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
if cookieName := strings.TrimSpace(config.SSOAccessCookieName); cookieName != "" {
req.Header.Set("Cookie", fmt.Sprintf("%s=%s", cookieName, token))
}
resp, err := profileClient.Do(req) resp, err := profileClient.Do(req)
if err != nil { if err != nil {
+19
View File
@@ -140,6 +140,23 @@ var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{
ProjectFlockStepAktif: "Aktif", ProjectFlockStepAktif: "Aktif",
} }
// -------------------------------------------------------------------
// Recording Approval
// -------------------------------------------------------------------
const (
ApprovalWorkflowRecording approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("RECORDINGS")
RecordingStepGradingTelur approvalutils.ApprovalStep = 1
RecordingStepPengajuan approvalutils.ApprovalStep = 2
RecordingStepDisetujui approvalutils.ApprovalStep = 3
)
var RecordingApprovalSteps = map[approvalutils.ApprovalStep]string{
RecordingStepGradingTelur: "Grading-Telur",
RecordingStepPengajuan: "Pengajuan",
RecordingStepDisetujui: "Disetujui",
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Validators // Validators
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -268,6 +285,8 @@ func IsValidSupplierCategory(v string) bool {
// example use // example use
// Recording helper
/** /**
if !utils.IsValidFlagType(req.FlagName) { if !utils.IsValidFlagType(req.FlagName) {
return fiber.NewError(fiber.StatusBadRequest, "Invalid flag type") return fiber.NewError(fiber.StatusBadRequest, "Invalid flag type")
+106
View File
@@ -0,0 +1,106 @@
package recording
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
)
func MapBodyWeights(recordingID uint, items []validation.BodyWeight) []entity.RecordingBW {
if len(items) == 0 {
return nil
}
result := make([]entity.RecordingBW, 0, len(items))
for _, item := range items {
totalWeight := item.TotalWeight
if totalWeight == nil {
calculated := item.AvgWeight * item.Qty
totalWeight = &calculated
}
result = append(result, entity.RecordingBW{
RecordingId: recordingID,
AvgWeight: item.AvgWeight,
Qty: item.Qty,
TotalWeight: *totalWeight,
})
}
return result
}
func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingStock {
if len(items) == 0 {
return nil
}
result := make([]entity.RecordingStock, 0, len(items))
for _, item := range items {
var usageAmount float64
if item.Qty != nil {
usageAmount = *item.Qty
}
usagePtr := new(float64)
*usagePtr = usageAmount
pending := item.PendingQty
if pending == nil {
pending = new(float64)
}
result = append(result, entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId,
UsageQty: usagePtr,
PendingQty: pending,
})
}
return result
}
func MapDepletions(recordingID uint, items []validation.Depletion) []entity.RecordingDepletion {
if len(items) == 0 {
return nil
}
result := make([]entity.RecordingDepletion, 0, len(items))
for _, item := range items {
result = append(result, entity.RecordingDepletion{
RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId,
Qty: item.Qty,
})
}
return result
}
func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity.RecordingEgg {
if len(items) == 0 {
return nil
}
result := make([]entity.RecordingEgg, 0, len(items))
for _, item := range items {
result = append(result, entity.RecordingEgg{
RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId,
Qty: item.Qty,
CreatedBy: createdBy,
})
}
return result
}
func ToGrams(weight float64) float64 {
if weight <= 0 {
return 0
}
if weight < 10 {
return weight * 1000
}
return weight
}
func GramsToKg(grams float64) float64 {
if grams <= 0 {
return 0
}
return grams / 1000
}
+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
}
+2 -1
View File
@@ -2,6 +2,7 @@ package test
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"testing" "testing"
@@ -58,7 +59,7 @@ func TestKandangIntegration(t *testing.T) {
flocID := createFlock(t, app, "Floc Test") flocID := createFlock(t, app, "Floc Test")
projectFloc := entities.ProjectFlock{ projectFloc := entities.ProjectFlock{
FlockId: flocID, FlockName: fmt.Sprintf("Project Flock %d", flocID),
AreaId: areaID, AreaId: areaID,
Category: string(utils.ProjectFlockCategoryGrowing), Category: string(utils.ProjectFlockCategoryGrowing),
FcrId: fcrID, FcrId: fcrID,
+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