diff --git a/.DS_Store b/.DS_Store index 4c14efd8..e39247fd 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/go.mod b/go.mod index 355f8e5c..abb6d004 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/redis/go-redis/v9 v9.14.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/viper v1.19.0 + github.com/xuri/excelize/v2 v2.9.0 golang.org/x/crypto v0.33.0 gorm.io/driver/postgres v1.5.9 gorm.io/gorm v1.25.11 @@ -71,9 +72,12 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.1.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect @@ -82,12 +86,15 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tinylib/msgp v1.1.8 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect + github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect + github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect diff --git a/go.sum b/go.sum index 188b0dae..73b36464 100644 --- a/go.sum +++ b/go.sum @@ -182,6 +182,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= @@ -195,6 +197,11 @@ github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -238,8 +245,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= @@ -252,6 +260,16 @@ github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8 github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY= +github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE= +github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE= +github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A= +github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -278,6 +296,8 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/internal/common/repository/common.approval.repository..go b/internal/common/repository/common.approval.repository..go index dc517f21..8c045084 100644 --- a/internal/common/repository/common.approval.repository..go +++ b/internal/common/repository/common.approval.repository..go @@ -84,8 +84,9 @@ func (r *approvalRepositoryImpl) LatestByTargets( result := make(map[uint]entity.Approval, len(approvableIDs)) q := r.DB().WithContext(ctx). + Select("DISTINCT ON (approvable_id) *"). Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs). - Order("action_at DESC") + Order("approvable_id, action_at DESC") if modifier != nil { q = modifier(q) diff --git a/internal/common/repository/common.exists.repository.go b/internal/common/repository/common.exists.repository.go index c6bc11f0..b8206eb9 100644 --- a/internal/common/repository/common.exists.repository.go +++ b/internal/common/repository/common.exists.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "gorm.io/gorm" @@ -9,45 +10,59 @@ import ( // Exists reports whether a record with the given ID exists for type T. func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) { - var count int64 - if err := db.WithContext(ctx). + var marker int + err := db.WithContext(ctx). Model(new(T)). + Select("1"). Where("id = ?", id). - Count(&count).Error; err != nil { + Limit(1). + Take(&marker).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { return false, err } - return count > 0, nil + return true, nil } func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) { - var count int64 q := db.WithContext(ctx). Model(new(T)). + Select("1"). Where("name = ?", name). Where("deleted_at IS NULL") if excludeID != nil { q = q.Where("id <> ?", *excludeID) } - if err := q.Count(&count).Error; err != nil { + var marker int + if err := q.Limit(1).Take(&marker).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } return false, err } - return count > 0, nil + return true, 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)). + Select("1"). 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 { + var marker int + if err := q.Limit(1).Take(&marker).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } return false, err } - return count > 0, nil + return true, nil } diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index e3b80268..bf97f831 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -505,12 +505,25 @@ func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWa var lots []stockLot for key, cfg := range configs { - selectStmt := fmt.Sprintf( - "%s AS id, %s AS available_qty, %s AS created_at", - cfg.Columns.ID, - fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), - cfg.Columns.CreatedAt, - ) + + usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID + + var selectStmt string + if usesNumericTime { + + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + ) + } else { + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, %s AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + cfg.Columns.CreatedAt, + ) + } var rows []struct { ID uint diff --git a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql index e029646b..4ece8942 100644 --- a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql +++ b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql @@ -29,7 +29,7 @@ ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id -- Relasi ke product_warehouses ALTER TABLE project_chickins -ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE; +ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE; -- Relasi ke users ALTER TABLE project_chickins diff --git a/internal/database/migrations/20251224033033_create_payment_table.down.sql b/internal/database/migrations/20251224033033_create_payment_table.down.sql new file mode 100644 index 00000000..14bc4ca1 --- /dev/null +++ b/internal/database/migrations/20251224033033_create_payment_table.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_payments_bank_id; +DROP INDEX IF EXISTS payments_party_polymorphic; +DROP TABLE IF EXISTS payments; diff --git a/internal/database/migrations/20251224033033_create_payment_table.up.sql b/internal/database/migrations/20251224033033_create_payment_table.up.sql new file mode 100644 index 00000000..d27c55f4 --- /dev/null +++ b/internal/database/migrations/20251224033033_create_payment_table.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS payments ( + id BIGSERIAL PRIMARY KEY, + payment_code VARCHAR(50) NOT NULL, + reference_number VARCHAR(100) NULL, + transaction_type VARCHAR(50), + party_type VARCHAR(50) NOT NULL, + party_id BIGINT NOT NULL, + payment_date TIMESTAMPTZ NOT NULL, + payment_method VARCHAR(20) NOT NULL, + bank_id BIGINT NULL REFERENCES banks(id) ON DELETE RESTRICT ON UPDATE CASCADE, + direction VARCHAR(5) NOT NULL, + nominal NUMERIC(15, 3) NOT NULL, + notes TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +-- Indexes +CREATE INDEX payments_party_polymorphic ON payments (party_type, party_id); +CREATE INDEX idx_payments_bank_id ON payments (bank_id); diff --git a/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql new file mode 100644 index 00000000..1d55147b --- /dev/null +++ b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql @@ -0,0 +1,18 @@ +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + END LOOP; +END $$; + +DROP FUNCTION IF EXISTS soft_delete_handle_fk(); diff --git a/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql new file mode 100644 index 00000000..161d3d89 --- /dev/null +++ b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql @@ -0,0 +1,126 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)', + fk.child_table, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql b/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql new file mode 100644 index 00000000..50996e8f --- /dev/null +++ b/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql @@ -0,0 +1 @@ +DROP SEQUENCE IF EXISTS payments_code_seq; diff --git a/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql b/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql new file mode 100644 index 00000000..875b0697 --- /dev/null +++ b/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql @@ -0,0 +1 @@ +CREATE SEQUENCE IF NOT EXISTS payments_code_seq START WITH 1 INCREMENT BY 1; diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql new file mode 100644 index 00000000..59e54379 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql @@ -0,0 +1,3 @@ +-- Rollback: restore document columns to expenses table +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS document_path JSON; +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS realization_document_path JSON; diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql new file mode 100644 index 00000000..c75bc307 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql @@ -0,0 +1,3 @@ +-- Delete document columns from expenses table since we now use Document service with polymorphic relations +ALTER TABLE expenses DROP COLUMN IF EXISTS document_path; +ALTER TABLE expenses DROP COLUMN IF EXISTS realization_document_path; diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql new file mode 100644 index 00000000..b9637077 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql @@ -0,0 +1,28 @@ +-- ============================================ +-- Rollback: Remove FIFO fields and restore qty column +-- ============================================ + +-- STEP 1: Drop indexes +DROP INDEX IF EXISTS idx_marketing_delivery_products_fifo_lookup; +DROP INDEX IF EXISTS idx_marketing_delivery_products_pending_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_usage_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_created_at; + +-- STEP 2: Drop constraints +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS chk_marketing_delivery_products_fifo_nonneg; + +-- STEP 3: Restore qty column from usage_qty data +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- Migrate data back from usage_qty to qty +UPDATE marketing_delivery_products +SET qty = usage_qty +WHERE qty = 0; + +-- STEP 4: Drop FIFO columns +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS created_at; diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql new file mode 100644 index 00000000..160c1f05 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql @@ -0,0 +1,58 @@ +-- ============================================ +-- Add FIFO fields to marketing_delivery_products +-- This migration adds fields needed for FIFO stock management +-- and removes the old qty field in favor of FIFO-based allocation +-- ============================================ + +-- STEP 0: Drop orphan indexes from previous migration +DROP INDEX IF EXISTS idx_marketing_delivery_products_deleted_at; + +-- STEP 1: Add created_at column (required for FIFO ordering) +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); + +-- STEP 2: Add FIFO tracking fields +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0; + +-- STEP 3: Migrate data from old qty to usage_qty for existing records +-- This preserves existing quantity data as allocated quantity +UPDATE marketing_delivery_products +SET + usage_qty = COALESCE(qty, 0), + pending_qty = 0 +WHERE usage_qty = 0; + +-- STEP 4: Drop the old qty column (replaced by usage_qty + pending_qty) +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS qty; + +-- STEP 5: Make FIFO fields NOT NULL +ALTER TABLE marketing_delivery_products +ALTER COLUMN usage_qty SET NOT NULL, +ALTER COLUMN pending_qty SET NOT NULL, +ALTER COLUMN created_at SET NOT NULL; + +-- STEP 6: Add constraints to ensure non-negative values +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT chk_marketing_delivery_products_fifo_nonneg CHECK ( + usage_qty >= 0 AND + pending_qty >= 0 +); + +-- STEP 7: Create indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_created_at +ON marketing_delivery_products(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_usage_qty +ON marketing_delivery_products(usage_qty) +WHERE usage_qty > 0; + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_pending_qty +ON marketing_delivery_products(pending_qty) +WHERE pending_qty > 0; + +-- Composite index for FIFO lookups +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_fifo_lookup +ON marketing_delivery_products(marketing_product_id, created_at DESC); diff --git a/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql new file mode 100644 index 00000000..15f3368a --- /dev/null +++ b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql @@ -0,0 +1,7 @@ +-- Remove foreign key constraint +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_product_warehouse; + +-- Drop product_warehouse_id column +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS product_warehouse_id; diff --git a/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql new file mode 100644 index 00000000..97e5b4ee --- /dev/null +++ b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql @@ -0,0 +1,19 @@ +-- Add product_warehouse_id column to marketing_delivery_products +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS product_warehouse_id INT NOT NULL DEFAULT 0; + +-- Fill product_warehouse_id from marketing_products +UPDATE marketing_delivery_products mdp +SET product_warehouse_id = mp.product_warehouse_id +FROM marketing_products mp +WHERE mdp.marketing_product_id = mp.id + AND mdp.product_warehouse_id = 0; + +-- Set NOT NULL constraint +ALTER TABLE marketing_delivery_products +ALTER COLUMN product_warehouse_id SET NOT NULL; + +-- Add foreign key constraint +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT fk_marketing_delivery_products_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id); diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql new file mode 100644 index 00000000..f5cc2237 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql @@ -0,0 +1,10 @@ +-- Drop indexes +DROP INDEX IF EXISTS idx_standard_growth_details_standard_week; +DROP INDEX IF EXISTS idx_production_standard_details_standard_week; +DROP INDEX IF EXISTS idx_production_standards_project_category; +DROP INDEX IF EXISTS idx_production_standards_deleted_at; + +-- Drop tables (in reverse order due to foreign keys) +DROP TABLE IF EXISTS standard_growth_details; +DROP TABLE IF EXISTS production_standard_details; +DROP TABLE IF EXISTS production_standards; diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql new file mode 100644 index 00000000..2af43d20 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql @@ -0,0 +1,96 @@ +-- Create production_standards table +CREATE TABLE IF NOT EXISTS production_standards ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + project_category VARCHAR(20) NOT NULL CHECK (project_category IN ('GROWING', 'LAYING')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT +); + +-- Create index for deleted_at (soft delete) +CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at); + +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE production_standards + ADD CONSTRAINT fk_production_standards_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- Index +CREATE INDEX idx_production_standards_created_by ON production_standards(created_by); + +-- Create production_standard_details table +CREATE TABLE IF NOT EXISTS production_standard_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + week INT NOT NULL, + target_hen_day_production NUMERIC(15, 3), + target_hen_house_production NUMERIC(15, 3), + target_egg_weight NUMERIC(15, 3), + target_egg_mass NUMERIC(15, 3), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE production_standard_details + ADD CONSTRAINT fk_production_standard_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_production_standard_details_standard_week + ON production_standard_details(production_standard_id, week); + +-- Create standard_growth_details table +CREATE TABLE IF NOT EXISTS standard_growth_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + target_mean_bw NUMERIC(15, 3), + max_depletion NUMERIC(15, 3), + min_uniformity NUMERIC(15, 3) NOT NULL, + week INT NOT NULL, + feed_intake NUMERIC(15, 3), + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by BIGINT +); + +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_standard_growth_details_standard_week + ON standard_growth_details(production_standard_id, week); + +-- Index +CREATE INDEX idx_standard_growth_details_created_by ON standard_growth_details(created_by); + +-- Create index for project_category +CREATE INDEX idx_production_standards_project_category ON production_standards(project_category); diff --git a/internal/database/migrations/20251227100000_update_expense_table.down.sql b/internal/database/migrations/20251227100000_update_expense_table.down.sql new file mode 100644 index 00000000..fbaff587 --- /dev/null +++ b/internal/database/migrations/20251227100000_update_expense_table.down.sql @@ -0,0 +1,24 @@ +-- Rollback: Update expense and expense_nonstocks tables + +-- Drop indexes +DROP INDEX IF EXISTS idx_expenses_project_flock_id; +DROP INDEX IF EXISTS idx_expenses_location_id; + +-- Drop Foreign Key constraint +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_expenses_location_id' + ) THEN + ALTER TABLE expenses + DROP CONSTRAINT fk_expenses_location_id; + END IF; +END $$; + +-- Drop columns from expenses table +ALTER TABLE expenses +DROP COLUMN IF EXISTS project_flock_id; + +ALTER TABLE expenses +DROP COLUMN IF EXISTS location_id; diff --git a/internal/database/migrations/20251227100000_update_expense_table.up.sql b/internal/database/migrations/20251227100000_update_expense_table.up.sql new file mode 100644 index 00000000..6415ac98 --- /dev/null +++ b/internal/database/migrations/20251227100000_update_expense_table.up.sql @@ -0,0 +1,29 @@ +-- Migration: Update expense and expense_nonstocks tables + +-- Add location_id column to expenses table +ALTER TABLE expenses +ADD COLUMN IF NOT EXISTS location_id BIGINT NOT NULL DEFAULT 1; + +-- Add project_flock_id column to expenses table (JSON type) +ALTER TABLE expenses +ADD COLUMN IF NOT EXISTS project_flock_id JSON NULL; + +-- Add Foreign Key constraint to locations table +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'locations') THEN + ALTER TABLE expenses + ADD CONSTRAINT fk_expenses_location_id + FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- Create index for location_id +CREATE INDEX IF NOT EXISTS idx_expenses_location_id ON expenses (location_id); + +-- Create index for project_flock_id +CREATE INDEX IF NOT EXISTS idx_expenses_project_flock_id ON expenses ((project_flock_id::text)); + +-- Ensure kandang_id is nullable in expense_nonstocks table +ALTER TABLE expense_nonstocks +ALTER COLUMN kandang_id DROP NOT NULL; diff --git a/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.down.sql b/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.down.sql new file mode 100644 index 00000000..19eaba80 --- /dev/null +++ b/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_deleted_at; +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_created_by; +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_week; +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_id; + +DROP TABLE IF EXISTS project_flock_kandang_uniformity; diff --git a/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.up.sql b/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.up.sql new file mode 100644 index 00000000..86bc5ed5 --- /dev/null +++ b/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.up.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS project_flock_kandang_uniformity ( + id BIGSERIAL PRIMARY KEY, + uniformity NUMERIC(15, 3), + week INT NOT NULL, + cv NUMERIC(15, 3), + chick_qty_of_weight NUMERIC(15, 3), + mean_up NUMERIC(15, 3), + mean_down NUMERIC(15, 3), + project_flock_kandang_id BIGINT NOT NULL, + uniform_qty NUMERIC(15, 3), + not_uniform_qty NUMERIC(15, 3), + uniform_date TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT NOT NULL +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang' + ) THEN + EXECUTE + 'ALTER TABLE project_flock_kandang_uniformity + ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_project_flock_kandang_uniformity_created_by' + ) THEN + EXECUTE + 'ALTER TABLE project_flock_kandang_uniformity + ADD CONSTRAINT fk_project_flock_kandang_uniformity_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_id + ON project_flock_kandang_uniformity (project_flock_kandang_id); + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_week + ON project_flock_kandang_uniformity (project_flock_kandang_id, week); + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_created_by + ON project_flock_kandang_uniformity (created_by); + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_deleted_at + ON project_flock_kandang_uniformity (deleted_at); diff --git a/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql new file mode 100644 index 00000000..9b5b8164 --- /dev/null +++ b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql @@ -0,0 +1,42 @@ +-- =============================================================== +-- ROLLBACK: Remove FIFO fields from STOCK_TRANSFER_DETAILS +-- =============================================================== + +-- Drop indexes +DROP INDEX IF EXISTS idx_stock_transfer_details_dest_pw; +DROP INDEX IF EXISTS idx_stock_transfer_details_source_pw; + +-- Drop foreign keys +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_source_pw' + ) THEN + EXECUTE 'ALTER TABLE stock_transfer_details + DROP CONSTRAINT fk_stock_transfer_details_source_pw'; + END IF; + + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_dest_pw' + ) THEN + EXECUTE 'ALTER TABLE stock_transfer_details + DROP CONSTRAINT fk_stock_transfer_details_dest_pw'; + END IF; +END $$; + +-- Drop FIFO columns +ALTER TABLE stock_transfer_details +DROP COLUMN IF EXISTS total_used, +DROP COLUMN IF EXISTS total_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS dest_product_warehouse_id, +DROP COLUMN IF EXISTS source_product_warehouse_id; + +-- Restore original columns (in case rollback) +ALTER TABLE stock_transfer_details +ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3), +ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3); diff --git a/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql new file mode 100644 index 00000000..7f6ad5cb --- /dev/null +++ b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql @@ -0,0 +1,83 @@ +-- =============================================================== +-- ADD FIFO FIELDS TO STOCK_TRANSFER_DETAILS +-- Enable transfer module to work with FIFO stock system +-- +-- Notes: +-- - Field 'quantity' will be removed (replaced by usage_qty + pending_qty) +-- - Fields 'before_quantity' & 'after_quantity' will be removed (unused legacy) +-- - New FIFO fields track actual allocation instead of requested quantity +-- =============================================================== + +-- Add FIFO tracking fields +ALTER TABLE stock_transfer_details +ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT, +ADD COLUMN IF NOT EXISTS dest_product_warehouse_id BIGINT, +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS total_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS total_used NUMERIC(15, 3) DEFAULT 0; + +-- Remove obsolete columns (quantity replaced by FIFO fields, legacy fields never used) +ALTER TABLE stock_transfer_details +DROP COLUMN IF EXISTS quantity, +DROP COLUMN IF EXISTS before_quantity, +DROP COLUMN IF EXISTS after_quantity; + +-- Add foreign keys for product warehouse references +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + -- Source warehouse foreign key + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_source_pw' + ) THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_source_pw + FOREIGN KEY (source_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + + -- Destination warehouse foreign key + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_dest_pw' + ) THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_dest_pw + FOREIGN KEY (dest_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; +END $$; + +-- Add indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_source_pw +ON stock_transfer_details (source_product_warehouse_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_dest_pw +ON stock_transfer_details (dest_product_warehouse_id); + +-- Add comments for documentation +COMMENT ON COLUMN stock_transfer_details.source_product_warehouse_id IS +'Source product warehouse ID - referensi warehouse asal (FIFO usable)'; + +COMMENT ON COLUMN stock_transfer_details.dest_product_warehouse_id IS +'Destination product warehouse ID - referensi warehouse tujuan (FIFO stockable)'; + +COMMENT ON COLUMN stock_transfer_details.usage_qty IS +'Actual quantity successfully taken from source warehouse (FIFO usable tracking) - replaces quantity field'; + +COMMENT ON COLUMN stock_transfer_details.pending_qty IS +'Quantity waiting for stock availability (FIFO usable tracking)'; + +COMMENT ON COLUMN stock_transfer_details.total_qty IS +'Total lot quantity available at destination warehouse (FIFO stockable tracking)'; + +COMMENT ON COLUMN stock_transfer_details.total_used IS +'Quantity already consumed from this lot at destination warehouse (FIFO stockable tracking)'; + diff --git a/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql new file mode 100644 index 00000000..9941a992 --- /dev/null +++ b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql @@ -0,0 +1,16 @@ +-- Rollback: Drop adjustment_stocks table + +BEGIN; + +DROP INDEX IF EXISTS idx_adjustment_stocks_product_warehouse; +DROP INDEX IF EXISTS idx_adjustment_stocks_stock_log; + +ALTER TABLE adjustment_stocks +DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_product_warehouse; + +ALTER TABLE adjustment_stocks +DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_stock_log; + +DROP TABLE IF EXISTS adjustment_stocks; + +COMMIT; diff --git a/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql new file mode 100644 index 00000000..1c79439b --- /dev/null +++ b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql @@ -0,0 +1,40 @@ +-- Migration: Create adjustment_stocks table for FIFO tracking +-- This table tracks FIFO allocation for stock adjustments (both increase and decrease) + +BEGIN; + +CREATE TABLE IF NOT EXISTS adjustment_stocks ( + id BIGSERIAL PRIMARY KEY, + stock_log_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + + -- FIFO fields for Adjustment INCREASE (Stockable) + -- Tracks stock added to warehouse via adjustment + total_qty NUMERIC(15, 3) DEFAULT 0, + total_used NUMERIC(15, 3) DEFAULT 0, + + -- FIFO fields for Adjustment DECREASE (Usable) + -- Tracks stock consumed from warehouse via adjustment + usage_qty NUMERIC(15, 3) DEFAULT 0, + pending_qty NUMERIC(15, 3) DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Foreign keys +ALTER TABLE adjustment_stocks +ADD CONSTRAINT fk_adjustment_stocks_stock_log +FOREIGN KEY (stock_log_id) REFERENCES stock_logs(id) +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE adjustment_stocks +ADD CONSTRAINT fk_adjustment_stocks_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id) +ON DELETE CASCADE ON UPDATE CASCADE; + +-- Indexes +CREATE INDEX idx_adjustment_stocks_stock_log ON adjustment_stocks(stock_log_id); +CREATE INDEX idx_adjustment_stocks_product_warehouse ON adjustment_stocks(product_warehouse_id); + +COMMIT; diff --git a/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.down.sql b/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.down.sql new file mode 100644 index 00000000..7433c93d --- /dev/null +++ b/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_project_flocks_production_standard_id; + +ALTER TABLE project_flocks + DROP CONSTRAINT IF EXISTS fk_project_flocks_production_standard_id; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS production_standard_id; diff --git a/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.up.sql b/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.up.sql new file mode 100644 index 00000000..f513c447 --- /dev/null +++ b/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.up.sql @@ -0,0 +1,15 @@ +-- Add production_standard_id to project_flocks +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS production_standard_id BIGINT; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE project_flocks + ADD CONSTRAINT fk_project_flocks_production_standard_id + FOREIGN KEY (production_standard_id) REFERENCES production_standards (id) ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_project_flocks_production_standard_id + ON project_flocks (production_standard_id); diff --git a/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.down.sql b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.down.sql new file mode 100644 index 00000000..b686a59a --- /dev/null +++ b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.down.sql @@ -0,0 +1,3 @@ +-- Remove standard_fcr column from production_standard_details table +ALTER TABLE production_standard_details +DROP COLUMN IF EXISTS standard_fcr; diff --git a/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.up.sql b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.up.sql new file mode 100644 index 00000000..560a24ac --- /dev/null +++ b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.up.sql @@ -0,0 +1,3 @@ +-- Add standard_fcr column to production_standard_details table +ALTER TABLE production_standard_details +ADD COLUMN standard_fcr NUMERIC(15, 3); diff --git a/internal/database/seed/seeder.backup b/internal/database/seed/seeder.backup new file mode 100644 index 00000000..c0e3628c --- /dev/null +++ b/internal/database/seed/seeder.backup @@ -0,0 +1,1047 @@ +// package seed + +// import ( +// "errors" +// "fmt" +// "strings" +// "time" + +// entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +// "gitlab.com/mbugroup/lti-api.git/internal/utils" + +// "gorm.io/gorm" +// ) + +// func Run(db *gorm.DB) error { +// return db.Transaction(func(tx *gorm.DB) error { +// users, err := seedUsers(tx) +// if err != nil { +// return err +// } +// adminID := users["admin"] + +// uoms, err := seedUoms(tx, adminID) +// if err != nil { +// return err +// } + +// areas, err := seedAreas(tx, adminID) +// if err != nil { +// return err +// } + +// locations, err := seedLocations(tx, adminID, areas) +// if err != nil { +// return err +// } + +// productCategories, err := seedProductCategories(tx, adminID) +// if err != nil { +// return err +// } + +// if _, err := seedFlocks(tx, adminID); err != nil { +// return err +// } + +// if _, err := seedFcr(tx, adminID); err != nil { +// return err +// } + +// kandangs, err := seedKandangs(tx, adminID, locations, users) +// if err != nil { +// return err +// } + +// if err := seedWarehouses(tx, adminID, areas, locations, kandangs); err != nil { +// return err +// } + +// suppliers, err := seedSuppliers(tx, adminID) +// if err != nil { +// return err +// } + +// if err := seedCustomers(tx, adminID, users); err != nil { +// return err +// } + +// if err := seedProducts(tx, adminID, uoms, productCategories, suppliers); err != nil { +// return err +// } + +// if err := seedNonstocks(tx, adminID, uoms, suppliers); err != nil { +// return err +// } + +// if err := seedBanks(tx, adminID); err != nil { +// return err +// } + +// if err := seedProductWarehouse(tx, adminID); err != nil { +// return err +// } + +// if err := seedTransferStock(tx); err != nil { +// return err +// } +// fmt.Println("✅ Master data seeding completed") +// return nil +// }) +// } + +// func seedUsers(tx *gorm.DB) (map[string]uint, error) { +// seeds := []struct { +// Key string +// Data entity.User +// }{ +// { +// Key: "admin", +// Data: entity.User{Email: "admin@mbugroup.id", IdUser: 1, Name: "Super Admin"}, +// }, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// var user entity.User +// err := tx.Where("email = ?", seed.Data.Email).First(&user).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// user = seed.Data +// if err := tx.Create(&user).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Key] = user.Id +// } + +// return result, nil +// } + +// func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor"} +// result := make(map[string]uint, len(names)) + +// for _, name := range names { +// var uom entity.Uom +// err := tx.Where("name = ?", name).First(&uom).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// uom = entity.Uom{Name: name, CreatedBy: createdBy} +// if err := tx.Create(&uom).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[name] = uom.Id +// } + +// return result, nil +// } + +// func seedAreas(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// names := []string{"Priangan", "Banten"} +// result := make(map[string]uint, len(names)) + +// for _, name := range names { +// var area entity.Area +// err := tx.Where("name = ?", name).First(&area).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// area = entity.Area{Name: name, CreatedBy: createdBy} +// if err := tx.Create(&area).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[name] = area.Id +// } + +// return result, nil +// } + +// func seedLocations(tx *gorm.DB, createdBy uint, areas map[string]uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Address string +// Area string +// }{ +// {"Singaparna", "Tasik", "Priangan"}, +// {"Cikaum", "Cikaum", "Banten"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// areaID, ok := areas[seed.Area] +// if !ok { +// return nil, fmt.Errorf("area %s not seeded", seed.Area) +// } + +// var loc entity.Location +// err := tx.Where("name = ?", seed.Name).First(&loc).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// loc = entity.Location{ +// Name: seed.Name, +// Address: seed.Address, +// AreaId: areaID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&loc).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Name] = loc.Id +// } + +// return result, nil +// } + +// func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// names := []string{"Flock Priangan", "Flock Banten"} +// result := make(map[string]uint, len(names)) + +// for _, name := range names { +// var flock entity.Flock +// err := tx.Where("name = ?", name).First(&flock).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// flock = entity.Flock{ +// Name: name, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&flock).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } else { +// if err := tx.Model(&entity.Flock{}).Where("id = ?", flock.Id).Updates(map[string]any{ +// "created_by": createdBy, +// }).Error; err != nil { +// return nil, err +// } +// } +// result[name] = flock.Id +// } + +// return result, nil +// } + +// func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Status utils.KandangStatus +// Capacity float64 +// Location string +// PicKey string +// }{ +// {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, +// {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, +// {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, +// {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// locID, ok := locations[seed.Location] +// if !ok { +// return nil, fmt.Errorf("location %s not seeded", seed.Location) +// } +// picID, ok := users[seed.PicKey] +// if !ok { +// return nil, fmt.Errorf("user %s not seeded", seed.PicKey) +// } + +// var kandang entity.Kandang +// err := tx.Where("name = ?", seed.Name).First(&kandang).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// kandang = entity.Kandang{ +// Name: seed.Name, +// Status: string(seed.Status), +// LocationId: locID, +// PicId: picID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&kandang).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } else { +// updates := map[string]any{ +// "location_id": locID, +// "pic_id": picID, +// "status": string(seed.Status), +// } +// if err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { +// return nil, err +// } +// } +// result[seed.Name] = kandang.Id +// } + +// return result, nil +// } + +// func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { +// seeds := []struct { +// Name string +// Type string +// Area string +// Location *string +// Kandang *string +// }{ +// {Name: "Gudang Priangan", Type: string(utils.WarehouseTypeArea), Area: "Priangan"}, +// {Name: "Gudang Singaparna", Type: string(utils.WarehouseTypeLokasi), Area: "Priangan", Location: strPtr("Singaparna")}, +// {Name: "Gudang Singaparna 1", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 1")}, +// {Name: "Gudang Singaparna 2", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 2")}, +// {Name: "Gudang Banten", Type: string(utils.WarehouseTypeArea), Area: "Banten"}, +// {Name: "Gudang Cikaum", Type: string(utils.WarehouseTypeLokasi), Area: "Banten", Location: strPtr("Cikaum")}, +// {Name: "Gudang Cikaum 1", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 1")}, +// {Name: "Gudang Cikaum 2", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 2")}, +// } + +// for _, seed := range seeds { +// areaID, ok := areas[seed.Area] +// if !ok { +// return fmt.Errorf("area %s not seeded", seed.Area) +// } + +// var warehouse entity.Warehouse +// err := tx.Where("name = ?", seed.Name).First(&warehouse).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// warehouse = entity.Warehouse{ +// Name: seed.Name, +// Type: seed.Type, +// AreaId: areaID, +// CreatedBy: createdBy, +// } +// } else if err != nil { +// return err +// } + +// if seed.Location != nil { +// locID, ok := locations[*seed.Location] +// if !ok { +// return fmt.Errorf("location %s not seeded", *seed.Location) +// } +// warehouse.LocationId = uintPtr(locID) +// } +// if seed.Kandang != nil { +// kandangID, ok := kandangs[*seed.Kandang] +// if !ok { +// return fmt.Errorf("kandang %s not seeded", *seed.Kandang) +// } +// warehouse.KandangId = uintPtr(kandangID) +// } + +// if warehouse.Id == 0 { +// if err := tx.Create(&warehouse).Error; err != nil { +// return err +// } +// } else { +// if err := tx.Model(&entity.Warehouse{}).Where("id = ?", warehouse.Id).Updates(map[string]any{ +// "type": warehouse.Type, +// "area_id": warehouse.AreaId, +// "location_id": warehouse.LocationId, +// "kandang_id": warehouse.KandangId, +// }).Error; err != nil { +// return err +// } +// } +// } + +// return nil +// } + +// func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Code string +// }{ +// {"Pullet", "PLT"}, +// {"Bahan Baku", "RAW"}, +// {"Day Old Chick", "DOC"}, +// {"Telur", "EGG"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// var category entity.ProductCategory +// err := tx.Where("name = ?", seed.Name).First(&category).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// category = entity.ProductCategory{Name: seed.Name, Code: seed.Code, CreatedBy: createdBy} +// if err := tx.Create(&category).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } else { +// if err := tx.Model(&entity.ProductCategory{}).Where("id = ?", category.Id).Updates(map[string]any{ +// "code": seed.Code, +// }).Error; err != nil { +// return nil, err +// } +// } +// result[seed.Name] = category.Id +// } + +// return result, nil +// } + +// func seedSuppliers(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Alias string +// Category string +// Email string +// Phone string +// Address string +// }{ +// {"PT CHAROEN POKPHAND INDONESIA Tbk", "CPI", string(utils.SupplierCategorySapronak), "cpi@gmail.com", "081200000001", "Jl. Pakan 1, Bekasi"}, +// {"BOP Vendor", "BOP", string(utils.SupplierCategoryBOP), "bop@gmail.com", "081200000002", "Jl. Veteriner 3, Bogor"}, +// {"Ekspedisi", "EKS", string(utils.SupplierCategoryBOP), "bop@gmail.com", "081200000002", "Jl. Veteriner 3, Bogor"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for idx, seed := range seeds { +// var supplier entity.Supplier +// err := tx.Where("name = ?", seed.Name).First(&supplier).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// supplier = entity.Supplier{ +// Name: seed.Name, +// Alias: seed.Alias, +// Pic: "John Doe", +// Type: string(utils.CustomerSupplierTypeBisnis), +// Category: seed.Category, +// Phone: seed.Phone, +// Email: seed.Email, +// Address: seed.Address, +// DueDate: 30, +// CreatedBy: createdBy, +// AccountNumber: strPtr(fmt.Sprintf("%03d", idx+1)), +// } +// if err := tx.Create(&supplier).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Name] = supplier.Id +// } + +// return result, nil +// } + +// func seedCustomers(tx *gorm.DB, createdBy uint, users map[string]uint) error { +// seeds := []struct { +// Name string +// PicKey string +// Address string +// Phone string +// Email string +// }{ +// {"Abdul Azis", "admin", "Jl. Raya Utama 1, Bekasi", "082100000001", "abdul.azis@gmail.com"}, +// } + +// for idx, seed := range seeds { +// picID, ok := users[seed.PicKey] +// if !ok { +// return fmt.Errorf("user %s not seeded", seed.PicKey) +// } + +// var customer entity.Customer +// err := tx.Where("name = ?", seed.Name).First(&customer).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// customer = entity.Customer{ +// Name: seed.Name, +// PicId: picID, +// Type: string(utils.CustomerSupplierTypeBisnis), +// Address: seed.Address, +// Phone: seed.Phone, +// Email: seed.Email, +// AccountNumber: *strPtr(fmt.Sprintf("%03d", idx+1)), +// CreatedBy: createdBy, +// } +// if err := tx.Create(&customer).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } +// } + +// return nil +// } + +// func seedFcr(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Standards []struct { +// Weight float64 +// FcrNumber float64 +// Mortality float64 +// } +// }{ +// { +// Name: "FCR Layer", +// Standards: []struct { +// Weight float64 +// FcrNumber float64 +// Mortality float64 +// }{ +// {Weight: 0.8, FcrNumber: 1.60, Mortality: 2.0}, +// {Weight: 1.5, FcrNumber: 1.75, Mortality: 3.5}, +// }, +// }, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// var fcr entity.Fcr +// err := tx.Where("name = ?", seed.Name).First(&fcr).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// fcr = entity.Fcr{Name: seed.Name, CreatedBy: createdBy} +// if err := tx.Create(&fcr).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Name] = fcr.Id + +// for _, std := range seed.Standards { +// var standard entity.FcrStandard +// err := tx.Where("fcr_id = ? AND weight = ?", fcr.Id, std.Weight).First(&standard).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// standard = entity.FcrStandard{ +// FcrID: fcr.Id, +// Weight: std.Weight, +// FcrNumber: std.FcrNumber, +// Mortality: std.Mortality, +// } +// if err := tx.Create(&standard).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } else { +// if err := tx.Model(&entity.FcrStandard{}).Where("id = ?", standard.Id).Updates(map[string]any{ +// "fcr_number": std.FcrNumber, +// "mortality": std.Mortality, +// }).Error; err != nil { +// return nil, err +// } +// } +// } +// } + +// return result, nil +// } + +// func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories map[string]uint, suppliers map[string]uint) error { +// seeds := []struct { +// Name string +// Brand string +// Sku string +// Uom string +// Category string +// Price float64 +// Selling *float64 +// Tax *float64 +// Expiry *int +// Suppliers []string +// Flags []utils.FlagType +// }{ +// { +// Name: "DOC Broiler", +// Brand: "MBU Broiler", +// Sku: "BRO0001", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 7500, +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagDOC}, +// }, +// { +// Name: "Ayam Pullet", +// Brand: "MBU Pullet", +// Sku: "PLT0001", +// Uom: "Ekor", +// Category: "Pullet", +// Price: 15000, +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagPullet}, +// }, +// { +// Name: "Ayam Afkir", +// Brand: "-", +// Sku: "1", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagAyamAfkir}, +// }, +// { +// Name: "Ayam Mati", +// Brand: "-", +// Sku: "2", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagAyamMati}, +// }, +// { +// Name: "Ayam Culling", +// Brand: "-", +// Sku: "3", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagAyamCulling}, +// }, +// { +// Name: "Telur Konsumsi Baik", +// Brand: "-", +// Sku: "4", +// Uom: "Unit", +// Category: "Telur", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagTelurUtuh}, +// }, +// { +// Name: "Telur Pecah", +// Brand: "-", +// Sku: "5", +// Uom: "Unit", +// Category: "Telur", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagTelurPecah}, +// }, +// { +// Name: "281 SPECIAL STARTER", +// Brand: "281 STARTER", +// Sku: "281", +// Uom: "Kilogram", +// Category: "Bahan Baku", +// Price: 7850, +// Expiry: intPtr(60), +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter}, +// }, +// { +// Name: "Ayam Layer", +// Brand: "-", +// Sku: "LYR0001", +// Uom: "Ekor", +// Category: "Pullet", +// Price: 20000, +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagLayer}, +// }, +// } + +// for _, seed := range seeds { +// uomID, ok := uoms[seed.Uom] +// if !ok { +// return fmt.Errorf("uom %s not seeded", seed.Uom) +// } +// categoryID, ok := categories[seed.Category] +// if !ok { +// return fmt.Errorf("product category %s not seeded", seed.Category) +// } + +// var product entity.Product +// err := tx.Where("name = ?", seed.Name).First(&product).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// selling := seed.Selling +// tax := seed.Tax +// product = entity.Product{ +// Name: seed.Name, +// Brand: seed.Brand, +// Sku: &seed.Sku, +// UomId: uomID, +// ProductCategoryId: categoryID, +// ProductPrice: seed.Price, +// SellingPrice: selling, +// Tax: tax, +// ExpiryPeriod: seed.Expiry, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&product).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// updates := map[string]any{ +// "brand": seed.Brand, +// "uom_id": uomID, +// "product_category_id": categoryID, +// "product_price": seed.Price, +// "selling_price": seed.Selling, +// "tax": seed.Tax, +// "expiry_period": seed.Expiry, +// } +// if seed.Sku != "" { +// updates["sku"] = seed.Sku +// } +// if err := tx.Model(&entity.Product{}).Where("id = ?", product.Id).Updates(updates).Error; err != nil { +// return err +// } +// } + +// for _, supplierName := range seed.Suppliers { +// supplierID, ok := suppliers[supplierName] +// if !ok { +// return fmt.Errorf("supplier %s not seeded", supplierName) +// } +// var existing entity.ProductSupplier +// err := tx.Where("product_id = ? AND supplier_id = ?", product.Id, supplierID).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// link := entity.ProductSupplier{ProductId: product.Id, SupplierId: supplierID} +// if err := tx.Create(&link).Error; err != nil { +// return err +// } +// } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { +// return err +// } +// } + +// if err := seedFlags(tx, product.Id, entity.FlagableTypeProduct, seed.Flags); err != nil { +// return err +// } +// } + +// return nil +// } + +// func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers map[string]uint) error { +// seeds := []struct { +// Name string +// Uom string +// Suppliers []string +// Flags []utils.FlagType +// }{ +// { +// Name: "Expedisi DOC", +// Uom: "Ekor", +// Suppliers: []string{"Ekspedisi"}, +// Flags: []utils.FlagType{utils.FlagEkspedisi}, +// }, +// { +// Name: "Solar", +// Uom: "Liter", +// Suppliers: []string{"BOP Vendor"}, +// Flags: []utils.FlagType{}, +// }, +// } + +// for _, seed := range seeds { +// uomID, ok := uoms[seed.Uom] +// if !ok { +// return fmt.Errorf("uom %s not seeded", seed.Uom) +// } + +// var nonstock entity.Nonstock +// err := tx.Where("name = ?", seed.Name).First(&nonstock).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// nonstock = entity.Nonstock{ +// Name: seed.Name, +// UomId: uomID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&nonstock).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// if err := tx.Model(&entity.Nonstock{}).Where("id = ?", nonstock.Id).Updates(map[string]any{ +// "uom_id": uomID, +// }).Error; err != nil { +// return err +// } +// } + +// for _, supplierName := range seed.Suppliers { +// supplierID, ok := suppliers[supplierName] +// if !ok { +// return fmt.Errorf("supplier %s not seeded", supplierName) +// } +// var existing entity.NonstockSupplier +// err := tx.Where("nonstock_id = ? AND supplier_id = ?", nonstock.Id, supplierID).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// link := entity.NonstockSupplier{NonstockId: nonstock.Id, SupplierId: supplierID} +// if err := tx.Create(&link).Error; err != nil { +// return err +// } +// } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { +// return err +// } +// } + +// if err := seedFlags(tx, nonstock.Id, entity.FlagableTypeNonstock, seed.Flags); err != nil { +// return err +// } +// } + +// return nil +// } + +// // nanti saya isi + +// func seedFlags(tx *gorm.DB, flagableID uint, flagableType string, flags []utils.FlagType) error { +// if len(flags) == 0 { +// return nil +// } +// for _, flag := range flags { +// name := strings.ToUpper(string(flag)) +// var existing entity.Flag +// err := tx.Where("name = ? AND flagable_id = ? AND flagable_type = ?", name, flagableID, flagableType).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// record := entity.Flag{ +// Name: name, +// FlagableID: flagableID, +// FlagableType: flagableType, +// } +// if err := tx.Create(&record).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } +// } +// return nil +// } + +// func seedBanks(tx *gorm.DB, createdBy uint) error { +// seeds := []struct { +// Name string +// Alias string +// Owner *string +// AccountNumber string +// }{ +// { +// Name: "Bank Central Asia", +// Alias: "BCA", +// AccountNumber: "1234567890", +// Owner: ptr("PT MBU Group"), +// }, +// { +// Name: "Bank Rakyat Indonesia", +// Alias: "BRI", +// AccountNumber: "9876543210", +// Owner: ptr("PT MBU Group"), +// }, +// { +// Name: "Bank Mandiri", +// Alias: "MAND", +// AccountNumber: "1122334455", +// Owner: ptr("PT MBU Group"), +// }, +// } + +// for _, seed := range seeds { +// var bank entity.Bank +// err := tx.Where("name = ?", seed.Name).First(&bank).Error + +// if errors.Is(err, gorm.ErrRecordNotFound) { +// bank = entity.Bank{ +// Name: seed.Name, +// Alias: seed.Alias, +// Owner: seed.Owner, +// AccountNumber: seed.AccountNumber, +// CreatedBy: createdBy, +// CreatedAt: time.Now(), +// UpdatedAt: time.Now(), +// } +// if err := tx.Create(&bank).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// // update data jika sudah ada +// if err := tx.Model(&entity.Bank{}).Where("id = ?", bank.Id).Updates(map[string]any{ +// "alias": seed.Alias, +// "owner": seed.Owner, +// "account_number": seed.AccountNumber, +// "updated_at": time.Now(), +// }).Error; err != nil { +// return err +// } +// } +// } + +// return nil +// } + +// func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { +// seeds := []struct { +// ProductName string +// WarehouseName string +// Quantity float64 +// }{ +// {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 0}, +// {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 0}, +// {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 0}, +// {ProductName: "DOC Broiler", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, +// {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, +// {ProductName: "Telur Pecah", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, +// {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Cikaum 1", Quantity: 0}, +// {ProductName: "Telur Pecah", WarehouseName: "Gudang Cikaum 1", Quantity: 0}, +// } + +// 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 +// err := tx.Where("product_id = ? AND warehouse_id = ?", product.Id, warehouse.Id).First(&productWarehouse).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// productWarehouse = entity.ProductWarehouse{ +// ProductId: product.Id, +// WarehouseId: warehouse.Id, +// Quantity: seed.Quantity, +// // CreatedBy: createdBy, +// } +// if err := tx.Create(&productWarehouse).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// if err := tx.Model(&productWarehouse).Updates(map[string]any{ +// "quantity": seed.Quantity, +// }).Error; err != nil { +// return err +// } +// } +// } + +// return nil +// } + +// func seedTransferStock(tx *gorm.DB) error { + +// transfer := entity.StockTransfer{ +// FromWarehouseId: 1, +// ToWarehouseId: 2, +// Reason: "Seed transfer stock", +// TransferDate: time.Now(), +// MovementNumber: "SEED-TRF-00001", +// CreatedBy: 1, +// } +// if err := tx.Create(&transfer).Error; err != nil { +// return err +// } + +// details := []entity.StockTransferDetail{ +// { +// StockTransferId: transfer.Id, +// ProductId: 1, + +// SourceProductWarehouseID: func() *uint64 { id := uint64(1); return &id }(), +// DestProductWarehouseID: func() *uint64 { id := uint64(2); return &id }(), +// UsageQty: 10, +// PendingQty: 0, +// TotalQty: 10, +// TotalUsed: 0, +// }, +// { +// StockTransferId: transfer.Id, +// ProductId: 2, + +// SourceProductWarehouseID: func() *uint64 { id := uint64(1); return &id }(), +// DestProductWarehouseID: func() *uint64 { id := uint64(2); return &id }(), +// UsageQty: 5, +// PendingQty: 0, +// TotalQty: 5, +// TotalUsed: 0, +// }, +// } +// for i := range details { +// if err := tx.Create(&details[i]).Error; err != nil { +// return err +// } +// } + +// deliveries := []entity.StockTransferDelivery{ +// { +// StockTransferId: transfer.Id, +// SupplierId: 1, +// VehiclePlate: "B 1234 XYZ", +// DriverName: "Driver Seed", +// DocumentPath: "seed.pdf", +// ShippingCostItem: 1000, +// ShippingCostTotal: 2000, +// }, +// } +// for i := range deliveries { +// if err := tx.Create(&deliveries[i]).Error; err != nil { +// return err +// } +// } + +// detailMap := make(map[uint64]uint64) +// for _, d := range details { +// detailMap[d.ProductId] = d.Id +// } + +// deliveryItems := []entity.StockTransferDeliveryItem{ +// { +// StockTransferDeliveryId: deliveries[0].Id, +// StockTransferDetailId: detailMap[1], +// Quantity: 50, +// }, +// { +// StockTransferDeliveryId: deliveries[0].Id, +// StockTransferDetailId: detailMap[2], +// Quantity: 30, +// }, +// } +// for i := range deliveryItems { +// if err := tx.Create(&deliveryItems[i]).Error; err != nil { +// return err +// } +// } + +// return nil +// } +// func ptr[T any](v T) *T { +// return &v +// } + +// func strPtr(s string) *string { +// return &s +// } + +// func intPtr(v int) *int { +// return &v +// } + +// func uintPtr(v uint) *uint { +// return &v +// } diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index bb4090bb..b4f6886e 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "strings" - "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -25,66 +24,20 @@ func Run(db *gorm.DB) error { return err } - areas, err := seedAreas(tx, adminID) - if err != nil { - return err - } - - locations, err := seedLocations(tx, adminID, areas) - if err != nil { - return err - } - productCategories, err := seedProductCategories(tx, adminID) if err != nil { return err } - if _, err := seedFlocks(tx, adminID); err != nil { - return err - } - - if _, err := seedFcr(tx, adminID); err != nil { - return err - } - - kandangs, err := seedKandangs(tx, adminID, locations, users) - if err != nil { - return err - } - - if err := seedWarehouses(tx, adminID, areas, locations, kandangs); err != nil { - return err - } - suppliers, err := seedSuppliers(tx, adminID) if err != nil { return err } - if err := seedCustomers(tx, adminID, users); err != nil { - return err - } - if err := seedProducts(tx, adminID, uoms, productCategories, suppliers); err != nil { return err } - if err := seedNonstocks(tx, adminID, uoms, suppliers); err != nil { - return err - } - - if err := seedBanks(tx, adminID); err != nil { - return err - } - - if err := seedProductWarehouse(tx, adminID); err != nil { - return err - } - - if err := seedTransferStock(tx); err != nil { - return err - } fmt.Println("✅ Master data seeding completed") return nil }) @@ -141,224 +94,6 @@ func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedAreas(tx *gorm.DB, createdBy uint) (map[string]uint, error) { - names := []string{"Priangan", "Banten"} - result := make(map[string]uint, len(names)) - - for _, name := range names { - var area entity.Area - err := tx.Where("name = ?", name).First(&area).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - area = entity.Area{Name: name, CreatedBy: createdBy} - if err := tx.Create(&area).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - result[name] = area.Id - } - - return result, nil -} - -func seedLocations(tx *gorm.DB, createdBy uint, areas map[string]uint) (map[string]uint, error) { - seeds := []struct { - Name string - Address string - Area string - }{ - {"Singaparna", "Tasik", "Priangan"}, - {"Cikaum", "Cikaum", "Banten"}, - } - - result := make(map[string]uint, len(seeds)) - - for _, seed := range seeds { - areaID, ok := areas[seed.Area] - if !ok { - return nil, fmt.Errorf("area %s not seeded", seed.Area) - } - - var loc entity.Location - err := tx.Where("name = ?", seed.Name).First(&loc).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - loc = entity.Location{ - Name: seed.Name, - Address: seed.Address, - AreaId: areaID, - CreatedBy: createdBy, - } - if err := tx.Create(&loc).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - result[seed.Name] = loc.Id - } - - return result, nil -} - -func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { - names := []string{"Flock Priangan", "Flock Banten"} - result := make(map[string]uint, len(names)) - - for _, name := range names { - var flock entity.Flock - err := tx.Where("name = ?", name).First(&flock).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - flock = entity.Flock{ - Name: name, - CreatedBy: createdBy, - } - if err := tx.Create(&flock).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } else { - if err := tx.Model(&entity.Flock{}).Where("id = ?", flock.Id).Updates(map[string]any{ - "created_by": createdBy, - }).Error; err != nil { - return nil, err - } - } - result[name] = flock.Id - } - - return result, nil -} - -func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { - seeds := []struct { - Name string - Status utils.KandangStatus - Capacity float64 - Location string - PicKey string - }{ - {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, - {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, - {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, - {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, - } - - result := make(map[string]uint, len(seeds)) - - for _, seed := range seeds { - locID, ok := locations[seed.Location] - if !ok { - return nil, fmt.Errorf("location %s not seeded", seed.Location) - } - picID, ok := users[seed.PicKey] - if !ok { - return nil, fmt.Errorf("user %s not seeded", seed.PicKey) - } - - var kandang entity.Kandang - err := tx.Where("name = ?", seed.Name).First(&kandang).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - kandang = entity.Kandang{ - Name: seed.Name, - Status: string(seed.Status), - LocationId: locID, - PicId: picID, - CreatedBy: createdBy, - } - if err := tx.Create(&kandang).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } else { - updates := map[string]any{ - "location_id": locID, - "pic_id": picID, - "status": string(seed.Status), - } - if err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { - return nil, err - } - } - result[seed.Name] = kandang.Id - } - - return result, nil -} - -func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { - seeds := []struct { - Name string - Type string - Area string - Location *string - Kandang *string - }{ - {Name: "Gudang Priangan", Type: string(utils.WarehouseTypeArea), Area: "Priangan"}, - {Name: "Gudang Singaparna", Type: string(utils.WarehouseTypeLokasi), Area: "Priangan", Location: strPtr("Singaparna")}, - {Name: "Gudang Singaparna 1", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 1")}, - {Name: "Gudang Singaparna 2", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 2")}, - {Name: "Gudang Banten", Type: string(utils.WarehouseTypeArea), Area: "Banten"}, - {Name: "Gudang Cikaum", Type: string(utils.WarehouseTypeLokasi), Area: "Banten", Location: strPtr("Cikaum")}, - {Name: "Gudang Cikaum 1", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 1")}, - {Name: "Gudang Cikaum 2", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 2")}, - } - - for _, seed := range seeds { - areaID, ok := areas[seed.Area] - if !ok { - return fmt.Errorf("area %s not seeded", seed.Area) - } - - var warehouse entity.Warehouse - err := tx.Where("name = ?", seed.Name).First(&warehouse).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - warehouse = entity.Warehouse{ - Name: seed.Name, - Type: seed.Type, - AreaId: areaID, - CreatedBy: createdBy, - } - } else if err != nil { - return err - } - - if seed.Location != nil { - locID, ok := locations[*seed.Location] - if !ok { - return fmt.Errorf("location %s not seeded", *seed.Location) - } - warehouse.LocationId = uintPtr(locID) - } - if seed.Kandang != nil { - kandangID, ok := kandangs[*seed.Kandang] - if !ok { - return fmt.Errorf("kandang %s not seeded", *seed.Kandang) - } - warehouse.KandangId = uintPtr(kandangID) - } - - if warehouse.Id == 0 { - if err := tx.Create(&warehouse).Error; err != nil { - return err - } - } else { - if err := tx.Model(&entity.Warehouse{}).Where("id = ?", warehouse.Id).Updates(map[string]any{ - "type": warehouse.Type, - "area_id": warehouse.AreaId, - "location_id": warehouse.LocationId, - "kandang_id": warehouse.KandangId, - }).Error; err != nil { - return err - } - } - } - - return nil -} - func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error) { seeds := []struct { Name string @@ -440,113 +175,6 @@ func seedSuppliers(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedCustomers(tx *gorm.DB, createdBy uint, users map[string]uint) error { - seeds := []struct { - Name string - PicKey string - Address string - Phone string - Email string - }{ - {"Abdul Azis", "admin", "Jl. Raya Utama 1, Bekasi", "082100000001", "abdul.azis@gmail.com"}, - } - - for idx, seed := range seeds { - picID, ok := users[seed.PicKey] - if !ok { - return fmt.Errorf("user %s not seeded", seed.PicKey) - } - - var customer entity.Customer - err := tx.Where("name = ?", seed.Name).First(&customer).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - customer = entity.Customer{ - Name: seed.Name, - PicId: picID, - Type: string(utils.CustomerSupplierTypeBisnis), - Address: seed.Address, - Phone: seed.Phone, - Email: seed.Email, - AccountNumber: *strPtr(fmt.Sprintf("%03d", idx+1)), - CreatedBy: createdBy, - } - if err := tx.Create(&customer).Error; err != nil { - return err - } - } else if err != nil { - return err - } - } - - return nil -} - -func seedFcr(tx *gorm.DB, createdBy uint) (map[string]uint, error) { - seeds := []struct { - Name string - Standards []struct { - Weight float64 - FcrNumber float64 - Mortality float64 - } - }{ - { - Name: "FCR Layer", - Standards: []struct { - Weight float64 - FcrNumber float64 - Mortality float64 - }{ - {Weight: 0.8, FcrNumber: 1.60, Mortality: 2.0}, - {Weight: 1.5, FcrNumber: 1.75, Mortality: 3.5}, - }, - }, - } - - result := make(map[string]uint, len(seeds)) - - for _, seed := range seeds { - var fcr entity.Fcr - err := tx.Where("name = ?", seed.Name).First(&fcr).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - fcr = entity.Fcr{Name: seed.Name, CreatedBy: createdBy} - if err := tx.Create(&fcr).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - result[seed.Name] = fcr.Id - - for _, std := range seed.Standards { - var standard entity.FcrStandard - err := tx.Where("fcr_id = ? AND weight = ?", fcr.Id, std.Weight).First(&standard).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - standard = entity.FcrStandard{ - FcrID: fcr.Id, - Weight: std.Weight, - FcrNumber: std.FcrNumber, - Mortality: std.Mortality, - } - if err := tx.Create(&standard).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } else { - if err := tx.Model(&entity.FcrStandard{}).Where("id = ?", standard.Id).Updates(map[string]any{ - "fcr_number": std.FcrNumber, - "mortality": std.Mortality, - }).Error; err != nil { - return nil, err - } - } - } - } - - return result, nil -} - func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories map[string]uint, suppliers map[string]uint) error { seeds := []struct { Name string @@ -560,92 +188,88 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories Expiry *int Suppliers []string Flags []utils.FlagType + IsVisible bool }{ { - Name: "DOC Broiler", - Brand: "MBU Broiler", - Sku: "BRO0001", + Name: "ISA Brown", + Brand: "ISA Brown", + Sku: "ISA0001", Uom: "Ekor", Category: "Day Old Chick", Price: 7500, Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagDOC}, + Flags: []utils.FlagType{utils.FlagDOC, utils.FlagPullet, utils.FlagLayer}, + IsVisible: true, }, { - Name: "Ayam Pullet", - Brand: "MBU Pullet", - Sku: "PLT0001", - Uom: "Ekor", - Category: "Pullet", - Price: 15000, - Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagPullet}, - }, - { - Name: "Ayam Afkir", - Brand: "-", - Sku: "1", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, - Flags: []utils.FlagType{utils.FlagAyamAfkir}, - }, - { - Name: "Ayam Mati", - Brand: "-", - Sku: "2", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, - Flags: []utils.FlagType{utils.FlagAyamMati}, - }, - { - Name: "Ayam Culling", - Brand: "-", - Sku: "3", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, - Flags: []utils.FlagType{utils.FlagAyamCulling}, - }, - { - Name: "Telur Konsumsi Baik", - Brand: "-", - Sku: "4", - Uom: "Unit", - Category: "Telur", - Price: 1, - Flags: []utils.FlagType{utils.FlagTelurUtuh}, - }, - { - Name: "Telur Pecah", - Brand: "-", - Sku: "5", - Uom: "Unit", - Category: "Telur", - Price: 1, - Flags: []utils.FlagType{utils.FlagTelurPecah}, - }, - { - Name: "281 SPECIAL STARTER", - Brand: "281 STARTER", - Sku: "281", - Uom: "Kilogram", - Category: "Bahan Baku", - Price: 7850, - Expiry: intPtr(60), - Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter}, - }, - { - Name: "Ayam Layer", + Name: "Ayam Afkir", Brand: "-", - Sku: "LYR0001", + Sku: "1", Uom: "Ekor", - Category: "Pullet", - Price: 20000, - Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagLayer}, + Category: "Day Old Chick", + Price: 1, + Flags: []utils.FlagType{utils.FlagAyamAfkir}, + IsVisible: false, + }, + { + Name: "Ayam Mati", + Brand: "-", + Sku: "2", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + Flags: []utils.FlagType{utils.FlagAyamMati}, + IsVisible: false, + }, + { + Name: "Ayam Culling", + Brand: "-", + Sku: "3", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + Flags: []utils.FlagType{utils.FlagAyamCulling}, + IsVisible: false, + }, + { + Name: "Telur Utuh", + Brand: "-", + Sku: "4", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurUtuh}, + IsVisible: false, + }, + { + Name: "Telur Pecah", + Brand: "-", + Sku: "5", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurPecah}, + IsVisible: false, + }, + { + Name: "Telur Putih", + Brand: "-", + Sku: "6", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurPutih}, + IsVisible: false, + }, + { + Name: "Telur Retak", + Brand: "-", + Sku: "7", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurRetak}, + IsVisible: false, }, } @@ -724,78 +348,78 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories return nil } -func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers map[string]uint) error { - seeds := []struct { - Name string - Uom string - Suppliers []string - Flags []utils.FlagType - }{ - { - Name: "Expedisi DOC", - Uom: "Ekor", - Suppliers: []string{"Ekspedisi"}, - Flags: []utils.FlagType{utils.FlagEkspedisi}, - }, - { - Name: "Solar", - Uom: "Liter", - Suppliers: []string{"BOP Vendor"}, - Flags: []utils.FlagType{}, - }, - } +// func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers map[string]uint) error { +// seeds := []struct { +// Name string +// Uom string +// Suppliers []string +// Flags []utils.FlagType +// }{ +// { +// Name: "LAJ", +// Uom: "Unit", +// Suppliers: []string{"Ekspedisi"}, +// Flags: []utils.FlagType{utils.FlagEkspedisi}, +// }, +// { +// Name: "Solar", +// Uom: "Liter", +// Suppliers: []string{"BOP Vendor"}, +// Flags: []utils.FlagType{}, +// }, +// } - for _, seed := range seeds { - uomID, ok := uoms[seed.Uom] - if !ok { - return fmt.Errorf("uom %s not seeded", seed.Uom) - } +// for _, seed := range seeds { +// uomID, ok := uoms[seed.Uom] +// if !ok { +// return fmt.Errorf("uom %s not seeded", seed.Uom) +// } - var nonstock entity.Nonstock - err := tx.Where("name = ?", seed.Name).First(&nonstock).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - nonstock = entity.Nonstock{ - Name: seed.Name, - UomId: uomID, - CreatedBy: createdBy, - } - if err := tx.Create(&nonstock).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - if err := tx.Model(&entity.Nonstock{}).Where("id = ?", nonstock.Id).Updates(map[string]any{ - "uom_id": uomID, - }).Error; err != nil { - return err - } - } +// var nonstock entity.Nonstock +// err := tx.Where("name = ?", seed.Name).First(&nonstock).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// nonstock = entity.Nonstock{ +// Name: seed.Name, +// UomId: uomID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&nonstock).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// if err := tx.Model(&entity.Nonstock{}).Where("id = ?", nonstock.Id).Updates(map[string]any{ +// "uom_id": uomID, +// }).Error; err != nil { +// return err +// } +// } - for _, supplierName := range seed.Suppliers { - supplierID, ok := suppliers[supplierName] - if !ok { - return fmt.Errorf("supplier %s not seeded", supplierName) - } - var existing entity.NonstockSupplier - err := tx.Where("nonstock_id = ? AND supplier_id = ?", nonstock.Id, supplierID).First(&existing).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - link := entity.NonstockSupplier{NonstockId: nonstock.Id, SupplierId: supplierID} - if err := tx.Create(&link).Error; err != nil { - return err - } - } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - } +// for _, supplierName := range seed.Suppliers { +// supplierID, ok := suppliers[supplierName] +// if !ok { +// return fmt.Errorf("supplier %s not seeded", supplierName) +// } +// var existing entity.NonstockSupplier +// err := tx.Where("nonstock_id = ? AND supplier_id = ?", nonstock.Id, supplierID).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// link := entity.NonstockSupplier{NonstockId: nonstock.Id, SupplierId: supplierID} +// if err := tx.Create(&link).Error; err != nil { +// return err +// } +// } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { +// return err +// } +// } - if err := seedFlags(tx, nonstock.Id, entity.FlagableTypeNonstock, seed.Flags); err != nil { - return err - } - } +// if err := seedFlags(tx, nonstock.Id, entity.FlagableTypeNonstock, seed.Flags); err != nil { +// return err +// } +// } - return nil -} +// return nil +// } // nanti saya isi @@ -823,213 +447,6 @@ func seedFlags(tx *gorm.DB, flagableID uint, flagableType string, flags []utils. return nil } -func seedBanks(tx *gorm.DB, createdBy uint) error { - seeds := []struct { - Name string - Alias string - Owner *string - AccountNumber string - }{ - { - Name: "Bank Central Asia", - Alias: "BCA", - AccountNumber: "1234567890", - Owner: ptr("PT MBU Group"), - }, - { - Name: "Bank Rakyat Indonesia", - Alias: "BRI", - AccountNumber: "9876543210", - Owner: ptr("PT MBU Group"), - }, - { - Name: "Bank Mandiri", - Alias: "MAND", - AccountNumber: "1122334455", - Owner: ptr("PT MBU Group"), - }, - } - - for _, seed := range seeds { - var bank entity.Bank - err := tx.Where("name = ?", seed.Name).First(&bank).Error - - if errors.Is(err, gorm.ErrRecordNotFound) { - bank = entity.Bank{ - Name: seed.Name, - Alias: seed.Alias, - Owner: seed.Owner, - AccountNumber: seed.AccountNumber, - CreatedBy: createdBy, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - if err := tx.Create(&bank).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - // update data jika sudah ada - if err := tx.Model(&entity.Bank{}).Where("id = ?", bank.Id).Updates(map[string]any{ - "alias": seed.Alias, - "owner": seed.Owner, - "account_number": seed.AccountNumber, - "updated_at": time.Now(), - }).Error; err != nil { - return err - } - } - } - - return nil -} - -func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { - seeds := []struct { - ProductName string - WarehouseName string - Quantity float64 - }{ - {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 100}, - {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 200}, - {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 300}, - {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 { - 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 - err := tx.Where("product_id = ? AND warehouse_id = ?", product.Id, warehouse.Id).First(&productWarehouse).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - productWarehouse = entity.ProductWarehouse{ - ProductId: product.Id, - WarehouseId: warehouse.Id, - Quantity: seed.Quantity, - // CreatedBy: createdBy, - } - if err := tx.Create(&productWarehouse).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - if err := tx.Model(&productWarehouse).Updates(map[string]any{ - "quantity": seed.Quantity, - }).Error; err != nil { - return err - } - } - } - - return nil -} - -func seedTransferStock(tx *gorm.DB) error { - - transfer := entity.StockTransfer{ - FromWarehouseId: 1, - ToWarehouseId: 2, - Reason: "Seed transfer stock", - TransferDate: time.Now(), - MovementNumber: "SEED-TRF-00001", - CreatedBy: 1, - } - if err := tx.Create(&transfer).Error; err != nil { - return err - } - - details := []entity.StockTransferDetail{ - { - StockTransferId: transfer.Id, - ProductId: 1, - Quantity: 10, - }, - { - StockTransferId: transfer.Id, - ProductId: 2, - Quantity: 5, - }, - } - for i := range details { - if err := tx.Create(&details[i]).Error; err != nil { - return err - } - } - - deliveries := []entity.StockTransferDelivery{ - { - StockTransferId: transfer.Id, - SupplierId: 1, - VehiclePlate: "B 1234 XYZ", - DriverName: "Driver Seed", - DocumentPath: "seed.pdf", - ShippingCostItem: 1000, - ShippingCostTotal: 2000, - }, - } - for i := range deliveries { - if err := tx.Create(&deliveries[i]).Error; err != nil { - return err - } - } - - detailMap := make(map[uint64]uint64) - for _, d := range details { - detailMap[d.ProductId] = d.Id - } - - deliveryItems := []entity.StockTransferDeliveryItem{ - { - StockTransferDeliveryId: deliveries[0].Id, - StockTransferDetailId: detailMap[1], - Quantity: 50, - }, - { - StockTransferDeliveryId: deliveries[0].Id, - StockTransferDetailId: detailMap[2], - Quantity: 30, - }, - } - for i := range deliveryItems { - if err := tx.Create(&deliveryItems[i]).Error; err != nil { - return err - } - } - - return nil -} -func ptr[T any](v T) *T { - return &v -} - func strPtr(s string) *string { return &s } - -func intPtr(v int) *int { - return &v -} - -func uintPtr(v uint) *uint { - return &v -} diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go new file mode 100644 index 00000000..bbc93167 --- /dev/null +++ b/internal/entities/adjustment_stock.go @@ -0,0 +1,29 @@ +package entities + +import "time" + +// AdjustmentStock tracks FIFO allocation for stock adjustments +// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse +// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse +type AdjustmentStock struct { + Id uint `gorm:"primaryKey"` + StockLogId uint `gorm:"column:stock_log_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + + // === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) === + // Tracks stock added to warehouse via adjustment INCREASE + TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available + TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot + + // === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) === + // Tracks stock consumed from warehouse via adjustment DECREASE + UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed + PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock) + + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` + + // Relations + StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/entities/expense.go b/internal/entities/expense.go index e6ab1d77..7bea3076 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -1,7 +1,6 @@ package entities import ( - "database/sql" "time" "gorm.io/gorm" @@ -13,8 +12,8 @@ type Expense struct { SupplierId uint64 `gorm:""` Category string `gorm:"type:varchar(50);not null"` PoNumber string `gorm:"type:varchar(50)"` - DocumentPath sql.NullString `gorm:"type:json"` - RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` + LocationId uint64 `gorm:"not null"` + ProjectFlockId *string `gorm:"type:json"` RealizationDate time.Time `gorm:"type:date;column:realization_date"` TransactionDate time.Time `gorm:"type:date;not null"` Notes string `gorm:"type:text;column:notes"` @@ -23,8 +22,11 @@ type Expense struct { UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` - LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` + Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` + Location *Location `gorm:"foreignKey:LocationId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` + Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"` + RealizationDocuments []Document `gorm:"foreignKey:DocumentableId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` } diff --git a/internal/entities/initial.go b/internal/entities/initial.go new file mode 100644 index 00000000..c562d748 --- /dev/null +++ b/internal/entities/initial.go @@ -0,0 +1,30 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Initial struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ReferenceNumber string `gorm:"type:varchar(100);not null"` + TransactionType string `gorm:"type:varchar(50);not null"` + InitialBalanceType string `gorm:"type:varchar(20);not null"` + PartyType string `gorm:"type:varchar(50);not null;index:initials_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:initials_party_polymorphic,priority:2"` + BankId *uint `gorm:"index"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedBy uint `gorm:"index" json:"-"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Bank Bank `gorm:"foreignKey:BankId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Customer *Customer `gorm:"foreignKey:PartyId;references:Id"` + Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/marketing_delivery_product.go b/internal/entities/marketing_delivery_product.go index 47f6e89d..7ac3d967 100644 --- a/internal/entities/marketing_delivery_product.go +++ b/internal/entities/marketing_delivery_product.go @@ -5,15 +5,20 @@ import ( ) type MarketingDeliveryProduct struct { - Id uint `gorm:"primaryKey;autoIncrement"` - MarketingProductId uint `gorm:"uniqueIndex;not null"` - Qty float64 `gorm:"type:numeric(15,3)"` - UnitPrice float64 `gorm:"type:numeric(15,3)"` - TotalWeight float64 `gorm:"type:numeric(15,3)"` - AvgWeight float64 `gorm:"type:numeric(15,3)"` - TotalPrice float64 `gorm:"type:numeric(15,3)"` - DeliveryDate *time.Time `gorm:"type:timestamptz"` - VehicleNumber string `gorm:"type:varchar(50)"` + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingProductId uint `gorm:"uniqueIndex;not null"` + ProductWarehouseId uint `gorm:"not null"` + UnitPrice float64 `gorm:"type:numeric(15,3)"` + TotalWeight float64 `gorm:"type:numeric(15,3)"` + AvgWeight float64 `gorm:"type:numeric(15,3)"` + TotalPrice float64 `gorm:"type:numeric(15,3)"` + DeliveryDate *time.Time `gorm:"type:timestamptz"` + VehicleNumber string `gorm:"type:varchar(50)"` + + // FIFO Fields + UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + CreatedAt *time.Time `gorm:"type:timestamptz;not null"` MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` } diff --git a/internal/entities/payment.go b/internal/entities/payment.go new file mode 100644 index 00000000..e48800fb --- /dev/null +++ b/internal/entities/payment.go @@ -0,0 +1,32 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Payment struct { + Id uint `gorm:"primaryKey;autoIncrement"` + PaymentCode string `gorm:"type:varchar(50);not null"` + ReferenceNumber *string `gorm:"type:varchar(100)"` + TransactionType string `gorm:"type:varchar(50)"` + PartyType string `gorm:"type:varchar(50);not null;index:payments_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:payments_party_polymorphic,priority:2"` + PaymentDate time.Time `gorm:"not null"` + PaymentMethod string `gorm:"type:varchar(20);not null"` + BankId *uint `gorm:"not null;index:idx_payments_bank_id"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedBy uint `gorm:"index" json:"-"` + + BankWarehouse Bank `gorm:"foreignKey:BankId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Customer *Customer `gorm:"foreignKey:PartyId;references:Id"` + Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/production_standard.go b/internal/entities/production_standard.go new file mode 100644 index 00000000..1214f6cf --- /dev/null +++ b/internal/entities/production_standard.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type ProductionStandard struct { + Id uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(100);uniqueIndex;not null"` + ProjectCategory string `gorm:"type:varchar(20);not null"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + DeletedAt *time.Time `gorm:"type:timestamptz"` + CreatedBy uint `gorm:"not null"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + ProductionStandardDetails []ProductionStandardDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` + StandardGrowthDetails []StandardGrowthDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/production_standard_detail.go b/internal/entities/production_standard_detail.go new file mode 100644 index 00000000..1a18c8b8 --- /dev/null +++ b/internal/entities/production_standard_detail.go @@ -0,0 +1,20 @@ +package entities + +import ( + "time" +) + +type ProductionStandardDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + Week int `gorm:"not null"` + TargetHenDayProduction *float64 `gorm:"type:numeric(15,3)"` + TargetHenHouseProduction *float64 `gorm:"type:numeric(15,3)"` + TargetEggWeight *float64 `gorm:"type:numeric(15,3)"` + TargetEggMass *float64 `gorm:"type:numeric(15,3)"` + StandardFCR *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/project_flock_kandang_uniformity.go b/internal/entities/project_flock_kandang_uniformity.go new file mode 100644 index 00000000..ecf90d19 --- /dev/null +++ b/internal/entities/project_flock_kandang_uniformity.go @@ -0,0 +1,33 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ProjectFlockKandangUniformity struct { + Id uint `gorm:"primaryKey"` + Uniformity float64 `gorm:"type:numeric(15,3)"` + Week int `gorm:"not null"` + Cv float64 `gorm:"type:numeric(15,3)"` + ChickQtyOfWeight float64 `gorm:"type:numeric(15,3)"` + MeanUp float64 `gorm:"type:numeric(15,3)"` + MeanDown float64 `gorm:"type:numeric(15,3)"` + ProjectFlockKandangId uint `gorm:"not null"` + UniformQty float64 `gorm:"type:numeric(15,3)"` + NotUniformQty float64 `gorm:"type:numeric(15,3)"` + UniformDate *time.Time `gorm:"type:timestamptz"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedBy uint `gorm:"not null"` + + ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} + +func (ProjectFlockKandangUniformity) TableName() string { + return "project_flock_kandang_uniformity" +} diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go index 0a92b54b..7243c9c4 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -12,6 +12,7 @@ type ProjectFlock struct { AreaId uint `gorm:"not null"` Category string `gorm:"type:varchar(20);not null"` FcrId uint `gorm:"not null"` + ProductionStandardId uint `gorm:"column:production_standard_id"` LocationId uint `gorm:"not null"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` @@ -20,6 +21,7 @@ type ProjectFlock struct { Area Area `gorm:"foreignKey:AreaId;references:Id"` Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` Kandangs []Kandang `gorm:"many2many:project_flock_kandangs;joinTableForeignKey:project_flock_id;joinTableReferences:kandang_id" json:"kandangs,omitempty"` diff --git a/internal/entities/standard_growth_detail.go b/internal/entities/standard_growth_detail.go new file mode 100644 index 00000000..a3d19fb8 --- /dev/null +++ b/internal/entities/standard_growth_detail.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type StandardGrowthDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + TargetMeanBw *float64 `gorm:"type:numeric(15,3)"` + MaxDepletion *float64 `gorm:"type:numeric(15,3)"` + MinUniformity float64 `gorm:"type:numeric(15,3);not null"` + Week int `gorm:"not null"` + FeedIntake *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + CreatedBy uint `gorm:"not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/stock-transfer.go b/internal/entities/stock-transfer.go index e003d601..7da7a9f5 100644 --- a/internal/entities/stock-transfer.go +++ b/internal/entities/stock-transfer.go @@ -20,4 +20,5 @@ type StockTransfer struct { Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` CreatedUser *User `gorm:"foreignKey:CreatedBy"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 310d8cf8..d6acafb8 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -2,16 +2,6 @@ package entities import "time" -const ( - LogTypeAdjustment = "ADJUSTMENT" - LogTypeTransfer = "TRANSFER" -) - -const ( - TransactionTypeIncrease = "INCREASE" - TransactionTypeDecrease = "DECREASE" -) - type StockLog struct { Id uint `gorm:"primaryKey;column:id"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"` diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 3a7562ea..0eeccc04 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -4,20 +4,21 @@ import "time" // DETAIL EKSPEDISI type StockTransferDelivery struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - StockTransferId uint64 - SupplierId uint64 - VehiclePlate string - DriverName string - DocumentNumber string - DocumentPath string - ShippingCostItem float64 - ShippingCostTotal float64 - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` - // Relations - StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` - Supplier *Supplier `gorm:"foreignKey:SupplierId"` - Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` -} \ No newline at end of file + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + SupplierId uint64 + VehiclePlate string + DriverName string + DocumentNumber string + DocumentPath string + ShippingCostItem float64 + ShippingCostTotal float64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Supplier *Supplier `gorm:"foreignKey:SupplierId"` + Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go index 253a3bf8..9ab27824 100644 --- a/internal/entities/stock_transfer_detail.go +++ b/internal/entities/stock_transfer_detail.go @@ -7,12 +7,28 @@ type StockTransferDetail struct { Id uint64 `gorm:"primaryKey;autoIncrement"` StockTransferId uint64 ProductId uint64 - Quantity float64 - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` - // Relations - StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` - Product *Product `gorm:"foreignKey:ProductId"` - DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` + + // === FIFO FIELDS - SOURCE WAREHOUSE (Usable) === + // Tracking stock yang DIAMBIL dari source warehouse + SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"` + UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil + PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock) + + // === FIFO FIELDS - DESTINATION WAREHOUSE (Stockable) === + // Tracking stock yang DITAMBAHKAN ke destination warehouse + DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"` + TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia + TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini + + // === METADATA === + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + + // === RELATIONS === + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Product *Product `gorm:"foreignKey:ProductId"` + SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"` + DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"` + DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` } diff --git a/internal/entities/transaction.go b/internal/entities/transaction.go new file mode 100644 index 00000000..b099bd08 --- /dev/null +++ b/internal/entities/transaction.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Transaction struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/uniformity.go b/internal/entities/uniformity.go new file mode 100644 index 00000000..8402ad3b --- /dev/null +++ b/internal/entities/uniformity.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Uniformity struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index f9f3ec6e..18a0e713 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -2,9 +2,10 @@ package middleware // project-flock const ( - P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" - P_ProjectFlockKandangsGetAll = "lti.production.project_flock_kandangs.list" - P_ProjectFlockKandangsGetOne = "lti.production.project_flock_kandangs.detail" + P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" + P_ProjectFlockKandangsCheckClosing = "lti.production.project_flock_kandangs.closing.detail" + P_ProjectFlockKandangsGetAll = "lti.production.project_flock_kandangs.list" + P_ProjectFlockKandangsGetOne = "lti.production.project_flock_kandangs.detail" P_ProjectFlockGetAll = "lti.production.project_flocks.list" P_ProjectFlockCreate = "lti.production.project_flocks.create" @@ -52,18 +53,8 @@ const ( P_ProductWarehouseGetOne = "lti.inventory.product_warehouses.detail" ) const ( - P_ClosingGetAll = "lti.closing.list" - P_ClosingPenjualan = "lti.closing.penjualan" - P_ClosingGetSummary = "lti.closing.getsummary" - P_ClosingGetOverhead = "lti.closing.getoverhead" - P_ClosingCountSapronakKandang = "lti.closing.getsapronakcount.kandang" - P_ClosingCountSapronak = "lti.closing.getsapronakcount" - P_ClosingSapronak = "lti.closing.getsapronak" - - P_ClosingExpeditionHpp = "lti.closing.expedition" - P_ClosingExpeditionHppByKandang = "lti.closing.expedition.kandang" - P_ClosingDataProduction = "lti.closing.production.data" - P_ClosingKeuangan = "lti.closing.keuangan" + P_ClosingGetAll = "lti.closing.list" + P_ClosingDetail = "lti.closing.detail" ) const ( @@ -73,19 +64,20 @@ const ( ) const ( - P_TransferToLaying_GetAll = "lti.production.transfer_to_laying.list" - P_TransferToLaying_GetOne = "lti.production.transfer_to_laying.detail" - P_TransferToLaying_CreateOne = "lti.production.transfer_to_laying.create" - P_TransferToLaying_UpdateOne = "lti.production.transfer_to_laying.update" - P_TransferToLaying_DeleteOne = "lti.production.transfer_to_laying.delete" - P_TransferToLaying_Approval = "lti.production.transfer_to_laying.approve" - P_TransferToLaying_GetAvailableQty = "lti.production.transfer_to_laying.getavailableqty" + P_TransferToLaying_GetAll = "lti.production.transfer_to_laying.list" + P_TransferToLaying_GetOne = "lti.production.transfer_to_laying.detail" + P_TransferToLaying_CreateOne = "lti.production.transfer_to_laying.create" + P_TransferToLaying_UpdateOne = "lti.production.transfer_to_laying.update" + P_TransferToLaying_DeleteOne = "lti.production.transfer_to_laying.delete" + P_TransferToLaying_Approval = "lti.production.transfer_to_laying.approve" + P_TransferToLaying_GetAvailableQty = "lti.production.transfer_to_laying.getavailableqty" ) const ( P_DeliveryGetAll = "lti.marketing.delivery_order.list" P_DeliveryGetOne = "lti.marketing.delivery_order.detail" P_DeliveryUpdateOne = "lti.marketing.delivery_order.update" + P_DeliveryCreateOne = "lti.marketing.delivery_order.Create" P_SalesOrderDelete = "lti.marketing.sales_order.delete" P_SalesOrderApproval = "lti.marketing.sales_order.approve" P_SalesOrderCreateOne = "lti.marketing.sales_order.create" @@ -170,8 +162,32 @@ const ( P_WarehousesCreateOne = "lti.master.warehouses.create" P_WarehousesUpdateOne = "lti.master.warehouses.update" P_WarehousesDeleteOne = "lti.master.warehouses.delete" + + P_Production_Standart_GetAll = "lti.master.production_standards.list" + P_Production_Standart_CreateOne = "lti.master.production_standards.create" + P_Production_Standart_GetOne = "lti.master.production_standards.detail" + P_Production_Standart_UpdateOne = "lti.master.production_standards.update" + P_Production_Standart_DeleteOne = "lti.master.production_standards.delete" ) +// finance +const ( + P_Finances_Initial_Balances_CreateOne = "lti.finance.initial_balances.create" + P_Finances_Initial_Balances_GetOne = "lti.finance.initial_balances.detail" + P_Finances_Initial_Balances_UpdateOne = "lti.finance.initial_balances.update" + + P_Finances_Injections_CreateOne = "lti.finance.injections.create" + P_Finances_Injections_GetOne = "lti.finance.injections.detail" + P_Finances_Injections_UpdateOne = "lti.finance.injections.update" + + P_Finances_Payments_CreateOne = "lti.finance.payments.create" + P_Finances_Payments_UpdateOne = "lti.finance.payments.update" + P_Finances_Payments_GetOne = "lti.finance.payments.detail" + + P_Finances_Transaction_GetAll = "lti.finance.transactions.list" + P_Finances_Transaction_GetOne = "lti.finance.transactions.detail" + P_Finances_Transaction_DeleteOne = "lti.finance.transactions.delete" +) const ( P_ChickinsCreateOne = "lti.production.chickins.create" P_ChickinsGetOne = "lti.production.chickins.detail" @@ -201,6 +217,16 @@ const ( P_PurchaseApprovalManager = "lti.Purchase.approve.manager" ) +const ( + P_Uniformities_GetAll = "lti.production.uniformity.list" + P_Uniformities_GetOne = "lti.production.uniformity.detail" + P_Uniformities_Verify = "lti.production.uniformity.verify" + P_Uniformities_CreateOne = "lti.production.uniformity.create" + P_Uniformities_UpdateOne = "lti.production.uniformity.update" + P_Uniformities_DeleteOne = "lti.production.uniformity.delete" + P_Uniformities_Approval = "lti.production.uniformity.approve" +) + const ( P_UserGetAll = "lti.users.list" P_UserGetOne = "lti.users.detail" diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index c05bd741..ac172c83 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -28,18 +28,19 @@ type ClosingDetailDTO struct { } type ClosingListItemDTO struct { - Id uint `json:"id"` - LocationID uint `json:"location_id"` - LocationName string `json:"location_name"` - ProjectCategory string `json:"project_category"` - Period int `json:"period"` - ClosingDate string `json:"closing_date"` - ShedLabel string `json:"shed_label"` - ShedCount int `json:"shed_count"` - SalesPaidAmount int64 `json:"sales_paid_amount"` - SalesRemainingAmount int64 `json:"sales_remaining_amount"` - SalesPaymentStatus string `json:"sales_payment_status"` - ProjectStatus string `json:"project_status"` + Id uint `json:"id"` + ProjectName string `json:"project_name"` + LocationID uint `json:"location_id"` + LocationName string `json:"location_name"` + ProjectCategory string `json:"project_category"` + Period int `json:"period"` + ClosingDate string `json:"closing_date"` + ShedLabel string `json:"shed_label"` + ShedCount int `json:"shed_count"` + // SalesPaidAmount int64 `json:"sales_paid_amount"` + // SalesRemainingAmount int64 `json:"sales_remaining_amount"` + // SalesPaymentStatus string `json:"sales_payment_status"` + ProjectStatus string `json:"project_status"` } type ClosingSummaryDTO struct { @@ -133,18 +134,19 @@ func ToClosingListItemDTO(project entity.ProjectFlock, projectStatus string) Clo shedCount := len(project.KandangHistory) return ClosingListItemDTO{ - Id: project.Id, - LocationID: project.LocationId, - LocationName: project.Location.Name, - ProjectCategory: project.Category, - Period: maxPeriod(project.KandangHistory), - ClosingDate: "17-Nov-2025", - ShedLabel: fmt.Sprintf("%d Kandang", shedCount), - ShedCount: shedCount, - SalesPaidAmount: 21993726, - SalesRemainingAmount: 11075919, - SalesPaymentStatus: "Lunas", - ProjectStatus: projectStatus, + Id: project.Id, + ProjectName: project.FlockName, + LocationID: project.LocationId, + LocationName: project.Location.Name, + ProjectCategory: project.Category, + Period: maxPeriod(project.KandangHistory), + ClosingDate: "17-Nov-2025", + ShedLabel: fmt.Sprintf("%d Kandang", shedCount), + ShedCount: shedCount, + // SalesPaidAmount: 21993726, + // SalesRemainingAmount: 11075919, + // SalesPaymentStatus: "Lunas", + ProjectStatus: projectStatus, } } diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go index 90dda2a9..08bfb5fc 100644 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ b/internal/modules/closings/dto/closingKeuangan.dto.go @@ -35,6 +35,7 @@ const ( type CalculationContext struct { TotalPopulation float64 TotalWeightProduced float64 + TotalEggWeightKg float64 TotalDepletion float64 TotalWeightSold float64 ActualPopulation float64 @@ -48,6 +49,7 @@ type ClosingKeuanganInput struct { DeliveryProducts []entities.MarketingDeliveryProduct Chickins []entities.ProjectChickin TotalWeightProduced float64 + TotalEggWeightKg float64 TotalDepletion float64 } @@ -77,8 +79,10 @@ type HppGroup struct { } type SummaryHpp struct { - Label string `json:"label"` - Comparison + Label string `json:"label"` + Comparison `json:"-"` + EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` + EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` } type HppPurchasesSection struct { @@ -231,7 +235,7 @@ func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entiti // === HPP SUMMARY === -func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) SummaryHpp { +func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp { purchaseTotal := sumPurchaseTotal(purchaseItems) budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) totalBudget := purchaseTotal + budgetTotal @@ -241,16 +245,34 @@ func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets [ budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced) realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) - return SummaryHpp{ + summary := SummaryHpp{ Label: label, Comparison: ToComparison( ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), ), } + + if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 { + budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg) + realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg) + + summary.EggBudgeting = &FinancialMetrics{ + RpPerBird: 0, + RpPerKg: budgetEggRpPerKg, + Amount: totalBudget, + } + summary.EggRealization = &FinancialMetrics{ + RpPerBird: 0, + RpPerKg: realizationEggRpPerKg, + Amount: totalRealization, + } + } + + return summary } -func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppPurchasesSection { +func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection { hppGroups := []HppGroup{ { GroupName: HPPGroupPengeluaran, @@ -259,7 +281,7 @@ func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []enti ToHppBahanBakuGroup(budgets, realizations, ctx), } - summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, ctx) + summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx) return HppPurchasesSection{ Hpp: hppGroups, @@ -322,11 +344,9 @@ func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.M func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { purchaseAmount := sumPurchaseTotal(purchases) - bopAmount := getOperationalExpenses(realizations) - totalCost := purchaseAmount + bopAmount return []PLItem{ - createPLItemWithMetrics(PLItemTypeSapronak, totalCost, ctx), + createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx), } } @@ -414,12 +434,13 @@ func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse { ctx := CalculationContext{ TotalPopulation: totalPopulation, TotalWeightProduced: input.TotalWeightProduced, + TotalEggWeightKg: input.TotalEggWeightKg, TotalDepletion: input.TotalDepletion, TotalWeightSold: totalWeightSold, ActualPopulation: totalPopulation - input.TotalDepletion, } - hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, ctx) + hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx) penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx) pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx) overheadItems := ToOverheadItems(input.Realizations, ctx) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 8c904561..4c7b4d35 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -64,7 +64,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { DoNumber: doNumber, Product: product, Customer: customer, - Qty: e.Qty, + Qty: e.UsageQty, // Show allocated quantity from FIFO Weight: e.TotalWeight, AvgWeight: e.AvgWeight, Price: e.UnitPrice, diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 6a59c5f9..912f2f25 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -31,6 +31,8 @@ type ClosingRepository interface { FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) + GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } type ClosingRepositoryImpl struct { @@ -328,13 +330,33 @@ SELECT COALESCE(p.po_number, '') AS reference_number, 'Purchase' AS transaction_type, prod.name AS product_name, - pc.name AS product_category, COALESCE(( - SELECT string_agg(f.name, ' ') + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, - 'External Supplier' AS source_warehouse, + '-' AS source_warehouse, w.name AS destination_warehouse, '' AS destination, pi.total_qty AS quantity, @@ -343,7 +365,6 @@ SELECT FROM purchase_items pi JOIN purchases p ON p.id = pi.purchase_id JOIN products prod ON prod.id = pi.product_id -JOIN product_categories pc ON pc.id = prod.product_category_id JOIN uoms u ON u.id = prod.uom_id JOIN warehouses w ON w.id = pi.warehouse_id WHERE pi.warehouse_id IN ? @@ -357,9 +378,29 @@ SELECT st.movement_number AS reference_number, 'Internal Transfer In' AS transaction_type, prod.name AS product_name, - pc.name AS product_category, COALESCE(( - SELECT string_agg(f.name, ' ') + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, @@ -374,7 +415,6 @@ JOIN stock_transfers st ON st.id = std.stock_transfer_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id JOIN products prod ON prod.id = std.product_id -JOIN product_categories pc ON pc.id = prod.product_category_id JOIN uoms u ON u.id = prod.uom_id WHERE st.to_warehouse_id IN ? ` @@ -387,9 +427,29 @@ SELECT st.movement_number AS reference_number, 'Internal Transfer Out' AS transaction_type, prod.name AS product_name, - pc.name AS product_category, COALESCE(( - SELECT string_agg(f.name, ' ') + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, @@ -404,7 +464,6 @@ JOIN stock_transfers st ON st.id = std.stock_transfer_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id JOIN products prod ON prod.id = std.product_id -JOIN product_categories pc ON pc.id = prod.product_category_id JOIN uoms u ON u.id = prod.uom_id WHERE st.from_warehouse_id IN ? ` @@ -417,9 +476,29 @@ SELECT m.so_number AS reference_number, 'Trading Sales' AS transaction_type, prod.name AS product_name, - pc.name AS product_category, COALESCE(( - SELECT string_agg(f.name, ' ') + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, @@ -433,7 +512,6 @@ FROM marketing_products mp JOIN marketings m ON m.id = mp.marketing_id JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id JOIN products prod ON prod.id = pw.product_id -JOIN product_categories pc ON pc.id = prod.product_category_id JOIN uoms u ON u.id = prod.uom_id JOIN warehouses w ON w.id = pw.warehouse_id WHERE pw.project_flock_kandang_id IN ? @@ -783,7 +861,7 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) } func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeAdjustment, false) + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false) if err != nil { return nil, nil, err } @@ -792,7 +870,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka } func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeTransfer, true) + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeTransfer), true) if err != nil { return nil, nil, err } @@ -804,3 +882,150 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand }) return in, out, nil } + +type ActualUsageCostRow struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + FlagName string `gorm:"column:flag_name"` + TotalQty float64 `gorm:"column:total_qty"` + TotalPrice float64 `gorm:"column:total_price"` + AveragePrice float64 `gorm:"column:average_price"` +} + +func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) { + if projectFlockID == 0 { + return []ActualUsageCostRow{}, nil + } + + db := r.DB().WithContext(ctx) + + // Get all project flock kandang IDs for this project flock + var pfkIDs []uint + err := db.Table("project_flock_kandangs"). + Where("project_flock_id = ?", projectFlockID). + Pluck("id", &pfkIDs).Error + if err != nil { + return nil, err + } + + if len(pfkIDs) == 0 { + return []ActualUsageCostRow{}, nil + } + + var rows []ActualUsageCostRow + + // Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll) + purchaseStockableKey := "PURCHASE_ITEMS" + transferStockableKey := "STOCK_TRANSFER_DETAILS" + + recordingQuery := db. + Table("recordings AS r"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + COALESCE(f.name, tf.name) AS flag_name, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + ELSE 0 + END + ), 0) AS total_qty, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END + ), 0) AS total_price, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + ELSE 0 + END + ), 0) AS qty_divisor, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END + ), 0) / NULLIF(COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + ELSE 0 + END + ), 0), 0) AS average_price`, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey). + Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", + "recording_stocks", entity.StockAllocationStatusActive). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). + Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey). + Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct). + Where("r.project_flock_kandangs_id IN ?", pfkIDs). + Where("r.deleted_at IS NULL"). + Group("pw.product_id, p.name, COALESCE(f.name, tf.name)") + + if err := recordingQuery.Scan(&rows).Error; err != nil { + return nil, err + } + + // Part 2: Get usage from project_chickins (DOC, Pullet) + chickinQuery := db. + Table("project_chickins AS pc"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag_name, + COALESCE(SUM(pc.usage_qty), 0) AS total_qty, + COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS total_price, + COALESCE(AVG(COALESCE(pi.price, 0)), 0) AS average_price + `). + Joins("JOIN product_warehouses AS pw ON pw.id = pc.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("pc.project_flock_kandang_id IN ?", pfkIDs). + Where("pc.usage_qty > 0"). + Group("pw.product_id, p.name, f.name") + + var chickinRows []ActualUsageCostRow + if err := chickinQuery.Scan(&chickinRows).Error; err != nil { + return nil, err + } + + // Merge results + rows = append(rows, chickinRows...) + + return rows, nil +} + +func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { + if len(productIDs) == 0 { + return []entity.Product{}, nil + } + + var products []entity.Product + err := r.DB().WithContext(ctx). + Preload("Flags"). + Where("id IN ?", productIDs). + Find(&products).Error + + if err != nil { + return nil, err + } + + return products, nil +} diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 51a84a04..52333b67 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -22,14 +22,14 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) route.Get("/", m.RequirePermissions(m.P_ClosingGetAll), ctrl.GetAll) - route.Get("/:project_flock_id/penjualan", m.RequirePermissions(m.P_ClosingPenjualan), ctrl.GetPenjualan) - route.Get("/:projectFlockId", m.RequirePermissions(m.P_ClosingGetSummary), ctrl.GetClosingSummary) - route.Get("/:project_flock_id/overhead", m.RequirePermissions(m.P_ClosingGetOverhead), ctrl.GetOverhead) - route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingCountSapronakKandang), ctrl.GetSapronakByKandang) - route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingCountSapronak), ctrl.GetSapronakByProject) - route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingSapronak), ctrl.GetClosingSapronak) - route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingExpeditionHpp), ctrl.GetExpeditionHPP) - route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingExpeditionHppByKandang), ctrl.GetExpeditionHPPByKandang) - route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDataProduction), ctrl.GetClosingDataProduksi) - route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingKeuangan), ctrl.GetClosingKeuangan) + route.Get("/:project_flock_id/penjualan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetPenjualan) + route.Get("/:projectFlockId", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSummary) + route.Get("/:project_flock_id/overhead", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetOverhead) + route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang) + route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByProject) + route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak) + route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPP) + route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang) + route.Get("/:projectFlockId/data-produksi", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) + route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 48728195..47e30a7f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -6,6 +6,7 @@ import ( "math" "strconv" "strings" + "time" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -332,18 +333,20 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID } var ( - minStep uint16 - statusProject string - completed int + minStep uint16 + statusProject string + completed int + latestActionAt time.Time ) for _, rec := range records { if minStep == 0 || rec.StepNumber < minStep { minStep = rec.StepNumber - statusProject = rec.StepName } - if rec.StepNumber == uint16(utils.ProjectFlockStepAktif) { - completed++ + + if latestActionAt.IsZero() || rec.ActionAt.After(latestActionAt) { + latestActionAt = rec.ActionAt + statusProject = rec.StepName } } @@ -426,11 +429,15 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") } - purchaseItems, err := s.PurchaseRepo.GetItemsByProjectFlockID(c.Context(), projectFlockID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch purchase items") + // Get actual usage cost instead of purchase items + actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost") } + // Convert actual usage rows to pseudo purchase items + purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows) + realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") @@ -455,6 +462,11 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) } + totalEggWeightKg, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalEggProductionWeightByProjectFlockID error: %v", err) + } + totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) if err != nil { s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) @@ -468,6 +480,7 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* DeliveryProducts: deliveryProducts, Chickins: chickins, TotalWeightProduced: totalWeightProduced, + TotalEggWeightKg: totalEggWeightKg, TotalDepletion: totalDepletion, } @@ -476,8 +489,6 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* return &report, nil } -// GetExpeditionHPP menghitung HPP ekspedisi per vendor untuk sebuah project flock. -// Jika projectFlockKandangID tidak nil, maka hanya data untuk kandang tersebut yang dihitung. func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") @@ -712,13 +723,13 @@ func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlo ) for _, product := range deliveryProducts { - if product.Qty == 0 { + if product.UsageQty == 0 { continue } projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate) - totalAgeWeeks += float64(ageWeeks) * product.Qty - totalQty += product.Qty + totalAgeWeeks += float64(ageWeeks) * product.UsageQty + totalQty += product.UsageQty } if totalQty == 0 { @@ -778,5 +789,54 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl } return closest.Mortality, closest.FcrNumber - +} + +func (s closingService) convertActualUsageToPurchaseItems(ctx context.Context, actualUsageRows []repository.ActualUsageCostRow) []entity.PurchaseItem { + if len(actualUsageRows) == 0 { + return []entity.PurchaseItem{} + } + + // Collect all product IDs + productIDs := make([]uint, len(actualUsageRows)) + for i, row := range actualUsageRows { + productIDs[i] = row.ProductID + } + + // Fetch products with flags from repository + products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, productIDs) + if err != nil { + s.Log.Warnf("Failed to fetch products for actual usage: %v", err) + products = []entity.Product{} + } + + // Create product map + productMap := make(map[uint]*entity.Product) + for i := range products { + productMap[products[i].Id] = &products[i] + } + + // Convert to pseudo purchase items + purchaseItems := make([]entity.PurchaseItem, 0, len(actualUsageRows)) + for _, row := range actualUsageRows { + product := productMap[row.ProductID] + + // Skip if product not found + if product == nil { + s.Log.Warnf("Product ID %d not found for actual usage", row.ProductID) + continue + } + + purchaseItem := entity.PurchaseItem{ + Id: 0, // Pseudo item, no ID + ProductId: row.ProductID, + TotalQty: row.TotalQty, + TotalPrice: row.TotalPrice, + Price: row.AveragePrice, + Product: product, + } + + purchaseItems = append(purchaseItems, purchaseItem) + } + + return purchaseItems } diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 55114ec8..49642231 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -90,6 +90,12 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { } req.SupplierID = supplierID + locationID, err := strconv.ParseUint(c.FormValue("location_id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format") + } + req.LocationID = locationID + form, err := c.MultipartForm() if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") @@ -106,17 +112,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) } - if singleExpenseNonstock.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required") - } - req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock} - } else { - for i, expenseNonstock := range req.ExpenseNonstocks { - if expenseNonstock.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i)) - } - } } } else { return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required") @@ -171,6 +167,15 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { req.SupplierID = &supplierID } + locationIDVal := c.FormValue("location_id") + if locationIDVal != "" { + locationID, err := strconv.ParseUint(locationIDVal, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format") + } + req.LocationID = &locationID + } + expenseNonstocksJSON := c.FormValue("expense_nonstocks") if expenseNonstocksJSON != "" { var expenseNonstocks []validation.ExpenseNonstock @@ -178,12 +183,6 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) } - for i, expenseNonstock := range expenseNonstocks { - if expenseNonstock.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i)) - } - } - req.ExpenseNonstocks = &expenseNonstocks } diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index c55dba2c..6402f8fd 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -1,7 +1,6 @@ package dto import ( - "encoding/json" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -41,8 +40,8 @@ type ExpenseListDTO struct { type ExpenseDetailDTO struct { ExpenseBaseDTO - Documents []DocumentDTO `json:"documents,omitempty"` - RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` + Documents []DocumentDTO `json:"documents"` + RealizationDocs []DocumentDTO `json:"realization_docs"` Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` TotalPengajuan float64 `json:"total_pengajuan"` TotalRealisasi float64 `json:"total_realisasi"` @@ -77,7 +76,6 @@ type ExpenseRealizationDTO struct { type KandangGroupDTO struct { Id uint64 `json:"id"` - KandangId uint64 `json:"kandang_id"` Name string `json:"name,omitempty"` Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"` Realisasi []ExpenseRealizationDTO `json:"realisasi,omitempty"` @@ -179,12 +177,18 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var pengajuans []ExpenseNonstockDTO var realisasi []ExpenseRealizationDTO - if e.DocumentPath.Valid && e.DocumentPath.String != "" { - json.Unmarshal([]byte(e.DocumentPath.String), &documents) + for _, doc := range e.Documents { + documents = append(documents, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } - if e.RealizationDocumentPath.Valid && e.RealizationDocumentPath.String != "" { - json.Unmarshal([]byte(e.RealizationDocumentPath.String), &realizationDocs) + for _, doc := range e.RealizationDocuments { + realizationDocs = append(realizationDocs, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } if len(e.Nonstocks) > 0 { @@ -264,6 +268,8 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO { func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO { kandangMap := make(map[uint64]*KandangGroupDTO) + var directPengajuans []ExpenseNonstockDTO + var directRealisasi []ExpenseRealizationDTO for _, p := range pengajuans { var kandangId uint64 @@ -280,16 +286,19 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali } if kandangId > 0 { + if kandangMap[kandangId] == nil { kandangMap[kandangId] = &KandangGroupDTO{ Id: kandangId, - KandangId: kandangId, Name: kandangName, Pengajuans: []ExpenseNonstockDTO{}, Realisasi: []ExpenseRealizationDTO{}, } } kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p) + } else { + + directPengajuans = append(directPengajuans, p) } } @@ -309,13 +318,24 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali if kandangMap[kandangId] == nil { kandangMap[kandangId] = &KandangGroupDTO{ Id: kandangId, - KandangId: kandangId, Name: kandangName, Pengajuans: []ExpenseNonstockDTO{}, Realisasi: []ExpenseRealizationDTO{}, } } kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) + } else { + } + } + + // If there are direct expenses (without kandang), add them as a special entry with id=0 + if len(directPengajuans) > 0 || len(directRealisasi) > 0 { + kandangMap[0] = &KandangGroupDTO{ + Id: 0, + + Name: "", + Pengajuans: directPengajuans, + Realisasi: directRealisasi, } } diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go index 6d276b5d..b495b5b9 100644 --- a/internal/modules/expenses/module.go +++ b/internal/modules/expenses/module.go @@ -1,6 +1,7 @@ package expenses import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -31,15 +32,20 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalRepo := commonRepo.NewApprovalRepository(db) realizationRepo := rExpense.NewExpenseRealizationRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } // Register workflow steps for EXPENSES approval if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) } - expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, validate) + expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate) userService := sUser.NewUserService(userRepo, validate) ExpenseRoutes(router, userService, expenseService) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 24ba4f2e..b4753451 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,11 +2,9 @@ package service import ( "context" - "database/sql" "encoding/json" "errors" "fmt" - "mime/multipart" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -49,9 +47,10 @@ type expenseService struct { ApprovalSvc commonSvc.ApprovalService RealizationRepository repository.ExpenseRealizationRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService } -func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService { +func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService { return &expenseService{ Log: utils.Log, Validate: validate, @@ -61,6 +60,7 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR ApprovalSvc: approvalSvc, RealizationRepository: realizationRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, } } @@ -72,7 +72,13 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Nonstocks.Realization"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.Kandang"). - Preload("Nonstocks.Kandang.Location") + Preload("Nonstocks.Kandang.Location"). + Preload("Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense)) + }). + Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization)) + }) } func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { @@ -139,11 +145,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen supplierID := uint(req.SupplierID) - supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) { - return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id) - } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc}, + commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists}, ); err != nil { return nil, err } @@ -194,11 +197,47 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } createdBy := uint64(actorID) + + hasKandang := false + for _, ens := range req.ExpenseNonstocks { + if ens.KandangID != nil { + hasKandang = true + break + } + } + + var projectFlockIdJSON *string + if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) { + projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction) + activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location") + } + + if len(activeProjectFlocks) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "No active project flocks found for this location") + } + + projectFlockIDs := make([]uint64, len(activeProjectFlocks)) + for i, pf := range activeProjectFlocks { + projectFlockIDs[i] = uint64(pf.Id) + } + + projectFlockIdsJSON, err := json.Marshal(projectFlockIDs) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids") + } + jsonStr := string(projectFlockIdsJSON) + projectFlockIdJSON = &jsonStr + } + expense = &entity.Expense{ ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, Category: req.Category, SupplierId: req.SupplierID, + LocationId: req.LocationID, + ProjectFlockId: projectFlockIdJSON, TransactionDate: expenseDate, CreatedBy: createdBy, } @@ -211,35 +250,36 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen for _, expenseNonstock := range req.ExpenseNonstocks { + isAttachingToKandang := (expenseNonstock.KandangID != nil) + var projectFlockKandangId *uint64 + var kandangId *uint64 - if req.Category == string(utils.ExpenseCategoryBOP) { + if isAttachingToKandang { + kandangId = expenseNonstock.KandangID - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + if req.Category == string(utils.ExpenseCategoryBOP) { + + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + id := uint64(projectFlockKandang.Id) + projectFlockKandangId = &id } - id := uint64(projectFlockKandang.Id) - projectFlockKandangId = &id + + } else { + kandangId = nil + projectFlockKandangId = nil } for _, costItem := range expenseNonstock.CostItems { nonstockId := costItem.NonstockID - var kandangId *uint64 - if req.Category == string(utils.ExpenseCategoryNonBOP) { - id := uint64(expenseNonstock.KandangID) - kandangId = &id - } else if req.Category == string(utils.ExpenseCategoryBOP) { - if projectFlockKandangId != nil { - kandangId = &expenseNonstock.KandangID - } - } - - expenseNonstock := &entity.ExpenseNonstock{ + newExpenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expense.Id, ProjectFlockKandangId: projectFlockKandangId, KandangId: kandangId, @@ -249,7 +289,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen Notes: costItem.Notes, } - if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } @@ -269,9 +309,23 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: expense.Id, + CreatedBy: &createdByUint, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -342,6 +396,11 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["supplier_id"] = *req.SupplierID } + if req.LocationID != nil { + locationID := uint(*req.LocationID) + updateBody["location_id"] = locationID + } + if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 { responseDTO, err := s.GetOne(c, id) @@ -456,18 +515,26 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) for _, expenseNonstock := range *req.ExpenseNonstocks { var projectFlockKandangId *uint64 + var kandangId *uint64 - if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { - projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + // Check if attaching to kandang + if expenseNonstock.KandangID != nil { + kandangId = expenseNonstock.KandangID + + if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { + // BOP with kandang: Get active project flock kandang + projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + id := uint64(projectFlockKandang.Id) + projectFlockKandangId = &id } - id := uint64(projectFlockKandang.Id) - projectFlockKandangId = &id + // NON-BOP: projectFlockKandangId stays nil } for _, costItem := range expenseNonstock.CostItems { @@ -479,18 +546,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return err } - var kandangId *uint64 - if updatedExpense.Category == string(utils.ExpenseCategoryNonBOP) { - id := uint64(expenseNonstock.KandangID) - kandangId = &id - } else if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { - if projectFlockKandangId != nil { - kandangId = &expenseNonstock.KandangID - } - } - expenseId := uint64(id) - expenseNonstock := &entity.ExpenseNonstock{ + newExpenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expenseId, ProjectFlockKandangId: projectFlockKandangId, KandangId: kandangId, @@ -500,7 +557,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) Notes: costItem.Notes, } - if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } @@ -527,9 +584,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: uint64(id), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -658,9 +729,24 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -833,9 +919,24 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -870,79 +971,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va return responseDTO, nil } -func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx repository.ExpenseRepository, expenseID uint, documents []*multipart.FileHeader, isRealization bool) error { - - if len(documents) == 0 { - return nil - } - - var existingDocuments []expenseDto.DocumentDTO - var fieldName string - - if isRealization { - fieldName = "realization_document_path" - } else { - fieldName = "document_path" - } - - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - - if !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document processing") - } - } else { - - var documentField sql.NullString - if isRealization { - documentField = expense.RealizationDocumentPath - } else { - documentField = expense.DocumentPath - } - - if documentField.Valid && documentField.String != "" { - if err := json.Unmarshal([]byte(documentField.String), &existingDocuments); err != nil { - existingDocuments = []expenseDto.DocumentDTO{} - } - } - } - - var startID uint64 = 1 - if len(existingDocuments) > 0 { - - maxID := uint64(0) - for _, doc := range existingDocuments { - if doc.ID > maxID { - maxID = doc.ID - } - } - startID = maxID + 1 - } - - for i, doc := range documents { - documentPath := doc.Filename - - document := expenseDto.DocumentDTO{ - ID: startID + uint64(i), - Path: documentPath, - } - existingDocuments = append(existingDocuments, document) - } - - documentJSON, err := json.Marshal(existingDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil -} - func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error { if err := commonSvc.EnsureRelations(ctx.Context(), @@ -951,62 +979,40 @@ func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, document return err } - if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { - expenseRepoTx := repository.NewExpenseRepository(tx) + if s.DocumentSvc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document deletion") + // Verify document exists and belongs to the expense + var documentableType string + if isRealization { + documentableType = string(utils.DocumentableTypeExpenseRealization) + } else { + documentableType = string(utils.DocumentableTypeExpense) + } + + documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID)) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents") + } + + documentFound := false + var documentIDsToDelete []uint + for _, doc := range documents { + if uint64(doc.Id) == documentID { + documentFound = true + documentIDsToDelete = append(documentIDsToDelete, doc.Id) + break } + } - var existingDocuments []expenseDto.DocumentDTO - var fieldName string + if !documentFound { + return fiber.NewError(fiber.StatusNotFound, "Document not found") + } - if isRealization { - fieldName = "realization_document_path" - if expense.RealizationDocumentPath.Valid && expense.RealizationDocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.RealizationDocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing realization documents") - } - } - } else { - fieldName = "document_path" - if expense.DocumentPath.Valid && expense.DocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.DocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing documents") - } - } - } - - var updatedDocuments []expenseDto.DocumentDTO - documentFound := false - - for _, doc := range existingDocuments { - if doc.ID == documentID { - documentFound = true - continue - } - updatedDocuments = append(updatedDocuments, doc) - } - - if !documentFound { - return fiber.NewError(fiber.StatusNotFound, "Document not found") - } - - documentJSON, err := json.Marshal(updatedDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil - }); err != nil { - return err + // Delete document from database and storage + if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document") } return nil diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 9dc2b07b..4501b87d 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -9,12 +9,13 @@ type Create struct { TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` + LocationID uint64 `form:"location_id" json:"location_id" validate:"required,gt=0"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"` } type ExpenseNonstock struct { - KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"` + KandangID *uint64 `form:"kandang_id" json:"kandang_id" validate:"omitempty"` CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"` } @@ -22,13 +23,14 @@ type CostItem struct { NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"` Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"` Price float64 `form:"price" json:"price" validate:"required,gt=0"` - Notes string `form:"notes" json:"notes" validate:"required,max=500"` + Notes string `form:"notes" json:"notes" validate:"omitempty,max=500"` } type Update struct { TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` + LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` } diff --git a/internal/modules/finance/initials/controllers/initial.controller.go b/internal/modules/finance/initials/controllers/initial.controller.go new file mode 100644 index 00000000..4aef677a --- /dev/null +++ b/internal/modules/finance/initials/controllers/initial.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type InitialController struct { + InitialService service.InitialService +} + +func NewInitialController(initialService service.InitialService) *InitialController { + return &InitialController{ + InitialService: initialService, + } +} + +func (u *InitialController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.InitialService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} + +func (u *InitialController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InitialService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} + +func (u *InitialController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InitialService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} diff --git a/internal/modules/finance/initials/dto/initial.dto.go b/internal/modules/finance/initials/dto/initial.dto.go new file mode 100644 index 00000000..5eb76e9c --- /dev/null +++ b/internal/modules/finance/initials/dto/initial.dto.go @@ -0,0 +1,163 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type InitialRelationDTO struct { + Id uint `json:"id"` + ReferenceNumber string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + InitialBalanceType string `json:"initial_balance_type"` + InitialBalanceTypeLabel string `json:"initial_balance_type_label"` + Party Party `json:"party"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + Direction string `json:"direction"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` +} + +type InitialListDTO struct { + InitialRelationDTO + CreatedBy uint `json:"created_by"` + CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type InitialDetailDTO struct { + InitialListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToInitialRelationDTO(e entity.Payment) InitialRelationDTO { + reference := "" + if e.ReferenceNumber != nil { + reference = *e.ReferenceNumber + } + + initialBalanceType := initialBalanceTypeFromPayment(e) + return InitialRelationDTO{ + Id: e.Id, + ReferenceNumber: reference, + TransactionType: transactionTypeLabel(e.TransactionType), + InitialBalanceType: initialBalanceType, + InitialBalanceTypeLabel: initialBalanceLabel(initialBalanceType), + Party: partyFromInitial(e), + Bank: bankFromInitial(e), + Direction: e.Direction, + Nominal: e.Nominal, + Notes: e.Notes, + } +} + +func ToInitialListDTO(e entity.Payment) InitialListDTO { + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return InitialListDTO{ + InitialRelationDTO: ToInitialRelationDTO(e), + CreatedBy: e.CreatedBy, + CreatedByUser: userFromInitial(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToInitialListDTOs(e []entity.Payment) []InitialListDTO { + result := make([]InitialListDTO, len(e)) + for i, r := range e { + result[i] = ToInitialListDTO(r) + } + return result +} + +func ToInitialDetailDTO(e entity.Payment) InitialDetailDTO { + return InitialDetailDTO{ + InitialListDTO: ToInitialListDTO(e), + } +} + +func partyFromInitial(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromInitial(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func transactionTypeLabel(transactionType string) string { + if strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) { + return "Saldo Awal" + } + return transactionType +} + +func initialBalanceLabel(balanceType string) string { + switch strings.ToUpper(strings.TrimSpace(balanceType)) { + case "NEGATIVE": + return "Saldo Awal Negatif" + case "POSITIVE": + return "Saldo Awal Positif" + default: + return balanceType + } +} + +func initialBalanceTypeFromPayment(e entity.Payment) string { + if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 { + return "NEGATIVE" + } + return "POSITIVE" +} diff --git a/internal/modules/finance/initials/module.go b/internal/modules/finance/initials/module.go new file mode 100644 index 00000000..051c8d3f --- /dev/null +++ b/internal/modules/finance/initials/module.go @@ -0,0 +1,36 @@ +package initials + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories" + sInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type InitialModule struct{} + +func (InitialModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + initialRepo := rInitial.NewInitialRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register initial approval workflow: %v", err)) + } + + initialService := sInitial.NewInitialService(initialRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + InitialRoutes(router, userService, initialService) +} diff --git a/internal/modules/finance/initials/repositories/initial.repository.go b/internal/modules/finance/initials/repositories/initial.repository.go new file mode 100644 index 00000000..9c285c5c --- /dev/null +++ b/internal/modules/finance/initials/repositories/initial.repository.go @@ -0,0 +1,51 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type InitialRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + CustomerExists(ctx context.Context, customerId uint) (bool, error) + SupplierExists(ctx context.Context, supplierId uint) (bool, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type InitialRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewInitialRepository(db *gorm.DB) InitialRepository { + return &InitialRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *InitialRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *InitialRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, customerId) +} + +func (r *InitialRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, supplierId) +} + +func (r *InitialRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/initials/route.go b/internal/modules/finance/initials/route.go new file mode 100644 index 00000000..b216fd23 --- /dev/null +++ b/internal/modules/finance/initials/route.go @@ -0,0 +1,21 @@ +package initials + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/controllers" + initial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func InitialRoutes(v1 fiber.Router, u user.UserService, s initial.InitialService) { + ctrl := controller.NewInitialController(s) + + route := v1.Group("/initial-balances") + route.Use(m.Auth(u)) + + route.Post("/",m.RequirePermissions(m.P_Finances_Initial_Balances_CreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_Finances_Initial_Balances_GetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_Finances_Initial_Balances_UpdateOne), ctrl.UpdateOne) +} diff --git a/internal/modules/finance/initials/services/initial.service.go b/internal/modules/finance/initials/services/initial.service.go new file mode 100644 index 00000000..2eb15d3b --- /dev/null +++ b/internal/modules/finance/initials/services/initial.service.go @@ -0,0 +1,336 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type InitialService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type initialService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.InitialRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewInitialService( + repo repository.InitialRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) InitialService { + return &initialService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowInitial, + } +} + +func (s initialService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + initial, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if err != nil { + s.Log.Errorf("Failed get initial by id: %+v", err) + return nil, err + } + if !isInitialTransaction(initial.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for initial %d: %+v", id, err) + } else { + initial.LatestApproval = approval + } + } + return initial, nil +} + +func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + party, err := normalizePartyType(req.PartyType) + if err != nil { + return nil, err + } + + if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil { + return nil, err + } + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + balanceType, err := normalizeInitialBalanceType(req.InitialBalanceType) + if err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generateInitialCode(c.Context()) + if err != nil { + return nil, err + } + + reference := req.ReferenceNumber + createBody := &entity.Payment{ + PaymentCode: code, + ReferenceNumber: &reference, + TransactionType: string(utils.TransactionTypeSaldoAwal), + PartyType: party, + PartyId: req.PartyId, + PaymentDate: time.Now(), + PaymentMethod: string(utils.PaymentMethodSaldo), + BankId: req.BankId, + Direction: directionForInitialType(balanceType), + Nominal: signedNominal(balanceType, req.Nominal), + Notes: req.Note, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + initialRepoTx := repository.NewInitialRepository(dbTransaction) + if err := initialRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.InitialStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create initial: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.ReferenceNumber != nil { + updateBody["reference_number"] = *req.ReferenceNumber + } + if req.Note != nil { + updateBody["notes"] = *req.Note + } + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + + requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil + requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil + var existing *entity.Payment + if requiresVerification { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if err != nil { + s.Log.Errorf("Failed get initial by id: %+v", err) + return nil, err + } + if !isInitialTransaction(current.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + existing = current + } + + if req.PartyType != nil || req.PartyId != nil { + partyType := existing.PartyType + partyId := existing.PartyId + + if req.PartyType != nil { + normalized, err := normalizePartyType(*req.PartyType) + if err != nil { + return nil, err + } + partyType = normalized + updateBody["party_type"] = partyType + } + if req.PartyId != nil { + partyId = *req.PartyId + updateBody["party_id"] = partyId + } + + if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { + return nil, err + } + } + + if req.InitialBalanceType != nil || req.Nominal != nil { + balanceType := balanceTypeFromPayment(existing) + if req.InitialBalanceType != nil { + normalized, err := normalizeInitialBalanceType(*req.InitialBalanceType) + if err != nil { + return nil, err + } + balanceType = normalized + } + + nominal := math.Abs(existing.Nominal) + if req.Nominal != nil { + nominal = *req.Nominal + } + + updateBody["direction"] = directionForInitialType(balanceType) + updateBody["nominal"] = signedNominal(balanceType, nominal) + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + s.Log.Errorf("Failed to update initial: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func isInitialTransaction(transactionType string) bool { + return strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) +} + +func balanceTypeFromPayment(payment *entity.Payment) string { + if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 { + return "NEGATIVE" + } + return "POSITIVE" +} + +func normalizePartyType(partyType string) (string, error) { + party := strings.ToUpper(strings.TrimSpace(partyType)) + if !utils.IsValidPaymentParty(party) { + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } + return party, nil +} + +func normalizeInitialBalanceType(balanceType string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(balanceType)) + switch normalized { + case "NEGATIVE", "POSITIVE": + return normalized, nil + default: + return "", utils.BadRequest("`initial_balance_type` must be `NEGATIVE` or `POSITIVE`") + } +} + +func directionForInitialType(balanceType string) string { + if strings.EqualFold(balanceType, "NEGATIVE") { + return "OUT" + } + return "IN" +} + +func signedNominal(balanceType string, nominal float64) float64 { + normalized := math.Abs(nominal) + if strings.EqualFold(balanceType, "NEGATIVE") { + return -normalized + } + return normalized +} + +func (s initialService) generateInitialCode(ctx context.Context) (string, error) { + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("INIT-%05d", sequence), nil +} + +func (s initialService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s initialService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists}, + ) + case utils.PaymentPartySupplier: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists}, + ) + default: + return utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} diff --git a/internal/modules/finance/initials/validations/initial.validation.go b/internal/modules/finance/initials/validations/initial.validation.go new file mode 100644 index 00000000..27df2eea --- /dev/null +++ b/internal/modules/finance/initials/validations/initial.validation.go @@ -0,0 +1,27 @@ +package validation + +type Create struct { + PartyType string `json:"party_type" validate:"required_strict,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"` + InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Note string `json:"note" validate:"required_strict,max=500"` +} + +type Update struct { + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty" validate:"omitempty,max=100"` + InitialBalanceType *string `json:"initial_balance_type,omitempty" validate:"omitempty,oneof=NEGATIVE POSITIVE"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Note *string `json:"note,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/injections/controllers/injection.controller.go b/internal/modules/finance/injections/controllers/injection.controller.go new file mode 100644 index 00000000..8f6c6b6d --- /dev/null +++ b/internal/modules/finance/injections/controllers/injection.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type InjectionController struct { + InjectionService service.InjectionService +} + +func NewInjectionController(injectionService service.InjectionService) *InjectionController { + return &InjectionController{ + InjectionService: injectionService, + } +} + +func (u *InjectionController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.InjectionService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get injection successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} + +func (u *InjectionController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InjectionService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Balance injection created successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} + +func (u *InjectionController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InjectionService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update injection successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} diff --git a/internal/modules/finance/injections/dto/injection.dto.go b/internal/modules/finance/injections/dto/injection.dto.go new file mode 100644 index 00000000..d0be7f3f --- /dev/null +++ b/internal/modules/finance/injections/dto/injection.dto.go @@ -0,0 +1,102 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type InjectionRelationDTO struct { + Id uint `json:"id"` + TransactionType string `json:"transaction_type"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + AdjustmentDate string `json:"adjustment_date"` + Direction string `json:"direction"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` +} + +type InjectionListDTO struct { + InjectionRelationDTO + CreatedBy uint `json:"created_by"` + CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type InjectionDetailDTO struct { + InjectionListDTO +} + +// === Mapper Functions === + +func ToInjectionRelationDTO(e entity.Payment) InjectionRelationDTO { + return InjectionRelationDTO{ + Id: e.Id, + TransactionType: transactionTypeLabel(e.TransactionType), + Bank: bankFromInjection(e), + AdjustmentDate: utils.FormatDate(e.PaymentDate), + Direction: e.Direction, + Nominal: e.Nominal, + Notes: e.Notes, + } +} + +func ToInjectionListDTO(e entity.Payment) InjectionListDTO { + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return InjectionListDTO{ + InjectionRelationDTO: ToInjectionRelationDTO(e), + CreatedBy: e.CreatedBy, + CreatedByUser: userFromInjection(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToInjectionListDTOs(e []entity.Payment) []InjectionListDTO { + result := make([]InjectionListDTO, len(e)) + for i, r := range e { + result[i] = ToInjectionListDTO(r) + } + return result +} + +func ToInjectionDetailDTO(e entity.Payment) InjectionDetailDTO { + return InjectionDetailDTO{ + InjectionListDTO: ToInjectionListDTO(e), + } +} + +func bankFromInjection(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromInjection(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func transactionTypeLabel(transactionType string) string { + if strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) { + return "Injection" + } + return transactionType +} diff --git a/internal/modules/finance/injections/module.go b/internal/modules/finance/injections/module.go new file mode 100644 index 00000000..0c4517e6 --- /dev/null +++ b/internal/modules/finance/injections/module.go @@ -0,0 +1,36 @@ +package injections + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories" + sInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type InjectionModule struct{} + +func (InjectionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + injectionRepo := rInjection.NewInjectionRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) + } + + injectionService := sInjection.NewInjectionService(injectionRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + InjectionRoutes(router, userService, injectionService) +} diff --git a/internal/modules/finance/injections/repositories/injection.repository.go b/internal/modules/finance/injections/repositories/injection.repository.go new file mode 100644 index 00000000..2e6869b7 --- /dev/null +++ b/internal/modules/finance/injections/repositories/injection.repository.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type InjectionRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type InjectionRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewInjectionRepository(db *gorm.DB) InjectionRepository { + return &InjectionRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *InjectionRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *InjectionRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/injections/route.go b/internal/modules/finance/injections/route.go new file mode 100644 index 00000000..25d1047f --- /dev/null +++ b/internal/modules/finance/injections/route.go @@ -0,0 +1,21 @@ +package injections + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/controllers" + injection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func InjectionRoutes(v1 fiber.Router, u user.UserService, s injection.InjectionService) { + ctrl := controller.NewInjectionController(s) + + route := v1.Group("/injections") + route.Use(m.Auth(u)) + + route.Post("/", m.RequirePermissions(m.P_Finances_Injections_CreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_Finances_Injections_GetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_Finances_Injections_UpdateOne), ctrl.UpdateOne) +} diff --git a/internal/modules/finance/injections/services/injection.service.go b/internal/modules/finance/injections/services/injection.service.go new file mode 100644 index 00000000..1b1062b4 --- /dev/null +++ b/internal/modules/finance/injections/services/injection.service.go @@ -0,0 +1,230 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type InjectionService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type injectionService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.InjectionRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewInjectionService( + repo repository.InjectionRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) InjectionService { + return &injectionService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowInjection, + } +} + +func (s injectionService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse") +} + +func (s injectionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + injection, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if err != nil { + s.Log.Errorf("Failed get injection by id: %+v", err) + return nil, err + } + if !isInjectionTransaction(injection.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for injection %d: %+v", id, err) + } else { + injection.LatestApproval = approval + } + } + return injection, nil +} + +func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + adjustmentDate, err := utils.ParseDateString(req.AdjustmentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generateInjectionCode(c.Context()) + if err != nil { + return nil, err + } + + createBody := &entity.Payment{ + PaymentCode: code, + TransactionType: string(utils.TransactionTypeInjection), + PartyType: string(utils.PaymentPartyCustomer), + PartyId: 0, + PaymentDate: adjustmentDate, + PaymentMethod: string(utils.PaymentMethodSaldo), + BankId: req.BankId, + Direction: "IN", + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + injectionRepoTx := repository.NewInjectionRepository(dbTransaction) + if err := injectionRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.InjectionStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create injection: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s injectionService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + requiresVerification := req.BankId != nil || req.AdjustmentDate != nil || req.Nominal != nil || req.Notes != nil + if requiresVerification { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if err != nil { + s.Log.Errorf("Failed get injection by id: %+v", err) + return nil, err + } + if !isInjectionTransaction(current.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + } + + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + if req.AdjustmentDate != nil { + parsedDate, err := utils.ParseDateString(*req.AdjustmentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + updateBody["payment_date"] = parsedDate + } + if req.Nominal != nil { + updateBody["nominal"] = *req.Nominal + } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + s.Log.Errorf("Failed to update injection: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func isInjectionTransaction(transactionType string) bool { + return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) +} + +func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) { + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("INJ-%05d", sequence), nil +} + +func (s injectionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s injectionService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} diff --git a/internal/modules/finance/injections/validations/injection.validation.go b/internal/modules/finance/injections/validations/injection.validation.go new file mode 100644 index 00000000..eb324525 --- /dev/null +++ b/internal/modules/finance/injections/validations/injection.validation.go @@ -0,0 +1,21 @@ +package validation + +type Create struct { + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + AdjustmentDate string `json:"adjustment_date" validate:"required_strict"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` +} + +type Update struct { + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/module.go b/internal/modules/finance/module.go new file mode 100644 index 00000000..ded5fbae --- /dev/null +++ b/internal/modules/finance/module.go @@ -0,0 +1,13 @@ +package finance + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type FinanceModule struct{} + +func (FinanceModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + RegisterRoutes(router, db, validate) +} diff --git a/internal/modules/finance/payments/controllers/payment.controller.go b/internal/modules/finance/payments/controllers/payment.controller.go new file mode 100644 index 00000000..5bccecf4 --- /dev/null +++ b/internal/modules/finance/payments/controllers/payment.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PaymentController struct { + PaymentService service.PaymentService +} + +func NewPaymentController(paymentService service.PaymentService) *PaymentController { + return &PaymentController{ + PaymentService: paymentService, + } +} + +func (u *PaymentController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.PaymentService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} + +func (u *PaymentController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PaymentService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} + +func (u *PaymentController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PaymentService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} diff --git a/internal/modules/finance/payments/dto/payment.dto.go b/internal/modules/finance/payments/dto/payment.dto.go new file mode 100644 index 00000000..23005e2d --- /dev/null +++ b/internal/modules/finance/payments/dto/payment.dto.go @@ -0,0 +1,189 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type PaymentRelationDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number,omitempty"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"` +} + +type PaymentListDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type PaymentDetailDTO struct { + PaymentListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToPaymentRelationDTO(e entity.Payment) PaymentRelationDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + + return PaymentRelationDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + } +} + +func ToPaymentListDTO(e entity.Payment) PaymentListDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return PaymentListDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToPaymentListDTOs(e []entity.Payment) []PaymentListDTO { + result := make([]PaymentListDTO, len(e)) + for i, r := range e { + result[i] = ToPaymentListDTO(r) + } + return result +} + +func ToPaymentDetailDTO(e entity.Payment) PaymentDetailDTO { + return PaymentDetailDTO{ + PaymentListDTO: ToPaymentListDTO(e), + } +} + +func partyFromPayment(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func paymentCodeFromPayment(e entity.Payment) string { + if e.PaymentCode != "" { + return e.PaymentCode + } + if e.ReferenceNumber != nil { + return *e.ReferenceNumber + } + return "" +} + +func transactionTypeFromPayment(e entity.Payment) string { + if e.TransactionType != "" { + return e.TransactionType + } + return e.Direction +} + +func paymentAmounts(direction string, nominal float64) (float64, float64) { + switch strings.ToUpper(direction) { + case "IN": + return 0, nominal + case "OUT": + return nominal, 0 + default: + return 0, 0 + } +} diff --git a/internal/modules/finance/payments/module.go b/internal/modules/finance/payments/module.go new file mode 100644 index 00000000..fdc0ce47 --- /dev/null +++ b/internal/modules/finance/payments/module.go @@ -0,0 +1,36 @@ +package payments + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gorm.io/gorm" + + rPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories" + sPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type PaymentModule struct{} + +func (PaymentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + paymentRepo := rPayment.NewPaymentRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) + } + + paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + PaymentRoutes(router, userService, paymentService) +} diff --git a/internal/modules/finance/payments/repositories/payment.repository.go b/internal/modules/finance/payments/repositories/payment.repository.go new file mode 100644 index 00000000..b16f8881 --- /dev/null +++ b/internal/modules/finance/payments/repositories/payment.repository.go @@ -0,0 +1,62 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type PaymentRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + CustomerExists(ctx context.Context, customerId uint) (bool, error) + SupplierExists(ctx context.Context, supplierId uint) (bool, error) + SupplierCategory(ctx context.Context, supplierId uint) (string, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type PaymentRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewPaymentRepository(db *gorm.DB) PaymentRepository { + return &PaymentRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *PaymentRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *PaymentRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, customerId) +} + +func (r *PaymentRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, supplierId) +} + +func (r *PaymentRepositoryImpl) SupplierCategory(ctx context.Context, supplierId uint) (string, error) { + var supplier entity.Supplier + if err := r.db.WithContext(ctx). + Select("id", "category"). + First(&supplier, supplierId).Error; err != nil { + return "", err + } + return supplier.Category, nil +} + +func (r *PaymentRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/payments/route.go b/internal/modules/finance/payments/route.go new file mode 100644 index 00000000..c5147fc0 --- /dev/null +++ b/internal/modules/finance/payments/route.go @@ -0,0 +1,21 @@ +package payments + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/controllers" + payment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func PaymentRoutes(v1 fiber.Router, u user.UserService, s payment.PaymentService) { + ctrl := controller.NewPaymentController(s) + + route := v1.Group("/payments") + route.Use(m.Auth(u)) + + route.Post("/",m.RequirePermissions(m.P_Finances_Payments_CreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_Finances_Payments_GetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_Finances_Payments_UpdateOne), ctrl.UpdateOne) +} diff --git a/internal/modules/finance/payments/services/payment.service.go b/internal/modules/finance/payments/services/payment.service.go new file mode 100644 index 00000000..356288f1 --- /dev/null +++ b/internal/modules/finance/payments/services/payment.service.go @@ -0,0 +1,362 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type PaymentService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type paymentService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.PaymentRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewPaymentService( + repo repository.PaymentRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) PaymentService { + return &paymentService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowPayment, + } +} + +func (s paymentService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s paymentService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + payment, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment by id: %+v", err) + return nil, err + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for payment %d: %+v", id, err) + } else { + payment.LatestApproval = approval + } + } + return payment, nil +} + +func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + //! CHECK PARTY TYPE + party, err := normalizePartyType(req.PartyType) + if err != nil { + return nil, err + } + + //! CHECK EXISTS + if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil { + return nil, err + } + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + //? NORMALIZE + paymentDate, err := utils.ParseDateString(req.PaymentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + method, err := normalizePaymentMethod(req.PaymentMethod) + if err != nil { + return nil, err + } + transactionType, err := s.resolveTransactionType(c.Context(), party, req.PartyId) + if err != nil { + return nil, err + } + + //? GET CREATED BY + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generatePaymentCode(c.Context(), party) + if err != nil { + return nil, err + } + + createBody := &entity.Payment{ + PaymentCode: code, + ReferenceNumber: req.ReferenceNumber, + TransactionType: transactionType, + PartyType: party, + PartyId: req.PartyId, + PaymentDate: paymentDate, + PaymentMethod: method, + BankId: req.BankId, + Direction: directionForParty(party), + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + paymentRepoTx := repository.NewPaymentRepository(dbTransaction) + if err := paymentRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.PaymentStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create payment: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s paymentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.PaymentDate != nil { + parsedDate, err := utils.ParseDateString(*req.PaymentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + updateBody["payment_date"] = parsedDate + } + if req.Nominal != nil { + updateBody["nominal"] = *req.Nominal + } + if req.ReferenceNumber != nil { + updateBody["reference_number"] = *req.ReferenceNumber + } + if req.PaymentMethod != nil { + method, err := normalizePaymentMethod(*req.PaymentMethod) + if err != nil { + return nil, err + } + updateBody["payment_method"] = method + } + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + + if req.PartyType != nil || req.PartyId != nil { + existing, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment by id: %+v", err) + return nil, err + } + + partyType := existing.PartyType + partyId := existing.PartyId + + if req.PartyType != nil { + normalized, err := normalizePartyType(*req.PartyType) + if err != nil { + return nil, err + } + partyType = normalized + updateBody["party_type"] = partyType + updateBody["direction"] = directionForParty(partyType) + } + if req.PartyId != nil { + partyId = *req.PartyId + updateBody["party_id"] = partyId + } + + if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { + return nil, err + } + + transactionType, err := s.resolveTransactionType(c.Context(), partyType, partyId) + if err != nil { + return nil, err + } + updateBody["transaction_type"] = transactionType + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + s.Log.Errorf("Failed to update payment: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func normalizePartyType(partyType string) (string, error) { + party := strings.ToUpper(strings.TrimSpace(partyType)) + if !utils.IsValidPaymentParty(party) { + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } + return party, nil +} + +func normalizePaymentMethod(method string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(method)) + if !utils.IsValidPaymentMethod(normalized) { + return "", utils.BadRequest("Invalid payment_method") + } + return normalized, nil +} + +func directionForParty(partyType string) string { + if utils.PaymentParty(partyType) == utils.PaymentPartyCustomer { + return "IN" + } + return "OUT" +} + +func (s paymentService) resolveTransactionType(ctx context.Context, partyType string, partyId uint) (string, error) { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return string(utils.TransactionTypePenjualan), nil + case utils.PaymentPartySupplier: + category, err := s.getSupplierCategory(ctx, partyId) + if err != nil { + return "", err + } + if isSupplierCategoryBiaya(category) { + return string(utils.TransactionTypeBiaya), nil + } + return string(utils.TransactionTypePembelian), nil + default: + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s paymentService) generatePaymentCode(ctx context.Context, partyType string) (string, error) { + prefix := "PAY" + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + prefix = "PAY-IN" + case utils.PaymentPartySupplier: + prefix = "PAY-OUT" + } + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("%s-%05d", prefix, sequence), nil +} + +func (s paymentService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s paymentService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists}, + ) + case utils.PaymentPartySupplier: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists}, + ) + default: + return utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s paymentService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} + +func (s paymentService) getSupplierCategory(ctx context.Context, supplierId uint) (string, error) { + category, err := s.Repository.SupplierCategory(ctx, supplierId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", utils.NotFound("Supplier not found") + } + return "", err + } + return strings.ToUpper(strings.TrimSpace(category)), nil +} + +func isSupplierCategoryBiaya(category string) bool { + switch strings.ToUpper(strings.TrimSpace(category)) { + case string(utils.SupplierCategoryBOP), "BIAYA": + return true + default: + return false + } +} diff --git a/internal/modules/finance/payments/validations/payment.validation.go b/internal/modules/finance/payments/validations/payment.validation.go new file mode 100644 index 00000000..14c8f151 --- /dev/null +++ b/internal/modules/finance/payments/validations/payment.validation.go @@ -0,0 +1,29 @@ +package validation + +type Create struct { + PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"` + Nominal float64 `json:"nominal" validate:"required_strict"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"` + BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` +} + +type Update struct { + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/route.go b/internal/modules/finance/route.go new file mode 100644 index 00000000..bc99bf7e --- /dev/null +++ b/internal/modules/finance/route.go @@ -0,0 +1,31 @@ +package finance + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/modules" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + payments "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments" + initials "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials" + injections "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections" + transactions "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions" + // MODULE IMPORTS +) + +func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + group := router.Group("/finance") + + allModules := []modules.Module{ + payments.PaymentModule{}, + initials.InitialModule{}, + injections.InjectionModule{}, + transactions.TransactionModule{}, + // MODULE REGISTRY + } + + for _, m := range allModules { + m.RegisterRoutes(group, db, validate) + } +} diff --git a/internal/modules/finance/transactions/controllers/transaction.controller.go b/internal/modules/finance/transactions/controllers/transaction.controller.go new file mode 100644 index 00000000..fa3e1369 --- /dev/null +++ b/internal/modules/finance/transactions/controllers/transaction.controller.go @@ -0,0 +1,96 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type TransactionController struct { + TransactionService service.TransactionService +} + +func NewTransactionController(transactionService service.TransactionService) *TransactionController { + return &TransactionController{ + TransactionService: transactionService, + } +} + +func (u *TransactionController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + 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.TransactionService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.TransactionListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all transactions successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToTransactionListDTOs(result), + }) +} + +func (u *TransactionController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.TransactionService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get transaction successfully", + Data: dto.ToTransactionListDTO(*result), + }) +} + +func (u *TransactionController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.TransactionService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete transaction successfully", + }) +} diff --git a/internal/modules/finance/transactions/dto/transaction.dto.go b/internal/modules/finance/transactions/dto/transaction.dto.go new file mode 100644 index 00000000..25740344 --- /dev/null +++ b/internal/modules/finance/transactions/dto/transaction.dto.go @@ -0,0 +1,189 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type TransactionRelationDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number,omitempty"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"` +} + +type TransactionListDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type TransactionDetailDTO struct { + TransactionListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToTransactionRelationDTO(e entity.Payment) TransactionRelationDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + + return TransactionRelationDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + } +} + +func ToTransactionListDTO(e entity.Payment) TransactionListDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return TransactionListDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToTransactionListDTOs(e []entity.Payment) []TransactionListDTO { + result := make([]TransactionListDTO, len(e)) + for i, r := range e { + result[i] = ToTransactionListDTO(r) + } + return result +} + +func ToTransactionDetailDTO(e entity.Payment) TransactionDetailDTO { + return TransactionDetailDTO{ + TransactionListDTO: ToTransactionListDTO(e), + } +} + +func partyFromPayment(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func paymentCodeFromPayment(e entity.Payment) string { + if e.PaymentCode != "" { + return e.PaymentCode + } + if e.ReferenceNumber != nil { + return *e.ReferenceNumber + } + return "" +} + +func transactionTypeFromPayment(e entity.Payment) string { + if e.TransactionType != "" { + return e.TransactionType + } + return e.Direction +} + +func paymentAmounts(direction string, nominal float64) (float64, float64) { + switch strings.ToUpper(direction) { + case "IN": + return 0, nominal + case "OUT": + return nominal, 0 + default: + return 0, 0 + } +} diff --git a/internal/modules/finance/transactions/module.go b/internal/modules/finance/transactions/module.go new file mode 100644 index 00000000..c98931a3 --- /dev/null +++ b/internal/modules/finance/transactions/module.go @@ -0,0 +1,42 @@ +package transactions + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories" + sTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type TransactionModule struct{} + +func (TransactionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + transactionRepo := rTransaction.NewTransactionRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) + } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register initial approval workflow: %v", err)) + } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) + } + + transactionService := sTransaction.NewTransactionService(transactionRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + TransactionRoutes(router, userService, transactionService) +} diff --git a/internal/modules/finance/transactions/repositories/transaction.repository.go b/internal/modules/finance/transactions/repositories/transaction.repository.go new file mode 100644 index 00000000..d1629e8b --- /dev/null +++ b/internal/modules/finance/transactions/repositories/transaction.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type TransactionRepository interface { + repository.BaseRepository[entity.Payment] +} + +type TransactionRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] +} + +func NewTransactionRepository(db *gorm.DB) TransactionRepository { + return &TransactionRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + } +} diff --git a/internal/modules/finance/transactions/route.go b/internal/modules/finance/transactions/route.go new file mode 100644 index 00000000..17baa9e8 --- /dev/null +++ b/internal/modules/finance/transactions/route.go @@ -0,0 +1,21 @@ +package transactions + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/controllers" + transaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func TransactionRoutes(v1 fiber.Router, u user.UserService, s transaction.TransactionService) { + ctrl := controller.NewTransactionController(s) + + route := v1.Group("/transactions") + route.Use(m.Auth(u)) + + route.Get("/",m.RequirePermissions(m.P_Finances_Transaction_GetAll), ctrl.GetAll) + route.Get("/:id",m.RequirePermissions(m.P_Finances_Transaction_GetOne), ctrl.GetOne) + route.Delete("/:id",m.RequirePermissions(m.P_Finances_Transaction_DeleteOne), ctrl.DeleteOne) +} diff --git a/internal/modules/finance/transactions/services/transaction.service.go b/internal/modules/finance/transactions/services/transaction.service.go new file mode 100644 index 00000000..f7398d43 --- /dev/null +++ b/internal/modules/finance/transactions/services/transaction.service.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "errors" + "strings" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type TransactionService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type transactionService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.TransactionRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflows map[string]approvalutils.ApprovalWorkflowKey +} + +func NewTransactionService( + repo repository.TransactionRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) TransactionService { + return &transactionService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflows: map[string]approvalutils.ApprovalWorkflowKey{ + string(utils.TransactionTypeSaldoAwal): utils.ApprovalWorkflowInitial, + string(utils.TransactionTypeInjection): utils.ApprovalWorkflowInjection, + }, + } +} + +func (s transactionService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" + return db.Where( + `LOWER(payment_code) LIKE ? OR + LOWER(COALESCE(reference_number, '')) LIKE ? OR + LOWER(COALESCE(transaction_type, '')) LIKE ? OR + LOWER(COALESCE(notes, '')) LIKE ?`, + like, like, like, like, + ) + } + return db.Order("payment_date DESC").Order("created_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get transactions: %+v", err) + return nil, 0, err + } + s.attachApprovals(c.Context(), transactions) + return transactions, total, nil +} + +func (s transactionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + transaction, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Transaction not found") + } + if err != nil { + s.Log.Errorf("Failed get transaction by id: %+v", err) + return nil, err + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget( + c.Context(), + s.workflowForTransaction(transaction), + id, + s.approvalQueryModifier(), + ) + if err != nil { + s.Log.Warnf("Unable to load latest approval for transaction %d: %+v", id, err) + } else { + transaction.LatestApproval = approval + } + } + return transaction, nil +} + +func (s transactionService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Transaction not found") + } + s.Log.Errorf("Failed to delete transaction: %+v", err) + return err + } + return nil +} + +func (s transactionService) attachApprovals(ctx context.Context, transactions []entity.Payment) { + if s.ApprovalSvc == nil || len(transactions) == 0 { + return + } + + workflowIDs := map[approvalutils.ApprovalWorkflowKey][]uint{} + for _, transaction := range transactions { + workflow := s.workflowForTransaction(&transaction) + workflowIDs[workflow] = append(workflowIDs[workflow], transaction.Id) + } + + approvalByWorkflow := make(map[approvalutils.ApprovalWorkflowKey]map[uint]*entity.Approval, len(workflowIDs)) + for workflow, ids := range workflowIDs { + if len(ids) == 0 { + continue + } + approvals, err := s.ApprovalSvc.LatestByTargets(ctx, workflow, ids, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for transactions: %+v", err) + continue + } + approvalByWorkflow[workflow] = approvals + } + + for i := range transactions { + workflow := s.workflowForTransaction(&transactions[i]) + if approvals, ok := approvalByWorkflow[workflow]; ok { + transactions[i].LatestApproval = approvals[transactions[i].Id] + } + } +} + +func (s transactionService) workflowForTransaction(transaction *entity.Payment) approvalutils.ApprovalWorkflowKey { + if transaction == nil { + return utils.ApprovalWorkflowPayment + } + transactionType := strings.TrimSpace(strings.ToUpper(transaction.TransactionType)) + if transactionType == "" { + return utils.ApprovalWorkflowPayment + } + if workflow, ok := s.approvalWorkflows[transactionType]; ok { + return workflow + } + return utils.ApprovalWorkflowPayment +} + +func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} diff --git a/internal/modules/finance/transactions/validations/transaction.validation.go b/internal/modules/finance/transactions/validations/transaction.validation.go new file mode 100644 index 00000000..7d16d3ee --- /dev/null +++ b/internal/modules/finance/transactions/validations/transaction.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index 610dc11e..08e556ea 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -5,6 +5,9 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" @@ -13,19 +16,67 @@ import ( rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type AdjustmentModule struct{} func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + // Repositories stockLogsRepo := rStockLogs.NewStockLogRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) + adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate, projectFlockKandangRepo) + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("ADJUSTMENT_IN"), + Table: "adjustment_stocks", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic("Failed to register ADJUSTMENT_IN as Stockable: " + err.Error()) + } + + err = fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("ADJUSTMENT_OUT"), + Table: "adjustment_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic("Failed to register ADJUSTMENT_OUT as Usable: " + err.Error()) + } + + adjustmentService := sAdjustment.NewAdjustmentService( + productRepo, + stockLogsRepo, + warehouseRepo, + productWarehouseRepo, + adjustmentStockRepo, + fifoService, + validate, + projectFlockKandangRepo, + ) userService := sUser.NewUserService(userRepo, validate) AdjustmentRoutes(router, userService, adjustmentService) diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go new file mode 100644 index 00000000..8d62b05c --- /dev/null +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -0,0 +1,50 @@ +package repositories + +import ( + "context" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type AdjustmentStockRepository interface { + CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error + GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) + WithTx(tx *gorm.DB) AdjustmentStockRepository + DB() *gorm.DB +} + +type adjustmentStockRepositoryImpl struct { + db *gorm.DB +} + +func NewAdjustmentStockRepository(db *gorm.DB) AdjustmentStockRepository { + return &adjustmentStockRepositoryImpl{db: db} +} + +func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error { + q := r.db.WithContext(ctx) + if modifier != nil { + q = modifier(q) + } + return q.Create(data).Error +} + +func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { + var record entity.AdjustmentStock + err := r.db.WithContext(ctx). + Where("stock_log_id = ?", stockLogID). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepository { + return &adjustmentStockRepositoryImpl{db: tx} +} + +func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB { + return r.db +} diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 7bcbca7e..edf5f72b 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -12,6 +12,7 @@ import ( common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" @@ -29,24 +30,37 @@ type AdjustmentService interface { } type adjustmentService struct { - Log *logrus.Logger - Validate *validator.Validate - StockLogsRepository stockLogsRepo.StockLogRepository - WarehouseRepo warehouseRepo.WarehouseRepository - ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository - ProductRepo productRepo.ProductRepository - ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + Log *logrus.Logger + Validate *validator.Validate + StockLogsRepository stockLogsRepo.StockLogRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository + ProductRepo productRepo.ProductRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository + FifoSvc common.FifoService } -func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) AdjustmentService { +func NewAdjustmentService( + productRepo productRepo.ProductRepository, + stockLogsRepo stockLogsRepo.StockLogRepository, + warehouseRepo warehouseRepo.WarehouseRepository, + productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, + adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository, + fifoSvc common.FifoService, + validate *validator.Validate, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, +) AdjustmentService { return &adjustmentService{ - Log: utils.Log, - Validate: validate, - StockLogsRepository: stockLogsRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - ProductRepo: productRepo, - ProjectFlockKandangRepo: projectFlockKandangRepo, + Log: utils.Log, + Validate: validate, + StockLogsRepository: stockLogsRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + AdjustmentStockRepository: adjustmentStockRepo, + FifoSvc: fifoSvc, } } @@ -70,7 +84,7 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LoggableType != entity.LogTypeAdjustment { + if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") } @@ -97,45 +111,43 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } transactionType := strings.ToUpper(req.TransactionType) - if transactionType != entity.TransactionTypeIncrease && transactionType != entity.TransactionTypeDecrease { + if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") } var createdLogId uint - isProductWarehouseExist, err := s.ProductWarehouseRepo.ProductWarehouseExistByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) - if err != nil { - s.Log.Errorf("Failed to check product warehouse existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + var projectFlockKandangID *uint + pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(req.WarehouseID)) + if err == nil && pfk != nil { + idCopy := uint(pfk.Id) + projectFlockKandangID = &idCopy } - if !isProductWarehouseExist { - projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) - if err != nil { - return nil, err + + pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk( + ctx, + uint(req.ProductID), + uint(req.WarehouseID), + projectFlockKandangID, + ) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to find product warehouse: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } + newPW := &entity.ProductWarehouse{ ProductId: uint(req.ProductID), WarehouseId: uint(req.WarehouseID), Quantity: 0, - ProjectFlockKandangId: &projectFlockKandangID, - // CreatedBy: 1, // TODO: should Get from auth middleware + ProjectFlockKandangId: projectFlockKandangID, } if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil { s.Log.Errorf("Failed to create product warehouse: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create product warehouse") } - s.Log.Infof("Product warehouse created: %+v", newPW.Id) - } - - pw, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( - ctx, - uint(req.ProductID), - uint(req.WarehouseID), - ) - if err != nil { - s.Log.Errorf("Failed to get product warehouse for project flock check: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + pw = newPW } if err := common.EnsureProjectFlockNotClosedForProductWarehouses( @@ -152,16 +164,17 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } + // Create StockLog for history tracking afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ - // TransactionType: transactionType, - LoggableType: entity.LogTypeAdjustment, + LoggableType: string(utils.StockLogTypeAdjustment), LoggableId: 0, Notes: req.Note, ProductWarehouseId: productWarehouse.Id, - CreatedBy: actorID, // TODO: should Get from auth middleware + CreatedBy: actorID, } - if transactionType == entity.TransactionTypeIncrease { + + if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity newLog.Increase = afterQuantity } else { @@ -177,6 +190,57 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return err } + // Create AdjustmentStock record for FIFO tracking + adjustmentStock := &entity.AdjustmentStock{ + StockLogId: newLog.Id, + ProductWarehouseId: productWarehouse.Id, + } + + if transactionType == string(utils.StockLogTransactionTypeIncrease) { + // Adjustment INCREASE → Replenish stock (Stockable) + note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) + replenishResult, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ + StockableKey: "ADJUSTMENT_IN", + StockableID: newLog.Id, + ProductWarehouseID: uint(productWarehouse.Id), + Quantity: req.Quantity, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err)) + } + + // Update stockable tracking fields + adjustmentStock.TotalQty = replenishResult.AddedQuantity + adjustmentStock.TotalUsed = 0 + + } else { + // Adjustment DECREASE → Consume stock (Usable) + consumeResult, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ + UsableKey: "ADJUSTMENT_OUT", + UsableID: newLog.Id, + ProductWarehouseID: uint(productWarehouse.Id), + Quantity: req.Quantity, + AllowPending: false, // Don't allow pending for adjustment + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err)) + } + + // Update usable tracking fields + adjustmentStock.UsageQty = consumeResult.UsageQuantity + adjustmentStock.PendingQty = consumeResult.PendingQuantity + } + + // Save AdjustmentStock record + if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { + s.Log.Errorf("Failed to create adjustment stock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") + } + + // Update ProductWarehouse quantity (for backward compatibility/reporting) productWarehouse.Quantity = afterQuantity if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) @@ -248,7 +312,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu db = s.withRelations(db) - db = db.Where("loggable_type = ?", entity.LogTypeAdjustment) + db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) if query.TransactionType != "" { db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 81fbec1f..57a13021 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -24,11 +24,12 @@ type ProductWarehousNestedDTO struct { type ProductWarehouseListDTO struct { ProductWarehouseRelationDTO - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` - CreatedUser *UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` + CreatedUser *UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type UserRelationDTO struct { @@ -71,6 +72,19 @@ type AreaRelationDTO struct { Name string `json:"name"` } +type ProjectFlockKandangRelationDTO struct { + Id uint `json:"id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Period int `json:"period"` + ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"` +} + +type ProjectFlockRelationDTO struct { + Id uint `json:"id"` + FlockName string `json:"flock_name"` +} + // === Mapper Functions === func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { @@ -105,6 +119,12 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT // Map Product relation jika ada if e.Product.Id != 0 { product := productDTO.ToProductRelationDTO(e.Product) + + // Tambahkan flock name ke product name jika ada project flock + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { + product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + } + dto.Product = &product } @@ -139,6 +159,26 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT dto.Warehouse = &warehouse } + // Map ProjectFlockKandang relation jika ada + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.Id != 0 { + pfkDTO := &ProjectFlockKandangRelationDTO{ + Id: e.ProjectFlockKandang.Id, + ProjectFlockId: e.ProjectFlockKandang.ProjectFlockId, + KandangId: e.ProjectFlockKandang.KandangId, + Period: e.ProjectFlockKandang.Period, + } + + // Map ProjectFlock jika ada + if e.ProjectFlockKandang.ProjectFlock.Id != 0 { + pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ + Id: e.ProjectFlockKandang.ProjectFlock.Id, + FlockName: e.ProjectFlockKandang.ProjectFlock.FlockName, + } + } + + dto.ProjectFlockKandang = pfkDTO + } + // Map CreatedUser relation jika ada // if e.CreatedUser.Id != 0 { // user := UserRelationDTO{ diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index 4f213f2c..503709b2 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -18,6 +18,7 @@ type ProductWarehouseRepository interface { ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) ExistsByID(ctx context.Context, id uint) (bool, error) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) + FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *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) GetByFlagAndWarehouseID(ctx context.Context, flagName string, warehouseId uint) ([]entity.ProductWarehouse, error) @@ -28,6 +29,8 @@ type ProductWarehouseRepository interface { IdExists(ctx context.Context, id uint) (bool, error) CleanupEmpty(ctx context.Context, affected map[uint]struct{}) error EnsureProductWarehouse(ctx context.Context, productID, warehouseID uint, projectFlockKandangID *uint, createdBy uint) (uint, error) + GetByProductWarehouseAndProjectFlockKandang(ctx context.Context, productId, warehouseId, projectFlockKandangId uint) (*entity.ProductWarehouse, error) + DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error } type ProductWarehouseRepositoryImpl struct { @@ -81,9 +84,43 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) { var productWarehouse entity.ProductWarehouse - if err := r.DB().WithContext(ctx).Where("product_id = ? AND warehouse_id = ?", productId, warehouseId).First(&productWarehouse).Error; err != nil { + + err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId). + Order("id DESC"). + Preload("ProjectFlockKandang"). + First(&productWarehouse).Error + + if err == nil { + + if productWarehouse.ProjectFlockKandang.ClosedAt == nil { + return &productWarehouse, nil + } + + } + + err = r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId). + First(&productWarehouse).Error + + if err != nil { return nil, err } + + return &productWarehouse, nil +} + +func (r *ProductWarehouseRepositoryImpl) FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + + err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT DISTINCT FROM ?", productID, warehouseID, projectFlockKandangID). + First(&productWarehouse).Error + + if err != nil { + return nil, err + } + return &productWarehouse, nil } @@ -237,6 +274,30 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( return entity.Id, nil } +func (r *ProductWarehouseRepositoryImpl) GetByProductWarehouseAndProjectFlockKandang( + ctx context.Context, + productId uint, + warehouseId uint, + projectFlockKandangId uint, +) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + if err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id = ?", productId, warehouseId, projectFlockKandangId). + First(&productWarehouse).Error; err != nil { + return nil, err + } + return &productWarehouse, nil +} + +func (r *ProductWarehouseRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error { + if len(projectFlockKandangIDs) == 0 { + return nil + } + return r.DB().WithContext(ctx). + Where("project_flock_kandang_id IN ?", projectFlockKandangIDs). + Delete(&entity.ProductWarehouse{}).Error +} + func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) { var productWarehouse entity.ProductWarehouse err := r.DB().WithContext(ctx). @@ -244,6 +305,8 @@ func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id u Preload("Warehouse"). Preload("Warehouse.Area"). Preload("Warehouse.Location"). + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock"). First(&productWarehouse, id).Error if err != nil { return nil, err diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index f690b2a2..152bfa24 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -44,7 +44,8 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Warehouse.Location"). Preload("Warehouse.Area"). Preload("Warehouse.Kandang"). - Preload("ProjectFlockKandang") + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index b53d6e9a..4f060dc2 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -68,7 +68,7 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } @@ -80,15 +80,19 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - // ambil file form, err := c.MultipartForm() if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") } - _ = form.File["documents"] - // todo: tunggu ada aws baru proses - result, err := u.TransferService.CreateOne(c, &req) + files := form.File["documents"] + + if len(files) != len(req.Deliveries) { + return fiber.NewError(fiber.StatusBadRequest, + fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message) + } + + result, err := u.TransferService.CreateOne(c, &req, files) if err != nil { return err } @@ -98,6 +102,6 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index fe97ce0f..8f075715 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -7,8 +7,6 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// === DTO Structs === - type TransferRelationDTO struct { Id uint64 `json:"id"` TransferReason string `json:"transfer_reason"` @@ -17,7 +15,6 @@ type TransferRelationDTO struct { DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` } -// Only id and name for warehouse simple view type WarehouseSimpleDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -43,6 +40,14 @@ type SupplierSimpleDTO struct { Name string `json:"name"` } +type DocumentDTO struct { + Id uint `json:"id"` + Path string `json:"path"` + Name string `json:"name"` + Ext string `json:"ext"` + Size float64 `json:"size"` +} + type WarehouseDetailDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -65,24 +70,22 @@ type TransferDetailDTO struct { Deliveries []TransferDeliveryDTO `json:"deliveries"` } -// Detail produk type TransferDetailItemDTO struct { Id uint64 `json:"id"` - Proudct ProductSimpleDTO `json:"product"` + Product ProductSimpleDTO `json:"product"` Quantity float64 `json:"quantity"` } -// Delivery ekspedisi type TransferDeliveryDTO struct { Id uint64 `json:"id"` Supplier SupplierSimpleDTO `json:"supplier"` VehiclePlate string `json:"vehicle_plate"` DriverName string `json:"driver_name"` DocumentNumber string `json:"document_number"` - DocumentPath string `json:"document_path"` ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` + Document *DocumentDTO `json:"document,omitempty"` } type TransferDeliveryItemDTO struct { @@ -91,10 +94,7 @@ type TransferDeliveryItemDTO struct { Quantity float64 `json:"quantity"` } -// === Mapper Functions === - func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { - var sourceWarehouse *WarehouseDetailDTO if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) @@ -140,7 +140,7 @@ func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { Id: w.Id, Name: w.Name, Location: toLocationDTO(w.Location), - Area: toAreaDTO(&w.Area), // Ambil area langsung dari warehouse (area_id) + Area: toAreaDTO(&w.Area), } } @@ -150,22 +150,21 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) createdUser = &mapped } - // Map details + var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ Id: d.Id, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, - Quantity: d.Quantity, + Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { - // Map delivery items var items []TransferDeliveryItemDTO for _, item := range del.Items { items = append(items, TransferDeliveryItemDTO{ @@ -174,6 +173,19 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { Quantity: item.Quantity, }) } + + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + } + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -183,12 +195,13 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, + Document: document, }) } + return TransferListDTO{ TransferRelationDTO: ToTransferRelationDTO(e), CreatedUser: createdUser, @@ -208,21 +221,32 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO { } func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { - // Map details var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ Id: d.Id, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, - Quantity: d.Quantity, + Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + } + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -232,11 +256,12 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, + Document: document, }) } + return TransferDetailDTO{ TransferListDTO: ToTransferListDTO(e), Details: details, diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 19a0ded6..60d1764a 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -1,10 +1,14 @@ package transfers import ( + "context" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "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" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" @@ -14,6 +18,8 @@ import ( rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type TransferModule struct{} @@ -29,8 +35,52 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate userRepo := rUser.NewUserRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(err) + } + + // Initialize FIFO Service + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + // Register Transfer as Stockable (adds stock to destination warehouse) + err = fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("STOCK_TRANSFER_IN"), + Table: "stock_transfer_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "dest_product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic(err) + } + + // Register Transfer as Usable (consumes stock from source warehouse) + err = fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("STOCK_TRANSFER_OUT"), + Table: "stock_transfer_details", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic(err) + } + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index f94295f6..1ca35a71 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "mime/multipart" "strings" "github.com/go-playground/validator/v10" @@ -27,7 +28,7 @@ import ( type TransferService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) - CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) + CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) } type transferService struct { @@ -42,9 +43,11 @@ type transferService struct { SupplierRepo rSupplier.SupplierRepository WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService + FifoSvc commonSvc.FifoService } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -57,6 +60,8 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr SupplierRepo: supplierRepo, WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, + FifoSvc: fifoSvc, } } @@ -72,7 +77,10 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("Details"). Preload("Details.Product"). Preload("Deliveries.Items"). - Preload("Deliveries.Supplier") + Preload("Deliveries.Supplier"). + Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeTransfer)) + }) } func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { @@ -94,13 +102,10 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } - s.Log.Infof("Retrieved %d transfers", len(transfers)) - return transfers, total, nil } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { - var transfer entity.StockTransfer transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) @@ -109,17 +114,15 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") } - s.Log.Errorf("Failed to get transfer by ID: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") } - s.Log.Infof("Retrieved transfer: %+v", transfer) - return transferPtr, nil } -func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { +func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { + // === VALIDASI SOURCE WAREHOUSE === pwIDs := make([]uint, 0, len(req.Products)) for _, product := range req.Products { @@ -146,6 +149,21 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, err } + destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID)) + if err != nil { + return nil, err + } + + if s.ProjectFlockKandangRepo != nil { + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + if projectFlockKandang.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") + } + } + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -180,7 +198,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) if err != nil { - s.Log.Errorf("Failed to get next movement number: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") } movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum) @@ -198,107 +215,33 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer: %+v", err) - return err - } - s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) - - var details []*entity.StockTransferDetail - for _, product := range req.Products { - details = append(details, &entity.StockTransferDetail{ - StockTransferId: entityTransfer.Id, - ProductId: uint64(product.ProductID), - Quantity: product.ProductQty, - }) - } - if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer details: %+v", err) - return err - } - s.Log.Infof("Stock transfer details created for transfer ID: %+v", entityTransfer.Id) - - var deliveries []*entity.StockTransferDelivery - for _, delivery := range req.Deliveries { - deliveries = append(deliveries, &entity.StockTransferDelivery{ - StockTransferId: entityTransfer.Id, - SupplierId: uint64(delivery.SupplierID), - VehiclePlate: delivery.VehiclePlate, - DriverName: delivery.DriverName, - DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", - ShippingCostItem: delivery.DeliveryCostPerItem, - ShippingCostTotal: delivery.DeliveryCost, - }) - } - if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) return err } - detailMap := make(map[uint64]uint64) - for _, d := range details { - detailMap[d.ProductId] = d.Id - } - - var deliveryItems []*entity.StockTransferDeliveryItem - - for i, delivery := range deliveries { - item := req.Deliveries[i] - for _, prod := range item.Products { - detailID, ok := detailMap[uint64(prod.ProductID)] - if !ok { - return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) - } - deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ - StockTransferDeliveryId: delivery.Id, - StockTransferDetailId: detailID, - Quantity: prod.ProductQty, - }) - } - } - if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer delivery items: %+v", err) - return err - } - s.Log.Infof("Stock transfer delivery items created for transfer ID: %+v", entityTransfer.Id) + // Prepare details and fetch product warehouses + details := make([]*entity.StockTransferDetail, 0, len(req.Products)) + detailMap := make(map[uint64]*entity.StockTransferDetail) for _, product := range req.Products { - sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) + // Get source product warehouse + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), + ) if err != nil { - s.Log.Errorf("Failed to get source product warehouse: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") - } - if sourcePW.Quantity < product.ProductQty { - s.Log.Errorf("Insufficient stock in source warehouse for product ID: %+v", product.ProductID) - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID)) - } - sourcePW.Quantity -= product.ProductQty - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil { - s.Log.Errorf("Failed to update source product warehouse: %+v", err) - return err - } - s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) - - decreaseLog := &entity.StockLog{ - Decrease: product.ProductQty, - Notes: "", - LoggableType: entity.LogTypeTransfer, - LoggableId: uint(entityTransfer.Id), - ProductWarehouseId: sourcePW.Id, - CreatedBy: actorID, - } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log decrease: %+v", err) - return err + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") } + // Get or create destination product warehouse destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to get destination product warehouse: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse destination") } - if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + if errors.Is(err, gorm.ErrRecordNotFound) { ctx := c.Context() projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) if err != nil { @@ -311,30 +254,137 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ProjectFlockKandangId: &projectFlockKandangID, } if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { - s.Log.Errorf("Failed to create destination product warehouse: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") } - s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) } - destPW.Quantity += product.ProductQty - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { - s.Log.Errorf("Failed to update destination product warehouse: %+v", err) - return err - } - s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) + detail := &entity.StockTransferDetail{ + StockTransferId: entityTransfer.Id, + ProductId: uint64(product.ProductID), - increaseLog := &entity.StockLog{ - Increase: product.ProductQty, - LoggableType: entity.LogTypeTransfer, - LoggableId: uint(entityTransfer.Id), - Notes: "", - ProductWarehouseId: destPW.Id, - CreatedBy: actorID, + SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(), + UsageQty: 0, + PendingQty: 0, + + DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(), + TotalQty: 0, + TotalUsed: 0, } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log increase: %+v", err) - return err + details = append(details, detail) + detailMap[uint64(product.ProductID)] = detail + } + + if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { + return err + } + + var deliveries []*entity.StockTransferDelivery + for _, delivery := range req.Deliveries { + deliveries = append(deliveries, &entity.StockTransferDelivery{ + StockTransferId: entityTransfer.Id, + SupplierId: uint64(delivery.SupplierID), + VehiclePlate: delivery.VehiclePlate, + DriverName: delivery.DriverName, + ShippingCostItem: delivery.DeliveryCostPerItem, + ShippingCostTotal: delivery.DeliveryCost, + }) + } + if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { + return err + } + + var deliveryItems []*entity.StockTransferDeliveryItem + + for i, delivery := range deliveries { + item := req.Deliveries[i] + for _, prod := range item.Products { + detail, ok := detailMap[uint64(prod.ProductID)] + if !ok { + return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) + } + deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ + StockTransferDeliveryId: delivery.Id, + StockTransferDetailId: detail.Id, + Quantity: prod.ProductQty, + }) + } + } + if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { + return err + } + + if s.DocumentSvc != nil && len(files) > 0 { + + for idx, file := range files { + documentFiles := []commonSvc.DocumentFile{ + { + File: file, + Type: string(utils.DocumentTypeTransfer), + Index: &idx, + }, + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeTransfer), + DocumentableID: deliveries[idx].Id, + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)", + idx+1, deliveries[idx].Id, file.Filename) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", idx+1, err)) + } + } + } + + // Execute FIFO operations for each product + for _, product := range req.Products { + detail := detailMap[uint64(product.ProductID)] + + // Step 1: Consume stock from source warehouse (STOCK_TRANSFER_OUT) + consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ + UsableKey: "STOCK_TRANSFER_OUT", + UsableID: uint(detail.Id), + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + Quantity: product.ProductQty, + AllowPending: false, // Don't allow pending, must have actual stock + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err)) + } + + // Update usage tracking fields for source warehouse + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "usage_qty": consumeResult.UsageQuantity, + "pending_qty": consumeResult.PendingQuantity, + }).Error; err != nil { + return fmt.Errorf("gagal update usage tracking: %w", err) + } + + // Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN) + note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) + replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + StockableKey: "STOCK_TRANSFER_IN", + StockableID: uint(detail.Id), + ProductWarehouseID: uint(*detail.DestProductWarehouseID), + Quantity: product.ProductQty, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err)) + } + + // Update total tracking fields for destination warehouse + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }).Error; err != nil { + return fmt.Errorf("gagal update total tracking: %w", err) } } @@ -342,8 +392,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) if err != nil { - s.Log.Errorf("Transaction failed in CreateOne: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err)) } result, err := s.GetOne(c, uint(entityTransfer.Id)) @@ -359,7 +408,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) } - s.Log.Errorf("Failed to get warehouse %d: %+v", warehouseID, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") } @@ -372,7 +420,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) } - s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") } diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index b2bb70d7..a6eea180 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -121,7 +121,7 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD return MarketingDeliveryProductDTO{ Id: e.Id, MarketingProductId: e.MarketingProductId, - Qty: e.Qty, + Qty: e.UsageQty, UnitPrice: e.UnitPrice, TotalWeight: e.TotalWeight, AvgWeight: e.AvgWeight, diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 586e7961..b93c6129 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -2,6 +2,7 @@ package marketing import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -13,11 +14,12 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type MarketingModule struct{} @@ -31,22 +33,38 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate customerRepo := rCustomer.NewCustomerRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - // Initialize approval service + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyMarketingDelivery, + Table: "marketing_delivery_products", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register marketing delivery usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) - // Register workflow steps for marketing approval if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) } warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) - // Initialize services - salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc,warehouseRepo,projectFlockKandangRepo, validate) - deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) + + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate) userService := sUser.NewUserService(userRepo, validate) - // Register routes RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) } diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index ba2c1133..04051009 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -17,6 +17,9 @@ type MarketingDeliveryProductRepository interface { GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) + UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error + GetUsageQty(ctx context.Context, id uint) (float64, error) + ResetFifoFields(ctx context.Context, id uint) error } type MarketingDeliveryProductRepositoryImpl struct { @@ -241,3 +244,33 @@ func containsJoin(db *gorm.DB, tableName string) bool { joinSQL := statement.SQL.String() return strings.Contains(joinSQL, "JOIN "+tableName) } + +func (r *MarketingDeliveryProductRepositoryImpl) UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": usageQty, + "pending_qty": pendingQty, + }).Error +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetUsageQty(ctx context.Context, id uint) (float64, error) { + var usageQty float64 + err := r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Select("usage_qty"). + Scan(&usageQty).Error + return usageQty, err +} + +func (r *MarketingDeliveryProductRepositoryImpl) ResetFifoFields(ctx context.Context, id uint) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_qty": 0, + }).Error +} diff --git a/internal/modules/marketing/route.go b/internal/modules/marketing/route.go index 139d1ee9..81402c7c 100644 --- a/internal/modules/marketing/route.go +++ b/internal/modules/marketing/route.go @@ -16,12 +16,15 @@ func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrde route := router.Group("/marketing") route.Use(m.Auth(userService)) - route.Get("/",m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) - route.Get("/:id",m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) - route.Delete("/:id",m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) + route.Get("/:id", m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) + route.Delete("/:id", m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) - route.Post("/sales-orders",m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) - route.Patch("/sales-orders/:id",m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) - route.Post("/sales-orders/approvals",m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) + route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) + route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + + route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne) + route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne) } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 793ed716..a1f4e1dd 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -15,10 +15,10 @@ import ( marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -30,12 +30,12 @@ type DeliveryOrdersService interface { } type deliveryOrdersService struct { - Log *logrus.Logger Validate *validator.Validate MarketingRepo marketingRepo.MarketingRepository MarketingProductRepo marketingRepo.MarketingProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewDeliveryOrdersService( @@ -43,15 +43,16 @@ func NewDeliveryOrdersService( marketingProductRepo marketingRepo.MarketingProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) DeliveryOrdersService { return &deliveryOrdersService{ - Log: utils.Log, Validate: validate, MarketingRepo: marketingRepo, MarketingProductRepo: marketingProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, } } @@ -108,7 +109,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO }) if err != nil { - s.Log.Errorf("Failed to get marketings: %+v", err) return nil, 0, err } for i := range marketings { @@ -116,7 +116,7 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO return db.Preload("ActionUser") }) if err != nil { - s.Log.Warnf("Failed to load approval for marketing %d: %+v", marketings[i].Id, err) + continue } marketings[i].LatestApproval = latestApproval } @@ -247,16 +247,21 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - deliveryProduct.Qty = requestedProduct.Qty + // Hitung total_weight dan total_price otomatis + totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight + totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty + + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight - deliveryProduct.TotalWeight = requestedProduct.TotalWeight - deliveryProduct.TotalPrice = requestedProduct.TotalPrice + deliveryProduct.TotalWeight = totalWeight + deliveryProduct.TotalPrice = totalPrice deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber if requestedProduct.Qty > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, requestedProduct.Qty); err != nil { + + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { return err } } @@ -354,23 +359,32 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO itemDeliveryDate = deliveryProduct.DeliveryDate } - oldQty := deliveryProduct.Qty - deliveryProduct.Qty = requestedProduct.Qty + oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + + // Hitung total_weight dan total_price otomatis + totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight + totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty + + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight - deliveryProduct.TotalWeight = requestedProduct.TotalWeight - deliveryProduct.TotalPrice = requestedProduct.TotalPrice + deliveryProduct.TotalWeight = totalWeight + deliveryProduct.TotalPrice = totalPrice deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber - qtyChange := requestedProduct.Qty - oldQty - if qtyChange > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, qtyChange); err != nil { - return err + if requestedProduct.Qty != oldRequestedQty { + + if oldRequestedQty > 0 { + if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct); err != nil { + return err + } } - } else if qtyChange < 0 { - if err := s.restoreProductWarehouseStock(c.Context(), dbTransaction, foundMarketingProduct, -qtyChange); err != nil { - return err + + if requestedProduct.Qty > 0 { + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { + return err + } } } @@ -393,50 +407,79 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return s.getMarketingWithDeliveries(c, id) } -func (s deliveryOrdersService) validateAndReduceProductWarehouse(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyDeliver float64) error { +func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64) error { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") + } + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + ProductWarehouseID: marketingProduct.ProductWarehouseId, + Quantity: requestedQty, + AllowPending: false, + Tx: tx, + }) + + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + if err2 != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + + if pw == nil || pw.Quantity < requestedQty { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } }(), requestedQty)) + } + + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + } + return nil } - if pw.Quantity < qtyDeliver { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for warehouse - available: %.2f, requested: %.2f", pw.Quantity, qtyDeliver)) + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } - pw.Quantity = pw.Quantity - qtyDeliver - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") - } return nil } -func (s deliveryOrdersService) restoreProductWarehouseStock(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyRestore float64) error { +func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct) error { + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return nil + } + if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) + currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") - } - - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + currentUsage = 0 } - pw.Quantity = pw.Quantity + qtyRestore - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") + if currentUsage == 0 { + return nil + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + Tx: tx, + }); err != nil { + return err + } + + if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { + return err } return nil diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 02cd2e42..d57b323e 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -75,7 +75,6 @@ func (s salesOrdersService) getOne(c *fiber.Ctx, id uint) (*entity.Marketing, er return nil, fiber.NewError(fiber.StatusNotFound, "SalesOrders not found") } if err != nil { - s.Log.Errorf("Failed get marketing by id: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order") } @@ -293,13 +292,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u for _, rp := range req.MarketingProducts { if old, ok := oldByPW[rp.ProductWarehouseId]; ok { + // Hitung total_weight dan total_price otomatis + totalWeight := rp.Qty * rp.AvgWeight + totalPrice := rp.UnitPrice * rp.Qty + updateBody := map[string]any{ "product_warehouse_id": rp.ProductWarehouseId, "qty": rp.Qty, "unit_price": rp.UnitPrice, "avg_weight": rp.AvgWeight, - "total_weight": rp.TotalWeight, - "total_price": rp.TotalPrice, + "total_weight": totalWeight, + "total_price": totalPrice, } if err := marketingProductRepoTx.PatchOne(c.Context(), old.Id, updateBody, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product") @@ -307,15 +310,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + mdp := &entity.MarketingDeliveryProduct{ MarketingProductId: old.Id, - Qty: 0, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") @@ -340,7 +345,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } if err == nil { - if deliveryProduct.DeliveryDate != nil || deliveryProduct.Qty > 0 { + if deliveryProduct.DeliveryDate != nil || deliveryProduct.UsageQty > 0 || deliveryProduct.PendingQty > 0 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) } @@ -587,14 +592,18 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { + // Hitung total_weight dan total_price otomatis + totalWeight := rp.Qty * rp.AvgWeight + totalPrice := rp.UnitPrice * rp.Qty + marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, ProductWarehouseId: rp.ProductWarehouseId, Qty: rp.Qty, UnitPrice: rp.UnitPrice, AvgWeight: rp.AvgWeight, - TotalWeight: rp.TotalWeight, - TotalPrice: rp.TotalPrice, + TotalWeight: totalWeight, + TotalPrice: totalPrice, } if err := marketingProductRepo.CreateOne(ctx, marketingProduct, nil); err != nil { return err @@ -602,13 +611,15 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ MarketingProductId: marketingProduct.Id, - Qty: 0, + ProductWarehouseId: marketingProduct.ProductWarehouseId, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { return err diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index 7db2cdd1..a879db6f 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -5,8 +5,6 @@ type DeliveryProduct struct { Qty float64 `json:"qty" validate:"omitempty,gte=0"` UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"` AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"` - TotalWeight float64 `json:"total_weight" validate:"omitempty,gte=0"` - TotalPrice float64 `json:"total_price" validate:"omitempty,gte=0"` DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"` VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"` } diff --git a/internal/modules/marketing/validations/salesorder.validation.go b/internal/modules/marketing/validations/salesorder.validation.go index 47d2e616..b69da394 100644 --- a/internal/modules/marketing/validations/salesorder.validation.go +++ b/internal/modules/marketing/validations/salesorder.validation.go @@ -12,10 +12,8 @@ type CreateMarketingProduct struct { VehicleNumber string `json:"vehicle_number" validate:"required,min=1,max=50"` ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,gt=0"` UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` - TotalWeight float64 `json:"total_weight" validate:"required,gt=0"` Qty float64 `json:"qty" validate:"required,gt=0"` AvgWeight float64 `json:"avg_weight" validate:"required,gt=0"` - TotalPrice float64 `json:"total_price" validate:"required,gt=0"` } type Update struct { diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index 1584b07f..baea9523 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -84,6 +84,7 @@ func ToKandangListDTO(e entity.Kandang) KandangListDTO { Name: e.Name, Status: e.Status, Location: location, + Capacity: e.Capacity, Pic: pic, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index b2af526c..9954ee76 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -1,11 +1,12 @@ package dto import ( + "time" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" - "time" ) // === DTO Structs === @@ -22,7 +23,7 @@ type NonstockListDTO struct { Name string `json:"name"` Flags []string `json:"flags"` Uom *uomDTO.UomRelationDTO `json:"uom"` - Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers,omitempty"` + Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -100,7 +101,7 @@ func ToNonstockDetailDTO(e entity.Nonstock) NonstockDetailDTO { func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.SupplierRelationDTO { if len(relations) == 0 { - return nil + return make([]supplierDTO.SupplierRelationDTO, 0) } result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations)) @@ -112,7 +113,7 @@ func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.S } if len(result) == 0 { - return nil + return make([]supplierDTO.SupplierRelationDTO, 0) } return result diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index c421b7ec..f3a298ef 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -3,8 +3,8 @@ package validation type Create struct { Name string `json:"name" validate:"required_strict,min=3,max=50"` UomID uint `json:"uom_id" validate:"required,gt=0"` - SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` - Flags []string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` + SupplierIDs []uint `json:"supplier_ids" validate:"dive,gt=0"` + Flags []string `json:"flags" validate:"dive,max=50"` } type Update struct { diff --git a/internal/modules/master/production-standards/controllers/production-standard.controller.go b/internal/modules/master/production-standards/controllers/production-standard.controller.go new file mode 100644 index 00000000..d7794e41 --- /dev/null +++ b/internal/modules/master/production-standards/controllers/production-standard.controller.go @@ -0,0 +1,145 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProductionStandardController struct { + ProductionStandardService service.ProductionStandardService +} + +func NewProductionStandardController(productionStandardService service.ProductionStandardService) *ProductionStandardController { + return &ProductionStandardController{ + ProductionStandardService: productionStandardService, + } +} + +func (u *ProductionStandardController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + ProjectCategory: c.Query("project_category", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ProductionStandardService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductionStandardRelationDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all productionStandards successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProductionStandardListDTOs(result), + }) +} + +func (u *ProductionStandardController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ProductionStandardService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProductionStandardService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProductionStandardService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.ProductionStandardService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete productionStandard successfully", + }) +} diff --git a/internal/modules/master/production-standards/dto/production-standard.dto.go b/internal/modules/master/production-standards/dto/production-standard.dto.go new file mode 100644 index 00000000..a77cdf8b --- /dev/null +++ b/internal/modules/master/production-standards/dto/production-standard.dto.go @@ -0,0 +1,166 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ProductionStandardRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + ProjectCategory string `json:"project_category"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` +} + +type ProductionStandardDetailDTO struct { + ProductionStandardRelationDTO + Details []WeeklyProductionStandardDTO `json:"details"` +} + +type GrowthStandardDetailDTO struct { + Id uint `json:"id"` + TargetMeanBW *float64 `json:"target_mean_bw"` + MaxDepletion *float64 `json:"max_depletion"` + MinUniformity float64 `json:"min_uniformity"` + FeedIntake *float64 `json:"feed_intake"` +} + +type EggProductionStandardDetailDTO struct { + Id uint `json:"id"` + TargetHenDayProduction *float64 `json:"target_hen_day_production"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production"` + TargetEggWeight *float64 `json:"target_egg_weight"` + TargetEggMass *float64 `json:"target_egg_mass"` + StandardFCR *float64 `json:"standard_fcr"` +} + +type WeeklyProductionStandardDTO struct { + Week int `json:"week"` + GrowthStandardDetail GrowthStandardDetailDTO `json:"growth_standard_detail"` + EggProductionStandardDetailDTO *EggProductionStandardDetailDTO `json:"egg_production_standard_detail,omitempty"` +} + +// === Mapper Functions === + +func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardRelationDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return ProductionStandardRelationDTO{ + Id: e.Id, + Name: e.Name, + ProjectCategory: e.ProjectCategory, + CreatedUser: createdUser, + } +} + +func ToProductionStandardRelationDTO(e entity.ProductionStandard) ProductionStandardRelationDTO { + return ProductionStandardRelationDTO{ + Id: e.Id, + Name: e.Name, + ProjectCategory: e.ProjectCategory, + } +} + +func ToProductionStandardListDTOs(e []entity.ProductionStandard) []ProductionStandardRelationDTO { + result := make([]ProductionStandardRelationDTO, len(e)) + for i, r := range e { + result[i] = ToProductionStandardListDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTO(e entity.StandardGrowthDetail) WeeklyProductionStandardDTO { + return WeeklyProductionStandardDTO{ + Week: e.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: e.Id, + TargetMeanBW: e.TargetMeanBw, + MaxDepletion: e.MaxDepletion, + MinUniformity: e.MinUniformity, + FeedIntake: e.FeedIntake, + }, + EggProductionStandardDetailDTO: nil, // GROWING category - no egg production details + } +} + +func ToWeeklyProductionStandardDTOWithDetails(growth entity.StandardGrowthDetail, detail entity.ProductionStandardDetail) WeeklyProductionStandardDTO { + eggDetail := &EggProductionStandardDetailDTO{ + Id: detail.Id, + TargetHenDayProduction: detail.TargetHenDayProduction, + TargetHenHouseProduction: detail.TargetHenHouseProduction, + TargetEggWeight: detail.TargetEggWeight, + TargetEggMass: detail.TargetEggMass, + StandardFCR: detail.StandardFCR, + } + + return WeeklyProductionStandardDTO{ + Week: growth.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: growth.Id, + TargetMeanBW: growth.TargetMeanBw, + MaxDepletion: growth.MaxDepletion, + MinUniformity: growth.MinUniformity, + FeedIntake: growth.FeedIntake, + }, + EggProductionStandardDetailDTO: eggDetail, // LAYING category - with egg production details + } +} + +func ToWeeklyProductionStandardDTOs(e []entity.StandardGrowthDetail) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(e)) + for i, r := range e { + result[i] = ToWeeklyProductionStandardDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTOsWithDetails( + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(growthDetails)) + + // Create map for production standard details by week + prodDetailMap := make(map[int]entity.ProductionStandardDetail) + for _, detail := range productionStandardDetails { + prodDetailMap[detail.Week] = detail + } + + // Map growth details and combine with production standard details + for i, growth := range growthDetails { + if prodDetail, exists := prodDetailMap[growth.Week]; exists { + result[i] = ToWeeklyProductionStandardDTOWithDetails(growth, prodDetail) + } else { + result[i] = ToWeeklyProductionStandardDTO(growth) + } + } + + return result +} + +func ToEggProductionStandardDetailDTO(e entity.ProductionStandardDetail) EggProductionStandardDetailDTO { + return EggProductionStandardDetailDTO{ + TargetHenDayProduction: e.TargetHenDayProduction, + TargetHenHouseProduction: e.TargetHenHouseProduction, + TargetEggWeight: e.TargetEggWeight, + TargetEggMass: e.TargetEggMass, + StandardFCR: e.StandardFCR, + } +} + +func ToProductionStandardDetailDTO( + standard entity.ProductionStandard, + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) ProductionStandardDetailDTO { + return ProductionStandardDetailDTO{ + ProductionStandardRelationDTO: ToProductionStandardRelationDTO(standard), + Details: ToWeeklyProductionStandardDTOsWithDetails(growthDetails, productionStandardDetails), + } +} diff --git a/internal/modules/master/production-standards/module.go b/internal/modules/master/production-standards/module.go new file mode 100644 index 00000000..bd08ee88 --- /dev/null +++ b/internal/modules/master/production-standards/module.go @@ -0,0 +1,33 @@ +package productionstandards + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProductionStandardModule struct{} + +func (ProductionStandardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + userRepo := rUser.NewUserRepository(db) + + productionStandardService := sProductionStandard.NewProductionStandardService( + productionStandardRepo, + productionStandardDetailRepo, + standardGrowthDetailRepo, + validate, + ) + userService := sUser.NewUserService(userRepo, validate) + + ProductionStandardRoutes(router, userService, productionStandardService) +} + diff --git a/internal/modules/master/production-standards/repositories/production_standard.repository.go b/internal/modules/master/production-standards/repositories/production_standard.repository.go new file mode 100644 index 00000000..26330d63 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard.repository.go @@ -0,0 +1,103 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProductionStandardRepository interface { + repository.BaseRepository[entity.ProductionStandard] + GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) + GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) + GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) +} + +type ProductionStandardRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandard] + db *gorm.DB +} + +func NewProductionStandardRepository(db *gorm.DB) ProductionStandardRepository { + return &ProductionStandardRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandard](db), + db: db, + } +} + +func (r *ProductionStandardRepositoryImpl) GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) { + var standards []entity.ProductionStandard + var total int64 + + // Build base query + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier for filters + if modifier != nil { + q = modifier(q) + } + + // Count total + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Re-apply modifier and add preloads for Find + q = r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + if modifier != nil { + q = modifier(q) + } + q = q.Preload("CreatedUser") + + // Find with offset and limit + if err := q.Offset(offset).Limit(limit).Find(&standards).Error; err != nil { + return nil, 0, err + } + + return standards, total, nil +} + +func (r *ProductionStandardRepositoryImpl) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) { + var standard entity.ProductionStandard + + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier + if modifier != nil { + q = modifier(q) + } + + // Ensure CreatedUser is preloaded + q = q.Preload("CreatedUser") + + if err := q.First(&standard, id).Error; err != nil { + return nil, err + } + + return &standard, nil +} + +func (r *ProductionStandardRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.ProductionStandard](ctx, r.db, name, excludeID) +} + +func (r *ProductionStandardRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandard](ctx, r.db, id) +} + +func (r *ProductionStandardRepositoryImpl) GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) { + var standards []entity.ProductionStandard + err := r.db.WithContext(ctx). + Preload("CreatedUser"). + Where("project_category = ?", projectCategory). + Where("deleted_at IS NULL"). + Find(&standards).Error + if err != nil { + return nil, err + } + return standards, nil +} diff --git a/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go new file mode 100644 index 00000000..ad680c01 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go @@ -0,0 +1,63 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProductionStandardDetailRepository interface { + repository.BaseRepository[entity.ProductionStandardDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type ProductionStandardDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandardDetail] + db *gorm.DB +} + +func NewProductionStandardDetailRepository(db *gorm.DB) ProductionStandardDetailRepository { + return &ProductionStandardDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandardDetail](db), + db: db, + } +} + +func (r *ProductionStandardDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandardDetail](ctx, r.db, id) +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) { + var details []entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) { + var detail entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.ProductionStandardDetail{}).Error +} diff --git a/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go new file mode 100644 index 00000000..afe93d81 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go @@ -0,0 +1,63 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StandardGrowthDetailRepository interface { + repository.BaseRepository[entity.StandardGrowthDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type StandardGrowthDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StandardGrowthDetail] + db *gorm.DB +} + +func NewStandardGrowthDetailRepository(db *gorm.DB) StandardGrowthDetailRepository { + return &StandardGrowthDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StandardGrowthDetail](db), + db: db, + } +} + +func (r *StandardGrowthDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.StandardGrowthDetail](ctx, r.db, id) +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) { + var details []entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) { + var detail entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.StandardGrowthDetail{}).Error +} diff --git a/internal/modules/master/production-standards/route.go b/internal/modules/master/production-standards/route.go new file mode 100644 index 00000000..e0296fd3 --- /dev/null +++ b/internal/modules/master/production-standards/route.go @@ -0,0 +1,23 @@ +package productionstandards + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/controllers" + productionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProductionStandardRoutes(v1 fiber.Router, u user.UserService, s productionStandard.ProductionStandardService) { + ctrl := controller.NewProductionStandardController(s) + + route := v1.Group("/production-standards") + route.Use(m.Auth(u)) + + route.Get("/", m.RequirePermissions(m.P_Production_Standart_GetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_Production_Standart_CreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_Production_Standart_GetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_Production_Standart_UpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_Production_Standart_DeleteOne), ctrl.DeleteOne) +} diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go new file mode 100644 index 00000000..4005b014 --- /dev/null +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -0,0 +1,301 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ProductionStandardService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProductionStandard, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type productionStandardService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProductionStandardRepository + ProductionStandardDetailRepo repository.ProductionStandardDetailRepository + StandardGrowthDetailRepo repository.StandardGrowthDetailRepository +} + +func NewProductionStandardService( + repo repository.ProductionStandardRepository, + productionStandardDetailRepo repository.ProductionStandardDetailRepository, + standardGrowthDetailRepo repository.StandardGrowthDetailRepository, + validate *validator.Validate, +) ProductionStandardService { + return &productionStandardService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ProductionStandardDetailRepo: productionStandardDetailRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + } +} + +func (s productionStandardService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProductionStandardDetails"). + Preload("StandardGrowthDetails") +} + +func (s productionStandardService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + productionStandards, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + if params.ProjectCategory != "" { + return db.Where("project_category = ?", params.ProjectCategory) + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get productionStandards: %+v", err) + return nil, 0, err + } + return productionStandards, total, nil +} + +func (s productionStandardService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductionStandard, error) { + productionStandard, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + if err != nil { + return nil, err + } + return productionStandard, nil +} + +func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + nameExists, err := s.Repository.NameExists(c.Context(), req.Name, nil) + if err != nil { + return nil, err + } + if nameExists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", req.Name)) + } + + var createdStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + newStandard := &entity.ProductionStandard{ + Name: req.Name, + ProjectCategory: req.ProjectCategory, + CreatedBy: actorID, + } + + if err := standardRepoTx.CreateOne(c.Context(), newStandard, nil); err != nil { + return fmt.Errorf("failed to create production standard: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if req.ProjectCategory == string(utils.ProjectFlockCategoryLaying) { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + StandardFCR: detailReq.ProductionStandardDetails.StandardFCR, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + + createdStandard = newStandard + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to create production standard: %+v", err) + return nil, err + } + + return s.GetOne(c, createdStandard.Id) +} + +func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + var updatedStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + existingStandard, err := standardRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + return fmt.Errorf("failed to get production standard: %w", err) + } + + updateBody := make(map[string]any) + if req.Name != nil { + + nameExists, err := s.Repository.NameExists(c.Context(), *req.Name, &id) + if err != nil { + return err + } + if nameExists { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + if req.ProjectCategory != nil { + updateBody["project_category"] = *req.ProjectCategory + } + + if len(updateBody) > 0 { + if err := standardRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return fmt.Errorf("failed to update production standard: %w", err) + } + } + + if req.Details != nil && len(req.Details) > 0 { + + projectCategory := existingStandard.ProjectCategory + if req.ProjectCategory != nil { + projectCategory = *req.ProjectCategory + } + + if err := productionStandardDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old production standard details: %w", err) + } + if err := standardGrowthDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old standard growth details: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if projectCategory == "LAYING" { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + StandardFCR: detailReq.ProductionStandardDetails.StandardFCR, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + } + + updatedStandard = existingStandard + return nil + }) + + if err != nil { + return nil, err + } + + return s.GetOne(c, updatedStandard.Id) +} + +func (s productionStandardService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + return err + } + return nil +} diff --git a/internal/modules/master/production-standards/validations/production-standard.validation.go b/internal/modules/master/production-standards/validations/production-standard.validation.go new file mode 100644 index 00000000..cdc321f8 --- /dev/null +++ b/internal/modules/master/production-standards/validations/production-standard.validation.go @@ -0,0 +1,42 @@ +package validation + +type ProductionStandardDetailItem struct { + TargetHenDayProduction *float64 `json:"target_hen_day_production" validate:"omitempty,gte=0"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production" validate:"omitempty,gte=0"` + TargetEggWeight *float64 `json:"target_egg_weight" validate:"omitempty,gte=0"` + TargetEggMass *float64 `json:"target_egg_mass" validate:"omitempty,gte=0"` + StandardFCR *float64 `json:"standard_fcr" validate:"omitempty,gte=0"` +} + +type StandardGrowthDetailItem struct { + TargetMeanBw *float64 `json:"target_mean_bw" validate:"omitempty,gte=0"` + MaxDepletion *float64 `json:"max_depletion" validate:"omitempty,gte=0,lte=100"` + MinUniformity float64 `json:"min_uniformity" validate:"required,gte=0,lte=100"` + FeedIntake *float64 `json:"feed_intake" validate:"omitempty,gte=0"` +} + +type DetailItem struct { + Week int `json:"week" validate:"required,gte=1"` + ProductionStandardDetails *ProductionStandardDetailItem `json:"production_standard_details,omitempty"` + ProductionStandardUniformityDetails *StandardGrowthDetailItem `json:"production_standard_uniformity_details" validate:"required"` +} + + +type Create struct { + Name string `json:"name" validate:"required,min=3"` + ProjectCategory string `json:"project_category" validate:"required,oneof=GROWING LAYING"` + Details []DetailItem `json:"details" validate:"required,min=1,dive"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + ProjectCategory *string `json:"project_category,omitempty" validate:"omitempty,oneof=GROWING LAYING"` + Details []DetailItem `json:"details,omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + ProjectCategory string `query:"project_category" validate:"omitempty,oneof=GROWING LAYING"` +} diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index 35e24927..f40d92be 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -70,6 +70,7 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = db.Where("is_visible = ?", true) if params.Search != "" { return db.Where("name LIKE ?", "%"+params.Search+"%") } diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 44702e1a..26ae28ee 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -20,6 +20,7 @@ import ( suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" + productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards" // MODULE IMPORTS ) @@ -40,6 +41,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida products.ProductModule{}, banks.BankModule{}, flocks.FlockModule{}, + productionStandards.ProductionStandardModule{}, // MODULE REGISTRY } diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index 6b5a0ae2..c4c892b5 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -12,6 +12,7 @@ type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) } type SupplierRepositoryImpl struct { @@ -33,3 +34,7 @@ func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, ex func (r *SupplierRepositoryImpl) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) { return repository.ExistsByField[entity.Supplier](ctx, r.db, "alias", alias, excludeID) } + +func (r *SupplierRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, id) +} diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index f4e91056..6c9b8984 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -2,6 +2,7 @@ package chickins import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -9,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" @@ -36,16 +38,43 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyProjectChickin, + Table: "project_chickins", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_usage_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } - chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, validate) + chickinService := sChickin.NewChickinService( + chickinRepo, + kandangRepo, + warehouseRepo, + productWarehouseRepo, + projectFlockRepo, + projectflockkandangrepo, + projectflockpopulationrepo, + chickinDetailRepo, + validate, + fifoService) userService := sUser.NewUserService(userRepo, validate) ChickinRoutes(router, userService, chickinService) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index b8eefa49..871c8fce 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "strings" @@ -15,7 +16,9 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -23,6 +26,8 @@ import ( "gorm.io/gorm" ) +var chickinUsableKey = fifo.UsableKeyProjectChickin + type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) @@ -43,9 +48,11 @@ type chickinService struct { ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository + FifoSvc commonSvc.FifoService + StockLogRepo rStockLogs.StockLogRepository } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -57,6 +64,8 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, + FifoSvc: fifoSvc, + StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), } } @@ -124,15 +133,14 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse for Kandang not found") } - category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } newChikins := make([]*entity.ProjectChickin, 0) + chickinQtyMap := make(map[uint]float64) - for _, chickinReq := range req.ChickinRequests { + for idx, chickinReq := range req.ChickinRequests { productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, nil) if err != nil { @@ -152,26 +160,23 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) } - availableQty, err := s.calculateAvailableQuantity(c, req.ProjectFlockKandangId, productWarehouse, category) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to calculate available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) - } - + availableQty := productWarehouse.Quantity if availableQty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin", chickinReq.ProductWarehouseId)) } newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, UsageQty: 0, - PendingUsageQty: availableQty, + PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, CreatedBy: actorID, } newChikins = append(newChikins, newChickin) + chickinQtyMap[uint(idx)] = availableQty } if len(newChikins) == 0 { @@ -188,30 +193,23 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) if err := s.Repository.WithTx(dbTransaction).CreateMany(c.Context(), newChikins, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } + for idx, chickin := range newChikins { + desiredQty := chickinQtyMap[uint(idx)] + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { + return err + } + } + latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") } - if category == string(utils.ProjectFlockCategoryLaying) { - for _, chickin := range newChikins { - updates := map[string]any{"qty": gorm.Expr("qty - ?", chickin.PendingUsageQty)} - - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update product warehouse quantity") - } - } - } - var approvalAction entity.ApprovalAction if isFirstTime { approvalAction = entity.ApprovalActionCreated @@ -301,6 +299,32 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { + chickin, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + return err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return err + } + + if chickin.UsageQty > 0 { + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { + return err + } + + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for deleted chickin %d: %+v", chickin.Id, err) + return err + } + } + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Chickin not found") @@ -311,54 +335,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func (s chickinService) calculateAvailableQuantity(ctx *fiber.Ctx, projectFlockKandangID uint, productWarehouse *entity.ProductWarehouse, category string) (float64, error) { - availableQty := productWarehouse.Quantity - - if category == string(utils.ProjectFlockCategoryGrowing) { - var totalPendingQty float64 - - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - - availableQty = productWarehouse.Quantity - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } else if category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - - populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(ctx.Context(), projectFlockKandangID, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } - - return availableQty, nil -} - func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -387,11 +363,10 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } for _, id := range approvableIDs { - idCopy := id - if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &idCopy, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { + + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { return nil, err } - latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) if err != nil { @@ -414,7 +389,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( @@ -472,9 +446,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } pfkID := approvableID - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID, &pfkID) + targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "LAYER", dbTransaction, actorID, &pfkID) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create LAYER product warehouse") } if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") @@ -491,27 +465,17 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit continue } - kandangForRejection, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("ProjectFlockKandang %d not found", approvableID)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get ProjectFlockKandang") - } - - categoryForRejection := strings.ToUpper(strings.TrimSpace(kandangForRejection.ProjectFlock.Category)) - for _, chickin := range chickins { - if categoryForRejection == string(utils.ProjectFlockCategoryGrowing) { - updates := map[string]any{"qty": gorm.Expr("qty + ?", chickin.PendingUsageQty)} + if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) + } - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found during rejection", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to restore product warehouse quantity for chickin %d", chickin.Id)) - } + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for rejected chickin %d: %+v", chickin.Id, err) + return err } if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { @@ -549,7 +513,7 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId products, err := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(ctx.Context(), categoryCode, warehouseId) if err == nil && len(products) > 0 { existingPW := &products[0] - // Update project_flock_kandang_id if not already set + if existingPW.ProjectFlockKandangId == nil && projectFlockKandangId != nil { existingPW.ProjectFlockKandangId = projectFlockKandangId if err := s.ProductWarehouseRepo.WithTx(dbTransaction).UpdateOne(ctx.Context(), existingPW.Id, existingPW, nil); err != nil { @@ -572,7 +536,6 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId WarehouseId: warehouseId, ProjectFlockKandangId: projectFlockKandangId, Quantity: 0, - // CreatedBy: actorID, } if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { @@ -588,10 +551,10 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return fmt.Errorf("invalid target product warehouse") } - chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + var totalQuantityAdded float64 + for _, chickin := range chickins { populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(ctx.Context(), chickin.Id) @@ -604,34 +567,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti continue } - quantityToConvert := chickin.PendingUsageQty - - if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ - "usage_qty": quantityToConvert, - "pending_usage_qty": 0, - }, nil); err != nil { - return fmt.Errorf("failed to update chickin %d qty: %w", chickin.Id, err) - } - - if chickin.ProductWarehouseId != targetPW.Id { - if err := productWarehouseTx.PatchOne(ctx.Context(), chickin.ProductWarehouseId, map[string]any{ - "qty": gorm.Expr("qty - ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Source product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fmt.Errorf("failed to deduct source warehouse quantity for chickin %d: %w", chickin.Id, err) - } - } - - if err := productWarehouseTx.PatchOne(ctx.Context(), targetPW.Id, map[string]any{ - "qty": gorm.Expr("qty + ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Target product warehouse %d not found", targetPW.Id)) - } - return fmt.Errorf("failed to update target warehouse quantity: %w", err) - } + quantityToConvert := chickin.UsageQty population := &entity.ProjectFlockPopulation{ ProjectChickinId: chickin.Id, @@ -644,7 +580,121 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti if err := ProjectFlockPopulationRepotx.CreateOne(ctx.Context(), population, nil); err != nil { return err } + + totalQuantityAdded += quantityToConvert + } + + if totalQuantityAdded > 0 { + if err := s.ProductWarehouseRepo.AdjustQuantities(ctx.Context(), map[uint]float64{ + targetPW.Id: totalQuantityAdded, + }, func(db *gorm.DB) *gorm.DB { + return dbTransaction + }); err != nil { + return fmt.Errorf("failed to update target product warehouse quantity: %w", err) + } } return nil } + +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + s.Log.Infof("ConsumeChickinStocks: chickin_id=%d, product_warehouse_id=%d, desired_qty=%.3f", + chickin.Id, chickin.ProductWarehouseId, desiredQty) + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + ProductWarehouseID: chickin.ProductWarehouseId, + Quantity: desiredQty, + AllowPending: true, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + s.Log.Infof("ConsumeChickinStocks result: usage_qty=%.3f, pending_qty=%.3f, allocated_allocations=%d", + result.UsageQuantity, result.PendingQuantity, len(result.AddedAllocations)) + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": result.UsageQuantity, + "pending_usage_qty": result.PendingQuantity, + }).Error; err != nil { + return err + } + + if result.UsageQuantity > 0 { + decreaseLog := &entity.StockLog{ + Decrease: result.UsageQuantity, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, decreaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + + } + } + + return nil +} + +func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + var currentUsage float64 + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { + s.Log.Warnf("Failed to get current usage for chickin %d: %+v", chickin.Id, err) + currentUsage = 0 + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_usage_qty": 0, + }).Error; err != nil { + return err + } + + // Create stock log for the restoration + if currentUsage > 0 { + increaseLog := &entity.StockLog{ + Increase: currentUsage, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, increaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + // Don't return error here, stock already released + } + } + + return nil +} + +func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { + if len(deltas) == 0 { + return nil + } + return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) +} diff --git a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go index 677f527b..a2ba8ad2 100644 --- a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go +++ b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go @@ -10,6 +10,7 @@ import ( fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/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" + productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" chickinDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto" @@ -30,6 +31,7 @@ type ProjectFlockDTO struct { Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Category string `json:"category"` Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -82,6 +84,7 @@ func toProjectFlockDTO(pf *projectFlockDTO.ProjectFlockListDTO) *ProjectFlockDTO Area: pf.Area, Category: pf.Category, Fcr: pf.Fcr, + ProductionStandard: pf.ProductionStandard, Location: pf.Location, CreatedUser: pf.CreatedUser, CreatedAt: pf.CreatedAt, diff --git a/internal/modules/production/project-flock-kandangs/route.go b/internal/modules/production/project-flock-kandangs/route.go index c5dba313..d48d9990 100644 --- a/internal/modules/production/project-flock-kandangs/route.go +++ b/internal/modules/production/project-flock-kandangs/route.go @@ -18,6 +18,6 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne) // route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing) // route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing) - route.Post("/:id/closing", ctrl.Closing) - route.Get("/:id/closing/check", ctrl.CheckClosing) + route.Post("/:id/closing",m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing) + route.Get("/:id/closing/check", m.RequirePermissions(m.P_ProjectFlockKandangsCheckClosing), ctrl.CheckClosing) } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index cf2d87ee..66fee8ce 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -555,6 +555,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty := productWarehouse.Quantity if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { @@ -568,15 +569,8 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty = 0 } } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - populations, err := s.PopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(c.Context(), projectFlockKandang.Id, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { @@ -584,7 +578,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous } } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty + availableQty = productWarehouse.Quantity - totalPendingQty if availableQty < 0 { availableQty = 0 } diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index c48e1e2a..4315b948 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -268,6 +268,7 @@ func (u *ProjectflockController) GetPeriodSummary(c *fiber.Ctx) error { func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { projectFlockId := c.QueryInt("project_flock_id", 0) kandangId := c.QueryInt("kandang_id", 0) + withPopulation := c.QueryBool("withpopulation", false) if projectFlockId == 0 || kandangId == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id") @@ -280,6 +281,13 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { dtoResult := dto.ToProjectFlockKandangDTO(*result) dtoResult.AvailableQuantity = float64(availableStock) + if withPopulation { + population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id) + if err != nil { + return err + } + dtoResult.Population = &population + } if dtoResult.ProjectFlock != nil { for i := range dtoResult.ProjectFlock.Kandangs { diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index 0922b160..504d439c 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -10,6 +10,7 @@ import ( kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" + productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/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" @@ -28,6 +29,7 @@ type ProjectFlockListDTO struct { Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Category string `json:"category"` Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"` @@ -103,6 +105,12 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF fcrSummary = &mapped } + var productionStandardSummary *productionStandardDTO.ProductionStandardRelationDTO + if e.ProductionStandard.Id != 0 { + mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProductionStandard) + productionStandardSummary = &mapped + } + var locationSummary *locationDTO.LocationRelationDTO if e.Location.Id != 0 { mapped := locationDTO.ToLocationRelationDTO(e.Location) @@ -122,6 +130,7 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF ProjectBudgets: ToProjectBudgetDTOs(e.Budgets), Category: e.Category, Fcr: fcrSummary, + ProductionStandard: productionStandardSummary, Location: locationSummary, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index d5ed5a46..8dedaf15 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -6,6 +6,7 @@ import ( fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/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" + productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -19,6 +20,7 @@ type ProjectFlockWithPivotDTO struct { Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Category string `json:"category"` Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` @@ -32,6 +34,7 @@ type ProjectFlockKandangDTO struct { Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` AvailableQuantity float64 `json:"available_quantity"` + Population *float64 `json:"population,omitempty"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { @@ -61,6 +64,10 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD mapped := fcrDTO.ToFcrRelationDTO(e.ProjectFlock.Fcr) pfLocal.Fcr = &mapped } + if e.ProjectFlock.ProductionStandard.Id != 0 { + mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProjectFlock.ProductionStandard) + pfLocal.ProductionStandard = &mapped + } if e.ProjectFlock.Location.Id != 0 { mapped := locationDTO.ToLocationRelationDTO(e.ProjectFlock.Location) pfLocal.Location = &mapped diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index acd77338..98e4a630 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -16,6 +16,7 @@ import ( rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectBudget "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" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -32,6 +33,8 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid nonstockRepo := rNonstock.NewNonstockRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) + projectFlockPopulationRepo := rProjectflock.NewProjectFlockPopulationRepository(db) + recordingRepo := rRecording.NewRecordingRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db) @@ -43,7 +46,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) } - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, approvalService, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go index a2b56dce..fd263b27 100644 --- a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -15,6 +15,7 @@ type ProjectFlockPopulationRepository interface { GetByProjectChickinIDAndProductWarehouseID(ctx context.Context, projectChickinID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) GetByProjectFlockKandangIDAndProductWarehouseID(ctx context.Context, projectFlockKandangID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) // subset of base repository methods used by services CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error @@ -106,3 +107,20 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI } return total, nil } + +func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + var total float64 + err := r.DB().WithContext(ctx). + Table("project_flock_populations"). + Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Scan(&total).Error + if err != nil { + return 0, err + } + if total < 0 { + total = 0 + } + return total, nil +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 15afaf59..6cd98a8f 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -19,8 +19,10 @@ type ProjectflockRepository interface { GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error) GetCurrentProjectPeriod(ctx context.Context, projectFlockID uint) (int, error) GetKandangPeriodSummaryRows(ctx context.Context, locationID uint) ([]KandangPeriodRow, error) + GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) AreaExists(ctx context.Context, id uint) (bool, error) FcrExists(ctx context.Context, id uint) (bool, error) + ProductionStandardExists(ctx context.Context, id uint) (bool, error) LocationExists(ctx context.Context, id uint) (bool, error) } type KandangPeriodRow struct { @@ -51,6 +53,7 @@ func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm Preload("CreatedUser"). Preload("Area"). Preload("Fcr"). + Preload("ProductionStandard"). Preload("Location"). Preload("Kandangs"). Preload("KandangHistory"). @@ -117,12 +120,14 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s 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 production_standards ON production_standards.id = project_flocks.production_standard_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(production_standards.name) LIKE ? OR LOWER(locations.name) LIKE ? OR LOWER(locations.address) LIKE ? OR LOWER(created_users.name) LIKE ? @@ -152,6 +157,7 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s likeQuery, likeQuery, likeQuery, + likeQuery, ) } @@ -163,6 +169,10 @@ func (r *ProjectflockRepositoryImpl) FcrExists(ctx context.Context, id uint) (bo return repository.Exists[entity.Fcr](ctx, r.DB(), id) } +func (r *ProjectflockRepositoryImpl) ProductionStandardExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandard](ctx, r.DB(), id) +} + func (r *ProjectflockRepositoryImpl) LocationExists(ctx context.Context, id uint) (bool, error) { return repository.Exists[entity.Location](ctx, r.DB(), id) } @@ -295,3 +305,17 @@ func (r *ProjectflockRepositoryImpl) ExistsByFlockName(ctx context.Context, floc } return count > 0, nil } + +func (r *ProjectflockRepositoryImpl) GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) { + var projectFlocks []entity.ProjectFlock + err := r.DB().WithContext(ctx). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.project_flock_id = project_flocks.id"). + Where("project_flocks.location_id = ?", locationID). + Where("project_flock_kandangs.closed_at IS NULL"). + Group("project_flocks.id"). + Find(&projectFlocks).Error + if err != nil { + return nil, err + } + return projectFlocks, nil +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 911c8b0b..42dcafd9 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -27,6 +27,7 @@ type ProjectFlockKandangRepository interface { MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) ProjectPeriodsByProjectIDs(ctx context.Context, projectIDs []uint) (map[uint]int, error) HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) + ListIDsByProjectAndKandang(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository DB() *gorm.DB IdExists(ctx context.Context, id uint) (bool, error) @@ -89,6 +90,20 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockID(ctx context.Cont return records, nil } +func (r *projectFlockKandangRepositoryImpl) ListIDsByProjectAndKandang(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) { + if len(kandangIDs) == 0 { + return []uint{}, nil + } + var ids []uint + if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs). + Pluck("id", &ids).Error; err != nil { + return nil, err + } + return ids, nil +} + func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) { var records []entity.ProjectFlockKandang var total int64 diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 62e1d389..1e859e47 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -21,6 +21,7 @@ import ( 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" + recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -37,6 +38,7 @@ type ProjectflockService interface { GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) DeleteOne(ctx *fiber.Ctx, id uint) error GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) + GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) @@ -54,6 +56,8 @@ type projectflockService struct { ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository ProjectBudgetRepo projectBudgetRepository.ProjectBudgetRepository PivotRepo repository.ProjectFlockKandangRepository + PopulationRepo repository.ProjectFlockPopulationRepository + RecordingRepo recordingRepo.RecordingRepository ApprovalSvc commonSvc.ApprovalService approvalWorkflow approvalutils.ApprovalWorkflowKey } @@ -73,6 +77,8 @@ func NewProjectflockService( productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository, projectBudgetRepo projectBudgetRepository.ProjectBudgetRepository, nonstockRepo nonstockRepository.NonstockRepository, + populationRepo repository.ProjectFlockPopulationRepository, + recordingRepo recordingRepo.RecordingRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, @@ -86,7 +92,10 @@ func NewProjectflockService( NonstockRepo: nonstockRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, + ProjectBudgetRepo: projectBudgetRepo, PivotRepo: pivotRepo, + PopulationRepo: populationRepo, + RecordingRepo: recordingRepo, ApprovalSvc: approvalSvc, approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } @@ -249,6 +258,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists}, commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: s.Repository.FcrExists}, + commonSvc.RelationCheck{Name: "Production Standard", ID: &req.ProductionStandardId, Exists: s.Repository.ProductionStandardExists}, commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists}, ); err != nil { return nil, err @@ -300,6 +310,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* AreaId: req.AreaId, Category: cat, FcrId: req.FcrId, + ProductionStandardId: req.ProductionStandardId, LocationId: req.LocationId, CreatedBy: actorID, } @@ -417,6 +428,34 @@ func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fibe return pfk, availableQuantity, nil } +func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) { + if s.PopulationRepo == nil { + return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured") + } + if projectFlockKandangID == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + if s.RecordingRepo != nil { + latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch latest recording for project flock kandang %d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population") + } + if latest != nil && latest.TotalChickQty != nil && *latest.TotalChickQty > 0 { + return *latest.TotalChickQty, nil + } + } + + total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population") + } + + return total, nil +} + func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) { idStr = strings.TrimSpace(idStr) projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) @@ -793,6 +832,9 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * } return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } + if err := s.ensureProjectFlockKandangProductWarehouses(ctx, dbTransaction, records); err != nil { + return err + } return nil } @@ -818,6 +860,23 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak dapat melepas kandang karena sudah memiliki recording: %s", strings.Join(names, ", "))) } + pfkIDs, err := s.pivotRepoWithTx(dbTransaction).ListIDsByProjectAndKandang(ctx, projectFlockID, kandangIDs) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to load project flock kandang ids") + } + + if len(pfkIDs) > 0 { + pwRepo := s.ProductWarehouseRepo + if dbTransaction != nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction) + } else if pwRepo == nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(s.Repository.DB()) + } + if err := pwRepo.DeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove product warehouses for project flock kandang") + } + } + if resetStatus { if err := s.kandangRepoWithTx(dbTransaction).UpdateStatusByIDs(ctx, kandangIDs, utils.KandangStatusNonActive); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") @@ -854,6 +913,81 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka return kandangRepository.NewKandangRepository(s.Repository.DB()) } +func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx context.Context, dbTransaction *gorm.DB, records []*entity.ProjectFlockKandang) error { + if len(records) == 0 { + return nil + } + + pwRepo := s.ProductWarehouseRepo + if dbTransaction != nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction) + } else if pwRepo == nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(s.Repository.DB()) + } + + warehouseRepo := s.WarehouseRepo + if dbTransaction != nil { + warehouseRepo = warehouseRepository.NewWarehouseRepository(dbTransaction) + } else if warehouseRepo == nil { + warehouseRepo = warehouseRepository.NewWarehouseRepository(s.Repository.DB()) + } + + flags := []utils.FlagType{ + utils.FlagAyamAfkir, + utils.FlagAyamCulling, + utils.FlagAyamMati, + utils.FlagTelurPecah, + utils.FlagTelurUtuh, + } + + productIDs := make(map[utils.FlagType]uint, len(flags)) + for _, flag := range flags { + product, err := pwRepo.GetFirstProductByFlag(ctx, string(flag)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product untuk flag %s tidak ditemukan", flag)) + } + return err + } + productIDs[flag] = product.Id + } + + for _, record := range records { + if record == nil || record.Id == 0 { + continue + } + + warehouse, err := warehouseRepo.GetByKandangID(ctx, record.KandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse untuk kandang %d belum tersedia", record.KandangId)) + } + return err + } + + for _, flag := range flags { + productID := productIDs[flag] + if _, err := pwRepo.GetByProductWarehouseAndProjectFlockKandang(ctx, productID, warehouse.Id, record.Id); err == nil { + continue + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + newPW := entity.ProductWarehouse{ + ProductId: productID, + WarehouseId: warehouse.Id, + ProjectFlockKandangId: &record.Id, + Quantity: 0, + } + if err := pwRepo.CreateOne(ctx, &newPW, nil); err != nil { + return err + } + } + } + + return nil +} + func (s projectflockService) Resubmit(c *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 66045dc1..5b2a9407 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,13 +1,14 @@ package validation type Create struct { - FlockName string `json:"flock_name" validate:"required_strict"` - AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` - Category string `json:"category" validate:"required_strict"` - FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` - LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` - ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"` + FlockName string `json:"flock_name" validate:"required_strict"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` + Category string `json:"category" validate:"required_strict"` + FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` + ProductionStandardId uint `json:"production_standard_id" validate:"required_strict,number,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` + ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"` } type Query struct { diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 6e362ba7..a615692f 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -17,6 +17,7 @@ type RecordingRepository interface { repository.BaseRepository[entity.Recording] WithRelations(db *gorm.DB) *gorm.DB + GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error @@ -81,6 +82,27 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Eggs.ProductWarehouse.Warehouse") } +func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) { + if projectFlockKandangId == 0 { + return nil, errors.New("project_flock_kandang_id is required") + } + + var record entity.Recording + err := r.DB().WithContext(ctx). + Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Order("record_datetime DESC"). + Order("created_at DESC"). + Limit(1). + Find(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) || record.Id == 0 { + return nil, nil + } + if err != nil { + return nil, err + } + return &record, nil +} + func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { var days []int if err := tx.Model(&entity.Recording{}). diff --git a/internal/modules/production/route.go b/internal/modules/production/route.go index d1425b7c..4066121a 100644 --- a/internal/modules/production/route.go +++ b/internal/modules/production/route.go @@ -8,10 +8,11 @@ import ( "gorm.io/gorm" chickins "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins" + projectFlockKandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs" projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks" recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" transferLayings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings" - projectFlockKandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs" + uniformitys "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities" // MODULE IMPORTS ) @@ -24,8 +25,9 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida chickins.ChickinModule{}, transferLayings.TransferLayingModule{}, projectFlockKandangs.ProjectFlockKandangModule{}, + uniformitys.UniformityModule{}, // MODULE REGISTRY -} + } for _, m := range allModules { m.RegisterRoutes(group, db, validate) diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go new file mode 100644 index 00000000..12cc3739 --- /dev/null +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -0,0 +1,292 @@ +package controller + +import ( + "math" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type UniformityController struct { + UniformityService service.UniformityService +} + +func NewUniformityController(uniformityService service.UniformityService) *UniformityController { + return &UniformityController{ + UniformityService: uniformityService, + } +} + +func (u *UniformityController) GetAll(c *fiber.Ctx) error { + query, err := validation.ParseQuery(c) + if err != nil { + return err + } + + result, totalResults, err := u.UniformityService.GetAll(c, query) + if err != nil { + return err + } + standards, err := u.UniformityService.MapStandards(c, result) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all production uniformities successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + Filters: fiber.Map{ + "location_id": "", + "project_flock_id": "", + "status": "Pengajuan", + }, + }, + Data: dto.ToUniformityListDTOsWithStandard(result, standards), + }) +} + +func (u *UniformityController) GetOne(c *fiber.Ctx) error { + id, err := validation.ParseIDParam(c, "id") + if err != nil { + return err + } + + result, err := u.UniformityService.GetOne(c, id) + if err != nil { + return err + } + + withDetails := c.QueryBool("with_details", false) + calculation := service.UniformityCalculation{} + var document *entity.Document + var meanWeight float64 + if result.MeanUp > 0 { + meanWeight = math.Round(result.MeanUp / 1.10) + } + if withDetails { + var err error + calculation, document, err = u.UniformityService.CalculateUniformityFromDocument(c, id) + if err != nil { + return err + } + } else { + calculation = service.UniformityCalculation{ + ChickQtyOfWeight: result.ChickQtyOfWeight, + MeanWeight: meanWeight, + MeanDown: result.MeanDown, + MeanUp: result.MeanUp, + UniformQty: result.UniformQty, + OutsideQty: result.NotUniformQty, + Uniformity: result.Uniformity, + Cv: result.Cv, + } + } + + standard, err := u.UniformityService.GetStandard(c, result) + if err != nil { + return err + } + var standardDTO *dto.UniformityStandardDTO + if standard != nil { + standardDTO = &dto.UniformityStandardDTO{ + MeanWeight: standard.MeanWeight, + Uniformity: standard.Uniformity, + } + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get production uniformity successfully", + Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO), + }) +} + +func (u *UniformityController) CreateOne(c *fiber.Ctx) error { + req, file, err := validation.ParseCreate(c) + if err != nil { + return err + } + + rows, err := u.UniformityService.ParseBodyWeightExcel(c, file) + if err != nil { + return err + } + + calculation, err := u.UniformityService.ComputeUniformity(rows) + if err != nil { + return err + } + + result, err := u.UniformityService.CreateOne(c, req, file, rows) + if err != nil { + return err + } + + document := dto.NewDocumentForResponse(file.Filename) + standard, err := u.UniformityService.GetStandard(c, result) + if err != nil { + return err + } + var standardDTO *dto.UniformityStandardDTO + if standard != nil { + standardDTO = &dto.UniformityStandardDTO{ + MeanWeight: standard.MeanWeight, + Uniformity: standard.Uniformity, + } + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create uniformity successfully", + Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO), + }) +} + +func (u *UniformityController) UploadBodyWeightExcel(c *fiber.Ctx) error { + files, err := validation.ParseUploadFiles(c) + if err != nil { + return err + } + + rows, err := u.UniformityService.ParseBodyWeightExcel(c, files[0]) + if err != nil { + return err + } + + calculation, err := u.UniformityService.ComputeUniformity(rows) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Uniformity verified successfully", + Data: dto.ToUniformityVerificationDTO(calculation), + }) +} + +func (u *UniformityController) UpdateOne(c *fiber.Ctx) error { + id, err := validation.ParseIDParam(c, "id") + if err != nil { + return err + } + + req, file, err := validation.ParseUpdate(c) + if err != nil { + return err + } + + var rows []service.BodyWeightExcelRow + if file != nil { + parsed, err := u.UniformityService.ParseBodyWeightExcel(c, file) + if err != nil { + return err + } + rows = parsed + } + + result, err := u.UniformityService.UpdateOne(c, req, id, file, rows) + if err != nil { + return err + } + + standard, err := u.UniformityService.GetStandard(c, result) + if err != nil { + return err + } + var standardDTO *dto.UniformityStandardDTO + if standard != nil { + standardDTO = &dto.UniformityStandardDTO{ + MeanWeight: standard.MeanWeight, + Uniformity: standard.Uniformity, + } + } + + calculation := service.UniformityCalculation{ + ChickQtyOfWeight: result.ChickQtyOfWeight, + MeanWeight: math.Round(result.MeanUp / 1.10), + MeanDown: result.MeanDown, + MeanUp: result.MeanUp, + UniformQty: result.UniformQty, + OutsideQty: result.NotUniformQty, + Uniformity: result.Uniformity, + Cv: result.Cv, + } + var document *entity.Document + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update uniformity successfully", + Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO), + }) +} + +func (u *UniformityController) DeleteOne(c *fiber.Ctx) error { + id, err := validation.ParseIDParam(c, "id") + if err != nil { + return err + } + + if err := u.UniformityService.DeleteOne(c, id); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete uniformity successfully", + }) +} + +func (u *UniformityController) Approve(c *fiber.Ctx) error { + req, err := validation.ParseApprove(c) + if err != nil { + return err + } + + results, err := u.UniformityService.Approval(c, req) + if err != nil { + return err + } + + var ( + data interface{} + message = "Submit uniformity approvals successfully" + ) + + if len(results) == 1 { + message = "Submit uniformity approval successfully" + data = dto.ToUniformityListDTOs(results)[0] + } else { + data = dto.ToUniformityListDTOs(results) + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: message, + Data: data, + }) +} diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go new file mode 100644 index 00000000..1324d805 --- /dev/null +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -0,0 +1,236 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" +) + +type UniformitySamplingDTO struct { + ChickQtyOfWeight float64 `json:"chick_qty_of_weight"` + MeanWeight float64 `json:"mean_weight"` + MeanDown float64 `json:"mean_down"` + MeanUp float64 `json:"mean_up"` +} + +type UniformityResultDTO struct { + UniformQty float64 `json:"uniform_qty"` + OutsideQty float64 `json:"outside_qty"` + Uniformity float64 `json:"uniformity"` + Cv float64 `json:"cv"` +} + +type UniformityStandardDTO struct { + MeanWeight *float64 `json:"mean_weight"` + Uniformity *float64 `json:"uniformity"` +} + +type UniformityDetailItemDTO struct { + Id int `json:"id"` + Weight float64 `json:"weight"` + Range string `json:"range"` +} + +type UniformityVerificationDTO struct { + Sampling UniformitySamplingDTO `json:"sampling"` + Result UniformityResultDTO `json:"result"` + UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` +} + +type UniformityInfoDTO struct { + Tanggal string `json:"tanggal"` + LokasiFarm string `json:"lokasi_farm"` + ProjectFlock string `json:"project_flock"` + Kandang string `json:"kandang"` + FileName string `json:"file_name"` +} + +type UniformityDetailDTO struct { + Id uint `json:"id"` + InfoUmum UniformityInfoDTO `json:"info_umum"` + Sampling UniformitySamplingDTO `json:"sampling"` + Result UniformityResultDTO `json:"result"` + Standard *UniformityStandardDTO `json:"standard"` + UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` +} + +type UniformityListDTO struct { + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + LocationName string `json:"location_name"` + FlockName string `json:"flock_name"` + KandangName string `json:"kandang_name"` + AppliedAt *time.Time `json:"applied_at"` + Week int `json:"week"` + Status string `json:"status"` + Uniformity float64 `json:"uniformity"` + Cv float64 `json:"cv"` + ChickQtyOfWeight float64 `json:"chick_qty_of_weight"` + UniformQty float64 `json:"uniform_qty"` + MeanUp float64 `json:"mean_up"` + MeanDown float64 `json:"mean_down"` + StandardMeanWeight *float64 `json:"standard_mean_weight"` + StandardUniformity *float64 `json:"standard_uniformity"` + CreatedAt time.Time `json:"created_at"` + CreatedBy uint `json:"created_by"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` +} + +func NewDocumentForResponse(name string) *entity.Document { + if name == "" { + return nil + } + return &entity.Document{Name: name} +} + +func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityVerificationDTO { + return UniformityVerificationDTO{ + Sampling: toUniformitySamplingDTO(calc), + Result: toUniformityResultDTO(calc), + UniformityDetails: toUniformityDetailItemsDTO(calc), + } +} + +func ToUniformityDetailDTO( + entityData entity.ProjectFlockKandangUniformity, + calc service.UniformityCalculation, + document *entity.Document, + standard *UniformityStandardDTO, +) UniformityDetailDTO { + info := UniformityInfoDTO{ + Tanggal: formatUniformityDate(entityData.UniformDate), + LokasiFarm: resolveLocationName(entityData.ProjectFlockKandang), + ProjectFlock: resolveProjectFlockName(entityData.ProjectFlockKandang), + Kandang: resolveKandangName(entityData.ProjectFlockKandang), + FileName: "", + } + if document != nil { + info.FileName = document.Name + } + + return UniformityDetailDTO{ + Id: entityData.Id, + InfoUmum: info, + Sampling: toUniformitySamplingDTO(calc), + Result: toUniformityResultDTO(calc), + Standard: standard, + UniformityDetails: toUniformityDetailItemsDTO(calc), + } +} + +func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []UniformityListDTO { + result := make([]UniformityListDTO, len(items)) + for i, item := range items { + var latestApproval *approvalDTO.ApprovalRelationDTO + status := "Pengajuan" + if item.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*item.LatestApproval) + latestApproval = &mapped + if mapped.StepName != "" { + status = mapped.StepName + } + } + + result[i] = UniformityListDTO{ + Id: item.Id, + ProjectFlockKandangId: item.ProjectFlockKandangId, + LocationName: resolveLocationName(item.ProjectFlockKandang), + FlockName: resolveProjectFlockName(item.ProjectFlockKandang), + KandangName: resolveKandangName(item.ProjectFlockKandang), + AppliedAt: item.UniformDate, + Week: item.Week, + Status: status, + Uniformity: item.Uniformity, + Cv: item.Cv, + ChickQtyOfWeight: item.ChickQtyOfWeight, + UniformQty: item.UniformQty, + MeanUp: item.MeanUp, + MeanDown: item.MeanDown, + CreatedAt: item.CreatedAt, + CreatedBy: item.CreatedBy, + LatestApproval: latestApproval, + } + } + return result +} + +func ToUniformityListDTOsWithStandard( + items []entity.ProjectFlockKandangUniformity, + standards map[uint]service.UniformityStandard, +) []UniformityListDTO { + result := ToUniformityListDTOs(items) + if len(result) == 0 || len(standards) == 0 { + return result + } + + for i := range result { + if std, ok := standards[result[i].Id]; ok { + result[i].StandardMeanWeight = std.MeanWeight + result[i].StandardUniformity = std.Uniformity + } + } + return result +} + +func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySamplingDTO { + return UniformitySamplingDTO{ + ChickQtyOfWeight: calc.ChickQtyOfWeight, + MeanWeight: calc.MeanWeight, + MeanDown: calc.MeanDown, + MeanUp: calc.MeanUp, + } +} + +func toUniformityResultDTO(calc service.UniformityCalculation) UniformityResultDTO { + return UniformityResultDTO{ + UniformQty: calc.UniformQty, + OutsideQty: calc.OutsideQty, + Uniformity: calc.Uniformity, + Cv: calc.Cv, + } +} + +func toUniformityDetailItemsDTO(calc service.UniformityCalculation) []UniformityDetailItemDTO { + result := make([]UniformityDetailItemDTO, len(calc.Details)) + for i, item := range calc.Details { + result[i] = UniformityDetailItemDTO{ + Id: item.Id, + Weight: item.Weight, + Range: item.Range, + } + } + return result +} + +func resolveLocationName(pfk entity.ProjectFlockKandang) string { + if pfk.Kandang.Id != 0 && pfk.Kandang.Location.Id != 0 { + return pfk.Kandang.Location.Name + } + if pfk.ProjectFlock.Id != 0 && pfk.ProjectFlock.Location.Id != 0 { + return pfk.ProjectFlock.Location.Name + } + return "" +} + +func resolveProjectFlockName(pfk entity.ProjectFlockKandang) string { + if pfk.ProjectFlock.Id != 0 { + return pfk.ProjectFlock.FlockName + } + return "" +} + +func resolveKandangName(pfk entity.ProjectFlockKandang) string { + if pfk.Kandang.Id != 0 { + return pfk.Kandang.Name + } + return "" +} + +func formatUniformityDate(date *time.Time) string { + if date == nil || date.IsZero() { + return "" + } + return date.Format("2006-01-02") +} diff --git a/internal/modules/production/uniformities/module.go b/internal/modules/production/uniformities/module.go new file mode 100644 index 00000000..b3162940 --- /dev/null +++ b/internal/modules/production/uniformities/module.go @@ -0,0 +1,57 @@ +package uniformitys + +import ( + "context" + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" + sUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type UniformityModule struct{} + +func (UniformityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + uniformityRepo := rUniformity.NewUniformityRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + userRepo := rUser.NewUserRepository(db) + + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } + + approvalSvc := commonSvc.NewApprovalService(approvalRepo) + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowUniformity, utils.UniformityApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register uniformity approval workflow: %v", err)) + } + + uniformityService := sUniformity.NewUniformityService( + uniformityRepo, + documentSvc, + approvalRepo, + approvalSvc, + projectFlockKandangRepo, + productionStandardRepo, + standardGrowthDetailRepo, + validate, + ) + userService := sUser.NewUserService(userRepo, validate) + + UniformityRoutes(router, userService, uniformityService) +} diff --git a/internal/modules/production/uniformities/repositories/uniformity.repository.go b/internal/modules/production/uniformities/repositories/uniformity.repository.go new file mode 100644 index 00000000..3bc66f4f --- /dev/null +++ b/internal/modules/production/uniformities/repositories/uniformity.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type UniformityRepository interface { + repository.BaseRepository[entity.ProjectFlockKandangUniformity] +} + +type UniformityRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectFlockKandangUniformity] +} + +func NewUniformityRepository(db *gorm.DB) UniformityRepository { + return &UniformityRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockKandangUniformity](db), + } +} diff --git a/internal/modules/production/uniformities/route.go b/internal/modules/production/uniformities/route.go new file mode 100644 index 00000000..ff2b1805 --- /dev/null +++ b/internal/modules/production/uniformities/route.go @@ -0,0 +1,25 @@ +package uniformitys + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/controllers" + uniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func UniformityRoutes(v1 fiber.Router, u user.UserService, s uniformity.UniformityService) { + ctrl := controller.NewUniformityController(s) + + route := v1.Group("/uniformities") + route.Use(m.Auth(u)) + + route.Get("/", m.RequirePermissions(m.P_Uniformities_GetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_Uniformities_CreateOne), ctrl.CreateOne) + route.Post("/verify", m.RequirePermissions(m.P_Uniformities_Verify), ctrl.UploadBodyWeightExcel) + route.Post("/approvals", m.RequirePermissions(m.P_Uniformities_Approval), ctrl.Approve) + route.Get("/:id", m.RequirePermissions(m.P_Uniformities_GetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_Uniformities_UpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_Uniformities_DeleteOne), ctrl.DeleteOne) +} diff --git a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go new file mode 100644 index 00000000..4e87f0cc --- /dev/null +++ b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go @@ -0,0 +1,200 @@ +package service + +import ( + "io" + "mime/multipart" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +type BodyWeightExcelRow struct { + No int `json:"no"` + Weight float64 `json:"weight"` + Range string `json:"range,omitempty"` +} + +func (s uniformityService) ParseBodyWeightExcel(_ *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) { + if file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "file is required") + } + + reader, err := file.Open() + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to open file") + } + defer reader.Close() + + rows, err := parseBodyWeightExcelReader(reader) + if err != nil { + return nil, err + } + + return rows, nil +} + +func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) { + xlsx, err := excelize.OpenReader(reader) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read excel file") + } + defer func() { + _ = xlsx.Close() + }() + + sheets := xlsx.GetSheetList() + if len(sheets) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file") + } + + sheetName := sheets[0] + if len(sheets) > 1 { + sheetName = sheets[1] + } + + rows, err := xlsx.GetRows(sheetName, excelize.Options{RawCellValue: true}) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows") + } + + return parseBodyWeightRows(rows) +} + +func parseBodyWeightRows(rows [][]string) ([]BodyWeightExcelRow, error) { + headerRowIdx, noCol, bwCol, rangeCol := findBodyWeightHeader(rows) + if headerRowIdx < 0 || bwCol < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "header BW not found") + } + + result := make([]BodyWeightExcelRow, 0) + lastNo := 0 + + for i := headerRowIdx + 1; i < len(rows); i++ { + row := rows[i] + weightStr := cellAt(row, bwCol) + weightVal, ok := parseNumber(weightStr) + if !ok { + continue + } + + noVal := 0 + if noCol >= 0 { + if parsed, ok := parseNumber(cellAt(row, noCol)); ok { + noVal = int(parsed) + } + } + if noVal <= 0 { + noVal = lastNo + 1 + } + if noVal > lastNo { + lastNo = noVal + } + + rangeVal := "" + if rangeCol >= 0 { + rangeVal = strings.TrimSpace(cellAt(row, rangeCol)) + } + + rowPayload := BodyWeightExcelRow{ + No: noVal, + Weight: weightVal, + Range: rangeVal, + } + if rowPayload.No <= 0 || rowPayload.Weight <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid body weight row data") + } + + result = append(result, rowPayload) + } + + if len(result) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + return result, nil +} + +func findBodyWeightHeader(rows [][]string) (rowIdx int, noCol int, bwCol int, rangeCol int) { + rowIdx = -1 + noCol = -1 + bwCol = -1 + rangeCol = -1 + + for i, row := range rows { + tempNo := -1 + tempBW := -1 + tempRange := -1 + for j, cell := range row { + label := normalizeHeader(cell) + switch label { + case "no": + tempNo = j + case "bw": + tempBW = j + case "outsiderange": + tempRange = j + default: + if strings.HasPrefix(label, "bw") { + tempBW = j + } else if strings.HasPrefix(label, "no") { + tempNo = j + } else if strings.Contains(label, "range") { + tempRange = j + } + } + } + if tempBW >= 0 { + rowIdx = i + bwCol = tempBW + noCol = tempNo + rangeCol = tempRange + break + } + } + + return rowIdx, noCol, bwCol, rangeCol +} + +func cellAt(row []string, idx int) string { + if idx < 0 || idx >= len(row) { + return "" + } + return strings.TrimSpace(row[idx]) +} + +func normalizeHeader(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + var b strings.Builder + for _, r := range trimmed { + if r >= 'a' && r <= 'z' { + b.WriteRune(r) + } + } + return b.String() +} + +func parseNumber(value string) (float64, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, false + } + + if strings.Contains(trimmed, ",") { + if strings.Contains(trimmed, ".") { + trimmed = strings.ReplaceAll(trimmed, ",", "") + } else { + trimmed = strings.ReplaceAll(trimmed, ",", ".") + } + } + + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, false + } + return parsed, true +} diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go new file mode 100644 index 00000000..2e76e48f --- /dev/null +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -0,0 +1,959 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "mime/multipart" + "net/http" + "strings" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type UniformityService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) + GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) + GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) + MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) + ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) + ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) + CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, error) +} + +type uniformityService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.UniformityRepository + DocumentSvc commonSvc.DocumentService + ApprovalRepo commonRepo.ApprovalRepository + ApprovalSvc commonSvc.ApprovalService + ProjectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository + ProductionStandardRepo rProductionStandard.ProductionStandardRepository + StandardGrowthDetailRepo rProductionStandard.StandardGrowthDetailRepository +} + +func NewUniformityService( + repo repository.UniformityRepository, + documentSvc commonSvc.DocumentService, + approvalRepo commonRepo.ApprovalRepository, + approvalSvc commonSvc.ApprovalService, + projectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository, + productionStandardRepo rProductionStandard.ProductionStandardRepository, + standardGrowthDetailRepo rProductionStandard.StandardGrowthDetailRepository, + validate *validator.Validate, +) UniformityService { + return &uniformityService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + DocumentSvc: documentSvc, + ApprovalRepo: approvalRepo, + ApprovalSvc: approvalSvc, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ProductionStandardRepo: productionStandardRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + } +} + +func (s uniformityService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("ProjectFlockKandang.ProjectFlock.Location"). + Preload("ProjectFlockKandang.Kandang.Location") +} + +func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + uniformitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.ProjectFlockKandangId != 0 { + db = db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) + } + if params.Week != 0 { + db = db.Where("week = ?", params.Week) + } + return db.Order("uniform_date DESC").Order("created_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get uniformitys: %+v", err) + return nil, 0, err + } + if err := s.attachLatestApprovals(c.Context(), uniformitys); err != nil { + return nil, 0, err + } + return uniformitys, total, nil +} + +func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { + uniformity, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + if err != nil { + s.Log.Errorf("Failed get uniformity by id: %+v", err) + return nil, err + } + if err := s.attachLatestApproval(c.Context(), uniformity); err != nil { + return nil, err + } + return uniformity, nil +} + +func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { + return s.GetOne(c, id) +} + +func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { + if uniformity == nil { + return nil, nil + } + return s.resolveUniformityStandard(c.Context(), *uniformity) +} + +func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) { + if len(items) == 0 { + return nil, nil + } + if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil { + return nil, nil + } + + categoryStandard := make(map[string]*entity.ProductionStandard) + detailCache := make(map[uint]map[int]entity.StandardGrowthDetail) + result := make(map[uint]UniformityStandard, len(items)) + + for _, item := range items { + if item.Id == 0 { + continue + } + standard, err := s.resolveCategoryStandard(c.Context(), item.ProjectFlockKandang.ProjectFlock.Category, categoryStandard) + if err != nil { + return nil, err + } + if standard == nil { + continue + } + + weekMap, ok := detailCache[standard.Id] + if !ok { + details, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(c.Context(), standard.Id) + if err != nil { + return nil, err + } + weekMap = make(map[int]entity.StandardGrowthDetail, len(details)) + for _, detail := range details { + weekMap[detail.Week] = detail + } + detailCache[standard.Id] = weekMap + } + + detail, ok := weekMap[item.Week] + if !ok { + continue + } + standardDTO := UniformityStandard{ + MeanWeight: cloneFloat64(detail.TargetMeanBw), + Uniformity: float64Ptr(detail.MinUniformity), + } + result[item.Id] = standardDTO + } + + return result, nil +} + +func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + if s.ProjectFlockKandangRepo == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available") + } + + if file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") + } + + uniformDate, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "date must be in YYYY-MM-DD format") + } + + if err := commonSvc.EnsureRelations( + c.Context(), + commonSvc.RelationCheck{Name: "Project Flock Kandang", ID: &req.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists}, + ); err != nil { + return nil, err + } + if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week, &uniformDate); err != nil { + return nil, err + } + + if len(rows) == 0 { + parsedRows, err := s.ParseBodyWeightExcel(c, file) + if err != nil { + return nil, err + } + rows = parsedRows + } + + calculation, err := s.ComputeUniformity(rows) + if err != nil { + return nil, err + } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + createBody := &entity.ProjectFlockKandangUniformity{ + Uniformity: calculation.Uniformity, + Week: req.Week, + Cv: calculation.Cv, + ChickQtyOfWeight: calculation.ChickQtyOfWeight, + MeanUp: calculation.MeanUp, + MeanDown: calculation.MeanDown, + ProjectFlockKandangId: req.ProjectFlockKandangId, + UniformQty: calculation.UniformQty, + NotUniformQty: calculation.OutsideQty, + UniformDate: &uniformDate, + CreatedBy: actorID, + } + + if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + if err := s.createUniformityApproval( + c.Context(), + tx, + createBody.Id, + utils.UniformityStepPengajuan, + entity.ApprovalActionCreated, + actorID, + nil, + ); err != nil { + return err + } + return nil + }); err != nil { + s.Log.Errorf("Failed to create uniformity: %+v", err) + return nil, err + } + + if s.DocumentSvc != nil { + actorIDCopy := actorID + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: "UNIFORMITY", + DocumentableID: uint64(createBody.Id), + CreatedBy: &actorIDCopy, + Files: []commonSvc.DocumentFile{ + { + File: file, + Type: "UNIFORMITY", + }, + }, + }) + if err != nil { + s.rollbackUniformityCreate(c.Context(), createBody.Id) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document") + } + } + + return s.GetOne(c, createBody.Id) +} + +func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + var uniformDate *time.Time + + if req.Date != nil { + parsed, err := time.Parse("2006-01-02", *req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "date must be in YYYY-MM-DD format") + } + updateBody["uniform_date"] = parsed + uniformDate = &parsed + } + if req.ProjectFlockKandangId != nil { + if s.ProjectFlockKandangRepo == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available") + } + if err := commonSvc.EnsureRelations( + c.Context(), + commonSvc.RelationCheck{Name: "Project Flock Kandang", ID: req.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists}, + ); err != nil { + return nil, err + } + updateBody["project_flock_kandang_id"] = *req.ProjectFlockKandangId + } + if req.Week != nil { + updateBody["week"] = *req.Week + } + + if req.Date != nil || req.ProjectFlockKandangId != nil || req.Week != nil { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + return nil, err + } + targetDate := uniformDate + if targetDate == nil { + targetDate = current.UniformDate + } + targetWeek := current.Week + if req.Week != nil { + targetWeek = *req.Week + } + targetPFKID := current.ProjectFlockKandangId + if req.ProjectFlockKandangId != nil { + targetPFKID = *req.ProjectFlockKandangId + } + if targetDate != nil { + if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek, targetDate); err != nil { + return nil, err + } + } + } + + if file != nil { + if s.DocumentSvc == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } + + if len(rows) == 0 { + parsedRows, err := s.ParseBodyWeightExcel(c, file) + if err != nil { + return nil, err + } + rows = parsedRows + } + + calculation, err := s.ComputeUniformity(rows) + if err != nil { + return nil, err + } + + updateBody["uniformity"] = calculation.Uniformity + updateBody["cv"] = calculation.Cv + updateBody["chick_qty_of_weight"] = calculation.ChickQtyOfWeight + updateBody["mean_up"] = calculation.MeanUp + updateBody["mean_down"] = calculation.MeanDown + updateBody["uniform_qty"] = calculation.UniformQty + updateBody["not_uniform_qty"] = calculation.OutsideQty + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if file == nil { + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + s.Log.Errorf("Failed to update uniformity: %+v", err) + return nil, err + } + + return s.GetOne(c, id) + } + + existingDocs, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(id)) + if err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + actorIDCopy := actorID + uploadResults, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: "UNIFORMITY", + DocumentableID: uint64(id), + CreatedBy: &actorIDCopy, + Files: []commonSvc.DocumentFile{ + { + File: file, + Type: "UNIFORMITY", + }, + }, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document") + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if len(uploadResults) > 0 { + ids := make([]uint, 0, len(uploadResults)) + for _, result := range uploadResults { + if result.Document.Id != 0 { + ids = append(ids, result.Document.Id) + } + } + if len(ids) > 0 { + _ = s.DocumentSvc.DeleteDocuments(c.Context(), ids, true) + } + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + s.Log.Errorf("Failed to update uniformity: %+v", err) + return nil, err + } + + if len(existingDocs) > 0 { + oldIDs := make([]uint, 0, len(existingDocs)) + for _, doc := range existingDocs { + if doc.Id != 0 { + oldIDs = append(oldIDs, doc.Id) + } + } + if len(oldIDs) > 0 { + _ = s.DocumentSvc.DeleteDocuments(c.Context(), oldIDs, true) + } + } + + return s.GetOne(c, id) +} + +func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int, uniformDate *time.Time) error { + if projectFlockKandangID == 0 || week == 0 || uniformDate == nil || uniformDate.IsZero() { + return nil + } + + query := s.Repository.DB().WithContext(ctx). + Model(&entity.ProjectFlockKandangUniformity{}). + Where("project_flock_kandang_id = ? AND week = ? AND uniform_date = ?", projectFlockKandangID, week, *uniformDate) + if id != 0 { + query = query.Where("id <> ?", id) + } + + var count int64 + if err := query.Count(&count).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity uniqueness") + } + if count > 0 { + return fiber.NewError(fiber.StatusConflict, "Uniformity already exists for the same project flock kandang, week, and date") + } + return nil +} + +func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + s.Log.Errorf("Failed to delete uniformity: %+v", err) + return err + } + return nil +} + +func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actionValue := strings.ToUpper(strings.TrimSpace(req.Action)) + var action entity.ApprovalAction + switch actionValue { + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + ids := uniqueUintSlice(req.ApprovableIds) + if len(ids) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + step := utils.UniformityStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.UniformityStepDisetujui + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + ctx := c.Context() + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + + for _, id := range ids { + if _, err := repoTx.GetByID(ctx, id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Uniformity %d not found", id)) + } + return err + } + + if _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowUniformity, + id, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + } + + return nil + }) + if transactionErr != nil { + if fiberErr, ok := transactionErr.(*fiber.Error); ok { + return nil, fiberErr + } + s.Log.Errorf("Failed to record approvals for uniformities %+v: %+v", ids, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to submit uniformity approval") + } + + results := make([]entity.ProjectFlockKandangUniformity, 0, len(ids)) + for _, id := range ids { + loaded, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + results = append(results, *loaded) + } + + return results, nil +} + +type UniformityDetailItem struct { + Id int + Weight float64 + Range string +} + +type UniformityCalculation struct { + ChickQtyOfWeight float64 + MeanWeight float64 + MeanDown float64 + MeanUp float64 + UniformQty float64 + OutsideQty float64 + Uniformity float64 + Cv float64 + Details []UniformityDetailItem +} + +type UniformityStandard struct { + MeanWeight *float64 + Uniformity *float64 +} + +func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { + return computeUniformity(rows) +} + +func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, error) { + if s.DocumentSvc == nil { + return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } + + documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(uniformityID)) + if err != nil { + return UniformityCalculation{}, nil, err + } + if len(documents) == 0 { + return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") + } + + document := documents[0] + url := s.DocumentSvc.PublicURL(document) + if url == "" { + return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available") + } + + req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil) + if err != nil { + return UniformityCalculation{}, nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return UniformityCalculation{}, nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") + } + + rows, err := parseBodyWeightExcelReader(resp.Body) + if err != nil { + return UniformityCalculation{}, nil, err + } + + calculation, err := computeUniformity(rows) + if err != nil { + return UniformityCalculation{}, nil, err + } + + return calculation, &document, nil +} + +func (s *uniformityService) createUniformityApproval( + ctx context.Context, + db *gorm.DB, + uniformityID uint, + step approvalutils.ApprovalStep, + action entity.ApprovalAction, + actorID uint, + notes *string, +) error { + if uniformityID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Uniformity tidak valid untuk approval") + } + if actorID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Actor Id tidak valid untuk approval") + } + + var svc commonSvc.ApprovalService + if db != nil { + svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + } else if s.ApprovalSvc != nil { + svc = s.ApprovalSvc + } else { + svc = commonSvc.NewApprovalService(s.ApprovalRepo) + } + + _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowUniformity, uniformityID, step, &action, actorID, notes) + return err +} + +func (s *uniformityService) attachLatestApprovals(ctx context.Context, items []entity.ProjectFlockKandangUniformity) error { + if len(items) == 0 || s.ApprovalSvc == nil { + return nil + } + + ids := make([]uint, 0, len(items)) + visited := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + if _, ok := visited[item.Id]; ok { + continue + } + visited[item.Id] = struct{}{} + ids = append(ids, item.Id) + } + + if len(ids) == 0 { + return nil + } + + latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowUniformity, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for uniformities: %+v", err) + return nil + } + + if len(latestMap) == 0 { + return nil + } + + for i := range items { + if items[i].Id == 0 { + continue + } + if approval, ok := latestMap[items[i].Id]; ok { + items[i].LatestApproval = approval + } + } + + return nil +} + +func (s *uniformityService) attachLatestApproval(ctx context.Context, item *entity.ProjectFlockKandangUniformity) error { + if item == nil || item.Id == 0 || s.ApprovalSvc == nil { + return nil + } + + approvals, err := s.ApprovalSvc.ListByTarget(ctx, utils.ApprovalWorkflowUniformity, item.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load approvals for uniformity %d: %+v", item.Id, err) + return nil + } + + if len(approvals) == 0 { + item.LatestApproval = nil + return nil + } + + latest := approvals[len(approvals)-1] + item.LatestApproval = &latest + return nil +} + +func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { + if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil { + return nil, nil + } + + standard, err := s.resolveCategoryStandard(ctx, item.ProjectFlockKandang.ProjectFlock.Category, nil) + if err != nil || standard == nil { + return nil, err + } + + detail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standard.Id, item.Week) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + return &UniformityStandard{ + MeanWeight: cloneFloat64(detail.TargetMeanBw), + Uniformity: float64Ptr(detail.MinUniformity), + }, nil +} + +func (s *uniformityService) resolveCategoryStandard( + ctx context.Context, + category string, + cache map[string]*entity.ProductionStandard, +) (*entity.ProductionStandard, error) { + category = strings.TrimSpace(category) + if category == "" { + return nil, nil + } + if cache != nil { + if cached, ok := cache[category]; ok { + return cached, nil + } + } + + var standard entity.ProductionStandard + err := s.ProductionStandardRepo.DB().WithContext(ctx). + Where("project_category = ?", category). + Where("deleted_at IS NULL"). + Order("created_at DESC"). + First(&standard).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + if cache != nil { + cache[category] = nil + } + return nil, nil + } + return nil, err + } + + standardCopy := standard + if cache != nil { + cache[category] = &standardCopy + } + return &standardCopy, nil +} + +func cloneFloat64(value *float64) *float64 { + if value == nil { + return nil + } + copy := *value + return © +} + +func float64Ptr(value float64) *float64 { + copy := value + return © +} + +func (s *uniformityService) rollbackUniformityCreate(ctx context.Context, uniformityID uint) { + if uniformityID == 0 { + return + } + + if s.ApprovalRepo != nil { + if err := s.ApprovalRepo.DeleteByTarget(ctx, utils.ApprovalWorkflowUniformity.String(), uniformityID); err != nil { + s.Log.WithError(err).Warnf("Failed to rollback uniformity approvals for %d", uniformityID) + } + } + + if err := s.Repository.DeleteOne(ctx, uniformityID); err != nil { + s.Log.WithError(err).Warnf("Failed to rollback uniformity %d", uniformityID) + } +} + +func uniqueUintSlice(values []uint) []uint { + if len(values) == 0 { + return nil + } + + seen := make(map[uint]struct{}, len(values)) + result := make([]uint, 0, len(values)) + for _, v := range values { + if v == 0 { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + return result +} + +func computeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { + weights := make([]float64, 0, len(rows)) + details := make([]UniformityDetailItem, 0, len(rows)) + hasRangeLabels := false + for idx, row := range rows { + if row.Weight <= 0 { + continue + } + id := row.No + if id <= 0 { + id = idx + 1 + } + weights = append(weights, row.Weight) + rangeLabel := strings.TrimSpace(row.Range) + if rangeLabel != "" { + upper := strings.ToUpper(rangeLabel) + if upper == "HIGH" || upper == "LOW" { + hasRangeLabels = true + } + rangeLabel = upper + } + details = append(details, UniformityDetailItem{ + Id: id, + Weight: row.Weight, + Range: rangeLabel, + }) + } + + total := float64(len(weights)) + if total == 0 { + return UniformityCalculation{}, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + var sum float64 + for _, w := range weights { + sum += w + } + mean := sum / total + meanUpThreshold := roundToPrecision(mean*1.10, 3) + meanDownThreshold := roundToPrecision(mean*0.90, 3) + + var uniformCount float64 + for i := range details { + if hasRangeLabels { + if details[i].Range == "HIGH" || details[i].Range == "LOW" { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + continue + } + + w := details[i].Weight + if w > meanUpThreshold || w < meanDownThreshold { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + } + outsideCount := total - uniformCount + + var cv float64 + if mean > 0 && total > 1 { + stddevWeights := weights + stddevCount := float64(len(stddevWeights)) + if stddevCount > 1 { + var stddevSum float64 + for _, w := range stddevWeights { + stddevSum += w + } + stddevMean := stddevSum / stddevCount + var sumSquares float64 + for _, w := range stddevWeights { + diff := w - stddevMean + sumSquares += diff * diff + } + stddev := math.Sqrt(sumSquares / (stddevCount - 1)) + cv = (stddev / mean) * 100 + } + } + + uniformity := (uniformCount / total) * 100 + + return UniformityCalculation{ + ChickQtyOfWeight: total, + MeanWeight: roundToPrecision(mean, 0), + MeanDown: roundToPrecision(mean*0.90, 0), + MeanUp: roundToPrecision(mean*1.10, 0), + UniformQty: uniformCount, + OutsideQty: outsideCount, + Uniformity: roundToPrecision(uniformity, 0), + Cv: roundToPrecision(cv, 1), + Details: details, + }, nil +} + +func roundToPrecision(value float64, precision int) float64 { + if precision < 0 { + return value + } + scale := math.Pow10(precision) + scaled := value * scale + fraction := scaled - math.Floor(scaled) + if fraction >= 0.5 { + return math.Ceil(scaled) / scale + } + return math.Floor(scaled) / scale +} diff --git a/internal/modules/production/uniformities/validations/uniformity.validation.go b/internal/modules/production/uniformities/validations/uniformity.validation.go new file mode 100644 index 00000000..b2aeaf26 --- /dev/null +++ b/internal/modules/production/uniformities/validations/uniformity.validation.go @@ -0,0 +1,164 @@ +package validation + +import ( + "mime/multipart" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type Create struct { + Date string `form:"date" validate:"required"` + ProjectFlockKandangId uint `form:"project_flock_kandang_id" validate:"required,number,min=1"` + Week int `form:"week" validate:"required,min=1"` +} + +type Update struct { + Date *string `json:"date,omitempty" form:"date" validate:"omitempty"` + ProjectFlockKandangId *uint `json:"project_flock_kandang_id,omitempty" form:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Week *int `json:"week,omitempty" form:"week" validate:"omitempty,min=1"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Week int `query:"week" validate:"omitempty,min=1"` +} + +type UploadExcelRequest struct { + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,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"` +} + +func ParseIDParam(c *fiber.Ctx, name string) (uint, error) { + raw := strings.TrimSpace(c.Params(name)) + if raw == "" { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + return uint(id), nil +} + +func ParseQuery(c *fiber.Ctx) (*Query, error) { + query := &Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), + Week: c.QueryInt("week", 0), + } + + if query.Page < 1 || query.Limit < 1 { + return nil, fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + return query, nil +} + +func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) { + date := strings.TrimSpace(c.FormValue("date")) + if date == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "date is required") + } + + projectFlockKandangIDStr := strings.TrimSpace(c.FormValue("project_flock_kandang_id")) + if projectFlockKandangIDStr == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + projectFlockKandangID, err := strconv.Atoi(projectFlockKandangIDStr) + if err != nil || projectFlockKandangID <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + weekStr := strings.TrimSpace(c.FormValue("week")) + if weekStr == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required") + } + + week, err := strconv.Atoi(weekStr) + if err != nil || week <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required") + } + + file, err := c.FormFile("document") + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "document is required") + } + + return &Create{ + Date: date, + ProjectFlockKandangId: uint(projectFlockKandangID), + Week: week, + }, file, nil +} + +func ParseUpdate(c *fiber.Ctx) (*Update, *multipart.FileHeader, error) { + contentType := strings.ToLower(c.Get("Content-Type")) + if strings.Contains(contentType, "multipart/form-data") { + req := &Update{} + + date := strings.TrimSpace(c.FormValue("date")) + if date != "" { + req.Date = &date + } + + projectFlockKandangIDStr := strings.TrimSpace(c.FormValue("project_flock_kandang_id")) + if projectFlockKandangIDStr != "" { + projectFlockKandangID, err := strconv.Atoi(projectFlockKandangIDStr) + if err != nil || projectFlockKandangID <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is invalid") + } + idCopy := uint(projectFlockKandangID) + req.ProjectFlockKandangId = &idCopy + } + + weekStr := strings.TrimSpace(c.FormValue("week")) + if weekStr != "" { + week, err := strconv.Atoi(weekStr) + if err != nil || week <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is invalid") + } + req.Week = &week + } + + file, err := c.FormFile("document") + if err != nil { + file = nil + } + + return req, file, nil + } + + req := new(Update) + if err := c.BodyParser(req); err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + return req, nil, nil +} + +func ParseUploadFiles(c *fiber.Ctx) ([]*multipart.FileHeader, error) { + file, err := c.FormFile("document") + if err != nil || file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") + } + + return []*multipart.FileHeader{file}, nil +} + +func ParseApprove(c *fiber.Ctx) (*Approve, error) { + req := new(Approve) + if err := c.BodyParser(req); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + return req, nil +} diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index ec1b24f7..fa10559d 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -1,6 +1,7 @@ package purchases import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -11,6 +12,7 @@ import ( expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" expenseService "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" @@ -34,6 +36,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate supplierRepo := rSupplier.NewSupplierRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) nonstockRepo := rNonstock.NewNonstockRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) expenseRepository := expenseRepo.NewExpenseRepository(db) expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) @@ -41,6 +44,11 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) + documentRepo := commonRepo.NewDocumentRepository(db) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) } @@ -54,12 +62,14 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalService, expenseRealizationRepo, projectFlockKandangRepository, + documentSvc, validate, ) expenseBridge := service.NewExpenseBridge( db, purchaseRepo, projectFlockKandangRepository, + kandangRepo, expenseServiceInstance, ) diff --git a/internal/modules/purchases/route.go b/internal/modules/purchases/route.go index 4be485e6..0fe038c3 100644 --- a/internal/modules/purchases/route.go +++ b/internal/modules/purchases/route.go @@ -17,10 +17,10 @@ func Routes(router fiber.Router, purchaseService service.PurchaseService, userSe route.Get("/",m.RequirePermissions(m.P_PurchaseGetAll), ctrl.GetAll) route.Get("/:id",m.RequirePermissions(m.P_PurchaseGetOne), ctrl.GetOne) - route.Post("/",m.RequirePermissions(m.P_PurchaseCreateOne), ctrl.CreateOne) - route.Post("/:id/approvals/staff",m.RequirePermissions(m.P_PurchaseApprovalStaff), ctrl.ApproveStaffPurchase) - route.Post("/:id/approvals/manager",m.RequirePermissions(m.P_PurchaseApprovalManager), ctrl.ApproveManagerPurchase) - route.Post("/:id/receipts",m.RequirePermissions(m.P_PurchaseReceive), ctrl.ReceiveProducts) - route.Delete("/:id",m.RequirePermissions(m.P_RecordingDeleteOne), ctrl.DeletePurchase) - route.Delete("/:id/items",m.RequirePermissions(m.P_PurchaseItemDeleteOne), ctrl.DeleteItems) + route.Post("/", ctrl.CreateOne) + route.Post("/:id/approvals/staff", ctrl.ApproveStaffPurchase) + route.Post("/:id/approvals/manager", ctrl.ApproveManagerPurchase) + route.Post("/:id/receipts",ctrl.ReceiveProducts) + route.Delete("/:id", ctrl.DeletePurchase) + route.Delete("/:id/items", ctrl.DeleteItems) } diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index d8356e6a..146f04f2 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -17,6 +17,7 @@ import ( expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" + kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -53,6 +54,7 @@ type expenseBridge struct { db *gorm.DB purchaseRepo rPurchase.PurchaseRepository projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + kandangRepo kandangRepo.KandangRepository expenseSvc expenseSvc.ExpenseService } @@ -60,12 +62,14 @@ func NewExpenseBridge( db *gorm.DB, purchaseRepo rPurchase.PurchaseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, + kandangRepo kandangRepo.KandangRepository, expenseSvc expenseSvc.ExpenseService, ) PurchaseExpenseBridge { return &expenseBridge{ db: db, purchaseRepo: purchaseRepo, projectFlockKandangRepo: projectFlockKandangRepo, + kandangRepo: kandangRepo, expenseSvc: expenseSvc, } } @@ -550,6 +554,16 @@ func (b *expenseBridge) createExpenseViaService( return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") } + kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB { + return db.Select("id, location_id") + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + } + if kandang == nil { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + } + costItems := make([]expenseValidation.CostItem, 0, len(items)) for _, gi := range items { note := fmt.Sprintf("purchase_item:%d", gi.payload.PurchaseItemID) @@ -570,8 +584,9 @@ func (b *expenseBridge) createExpenseViaService( TransactionDate: utils.FormatDate(expenseDate), Category: "BOP", SupplierID: uint64(supplierID), + LocationID: uint64(kandang.LocationId), ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ - KandangID: uint64(*kandangID), + KandangID: func() *uint64 { id := uint64(*kandangID); return &id }(), CostItems: costItems, }}, } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 64a91e9d..366a8c0e 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -247,7 +247,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return nil, nil, utils.Internal("Failed to get warehouse") } if warehouse.KandangId == nil || *warehouse.KandangId == 0 { - return nil, nil, utils.BadRequest(fmt.Sprintf("Warehouse %d is not linked to a kandang", id)) + return nil, nil, utils.BadRequest(fmt.Sprintf("%s is not linked to a kandang", warehouse.Name)) } var pfkID *uint if s.ProjectFlockKandangRepo != nil { @@ -258,7 +258,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase idCopy := uint(pfk.Id) pfkID = &idCopy } else if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil, utils.BadRequest(fmt.Sprintf("Warehouse %d has no active project flock", id)) + return nil, nil, utils.BadRequest(fmt.Sprintf("%s has no active project flock", warehouse.Name)) } else if err != nil { s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err) return nil, nil, utils.Internal("Failed to validate project flock") @@ -794,6 +794,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation deltas := make(map[uint]float64) affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) + priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared)) fifoAdds := make([]struct { itemID uint pwID uint @@ -862,6 +863,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } updates = append(updates, update) + + if item.Price > 0 && prep.receivedQty >= 0 { + priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{ + ItemID: item.Id, + Price: item.Price, + TotalPrice: item.Price * prep.receivedQty, + }) + } } if err := repoTx.UpdateReceivingDetails(c.Context(), purchase.Id, updates); err != nil { @@ -876,6 +885,12 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } + if len(priceUpdates) > 0 { + if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { + return err + } + } + // Update due_date based on earliest received date when receiving approved. if earliestReceived != nil { due := earliestReceived.AddDate(0, 0, purchase.CreditTerm) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 9c026590..90c2fe50 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -61,7 +61,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId) - totalWeightKg := mdp.Qty * mdp.AvgWeight + totalWeightKg := mdp.UsageQty * mdp.AvgWeight salesAmount := totalWeightKg * mdp.UnitPrice var hpp float64 @@ -78,7 +78,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK AgingDays: agingDays, DoNumber: doNumber, MarketingType: getMarketingType(mdp), - Qty: mdp.Qty, + Qty: mdp.UsageQty, AverageWeightKg: mdp.AvgWeight, TotalWeightKg: totalWeightKg, SalesPricePerKg: mdp.UnitPrice, @@ -194,8 +194,8 @@ func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, ca totalHppAmount := int64(0) for _, mdp := range mdps { - calculatedTotalWeight := mdp.Qty * mdp.AvgWeight - totalQty += int(mdp.Qty) + calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight + totalQty += int(mdp.UsageQty) totalWeightKg += calculatedTotalWeight totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index d2a5c982..9f54fad8 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -161,7 +161,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing func (s *repportService) calculateHppPricePerKg(ctx context.Context, projectFlockID uint, category string) float64 { totalCost := s.getTotalProjectCost(ctx, projectFlockID) if totalCost == 0 { - s.Log.Warnf("HPP calculation: No cost found for project flock ID %d. Check if purchase items are linked to project_flock_kandang_id", projectFlockID) return 0 } diff --git a/internal/modules/sso/controllers/refresh_token_response.go b/internal/modules/sso/controllers/refresh_token_response.go new file mode 100644 index 00000000..1825342a --- /dev/null +++ b/internal/modules/sso/controllers/refresh_token_response.go @@ -0,0 +1,13 @@ +package controllers + +type refreshTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` + Error string `json:"error"` + Description string `json:"error_description"` +} + diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index f11a31c8..99bd67d6 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -138,6 +138,86 @@ func (h *Controller) Start(c *fiber.Ctx) error { return c.Redirect(authorizeURL.String(), fiber.StatusFound) } +// Refresh exchanges the current SSO refresh token for a new access/refresh pair +// without redirecting the browser to the SSO login page. +func (h *Controller) Refresh(c *fiber.Ctx) error { + refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") + refreshToken := strings.TrimSpace(c.Cookies(refreshName)) + if refreshToken == "" { + return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") + } + + tokenEndpoint := strings.TrimSpace(config.SSOTokenURL) + if tokenEndpoint == "" { + return fiber.NewError(fiber.StatusInternalServerError, "token endpoint not configured") + } + + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + + req, err := http.NewRequestWithContext(c.Context(), http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to create refresh request") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := h.httpClient.Do(req) + if err != nil { + utils.Log.Errorf("token refresh request failed: %v", err) + return fiber.NewError(fiber.StatusBadGateway, "failed to refresh access token") + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + utils.Log.Warnf("token refresh response status %d", resp.StatusCode) + return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") + } + + var tokenResp refreshTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return fiber.NewError(fiber.StatusBadGateway, "invalid token response") + } + if tokenResp.Error != "" { + return fiber.NewError(fiber.StatusBadGateway, tokenResp.Description) + } + if tokenResp.AccessToken == "" { + return fiber.NewError(fiber.StatusBadGateway, "missing access token") + } + + verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) + if err != nil { + utils.Log.Errorf("access token verification failed: %v", err) + return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") + } + + issueCookies(c, struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` + Error string `json:"error"` + Description string `json:"error_description"` + }{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + TokenType: tokenResp.TokenType, + ExpiresIn: tokenResp.ExpiresIn, + Scope: tokenResp.Scope, + IDToken: tokenResp.IDToken, + Error: tokenResp.Error, + Description: tokenResp.Description, + }, verification) + + utils.Log.WithFields(logrus.Fields{ + "user_id": verification.UserID, + }).Info("sso refresh successful") + + return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) +} + // Callback handles the redirect from SSO containing the authorization code. func (h *Controller) Callback(c *fiber.Ctx) error { state := strings.TrimSpace(c.Query("state")) diff --git a/internal/modules/sso/route.go b/internal/modules/sso/route.go index a7288ef9..3f2a699e 100644 --- a/internal/modules/sso/route.go +++ b/internal/modules/sso/route.go @@ -31,6 +31,7 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { group.Get("/start", middleware.NewLimiter(30, time.Minute), ctrl.Start) group.Get("/callback", ctrl.Callback) group.Get("/userinfo", middleware.NewLimiter(60, time.Minute), ctrl.UserInfo) + group.Post("/refresh", middleware.NewLimiter(60, time.Minute), ctrl.Refresh) group.Post("/logout", middleware.NewLimiter(60, time.Minute), ctrl.Logout) group.Post("/users/sync", middleware.NewLimiter(30, time.Minute), syncCtrl.Sync) } diff --git a/internal/route/route.go b/internal/route/route.go index 294fc900..877ec875 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -12,14 +12,15 @@ import ( closings "gitlab.com/mbugroup/lti-api.git/internal/modules/closings" constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses" + finance "gitlab.com/mbugroup/lti-api.git/internal/modules/finance" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" marketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" + repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" - repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" // MODULE IMPORTS ) @@ -43,7 +44,8 @@ func Routes(app *fiber.App, db *gorm.DB) { expenses.ExpenseModule{}, ssoModule.Module{}, closings.ClosingModule{}, - repports.RepportModule{}, + repports.RepportModule{}, + finance.FinanceModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 85b33f9b..c94ac7bc 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -111,6 +111,8 @@ type StockLogType string const ( StockLogTypeAdjustment StockLogType = "ADJUSTMENT" StockLogTypeTransfer StockLogType = "TRANSFER" + StockLogTypeMarketing StockLogType = "MARKETING" + StockLogTypeChikin StockLogType = "CHICKIN" ) // ------------------------------------------------------------------- @@ -146,6 +148,45 @@ const ( ExpenseCategoryNonBOP ExpenseCategory = "NON-BOP" ) +// ------------------------------------------------------------------- +// Payment Method +// ------------------------------------------------------------------- + +type PaymentMethod string + +const ( + PaymentMethodTransfer PaymentMethod = "TRANSFER" + PaymentMethodCash PaymentMethod = "CASH" + PaymentMethodCard PaymentMethod = "CARD" + PaymentMethodCheque PaymentMethod = "CHEQUE" + PaymentMethodSaldo PaymentMethod = "SALDO" +) + +// ------------------------------------------------------------------- +// Trasaction Type +// ------------------------------------------------------------------- + +type TransactionType string + +const ( + TransactionTypePenjualan TransactionType = "PENJUALAN" + TransactionTypePembelian TransactionType = "PEMBELIAN" + TransactionTypeBiaya TransactionType = "BIAYA" + TransactionTypeInjection TransactionType = "INJECTION" + TransactionTypeSaldoAwal TransactionType = "SALDO_AWAL" +) + +// ------------------------------------------------------------------- +// Payment Party +// ------------------------------------------------------------------- + +type PaymentParty string + +const ( + PaymentPartyCustomer PaymentParty = "CUSTOMER" + PaymentPartySupplier PaymentParty = "SUPPLIER" +) + // ------------------------------------------------------------------- // Kandang Status // ------------------------------------------------------------------- @@ -248,6 +289,21 @@ var RecordingApprovalSteps = map[approvalutils.ApprovalStep]string{ RecordingStepDisetujui: "Disetujui", } +// ------------------------------------------------------------------- +// Uniformity Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowUniformity approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("UNIFORMITIES") + UniformityStepPengajuan approvalutils.ApprovalStep = 1 + UniformityStepDisetujui approvalutils.ApprovalStep = 2 +) + +var UniformityApprovalSteps = map[approvalutils.ApprovalStep]string{ + UniformityStepPengajuan: "Pengajuan", + UniformityStepDisetujui: "Disetujui", +} + // ------------------------------------------------------------------- // Purchase Approval // ------------------------------------------------------------------- @@ -314,6 +370,68 @@ var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepSelesai: "Selesai", } +// ------------------------------------------------------------------- +// Payment Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowPayment approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PAYMENTS") + PaymentStepPengajuan approvalutils.ApprovalStep = 1 + PaymentStepDisetujui approvalutils.ApprovalStep = 2 +) + +var PaymentApprovalSteps = map[approvalutils.ApprovalStep]string{ + PaymentStepPengajuan: "Pengajuan", + PaymentStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Inisial Balance Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInitial approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INITIAL_BALANCES") + InitialStepPengajuan approvalutils.ApprovalStep = 1 + InitialStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InitialApprovalSteps = map[approvalutils.ApprovalStep]string{ + InitialStepPengajuan: "Pengajuan", + InitialStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Injection Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInjection approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INJECTIONS") + InjectionStepPengajuan approvalutils.ApprovalStep = 1 + InjectionStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InjectionApprovalSteps = map[approvalutils.ApprovalStep]string{ + InjectionStepPengajuan: "Pengajuan", + InjectionStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Document +// ------------------------------------------------------------------- + +type DocumentType string +type DocumentableType string + +const ( + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" + DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" + + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeExpense DocumentableType = "EXPENSE" + DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" +) + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -448,6 +566,30 @@ func IsValidExpenseCategory(v string) bool { return false } +func IsValidPaymentMethod(v string) bool { + switch PaymentMethod(v) { + case PaymentMethodTransfer, PaymentMethodCash, PaymentMethodCard, PaymentMethodCheque, PaymentMethodSaldo: + return true + } + return false +} + +func IsValidTransactionType(v string) bool { + switch TransactionType(v) { + case TransactionTypePenjualan, TransactionTypePembelian, TransactionTypeBiaya, TransactionTypeInjection, TransactionTypeSaldoAwal: + return true + } + return false +} + +func IsValidPaymentParty(v string) bool { + switch PaymentParty(v) { + case PaymentPartyCustomer, PaymentPartySupplier: + return true + } + return false +} + // example use // Recording helper diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index c47d3cd7..ea6f96c0 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -1,5 +1,7 @@ package fifo const ( - UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" ) diff --git a/test/integration/inventory/transfers/transfer_fifo_integration_test.go b/test/integration/inventory/transfers/transfer_fifo_integration_test.go new file mode 100644 index 00000000..d9f127a1 --- /dev/null +++ b/test/integration/inventory/transfers/transfer_fifo_integration_test.go @@ -0,0 +1,304 @@ +package test + +import ( + "context" + "math" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" +) + +// Test Transfer FIFO with Purchase as initial stockable +func TestTransferFIFO_PurchaseToTransfer(t *testing.T) { + db, fifoSvc := setupTransferFIFOTest(t) + ctx := context.Background() + + // Setup warehouses + sourcePW := createProductWarehouseRow(t, db, 100) // 100 qty from purchase + destPW := createProductWarehouseRow(t, db, 0) // 0 qty initially + + // Step 1: Simulate Purchase - Replenish stock to source warehouse + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS") + if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: purchaseStockableKey, + StockableID: 1, // PurchaseItem ID + ProductWarehouseID: sourcePW.Id, + Quantity: 100, + }); err != nil { + t.Fatalf("Failed to replenish from purchase: %v", err) + } + + // Verify source warehouse has stock + assertWarehouseQuantity(t, db, sourcePW.Id, 100) + assertAllocationCount(t, db, 1) // 1 allocation from purchase + + // Step 2: Create Transfer - will consume from source (usable) and replenish to dest (stockable) + + // Register Transfer as Usable (source warehouse - STOCK_TRANSFER_OUT) + transferUsableKey := fifo.UsableKey("STOCK_TRANSFER_OUT") + if err := fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: transferUsableKey, + Table: "stock_transfer_details", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("Failed to register STOCK_TRANSFER_OUT as Usable: %v", err) + } + + // Register Transfer as Stockable (destination warehouse - STOCK_TRANSFER_IN) + transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_IN") + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: transferStockableKey, + Table: "stock_transfer_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "dest_product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("Failed to register STOCK_TRANSFER_IN as Stockable: %v", err) + } + + // Create transfer detail record + transferDetail := entity.StockTransferDetail{ + Id: 1, + StockTransferId: 1, + ProductId: 1, + SourceProductWarehouseID: uint64Ptr(uint64(sourcePW.Id)), + DestProductWarehouseID: uint64Ptr(uint64(destPW.Id)), + UsageQty: 0, + PendingQty: 0, + TotalQty: 0, + TotalUsed: 0, + } + transferDetailID := uint(transferDetail.Id) + if err := db.Create(&transferDetail).Error; err != nil { + t.Fatalf("Failed to create transfer detail: %v", err) + } + + transferQty := 50.0 + + // Consume from source warehouse (STOCK_TRANSFER_OUT) + consumeResult, err := fifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: "STOCK_TRANSFER_OUT", + UsableID: transferDetailID, + ProductWarehouseID: sourcePW.Id, + Quantity: transferQty, + AllowPending: false, // Don't allow pending + }) + if err != nil { + t.Fatalf("Failed to consume from source warehouse: %v", err) + } + + // Verify consumption + if mathAbs(consumeResult.UsageQuantity-transferQty) > 1e-6 { + t.Fatalf("Expected usage quantity %.2f, got %.2f", transferQty, consumeResult.UsageQuantity) + } + if mathAbs(consumeResult.PendingQuantity) > 1e-6 { + t.Fatalf("Expected pending quantity 0, got %.2f", consumeResult.PendingQuantity) + } + + // Update transfer detail usable fields + if err := db.Model(&entity.StockTransferDetail{}). + Where("id = ?", transferDetail.Id). + Updates(map[string]interface{}{ + "usage_qty": consumeResult.UsageQuantity, + "pending_qty": consumeResult.PendingQuantity, + }).Error; err != nil { + t.Fatalf("Failed to update transfer detail usable fields: %v", err) + } + + // Verify source warehouse decreased + assertWarehouseQuantity(t, db, sourcePW.Id, 50) // 100 - 50 = 50 + + // Verify allocation updated - should have 50 allocated to transfer + allocations := fetchAllocationsByUsable(t, db, "STOCK_TRANSFER_OUT", transferDetailID) + if len(allocations) != 1 { + t.Fatalf("Expected 1 allocation, got %d", len(allocations)) + } + if mathAbs(allocations[0].Qty-transferQty) > 1e-6 { + t.Fatalf("Expected allocation qty %.2f, got %.2f", transferQty, allocations[0].Qty) + } + + // Replenish to destination warehouse (STOCK_TRANSFER_IN) + note := "Transfer #1" + replenishResult, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: "STOCK_TRANSFER_IN", + StockableID: transferDetailID, + ProductWarehouseID: destPW.Id, + Quantity: transferQty, + Note: ¬e, + }) + if err != nil { + t.Fatalf("Failed to replenish to destination warehouse: %v", err) + } + + // Verify replenishment + if mathAbs(replenishResult.AddedQuantity-transferQty) > 1e-6 { + t.Fatalf("Expected added quantity %.2f, got %.2f", transferQty, replenishResult.AddedQuantity) + } + + // Update transfer detail stockable fields + if err := db.Model(&entity.StockTransferDetail{}). + Where("id = ?", transferDetail.Id). + Updates(map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }).Error; err != nil { + t.Fatalf("Failed to update transfer detail stockable fields: %v", err) + } + + // Verify destination warehouse increased + assertWarehouseQuantity(t, db, destPW.Id, transferQty) + + // Verify new stockable allocation created + stockableAllocations := fetchAllocationsByStockable(t, db, "STOCK_TRANSFER_IN", transferDetailID) + if len(stockableAllocations) != 1 { + t.Fatalf("Expected 1 stockable allocation, got %d", len(stockableAllocations)) + } + if mathAbs(stockableAllocations[0].Qty-transferQty) > 1e-6 { + t.Fatalf("Expected stockable allocation qty %.2f, got %.2f", transferQty, stockableAllocations[0].Qty) + } + + t.Logf("✅ Transfer FIFO test passed:") + t.Logf(" - Source warehouse: 100 → 50 (consumed %d)", int(transferQty)) + t.Logf(" - Destination warehouse: 0 → %d (replenished)", int(transferQty)) + t.Logf(" - Usable allocation: %.2f allocated to transfer", allocations[0].Qty) + t.Logf(" - Stockable allocation: %.2f available at destination", stockableAllocations[0].Qty) +} + +// Setup function for transfer FIFO test +func setupTransferFIFOTest(t *testing.T) (*gorm.DB, commonSvc.FifoService) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open db: %v", err) + } + + if err := db.AutoMigrate( + &entity.ProductWarehouse{}, + &entity.StockAllocation{}, + &entity.StockTransferDetail{}, + ); err != nil { + t.Fatalf("auto migrate entities: %v", err) + } + + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + // Register Purchase as Stockable + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS") + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: purchaseStockableKey, + Table: "purchase_items", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("register purchase stockable: %v", err) + } + + return db, fifoSvc +} + +// Helper functions + +func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse { + t.Helper() + pw := entity.ProductWarehouse{ + ProductId: 1, + WarehouseId: 1, + Quantity: qty, + } + if err := db.Create(&pw).Error; err != nil { + t.Fatalf("create product warehouse: %v", err) + } + return pw +} + +func assertWarehouseQuantity(t *testing.T, db *gorm.DB, pwID uint, expected float64) { + t.Helper() + var pw entity.ProductWarehouse + if err := db.First(&pw, pwID).Error; err != nil { + t.Fatalf("fetch product warehouse %d: %v", pwID, err) + } + if mathAbs(pw.Quantity-expected) > 1e-6 { + t.Fatalf("expected warehouse quantity %.2f, got %.2f", expected, pw.Quantity) + } +} + +func assertAllocationCount(t *testing.T, db *gorm.DB, expected int) { + t.Helper() + var count int64 + if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil { + t.Fatalf("count allocations: %v", err) + } + if int(count) != expected { + t.Fatalf("expected %d allocations, got %d", expected, count) + } +} + +func fetchAllocationsByUsable(t *testing.T, db *gorm.DB, usableType string, usableID uint) []entity.StockAllocation { + t.Helper() + var allocations []entity.StockAllocation + if err := db.Where("usable_type = ? AND usable_id = ?", usableType, usableID). + Find(&allocations).Error; err != nil { + t.Fatalf("fetch allocations by usable: %v", err) + } + return allocations +} + +func fetchAllocationsByStockable(t *testing.T, db *gorm.DB, stockableType string, stockableID uint) []entity.StockAllocation { + t.Helper() + var allocations []entity.StockAllocation + if err := db.Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID). + Find(&allocations).Error; err != nil { + t.Fatalf("fetch allocations by stockable: %v", err) + } + return allocations +} + +func floatPtr(f float64) *float64 { + return &f +} + +func uint64Ptr(u uint64) *uint64 { + return &u +} + +func mathAbs(f float64) float64 { + return math.Abs(f) +} + +func sanitizeKey(name string) string { + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return '_' + }, name) +} diff --git a/tools/templates/route.tmpl b/tools/templates/route.tmpl index 26958deb..9dea2530 100644 --- a/tools/templates/route.tmpl +++ b/tools/templates/route.tmpl @@ -1,7 +1,7 @@ {{define "route"}}package {{Kebab .Entity}}s import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/controllers" {{Camel .Entity}} "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,17 +13,12 @@ func {{Pascal .Entity}}Routes(v1 fiber.Router, u user.UserService, s {{Camel .En ctrl := controller.New{{Pascal .Entity}}Controller(s) route := v1.Group("/{{Kebab .Entity}}s") + route.Use(m.Auth(u)) - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_FinanceGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_FinanceGetOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_FinanceCreateOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_FinanceUpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_FinanceDeleteOne), ctrl.DeleteOne) } {{end}}