Compare commits

...

37 Commits

Author SHA1 Message Date
Hafizh A. Y. 2d098cb6b1 Merge branch 'feat/BE/US-281-uniformity' into 'feat/BE/Sprint-8'
feat(BE-281): uniformity and create project flock triger... unfinished s3 read

See merge request mbugroup/lti-api!121
2025-12-31 02:20:01 +00:00
ragilap 709e304f7f feat(BE-281): adjustment bug erorr 500 if 404 record projectflock 2025-12-31 07:39:20 +07:00
ragilap d994cfdce7 productstock 2025-12-31 07:08:03 +07:00
Hafizh A. Y. d3bb00a06a Merge branch 'fix/nonstock-undefined-field' into 'development'
fix(be): nonstock response supplier null to empty array

See merge request mbugroup/lti-api!120
2025-12-30 23:57:54 +00:00
Hafizh A. Y 5302713811 fix(be): nonstock response supplier null to empty array 2025-12-31 06:52:38 +07:00
Hafizh A. Y. f698ca070c Merge branch 'fix/nonstock-undefined-field' into 'development'
fix(be): remove omitempty in dto and validation nonstock

See merge request mbugroup/lti-api!118
2025-12-30 23:44:27 +00:00
Hafizh A. Y 6c42119f4d fix(be): remove omitempty in dto and validation nonstock 2025-12-31 06:43:34 +07:00
Hafizh A. Y. 299c8c7177 Merge branch 'fix/setup-seeder' into 'development'
fix: setup seeder for development

See merge request mbugroup/lti-api!117
2025-12-30 16:54:23 +00:00
Hafizh A. Y 78359db880 fix: setup seeder for development 2025-12-30 23:52:37 +07:00
Hafizh A. Y. 10799cc1ed Merge branch 'feat/BE/Sprint-8' into 'development'
[FEAT/BE] Partial Merge #3

See merge request mbugroup/lti-api!116
2025-12-30 12:06:19 +00:00
Hafizh A. Y. c9c581ef30 Merge branch 'sprint-8/gio' into 'feat/BE/Sprint-8'
fix api get all closing; fix get closing sapronak; fix get all maste data product

See merge request mbugroup/lti-api!115
2025-12-30 09:36:48 +00:00
Hafizh A. Y. 6ee795cf2a Merge branch 'dev/teguh' into 'feat/BE/Sprint-8'
feat(BE US#386): add standard_fcr column to production_standard_details and update existing API

See merge request mbugroup/lti-api!114
2025-12-30 09:36:00 +00:00
aguhh18 471fd1dbbf feat(BE): enhance product warehouse handling and automatic calculations for delivery and sales orders 2025-12-30 16:30:44 +07:00
ragilap 4e5caa8cba feat(BE-281): add rbac for uniformity 2025-12-30 15:23:34 +07:00
MacBook Air M1 0285852c42 fix api get all closing; fix get closing sapronak; fix get all maste data product 2025-12-30 14:42:53 +07:00
ragilap 0c776e8332 feat(BE-281): uncoment auth 2025-12-30 12:08:49 +07:00
ragilap 90125ffe1a feat(BE-281):add dto standart mean bw and uniformity 2025-12-30 12:07:28 +07:00
aguhh18 c36719cc1a Merge branch 'feat/BE/Sprint-8' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-30 10:42:04 +07:00
aguhh18 e4acd9a21e feat(BE): add standard_fcr column to production_standard_details and update related services and validations 2025-12-30 10:27:12 +07:00
ragilap 9a094b8bfe feat(BE-281):fix document payload 2025-12-30 09:56:48 +07:00
Hafizh A. Y. ddda696454 Merge branch 'fix/BE/US-74-add_production_standart_project_flock' into 'feat/BE/Sprint-8'
feat(BE-74): add production standart to project_flock and implement rbac...

See merge request mbugroup/lti-api!113
2025-12-29 16:22:29 +00:00
ragilap 635049163e feat(BE-74): add production standart to project_flock and implement rbac finance and standart production 2025-12-29 23:15:34 +07:00
Hafizh A. Y. 49af2d6448 Merge branch 'feat/BE/Sprint-8' into 'development'
Feat[BE]: Partial Merge

See merge request mbugroup/lti-api!112
2025-12-29 14:39:58 +00:00
Hafizh A. Y. 68703d8752 Merge branch 'dev/teguh' into 'feat/BE/Sprint-8'
feat(BE): expense(adjust expense add option attach to farm and not to kandang ).

See merge request mbugroup/lti-api!111
2025-12-29 14:39:05 +00:00
Hafizh A. Y. f19a3cb76e Merge branch 'dev/hafizh' into 'feat/BE/Sprint-8'
feat(BE): finance (payment, initial_balance, injection). fix(BE): kandang capacity

See merge request mbugroup/lti-api!110
2025-12-29 14:37:42 +00:00
ragilap 6523290aaf feat(BE-281): change template excel 2025-12-29 19:44:10 +07:00
ragilap a2066979c1 feat(BE-281): adjustment uniformity for make unique for week,projectflockandang, and date 2025-12-29 19:04:10 +07:00
ragilap 8dfb224614 feat(BE-281): changes std deviasi first 100 data to all 2025-12-29 10:13:29 +07:00
aguhh18 db4e8232b9 feat(BE): enhance closing service and repository with actual usage cost calculations and egg weight tracking 2025-12-29 08:03:00 +07:00
ragilap 644896edfa feat(BE-281): unfinished uniformity and create project flock triger productwarehouse and add new filtering lookup 2025-12-29 00:21:26 +07:00
aguhh18 d945fcd19c Merge branch 'dev/gio' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-28 19:16:53 +07:00
aguhh18 812db3f79e feat(BE): integrate FIFO service for stock adjustments and transfers
- Added FIFO service integration in the adjustments module to manage stockable and usable items for adjustments.
- Created a new repository for adjustment stocks to handle database operations.
- Enhanced the adjustment service to track stock adjustments using FIFO logic for both increase and decrease operations.
- Updated product warehouse DTOs and repositories to include project flock information.
- Implemented FIFO logic in the transfer module to manage stock transfers between warehouses.
- Added integration tests for FIFO operations in stock transfers, ensuring correct stock consumption and replenishment.
2025-12-28 19:15:41 +07:00
MacBook Air M1 10f42ed9c4 feat[BE-378]:Create API Get All HPP Harian Kandang 2025-12-28 18:41:46 +07:00
aguhh18 a0d2c1c7dd feat[BE]: enhance marketing module by adding ProductWarehouseId to marketing delivery product creation 2025-12-28 10:40:20 +07:00
aguhh18 56811f7c5b feat[BE]: integrate kandang repository into expense bridge for enhanced expense management 2025-12-28 08:57:35 +07:00
aguhh18 647bfbb667 Merge branch 'feat/BE/Sprint-8' of https://gitlab.com/mbugroup/lti-api into dev/teguh 2025-12-28 08:20:32 +07:00
aguhh18 ec6da57510 feat[BE]: enhance expense management with location and project flock integration, including updates to migrations, entities, services, and validations 2025-12-28 08:13:50 +07:00
93 changed files with 6264 additions and 1075 deletions
Vendored
BIN
View File
Binary file not shown.
+7
View File
@@ -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
+21 -1
View File
@@ -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=
@@ -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
@@ -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;
@@ -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;
@@ -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;
@@ -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);
@@ -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);
@@ -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)';
@@ -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;
@@ -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;
@@ -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;
@@ -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);
@@ -0,0 +1,3 @@
-- Remove standard_fcr column from production_standard_details table
ALTER TABLE production_standard_details
DROP COLUMN IF EXISTS standard_fcr;
@@ -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);
File diff suppressed because it is too large Load Diff
+139 -722
View File
@@ -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
}
+29
View File
@@ -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"`
}
+3
View File
@@ -12,6 +12,8 @@ type Expense struct {
SupplierId uint64 `gorm:""`
Category string `gorm:"type:varchar(50);not null"`
PoNumber string `gorm:"type:varchar(50)"`
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"`
@@ -21,6 +23,7 @@ type Expense struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
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"`
@@ -12,6 +12,7 @@ type ProductionStandardDetail struct {
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"`
@@ -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"
}
+2
View File
@@ -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"`
+24 -8
View File
@@ -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"`
}
+18
View File
@@ -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"`
}
+5 -6
View File
@@ -104,12 +104,11 @@ func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) {
}
func ActorIDFromContext(c *fiber.Ctx) (uint, error) {
// user, ok := AuthenticatedUser(c)
// if !ok || user == nil || user.Id == 0 {
// return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
// }
// return user.Id, nil
return 1, nil
user, ok := AuthenticatedUser(c)
if !ok || user == nil || user.Id == 0 {
return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
}
return user.Id, nil
}
// AuthDetails returns the full authentication context (token, claims, user).
+31 -6
View File
@@ -162,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"
@@ -194,12 +218,13 @@ const (
)
const (
P_FinanceGetAll = "lti.finance.list"
P_FinanceGetOne = "lti.finance.detail"
P_FinanceCreateOne = "lti.finance.create"
P_FinanceUpdateOne = "lti.finance.update"
P_FinanceDeleteOne = "lti.finance.delete"
P_FinanceApproval = "lti.finance.approve"
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 (
+26 -24
View File
@@ -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,
}
}
@@ -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)
@@ -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 ?
@@ -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
}
@@ -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")
@@ -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
}
@@ -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
}
+18 -5
View File
@@ -76,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"`
@@ -178,7 +177,6 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
var pengajuans []ExpenseNonstockDTO
var realisasi []ExpenseRealizationDTO
// Map documents from Document service
for _, doc := range e.Documents {
documents = append(documents, DocumentDTO{
ID: uint64(doc.Id),
@@ -186,7 +184,6 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
})
}
// Map realization documents from Document service
for _, doc := range e.RealizationDocuments {
realizationDocs = append(realizationDocs, DocumentDTO{
ID: uint64(doc.Id),
@@ -271,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
@@ -287,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)
}
}
@@ -316,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,
}
}
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -144,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
}
@@ -199,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,
}
@@ -216,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,
@@ -254,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")
}
}
@@ -361,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)
@@ -475,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 {
@@ -498,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,
@@ -519,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")
}
}
@@ -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"`
}
+5 -5
View File
@@ -1,7 +1,7 @@
package initials
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/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"
@@ -13,9 +13,9 @@ 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.Use(m.Auth(u))
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
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)
}
+5 -5
View File
@@ -1,7 +1,7 @@
package injections
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/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"
@@ -13,9 +13,9 @@ func InjectionRoutes(v1 fiber.Router, u user.UserService, s injection.InjectionS
ctrl := controller.NewInjectionController(s)
route := v1.Group("/injections")
// route.Use(m.Auth(u))
route.Use(m.Auth(u))
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
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)
}
+5 -5
View File
@@ -1,7 +1,7 @@
package payments
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/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"
@@ -13,9 +13,9 @@ 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.Use(m.Auth(u))
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
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)
}
@@ -1,7 +1,7 @@
package transactions
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/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"
@@ -13,9 +13,9 @@ func TransactionRoutes(v1 fiber.Router, u user.UserService, s transaction.Transa
ctrl := controller.NewTransactionController(s)
route := v1.Group("/transactions")
// route.Use(m.Auth(u))
route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll)
route.Get("/:id", ctrl.GetOne)
route.Delete("/:id", ctrl.DeleteOne)
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)
}
@@ -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)
@@ -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
}
@@ -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,
}
}
@@ -103,39 +117,37 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
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,15 +164,16 @@ 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{
LoggableType: string(utils.StockLogTypeAdjustment),
LoggableId: 0,
Notes: req.Note,
ProductWarehouseId: productWarehouse.Id,
CreatedBy: actorID, // TODO: should Get from auth middleware
CreatedBy: actorID,
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
afterQuantity += req.Quantity
newLog.Increase = afterQuantity
@@ -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: &note,
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)
@@ -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{
@@ -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
@@ -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) {
@@ -159,7 +159,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
Id: d.Product.Id,
Name: d.Product.Name,
},
Quantity: d.Quantity,
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
})
}
@@ -229,7 +229,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
Id: d.Product.Id,
Name: d.Product.Name,
},
Quantity: d.Quantity,
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
})
}
+41 -1
View File
@@ -18,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{}
@@ -34,13 +36,51 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil {
panic(err)
}
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc)
// 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)
@@ -44,9 +44,10 @@ type transferService struct {
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, documentSvc commonSvc.DocumentService) 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,
@@ -60,6 +61,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
WarehouseRepo: warehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc,
FifoSvc: fifoSvc,
}
}
@@ -104,28 +106,23 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
}
func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) {
s.Log.Infof("Attempting to get StockTransfer with ID: %d", id)
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db)
})
if err != nil {
s.Log.Errorf("Error getting StockTransfer ID %d: %+v", id, err)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer")
}
if transferPtr != nil {
s.Log.Infof("StockTransfer %d has %d documents", transferPtr.Id, len(transferPtr.Documents))
}
return transferPtr, nil
}
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 {
@@ -152,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
@@ -206,14 +218,62 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return err
}
var details []*entity.StockTransferDetail
// 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 {
details = append(details, &entity.StockTransferDetail{
// Get source product warehouse
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
)
if err != nil {
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) {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse destination")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context()
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID))
if err != nil {
return err
}
destPW = &entity.ProductWarehouse{
ProductId: uint(product.ProductID),
WarehouseId: uint(req.DestinationWarehouseID),
Quantity: 0,
ProjectFlockKandangId: &projectFlockKandangID,
}
if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination")
}
}
detail := &entity.StockTransferDetail{
StockTransferId: entityTransfer.Id,
ProductId: uint64(product.ProductID),
Quantity: product.ProductQty,
})
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,
}
details = append(details, detail)
detailMap[uint64(product.ProductID)] = detail
}
if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil {
return err
}
@@ -233,23 +293,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
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)]
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: detailID,
StockTransferDetailId: detail.Id,
Quantity: prod.ProductQty,
})
}
@@ -275,74 +330,61 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d", idx+1))
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 {
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID))
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.StatusInternalServerError, "Failed to get source product warehouse")
}
if sourcePW.Quantity < product.ProductQty {
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 {
return err
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err))
}
decreaseLog := &entity.StockLog{
Decrease: product.ProductQty,
Notes: "",
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(entityTransfer.Id),
ProductWarehouseId: sourcePW.Id,
CreatedBy: actorID,
}
if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil {
return 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)
}
destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse")
}
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context()
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID))
if err != nil {
return err
}
destPW = &entity.ProductWarehouse{
ProductId: uint(product.ProductID),
WarehouseId: uint(req.DestinationWarehouseID),
Quantity: 0,
ProjectFlockKandangId: &projectFlockKandangID,
}
if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse")
}
// 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: &note,
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))
}
destPW.Quantity += product.ProductQty
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil {
return err
}
increaseLog := &entity.StockLog{
Increase: product.ProductQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(entityTransfer.Id),
Notes: "",
ProductWarehouseId: destPW.Id,
CreatedBy: actorID,
}
if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil {
return 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)
}
}
@@ -350,7 +392,6 @@ 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, fmt.Sprintf("Failed to process transfer transaction: %v", err))
}
+1 -8
View File
@@ -33,18 +33,15 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
customerRepo := rCustomer.NewCustomerRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
// Initialize FIFO service
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
// Register marketing_delivery_products as FIFO Usable
// Note: ProductWarehouseID comes from marketing_products table via preload
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyMarketingDelivery,
Table: "marketing_delivery_products",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id", // Resolved from marketing_products via preload
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
@@ -55,11 +52,9 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
}
}
// Initialize approval service
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))
}
@@ -67,11 +62,9 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
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, fifoService, validate)
userService := sUser.NewUserService(userRepo, validate)
// Register routes
RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService)
}
@@ -247,11 +247,15 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
itemDeliveryDate = &parsedDate
}
// 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
@@ -357,11 +361,15 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
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
@@ -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")
@@ -589,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
@@ -604,6 +611,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
marketingDeliveryProduct := &entity.MarketingDeliveryProduct{
MarketingProductId: marketingProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
UnitPrice: 0,
TotalWeight: 0,
AvgWeight: 0,
@@ -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"`
}
@@ -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 {
@@ -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
@@ -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 {
@@ -40,7 +40,7 @@ func (u *ProductionStandardController) GetAll(c *fiber.Ctx) error {
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.ProductionStandardListDTO]{
JSON(response.SuccessWithPaginate[dto.ProductionStandardRelationDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all productionStandards successfully",
@@ -7,7 +7,7 @@ import (
// === DTO Structs ===
type ProductionStandardListDTO struct {
type ProductionStandardRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
ProjectCategory string `json:"project_category"`
@@ -15,7 +15,7 @@ type ProductionStandardListDTO struct {
}
type ProductionStandardDetailDTO struct {
ProductionStandardListDTO
ProductionStandardRelationDTO
Details []WeeklyProductionStandardDTO `json:"details"`
}
@@ -33,6 +33,7 @@ type EggProductionStandardDetailDTO struct {
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 {
@@ -43,14 +44,14 @@ type WeeklyProductionStandardDTO struct {
// === Mapper Functions ===
func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardListDTO {
func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardRelationDTO {
var createdUser *userDTO.UserRelationDTO
if e.CreatedUser.Id != 0 {
mapped := userDTO.ToUserRelationDTO(e.CreatedUser)
createdUser = &mapped
}
return ProductionStandardListDTO{
return ProductionStandardRelationDTO{
Id: e.Id,
Name: e.Name,
ProjectCategory: e.ProjectCategory,
@@ -58,8 +59,16 @@ func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandard
}
}
func ToProductionStandardListDTOs(e []entity.ProductionStandard) []ProductionStandardListDTO {
result := make([]ProductionStandardListDTO, len(e))
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)
}
@@ -87,6 +96,7 @@ func ToWeeklyProductionStandardDTOWithDetails(growth entity.StandardGrowthDetail
TargetHenHouseProduction: detail.TargetHenHouseProduction,
TargetEggWeight: detail.TargetEggWeight,
TargetEggMass: detail.TargetEggMass,
StandardFCR: detail.StandardFCR,
}
return WeeklyProductionStandardDTO{
@@ -140,6 +150,7 @@ func ToEggProductionStandardDetailDTO(e entity.ProductionStandardDetail) EggProd
TargetHenHouseProduction: e.TargetHenHouseProduction,
TargetEggWeight: e.TargetEggWeight,
TargetEggMass: e.TargetEggMass,
StandardFCR: e.StandardFCR,
}
}
@@ -149,7 +160,7 @@ func ToProductionStandardDetailDTO(
productionStandardDetails []entity.ProductionStandardDetail,
) ProductionStandardDetailDTO {
return ProductionStandardDetailDTO{
ProductionStandardListDTO: ToProductionStandardListDTO(standard),
ProductionStandardRelationDTO: ToProductionStandardRelationDTO(standard),
Details: ToWeeklyProductionStandardDTOsWithDetails(growthDetails, productionStandardDetails),
}
}
@@ -15,9 +15,9 @@ func ProductionStandardRoutes(v1 fiber.Router, u user.UserService, s productionS
route := v1.Group("/production-standards")
route.Use(m.Auth(u))
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_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)
}
@@ -84,7 +84,6 @@ func (s productionStandardService) GetOne(c *fiber.Ctx, id uint) (*entity.Produc
return nil, fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found")
}
if err != nil {
s.Log.Errorf("Failed get productionStandard by id: %+v", err)
return nil, err
}
return productionStandard, nil
@@ -111,6 +110,7 @@ func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Crea
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)
@@ -142,6 +142,7 @@ func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Crea
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 {
@@ -206,7 +207,6 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat
nameExists, err := s.Repository.NameExists(c.Context(), *req.Name, &id)
if err != nil {
s.Log.Errorf("Failed to check existing production standard: %+v", err)
return err
}
if nameExists {
@@ -255,6 +255,7 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat
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 {
@@ -283,7 +284,6 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat
})
if err != nil {
s.Log.Errorf("Failed to update production standard: %+v", err)
return nil, err
}
@@ -295,7 +295,6 @@ func (s productionStandardService) DeleteOne(c *fiber.Ctx, id uint) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found")
}
s.Log.Errorf("Failed to delete productionStandard: %+v", err)
return err
}
return nil
@@ -5,6 +5,7 @@ type ProductionStandardDetailItem struct {
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 {
@@ -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+"%")
}
@@ -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)
}
@@ -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,
@@ -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 {
@@ -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,
@@ -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
@@ -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)
@@ -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
}
@@ -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
}
@@ -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
@@ -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
@@ -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 {
@@ -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{}).
+4 -2
View File
@@ -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)
@@ -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,
})
}
@@ -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")
}
@@ -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)
}
@@ -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),
}
}
@@ -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)
}
@@ -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
}
@@ -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 &copy
}
func float64Ptr(value float64) *float64 {
copy := value
return &copy
}
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
}
@@ -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
}
+3
View File
@@ -12,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"
@@ -35,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)
@@ -67,6 +69,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
db,
purchaseRepo,
projectFlockKandangRepository,
kandangRepo,
expenseServiceInstance,
)
@@ -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,
}},
}
@@ -164,3 +164,26 @@ func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error {
Data: result,
})
}
func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error {
data, meta, err := c.RepportService.GetHppPerKandang(ctx)
if err != nil {
return err
}
resp := struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Meta dto.HppPerKandangMetaDTO `json:"meta"`
Data dto.HppPerKandangResponseData `json:"data"`
}{
Code: fiber.StatusOK,
Status: "success",
Message: "Get HPP harian kandang layer successfully",
Meta: *meta,
Data: *data,
}
return ctx.Status(fiber.StatusOK).JSON(resp)
}
@@ -0,0 +1,123 @@
package dto
type HppPerKandangFiltersDTO struct {
AreaID string `json:"area_id"`
LocationID string `json:"location_id"`
KandangID string `json:"kandang_id"`
WeightMin string `json:"weight_min"`
WeightMax string `json:"weight_max"`
Period string `json:"period"`
ShowUnrecorded string `json:"show_unrecorded"`
}
type HppPerKandangMetaDTO struct {
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int64 `json:"total_pages"`
TotalResults int64 `json:"total_results"`
Filters HppPerKandangFiltersDTO `json:"filters"`
}
type HppPerKandangResponseData struct {
Period string `json:"period"`
Rows []HppPerKandangRowDTO `json:"rows"`
Summary HppPerKandangSummaryDTO `json:"summary"`
}
type HppPerKandangRowDTO struct {
ID int `json:"id"`
Kandang HppPerKandangRowKandangDTO `json:"kandang"`
WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"`
RemainingChickenBirds int64 `json:"remaining_chicken_birds"`
RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"`
AvgWeightKg float64 `json:"avg_weight_kg"`
EggProductionPieces int64 `json:"egg_production_pieces"`
EggProductionKg float64 `json:"egg_production_kg"`
// FeedCostRp float64 `json:"feed_cost_rp"`
// OvkCostRp float64 `json:"ovk_cost_rp"`
EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"`
EggValueRp int64 `json:"egg_value_rp"`
FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"`
DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"`
AverageDocPriceRp int64 `json:"average_doc_price_rp"`
HppRp float64 `json:"hpp_rp"`
RemainingValueRp int64 `json:"remaining_value_rp"`
}
type HppPerKandangRowKandangDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Location HppPerKandangLocationDTO `json:"location"`
Pic HppPerKandangPICDTO `json:"pic"`
}
type HppPerKandangLocationDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type HppPerKandangPICDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type HppPerKandangWeightRangeDTO struct {
WeightMin float64 `json:"weight_min"`
WeightMax float64 `json:"weight_max"`
}
type HppPerKandangSupplierDTO struct {
ID int64 `json:"id"`
Name string `json:"name"`
Alias string `json:"alias"`
Category string `json:"category"`
}
type HppPerKandangSummaryDTO struct {
PerWeightRange []HppPerKandangSummaryWeightRangeDTO `json:"per_weight_range"`
Total HppPerKandangSummaryTotalDTO `json:"total"`
}
type HppPerKandangSummaryWeightRangeDTO struct {
ID int `json:"id"`
WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"`
Label string `json:"label"`
RemainingChickenBirds int64 `json:"remaining_chicken_birds"`
RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"`
AvgWeightKg float64 `json:"avg_weight_kg"`
EggProductionPieces int64 `json:"egg_production_pieces"`
EggProductionKg float64 `json:"egg_production_kg"`
EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"`
EggValueRp int64 `json:"egg_value_rp"`
FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"`
DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"`
AverageDocPriceRp float64 `json:"average_doc_price_rp"`
HppRp float64 `json:"hpp_rp"`
RemainingValueRp int64 `json:"remaining_value_rp"`
}
type HppPerKandangSummaryTotalDTO struct {
TotalRemainingChickenBirds int64 `json:"total_remaining_chicken_birds"`
TotalRemainingChickenWeightKg float64 `json:"total_remaining_chicken_weight_kg"`
AverageWeightKg float64 `json:"average_weight_kg"`
TotalRemainingValueRp int64 `json:"total_remaining_value_rp"`
TotalEggProductionPieces int64 `json:"total_egg_production_pieces"`
TotalEggProductionKg float64 `json:"total_egg_production_kg"`
AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"`
TotalEggValueRp int64 `json:"total_egg_value_rp"`
TotalHppRp float64 `json:"total_hpp_rp"`
TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"`
}
func NewHppPerKandangFiltersDTO(area, location, kandang, weightMin, weightMax, period, showUnrecorded string) HppPerKandangFiltersDTO {
return HppPerKandangFiltersDTO{
AreaID: area,
LocationID: location,
KandangID: kandang,
WeightMin: weightMin,
WeightMax: weightMax,
Period: period,
ShowUnrecorded: showUnrecorded,
}
}
+2 -1
View File
@@ -31,10 +31,11 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
recordingRepository := recordingRepo.NewRecordingRepository(db)
approvalRepository := commonRepo.NewApprovalRepository(db)
purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db)
hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db)
userRepository := rUser.NewUserRepository(db)
approvalSvc := approvalService.NewApprovalService(approvalRepository)
repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository)
repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, hppPerKandangRepository)
userService := sUser.NewUserService(userRepository, validate)
RepportRoutes(router, userService, repportService)
@@ -0,0 +1,361 @@
package repositories
import (
"context"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type HppPerKandangRow struct {
KandangID uint
KandangName string
KandangStatus string
LocationID uint
LocationName string
PicID uint
PicName string
RemainingChickenBirds float64
RemainingChickenWeight float64
EggProductionWeightKg float64
EggProductionPieces float64
}
type HppPerKandangCostRow struct {
KandangID uint
FeedCost float64
OvkCost float64
DocCost float64
DocQty float64
BudgetCost float64
ExpenseCost float64
}
type HppPerKandangSupplierRow struct {
KandangID uint
SupplierID uint
SupplierName string
SupplierAlias string
Category string
}
type HppPerKandangRepository interface {
GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error)
GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error)
}
type hppPerKandangRepository struct {
db *gorm.DB
}
func NewHppPerKandangRepository(db *gorm.DB) HppPerKandangRepository {
return &hppPerKandangRepository{db: db}
}
func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) {
var rows []HppPerKandangRow
query := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
k.id AS kandang_id,
k.name AS kandang_name,
k.status AS kandang_status,
loc.id AS location_id,
loc.name AS location_name,
pic.id AS pic_id,
pic.name AS pic_name,
COALESCE(MAX(r.total_chick_qty), 0) AS remaining_chicken_birds,
COALESCE(SUM(rbw.total_weight), 0) AS remaining_chicken_weight,
COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg,
COALESCE(SUM(re.qty), 0) AS egg_production_pieces`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("JOIN users AS pic ON pic.id = k.pic_id").
Joins("LEFT JOIN recording_bws AS rbw ON rbw.recording_id = r.id").
Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs)
query = query.Group("k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name").
Order("k.id ASC")
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) {
var rows []HppPerKandangCostRow
recordingPfk := r.db.WithContext(ctx).
Table("recordings AS r").
Select("DISTINCT pfk.id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs)
purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS").String()
transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_DETAILS").String()
query := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
k.id AS kandang_id,
COALESCE(SUM(CASE
WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
ELSE 0
END), 0) AS feed_cost,
COALESCE(SUM(CASE
WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
ELSE 0
END), 0) AS ovk_cost`,
utils.FlagPakan, transferStockableKey, utils.FlagPakan,
utils.FlagOVK, transferStockableKey, utils.FlagOVK).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), 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 (?)", recordingPfk.Session(&gorm.Session{NewDB: true})).
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs)
query = query.Group("k.id").Order("k.id ASC")
if err := query.Scan(&rows).Error; err != nil {
return nil, nil, err
}
docRows := make([]struct {
KandangID uint
DocCost float64
DocQty float64
SupplierID *uint
SupplierName *string
SupplierAlias *string
}, 0)
docQuery := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
pfk.kandang_id AS kandang_id,
COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost,
COALESCE(SUM(pc.usage_qty), 0) AS doc_qty,
s.id AS supplier_id,
s.name AS supplier_name,
s.alias AS supplier_alias`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id").
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
Where("pc.project_flock_kandang_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})).
Group("pfk.kandang_id, s.id, s.name, s.alias")
docQuery = applyLocationFilters(docQuery, areaIDs, locationIDs, kandangIDs)
if err := docQuery.Scan(&docRows).Error; err != nil {
return nil, nil, err
}
costMap := make(map[uint]*HppPerKandangCostRow, len(rows))
for i := range rows {
row := rows[i]
costMap[row.KandangID] = &rows[i]
}
docSuppliers := make([]HppPerKandangSupplierRow, 0)
docSeen := make(map[uint]map[uint]bool)
for _, doc := range docRows {
entry, ok := costMap[doc.KandangID]
if !ok {
rows = append(rows, HppPerKandangCostRow{
KandangID: doc.KandangID,
})
entry = &rows[len(rows)-1]
costMap[doc.KandangID] = entry
}
entry.DocCost += doc.DocCost
entry.DocQty += doc.DocQty
if doc.SupplierID != nil {
if docSeen[doc.KandangID] == nil {
docSeen[doc.KandangID] = make(map[uint]bool)
}
if !docSeen[doc.KandangID][*doc.SupplierID] {
docSeen[doc.KandangID][*doc.SupplierID] = true
supplierName := ""
if doc.SupplierName != nil {
supplierName = *doc.SupplierName
}
supplierAlias := ""
if doc.SupplierAlias != nil {
supplierAlias = *doc.SupplierAlias
}
docSuppliers = append(docSuppliers, HppPerKandangSupplierRow{
KandangID: doc.KandangID,
SupplierID: *doc.SupplierID,
SupplierName: supplierName,
SupplierAlias: supplierAlias,
Category: "DOC",
})
}
}
}
budgetRows := make([]struct {
KandangID uint
BudgetCost float64
}, 0)
pfkUsageSub := r.db.
Table("project_chickins AS pc").
Select(`
pc.project_flock_kandang_id,
SUM(pc.usage_qty) AS kandang_usage_qty`).
Group("pc.project_flock_kandang_id")
projectUsageSub := r.db.
Table("project_chickins AS pc").
Select(`
pfk.project_flock_id,
SUM(pc.usage_qty) AS project_usage_qty`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
Group("pfk.project_flock_id")
budgetQuery := r.db.WithContext(ctx).
Table("project_flock_kandangs AS pfk").
Select(`
k.id AS kandang_id,
COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0) AS budget_cost`).
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id").
Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub).
Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub).
Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})).
Group("k.id")
budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs)
if err := budgetQuery.Scan(&budgetRows).Error; err != nil {
return nil, nil, err
}
for _, budget := range budgetRows {
entry, ok := costMap[budget.KandangID]
if !ok {
rows = append(rows, HppPerKandangCostRow{
KandangID: budget.KandangID,
})
entry = &rows[len(rows)-1]
costMap[budget.KandangID] = entry
}
entry.BudgetCost += budget.BudgetCost
}
expenseRows := make([]struct {
KandangID uint
ExpenseCost float64
}, 0)
expenseQuery := r.db.WithContext(ctx).
Table("project_flock_kandangs AS pfk").
Select(`
k.id AS kandang_id,
COALESCE(SUM(er.qty * er.price), 0) AS expense_cost`).
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id").
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})).
Group("k.id")
expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs)
if err := expenseQuery.Scan(&expenseRows).Error; err != nil {
return nil, nil, err
}
for _, exp := range expenseRows {
entry, ok := costMap[exp.KandangID]
if !ok {
rows = append(rows, HppPerKandangCostRow{
KandangID: exp.KandangID,
})
entry = &rows[len(rows)-1]
costMap[exp.KandangID] = entry
}
entry.ExpenseCost += exp.ExpenseCost
}
feedSuppliers := make([]HppPerKandangSupplierRow, 0)
feedQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select("DISTINCT k.id AS kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey).
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}).
Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})).
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs)
if err := feedQuery.Scan(&feedSuppliers).Error; err != nil {
return nil, nil, err
}
for i := range feedSuppliers {
if _, exists := costMap[feedSuppliers[i].KandangID]; !exists {
rows = append(rows, HppPerKandangCostRow{
KandangID: feedSuppliers[i].KandangID,
})
costMap[feedSuppliers[i].KandangID] = &rows[len(rows)-1]
}
feedSuppliers[i].Category = "FEED"
}
supplierRows := append(docSuppliers, feedSuppliers...)
return rows, supplierRows, nil
}
func applyLocationFilters(query *gorm.DB, areaIDs, locationIDs, kandangIDs []int64) *gorm.DB {
if len(areaIDs) > 0 {
query = query.Where("loc.area_id IN ?", areaIDs)
}
if len(locationIDs) > 0 {
query = query.Where("k.location_id IN ?", locationIDs)
}
if len(kandangIDs) > 0 {
query = query.Where("k.id IN ?", kandangIDs)
}
return query
}
+2
View File
@@ -18,4 +18,6 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService
route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense)
route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing)
route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier)
route.Get("/hpp-per-kandang", ctrl.GetHppPerKandang)
}
@@ -2,6 +2,12 @@ package service
import (
"context"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
@@ -28,6 +34,7 @@ type RepportService interface {
GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error)
GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error)
GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error)
GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error)
}
type repportService struct {
@@ -40,6 +47,16 @@ type repportService struct {
RecordingRepo recordingRepo.RecordingRepository
ApprovalSvc approvalService.ApprovalService
PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository
HppPerKandangRepo repportRepo.HppPerKandangRepository
}
type HppCostAggregate struct {
FeedCost float64
OvkCost float64
DocCost float64
DocQty float64
BudgetCost float64
ExpenseCost float64
}
func NewRepportService(
@@ -51,6 +68,7 @@ func NewRepportService(
recordingRepo recordingRepo.RecordingRepository,
approvalSvc approvalService.ApprovalService,
purchaseSupplierRepo repportRepo.PurchaseSupplierRepository,
hppPerKandangRepo repportRepo.HppPerKandangRepository,
) RepportService {
return &repportService{
Log: utils.Log,
@@ -62,6 +80,7 @@ func NewRepportService(
RecordingRepo: recordingRepo,
ApprovalSvc: approvalSvc,
PurchaseSupplierRepo: purchaseSupplierRepo,
HppPerKandangRepo: hppPerKandangRepo,
}
}
@@ -264,3 +283,438 @@ func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.Pu
return result, totalSuppliers, nil
}
func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) {
params, filters, err := s.parseHppPerKandangQuery(ctx)
if err != nil {
return nil, nil, err
}
if err := s.Validate.Struct(params); err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
}
periodDate, err := time.ParseInLocation("2006-01-02", params.Period, location)
if err != nil {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD")
}
startOfDay := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, location)
endOfDay := startOfDay.Add(24 * time.Hour)
repoRows, err := s.HppPerKandangRepo.GetRowsByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs)
if err != nil {
return nil, nil, err
}
costRows, supplierRows, err := s.HppPerKandangRepo.GetFeedOvkDocCostByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs)
if err != nil {
return nil, nil, err
}
costMap := make(map[uint]HppCostAggregate, len(costRows))
for _, row := range costRows {
costMap[row.KandangID] = HppCostAggregate{
FeedCost: row.FeedCost,
OvkCost: row.OvkCost,
DocCost: row.DocCost,
DocQty: row.DocQty,
BudgetCost: row.BudgetCost,
ExpenseCost: row.ExpenseCost,
}
}
docSupplierMap := make(map[uint][]dto.HppPerKandangSupplierDTO)
feedSupplierMap := make(map[uint][]dto.HppPerKandangSupplierDTO)
docSeen := make(map[uint]map[uint]bool)
feedSeen := make(map[uint]map[uint]bool)
for _, sup := range supplierRows {
if sup.SupplierID == 0 {
continue
}
targetMap := feedSupplierMap
seen := feedSeen
category := "FEED"
if strings.EqualFold(sup.Category, "DOC") {
targetMap = docSupplierMap
seen = docSeen
category = "DOC"
}
if seen[sup.KandangID] == nil {
seen[sup.KandangID] = make(map[uint]bool)
}
if seen[sup.KandangID][sup.SupplierID] {
continue
}
seen[sup.KandangID][sup.SupplierID] = true
targetMap[sup.KandangID] = append(targetMap[sup.KandangID], dto.HppPerKandangSupplierDTO{
ID: int64(sup.SupplierID),
Name: sup.SupplierName,
Alias: sup.SupplierAlias,
Category: category,
})
}
type weightRangeKey struct {
Min float64
Max float64
}
type weightRangeAggregate struct {
Summary *dto.HppPerKandangSummaryWeightRangeDTO
EggHppSum float64
EggHppCount int
}
dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows))
perRangeMap := make(map[weightRangeKey]*weightRangeAggregate)
var totalBirds int64
var totalWeight float64
var totalEggPieces int64
var totalEggKg float64
var totalRemainingValueRp int64
var totalEggValueRp int64
var totalHppSum float64
var totalHppCount int
var totalDocPriceSum float64
var totalDocPriceCount int
var totalEggHppSum float64
var totalEggHppCount int
for _, row := range repoRows {
birdsFloat := row.RemainingChickenBirds
if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) {
birdsFloat = 0
}
weightFloat := row.RemainingChickenWeight
if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) {
weightFloat = 0
}
eggPiecesFloat := row.EggProductionPieces
if math.IsNaN(eggPiecesFloat) || math.IsInf(eggPiecesFloat, 0) {
eggPiecesFloat = 0
}
eggWeightFloat := row.EggProductionWeightKg
if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) {
eggWeightFloat = 0
}
avgWeight := 0.0
if birdsFloat > 0 {
avgWeight = weightFloat / birdsFloat
}
weightMin := math.Floor(avgWeight*10) / 10
if weightMin < 0 {
weightMin = 0
}
weightMax := weightMin + 0.09
rangeKey := weightRangeKey{Min: weightMin, Max: weightMax}
rowBirds := int64(math.Round(birdsFloat))
costEntry := costMap[row.KandangID]
totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost
hppRp := 0.0
if weightFloat > 0 {
hppRp = totalCost / weightFloat
}
eggHpp := 0.0
if eggWeightFloat > 0 {
eggHpp = totalCost / eggWeightFloat
}
rowEggPieces := int64(math.Round(eggPiecesFloat))
rowEggValue := int64(eggHpp * eggWeightFloat)
rowRemainingValue := int64(hppRp * weightFloat)
avgDocPrice := int64(0)
if costEntry.DocQty > 0 {
avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty))
}
dataRows = append(dataRows, dto.HppPerKandangRowDTO{
ID: int(row.KandangID),
Kandang: dto.HppPerKandangRowKandangDTO{
ID: int64(row.KandangID),
Name: row.KandangName,
Status: row.KandangStatus,
Location: dto.HppPerKandangLocationDTO{
ID: int64(row.LocationID),
Name: row.LocationName,
},
Pic: dto.HppPerKandangPICDTO{
ID: int64(row.PicID),
Name: row.PicName,
},
},
WeightRange: dto.HppPerKandangWeightRangeDTO{
WeightMin: weightMin,
WeightMax: weightMax,
},
RemainingChickenBirds: rowBirds,
RemainingChickenWeightKg: weightFloat,
AvgWeightKg: avgWeight,
// FeedCostRp: costEntry.FeedCost,
// OvkCostRp: costEntry.OvkCost,
DocSuppliers: docSupplierMap[row.KandangID],
FeedSuppliers: feedSupplierMap[row.KandangID],
EggProductionPieces: rowEggPieces,
EggProductionKg: eggWeightFloat,
AverageDocPriceRp: avgDocPrice,
HppRp: hppRp,
EggHppRpPerKg: eggHpp,
RemainingValueRp: rowRemainingValue,
EggValueRp: rowEggValue,
})
totalBirds += rowBirds
totalWeight += weightFloat
totalEggPieces += rowEggPieces
totalEggKg += eggWeightFloat
totalRemainingValueRp += rowRemainingValue
totalEggValueRp += rowEggValue
if weightFloat > 0 {
totalHppSum += hppRp
totalHppCount++
}
if avgDocPrice > 0 {
totalDocPriceSum += float64(avgDocPrice)
totalDocPriceCount++
}
if eggWeightFloat > 0 {
totalEggHppSum += eggHpp
totalEggHppCount++
}
rangeAgg, exists := perRangeMap[rangeKey]
if !exists {
rangeAgg = &weightRangeAggregate{
Summary: &dto.HppPerKandangSummaryWeightRangeDTO{
WeightRange: dto.HppPerKandangWeightRangeDTO{
WeightMin: weightMin,
WeightMax: weightMax,
},
Label: fmt.Sprintf("%.2f - %.2f", weightMin, weightMax),
},
}
perRangeMap[rangeKey] = rangeAgg
}
rangeSummary := rangeAgg.Summary
rangeSummary.RemainingChickenBirds += rowBirds
rangeSummary.RemainingChickenWeightKg += row.RemainingChickenWeight
rangeSummary.EggProductionPieces += rowEggPieces
rangeSummary.EggProductionKg += eggWeightFloat
rangeSummary.RemainingValueRp += rowRemainingValue
rangeSummary.EggValueRp += rowEggValue
if eggWeightFloat > 0 {
rangeAgg.EggHppSum += eggHpp
rangeAgg.EggHppCount++
}
}
rangeKeys := make([]weightRangeKey, 0, len(perRangeMap))
for key := range perRangeMap {
rangeKeys = append(rangeKeys, key)
}
sort.Slice(rangeKeys, func(i, j int) bool {
if rangeKeys[i].Min == rangeKeys[j].Min {
return rangeKeys[i].Max < rangeKeys[j].Max
}
return rangeKeys[i].Min < rangeKeys[j].Min
})
perRangeSummary := make([]dto.HppPerKandangSummaryWeightRangeDTO, 0, len(rangeKeys))
for idx, key := range rangeKeys {
agg := perRangeMap[key]
entry := agg.Summary
entry.ID = idx + 1
if entry.RemainingChickenBirds > 0 {
entry.AvgWeightKg = entry.RemainingChickenWeightKg / float64(entry.RemainingChickenBirds)
}
if agg.EggHppCount > 0 {
entry.EggHppRpPerKg = agg.EggHppSum / float64(agg.EggHppCount)
}
perRangeSummary = append(perRangeSummary, *entry)
}
totalSummary := dto.HppPerKandangSummaryTotalDTO{
TotalRemainingChickenBirds: totalBirds,
TotalRemainingChickenWeightKg: totalWeight,
TotalEggProductionPieces: totalEggPieces,
TotalEggProductionKg: totalEggKg,
TotalRemainingValueRp: totalRemainingValueRp,
TotalEggValueRp: totalEggValueRp,
}
if totalBirds > 0 {
totalSummary.AverageWeightKg = totalWeight / float64(totalBirds)
}
if totalEggHppCount > 0 {
totalSummary.AverageEggHppRpPerKg = totalEggHppSum / float64(totalEggHppCount)
}
if totalHppCount > 0 {
totalSummary.TotalHppRp = totalHppSum / float64(totalHppCount)
}
if totalDocPriceCount > 0 {
totalSummary.TotalAverageDocPriceRp = totalDocPriceSum / float64(totalDocPriceCount)
}
limit := params.Limit
if limit <= 0 {
limit = 10
}
totalCount := len(dataRows)
offset := (params.Page - 1) * limit
if offset < 0 {
offset = 0
}
if offset > totalCount {
offset = totalCount
}
end := offset + limit
if end > totalCount {
end = totalCount
}
pagedRows := dataRows[offset:end]
data := dto.HppPerKandangResponseData{
Period: params.Period,
Rows: pagedRows,
Summary: dto.HppPerKandangSummaryDTO{
PerWeightRange: perRangeSummary,
Total: totalSummary,
},
}
totalResults := int64(totalCount)
totalPages := int64(0)
if totalResults > 0 {
totalPages = int64(math.Ceil(float64(totalResults) / float64(limit)))
}
if totalPages == 0 {
totalPages = 1
}
meta := &dto.HppPerKandangMetaDTO{
Page: params.Page,
Limit: limit,
TotalPages: totalPages,
TotalResults: totalResults,
Filters: filters,
}
return &data, meta, nil
}
func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.HppPerKandangQuery, dto.HppPerKandangFiltersDTO, error) {
page := ctx.QueryInt("page", 1)
if page < 1 {
page = 1
}
limit := ctx.QueryInt("limit", 10)
if limit < 1 {
limit = 10
}
rawArea := ctx.Query("area_id", "")
rawLocation := ctx.Query("location_id", "")
rawKandang := ctx.Query("kandang_id", "")
rawWeightMin := ctx.Query("weight_min", "")
rawWeightMax := ctx.Query("weight_max", "")
period := ctx.Query("period", "")
showUnrecorded := ctx.QueryBool("show_unrecorded", false)
areaIDs, err := parseCommaSeparatedInt64s(rawArea)
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
locationIDs, err := parseCommaSeparatedInt64s(rawLocation)
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
kandangIDs, err := parseCommaSeparatedInt64s(rawKandang)
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
weightMin, err := parseOptionalFloat64(rawWeightMin)
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
weightMax, err := parseOptionalFloat64(rawWeightMax)
if err != nil {
return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
params := &validation.HppPerKandangQuery{
Page: page,
Limit: limit,
Period: period,
ShowUnrecorded: showUnrecorded,
AreaIDs: areaIDs,
LocationIDs: locationIDs,
KandangIDs: kandangIDs,
WeightMin: weightMin,
WeightMax: weightMax,
}
showUnrecordedFilter := ""
if showUnrecorded {
showUnrecordedFilter = "true"
}
filters := dto.NewHppPerKandangFiltersDTO(
rawArea,
rawLocation,
rawKandang,
rawWeightMin,
rawWeightMax,
period,
showUnrecordedFilter,
)
return params, filters, nil
}
func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
result := make([]int64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid integer value '%s'", part)
}
result = append(result, id)
}
return result, nil
}
func parseOptionalFloat64(raw string) (*float64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return nil, fmt.Errorf("invalid float value '%s'", raw)
}
return &value, nil
}
@@ -42,3 +42,15 @@ type PurchaseSupplierQuery struct {
SortBy string `query:"sort_by" validate:"omitempty"`
FilterBy string `query:"filter_by" validate:"omitempty"`
}
type HppPerKandangQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"`
Period string `query:"period" validate:"required"`
ShowUnrecorded bool `query:"show_unrecorded"`
AreaIDs []int64 `query:"-"`
LocationIDs []int64 `query:"-"`
KandangIDs []int64 `query:"-"`
WeightMin *float64 `query:"-"`
WeightMax *float64 `query:"-"`
}
+19 -4
View File
@@ -289,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
// -------------------------------------------------------------------
@@ -408,12 +423,12 @@ type DocumentType string
type DocumentableType string
const (
DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT"
DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT"
DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT"
DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT"
DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT"
DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER"
DocumentableTypeExpense DocumentableType = "EXPENSE"
DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER"
DocumentableTypeExpense DocumentableType = "EXPENSE"
DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION"
)
@@ -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: &note,
})
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)
}