From e8905be856e7fe33207944384a0135d1ad1c2714 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Thu, 2 Oct 2025 10:51:15 +0700 Subject: [PATCH] Feat(BE-36,37,38,39): master area, customer, kandang, location, warehouse --- Makefile | 5 +- go.mod | 12 +- go.sum | 23 ++ internal/common/repository/helpers.go | 34 +++ .../{ => common}/repository/repository.go | 0 internal/common/service/relation.go | 41 +++ .../validation/custom_validation.go | 0 .../{ => common}/validation/validation.go | 1 - ...250925040409_create_master_tables.down.sql | 12 + ...20250925040409_create_master_tables.up.sql | 59 ++-- internal/database/seed/seeder.go | 4 +- internal/entities/area.go | 19 ++ internal/entities/customer.go | 26 ++ internal/entities/kandang.go | 22 ++ internal/entities/location.go | 21 ++ internal/entities/uom.go | 18 ++ internal/entities/user.go | 17 ++ internal/entities/warehouse.go | 25 ++ internal/middleware/trim/json_body.go | 64 +++++ .../areas/controllers/area.controller.go | 140 +++++++++ internal/modules/master/areas/dto/area.dto.go | 58 ++++ internal/modules/master/areas/module.go | 26 ++ .../areas/repositories/area.repository.go | 31 ++ internal/modules/master/areas/route.go | 28 ++ .../master/areas/services/area.service.go | 145 ++++++++++ .../areas/validations/area.validation.go | 15 + .../controllers/customer.controller.go | 140 +++++++++ .../master/customers/dto/customer.dto.go | 79 ++++++ internal/modules/master/customers/module.go | 26 ++ .../repositories/customer.repository.go | 35 +++ internal/modules/master/customers/route.go | 28 ++ .../customers/services/customer.service.go | 179 ++++++++++++ .../validations/customer.validation.go | 27 ++ .../controllers/kandang.controller.go | 140 +++++++++ .../master/kandangs/dto/kandang.dto.go | 75 +++++ internal/modules/master/kandangs/module.go | 26 ++ .../repositories/kandang.repository.go | 40 +++ internal/modules/master/kandangs/route.go | 28 ++ .../kandangs/services/kandang.service.go | 169 +++++++++++ .../validations/kandang.validation.go | 19 ++ .../controllers/location.controller.go | 140 +++++++++ .../master/locations/dto/location.dto.go | 69 +++++ internal/modules/master/locations/module.go | 26 ++ .../repositories/location.repository.go | 35 +++ internal/modules/master/locations/route.go | 28 ++ .../locations/services/location.service.go | 171 +++++++++++ .../validations/location.validation.go | 19 ++ internal/modules/master/route.go | 12 +- internal/modules/master/uoms/dto/uom.dto.go | 39 ++- .../modules/master/uoms/models/uom.model.go | 19 -- .../uoms/repositories/uom.repository.go | 19 +- .../master/uoms/services/uom.service.go | 49 +++- .../controllers/warehouse.controller.go | 140 +++++++++ .../master/warehouses/dto/warehouse.dto.go | 87 ++++++ internal/modules/master/warehouses/module.go | 26 ++ .../repositories/warehouse.repository.go | 45 +++ internal/modules/master/warehouses/route.go | 28 ++ .../warehouses/services/warehouse.service.go | 268 ++++++++++++++++++ .../validations/warehouse.validation.go | 23 ++ internal/modules/users/dto/user.dto.go | 14 +- internal/modules/users/models/user.model.go | 17 -- .../users/repositories/user.repository.go | 10 +- .../modules/users/services/user.service.go | 26 +- internal/route/route.go | 4 +- internal/utils/constant.go | 39 +++ internal/utils/error.go | 2 +- internal/utils/strings.go | 13 + test/integration/master_data/area_test.go | 27 ++ test/integration/master_data/customer_test.go | 189 ++++++++++++ test/integration/master_data/kandang_test.go | 36 +++ test/integration/master_data/location_test.go | 35 +++ test/integration/master_data/master_data.go | 188 ++++++++++++ .../integration/master_data/warehouse_test.go | 96 +++++++ tools/gen.go | 9 +- tools/templates/dto.tmpl | 31 +- tools/templates/entity.tmpl | 19 ++ tools/templates/model.tmpl | 17 -- tools/templates/repository.tmpl | 10 +- tools/templates/service.tmpl | 32 ++- 79 files changed, 3745 insertions(+), 169 deletions(-) create mode 100644 internal/common/repository/helpers.go rename internal/{ => common}/repository/repository.go (100%) create mode 100644 internal/common/service/relation.go rename internal/{ => common}/validation/custom_validation.go (100%) rename internal/{ => common}/validation/validation.go (99%) create mode 100644 internal/entities/area.go create mode 100644 internal/entities/customer.go create mode 100644 internal/entities/kandang.go create mode 100644 internal/entities/location.go create mode 100644 internal/entities/uom.go create mode 100644 internal/entities/user.go create mode 100644 internal/entities/warehouse.go create mode 100644 internal/middleware/trim/json_body.go create mode 100644 internal/modules/master/areas/controllers/area.controller.go create mode 100644 internal/modules/master/areas/dto/area.dto.go create mode 100644 internal/modules/master/areas/module.go create mode 100644 internal/modules/master/areas/repositories/area.repository.go create mode 100644 internal/modules/master/areas/route.go create mode 100644 internal/modules/master/areas/services/area.service.go create mode 100644 internal/modules/master/areas/validations/area.validation.go create mode 100644 internal/modules/master/customers/controllers/customer.controller.go create mode 100644 internal/modules/master/customers/dto/customer.dto.go create mode 100644 internal/modules/master/customers/module.go create mode 100644 internal/modules/master/customers/repositories/customer.repository.go create mode 100644 internal/modules/master/customers/route.go create mode 100644 internal/modules/master/customers/services/customer.service.go create mode 100644 internal/modules/master/customers/validations/customer.validation.go create mode 100644 internal/modules/master/kandangs/controllers/kandang.controller.go create mode 100644 internal/modules/master/kandangs/dto/kandang.dto.go create mode 100644 internal/modules/master/kandangs/module.go create mode 100644 internal/modules/master/kandangs/repositories/kandang.repository.go create mode 100644 internal/modules/master/kandangs/route.go create mode 100644 internal/modules/master/kandangs/services/kandang.service.go create mode 100644 internal/modules/master/kandangs/validations/kandang.validation.go create mode 100644 internal/modules/master/locations/controllers/location.controller.go create mode 100644 internal/modules/master/locations/dto/location.dto.go create mode 100644 internal/modules/master/locations/module.go create mode 100644 internal/modules/master/locations/repositories/location.repository.go create mode 100644 internal/modules/master/locations/route.go create mode 100644 internal/modules/master/locations/services/location.service.go create mode 100644 internal/modules/master/locations/validations/location.validation.go delete mode 100644 internal/modules/master/uoms/models/uom.model.go create mode 100644 internal/modules/master/warehouses/controllers/warehouse.controller.go create mode 100644 internal/modules/master/warehouses/dto/warehouse.dto.go create mode 100644 internal/modules/master/warehouses/module.go create mode 100644 internal/modules/master/warehouses/repositories/warehouse.repository.go create mode 100644 internal/modules/master/warehouses/route.go create mode 100644 internal/modules/master/warehouses/services/warehouse.service.go create mode 100644 internal/modules/master/warehouses/validations/warehouse.validation.go delete mode 100644 internal/modules/users/models/user.model.go create mode 100644 internal/utils/strings.go create mode 100644 test/integration/master_data/area_test.go create mode 100644 test/integration/master_data/customer_test.go create mode 100644 test/integration/master_data/kandang_test.go create mode 100644 test/integration/master_data/location_test.go create mode 100644 test/integration/master_data/master_data.go create mode 100644 test/integration/master_data/warehouse_test.go create mode 100644 tools/templates/entity.tmpl delete mode 100644 tools/templates/model.tmpl diff --git a/Makefile b/Makefile index 324ecd76..a18b33ec 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ WAIT_DB := docker run --rm --network $(NETWORK) postgres:alpine \ .DEFAULT_GOAL := start # --- Daftar phony targets --- -.PHONY: start build lint gen \ +.PHONY: start build test lint gen \ db-up wait-db \ migration-% migrate-up migrate-down migrate-fresh \ seed \ @@ -40,6 +40,9 @@ start: build: @go build -o tmp/app ./cmd/api +test: + @go test ./test/... + lint: @golangci-lint run diff --git a/go.mod b/go.mod index 62841660..a4ad7610 100644 --- a/go.mod +++ b/go.mod @@ -24,8 +24,11 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -38,15 +41,17 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.17.9 // indirect - github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/klauspost/cpuid/v2 v2.2.3 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect @@ -71,4 +76,9 @@ require ( golang.org/x/text v0.22.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/sqlite v1.5.5 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/go.sum b/go.sum index 5100b618..62bd157a 100644 --- a/go.sum +++ b/go.sum @@ -23,12 +23,18 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -65,6 +71,7 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -81,6 +88,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= @@ -92,6 +101,9 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +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/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= @@ -167,6 +179,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -198,6 +211,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= +gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/common/repository/helpers.go b/internal/common/repository/helpers.go new file mode 100644 index 00000000..ef371330 --- /dev/null +++ b/internal/common/repository/helpers.go @@ -0,0 +1,34 @@ +package repository + +import ( + "context" + + "gorm.io/gorm" +) + +// Exists reports whether a record with the given ID exists for type T. +func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) { + var count int64 + if err := db.WithContext(ctx). + Model(new(T)). + Where("id = ?", id). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) { + var count int64 + q := db.WithContext(ctx). + Model(new(T)). + Where("name = ?", name). + Where("deleted_at IS NULL") + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/internal/repository/repository.go b/internal/common/repository/repository.go similarity index 100% rename from internal/repository/repository.go rename to internal/common/repository/repository.go diff --git a/internal/common/service/relation.go b/internal/common/service/relation.go new file mode 100644 index 00000000..47cc2b9d --- /dev/null +++ b/internal/common/service/relation.go @@ -0,0 +1,41 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// RelationCheck describes a foreign-key style dependency that must exist before processing. +type RelationCheck struct { + Name string + ID *uint + Exists func(context.Context, uint) (bool, error) +} + +// EnsureRelations validates that each RelationCheck is satisfied, returning consistent Fiber errors. +func EnsureRelations(ctx context.Context, checks ...RelationCheck) error { + for _, check := range checks { + if check.ID == nil { + continue + } + + exists, err := check.Exists(ctx, *check.ID) + if err != nil { + return fiber.NewError( + fiber.StatusInternalServerError, + fmt.Sprintf("Failed to check %s", strings.ToLower(check.Name)), + ) + } + if !exists { + return fiber.NewError( + fiber.StatusNotFound, + fmt.Sprintf("%s with id %d not found", check.Name, *check.ID), + ) + } + } + + return nil +} diff --git a/internal/validation/custom_validation.go b/internal/common/validation/custom_validation.go similarity index 100% rename from internal/validation/custom_validation.go rename to internal/common/validation/custom_validation.go diff --git a/internal/validation/validation.go b/internal/common/validation/validation.go similarity index 99% rename from internal/validation/validation.go rename to internal/common/validation/validation.go index a2400047..426974f3 100644 --- a/internal/validation/validation.go +++ b/internal/common/validation/validation.go @@ -70,6 +70,5 @@ func Validator() *validator.Validate { if err := validate.RegisterValidation("omitempty_strict", OmitemptyStrict); err != nil { return nil } - return validate } diff --git a/internal/database/migrations/20250925040409_create_master_tables.down.sql b/internal/database/migrations/20250925040409_create_master_tables.down.sql index e0cd201a..729fed68 100644 --- a/internal/database/migrations/20250925040409_create_master_tables.down.sql +++ b/internal/database/migrations/20250925040409_create_master_tables.down.sql @@ -1,20 +1,32 @@ DROP TABLE IF EXISTS fcr_standards; +DROP INDEX IF EXISTS suppliers_name_unique; DROP INDEX IF EXISTS products_sku_unique; +DROP INDEX IF EXISTS products_name_unique; DROP TABLE IF EXISTS products; DROP TABLE IF EXISTS flags; +DROP INDEX IF EXISTS customers_name_unique; DROP INDEX IF EXISTS customers_email_unique; DROP TABLE IF EXISTS customers; +DROP INDEX IF EXISTS warehouses_name_unique; DROP INDEX IF EXISTS product_categories_code_unique; +DROP INDEX IF EXISTS product_categories_name_unique; DROP TABLE IF EXISTS product_categories; +DROP INDEX IF EXISTS nonstocks_name_unique; DROP TABLE IF EXISTS nonstocks; +DROP INDEX IF EXISTS banks_name_unique; DROP TABLE IF EXISTS banks; +DROP INDEX IF EXISTS kandangs_name_unique; DROP TABLE IF EXISTS warehouses; DROP TABLE IF EXISTS kandangs; +DROP INDEX IF EXISTS locations_name_unique; DROP TABLE IF EXISTS locations; +DROP INDEX IF EXISTS areas_name_unique; DROP TABLE IF EXISTS areas; +DROP INDEX IF EXISTS uoms_name_unique; DROP TABLE IF EXISTS uoms; DROP TABLE IF EXISTS suppliers; +DROP INDEX IF EXISTS fcrs_name_unique; DROP TABLE IF EXISTS fcrs; DROP TABLE IF EXISTS projects; DROP INDEX IF EXISTS users_id_user_unique; diff --git a/internal/database/migrations/20250925040409_create_master_tables.up.sql b/internal/database/migrations/20250925040409_create_master_tables.up.sql index ec75e077..a2143806 100644 --- a/internal/database/migrations/20250925040409_create_master_tables.up.sql +++ b/internal/database/migrations/20250925040409_create_master_tables.up.sql @@ -31,8 +31,9 @@ CREATE TABLE product_categories ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX product_categories_name_unique ON product_categories (name) WHERE deleted_at IS NULL; CREATE UNIQUE INDEX product_categories_code_unique ON product_categories (code) WHERE deleted_at IS NULL; -- UOM @@ -42,8 +43,9 @@ CREATE TABLE uoms ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX uoms_name_unique ON uoms (name) WHERE deleted_at IS NULL; -- PRODUCTS CREATE TABLE products ( @@ -51,8 +53,8 @@ CREATE TABLE products ( name VARCHAR NOT NULL, brand VARCHAR NOT NULL, sku VARCHAR(100), - uom_id BIGINT NOT NULL REFERENCES uoms(id), - product_category_id BIGINT NOT NULL REFERENCES product_categories(id), + uom_id BIGINT NOT NULL REFERENCES uoms(id) ON DELETE RESTRICT ON UPDATE CASCADE, + product_category_id BIGINT NOT NULL REFERENCES product_categories(id) ON DELETE RESTRICT ON UPDATE CASCADE, product_price NUMERIC(15,3) NOT NULL, selling_price NUMERIC(15,3), tax NUMERIC(15,3), @@ -60,8 +62,9 @@ CREATE TABLE products ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX products_name_unique ON products (name) WHERE deleted_at IS NULL; CREATE UNIQUE INDEX products_sku_unique ON products (sku) WHERE deleted_at IS NULL; -- BANKS @@ -74,8 +77,9 @@ CREATE TABLE banks ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX banks_name_unique ON banks (name) WHERE deleted_at IS NULL; -- AREAS CREATE TABLE areas ( @@ -84,51 +88,56 @@ CREATE TABLE areas ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX areas_name_unique ON areas (name) WHERE deleted_at IS NULL; -- LOCATIONS CREATE TABLE locations ( id BIGSERIAL PRIMARY KEY, name VARCHAR NOT NULL, address TEXT NOT NULL, - area_id BIGINT NOT NULL REFERENCES areas(id), + area_id BIGINT NOT NULL REFERENCES areas(id) ON DELETE RESTRICT ON UPDATE CASCADE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX locations_name_unique ON locations (name) WHERE deleted_at IS NULL; -- KANDANG CREATE TABLE kandangs ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(191) NOT NULL, - location_id BIGINT NOT NULL REFERENCES locations(id), - pic_id BIGINT NOT NULL REFERENCES users(id), + name VARCHAR NOT NULL, + location_id BIGINT NOT NULL REFERENCES locations(id) ON DELETE RESTRICT ON UPDATE CASCADE, + pic_id BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX kandangs_name_unique ON kandangs (name) WHERE deleted_at IS NULL; -- WAREHOUSES CREATE TABLE warehouses ( id BIGSERIAL PRIMARY KEY, name VARCHAR NOT NULL, type VARCHAR(50) NOT NULL, - location_id BIGINT NOT NULL REFERENCES locations(id), - kandang_id BIGINT REFERENCES kandangs(id), + area_id BIGINT NOT NULL REFERENCES areas(id) ON DELETE RESTRICT ON UPDATE CASCADE, + location_id BIGINT REFERENCES locations(id) ON DELETE SET NULL ON UPDATE CASCADE, + kandang_id BIGINT REFERENCES kandangs(id) ON DELETE SET NULL ON UPDATE CASCADE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX warehouses_name_unique ON warehouses (name) WHERE deleted_at IS NULL; -- CUSTOMERS CREATE TABLE customers ( id BIGSERIAL PRIMARY KEY, name VARCHAR NOT NULL, - pic_id BIGINT REFERENCES users(id), + pic_id BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, type VARCHAR(50) NOT NULL, address TEXT NOT NULL, phone VARCHAR(20) NOT NULL, @@ -138,19 +147,21 @@ CREATE TABLE customers ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX customers_name_unique ON customers (name) WHERE deleted_at IS NULL; -- NONSTOCK CREATE TABLE nonstocks ( id BIGSERIAL PRIMARY KEY, name VARCHAR NOT NULL, - uom_id BIGINT NOT NULL REFERENCES uoms(id), + uom_id BIGINT NOT NULL REFERENCES uoms(id) ON DELETE RESTRICT ON UPDATE CASCADE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX nonstocks_name_unique ON nonstocks (name) WHERE deleted_at IS NULL; -- FCR CREATE TABLE fcrs ( @@ -159,8 +170,9 @@ CREATE TABLE fcrs ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX fcrs_name_unique ON fcrs (name) WHERE deleted_at IS NULL; CREATE TABLE fcr_standards ( id BIGSERIAL PRIMARY KEY, @@ -191,8 +203,9 @@ CREATE TABLE suppliers ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); +CREATE UNIQUE INDEX suppliers_name_unique ON suppliers (name) WHERE deleted_at IS NULL; -- PROJECTS CREATE TABLE projects ( @@ -200,5 +213,5 @@ CREATE TABLE projects ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL REFERENCES users(id) + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE ); diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index b570cccd..c80fff7a 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -3,7 +3,7 @@ package seed import ( "fmt" - mUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/models" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" ) @@ -11,7 +11,7 @@ import ( func Run(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { // ===== Users (user) ===== - user := mUser.User{ + user := entity.User{ Email: "admin@mbugroup.id", IdUser: 1, Name: "Super Admin", diff --git a/internal/entities/area.go b/internal/entities/area.go new file mode 100644 index 00000000..dc1a2af7 --- /dev/null +++ b/internal/entities/area.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Area struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not 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"` + Locations []Location `gorm:"foreignKey:AreaId;references:Id"` +} diff --git a/internal/entities/customer.go b/internal/entities/customer.go new file mode 100644 index 00000000..66afeb93 --- /dev/null +++ b/internal/entities/customer.go @@ -0,0 +1,26 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Customer struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + PicId uint `gorm:"not null"` + Type string `gorm:"not null"` + Address string `gorm:"not null"` + Phone string `gorm:"not null"` + Email string `gorm:"not null"` + AccountNumber string `gorm:"not null"` + Balance float64 `gorm:"default:0"` + 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"` + Pic User `gorm:"foreignKey:PicId;references:Id"` +} diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go new file mode 100644 index 00000000..9c65aaa1 --- /dev/null +++ b/internal/entities/kandang.go @@ -0,0 +1,22 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Kandang struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + LocationId uint `gorm:"not null"` + PicId uint `gorm:"not 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"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + Pic User `gorm:"foreignKey:PicId;references:Id"` +} diff --git a/internal/entities/location.go b/internal/entities/location.go new file mode 100644 index 00000000..4216f2ad --- /dev/null +++ b/internal/entities/location.go @@ -0,0 +1,21 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Location struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + Address string `gorm:"not null"` + AreaId uint `gorm:"not 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"` + Area Area `gorm:"foreignKey:AreaId;references:Id"` +} diff --git a/internal/entities/uom.go b/internal/entities/uom.go new file mode 100644 index 00000000..1db4458c --- /dev/null +++ b/internal/entities/uom.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Uom struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/user.go b/internal/entities/user.go new file mode 100644 index 00000000..dcef91d0 --- /dev/null +++ b/internal/entities/user.go @@ -0,0 +1,17 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + Id uint `gorm:"primaryKey"` + IdUser int64 `gorm:"uniqueIndex"` + Email string `gorm:"uniqueIndex"` + Name string `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/internal/entities/warehouse.go b/internal/entities/warehouse.go new file mode 100644 index 00000000..31a0476e --- /dev/null +++ b/internal/entities/warehouse.go @@ -0,0 +1,25 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Warehouse struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + Type string `gorm:"not null"` + AreaId uint `gorm:"not null"` + LocationId *uint + KandangId *uint + 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"` + Area Area `gorm:"foreignKey:AreaId;references:Id"` + Location *Location `gorm:"foreignKey:LocationId;references:Id"` + Kandang *Kandang `gorm:"foreignKey:KandangId;references:Id"` +} diff --git a/internal/middleware/trim/json_body.go b/internal/middleware/trim/json_body.go new file mode 100644 index 00000000..0d9f9502 --- /dev/null +++ b/internal/middleware/trim/json_body.go @@ -0,0 +1,64 @@ +package trim + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// JSONBody trims whitespace from string fields in JSON request bodies. +func JSONBody() fiber.Handler { + return func(c *fiber.Ctx) error { + contentType := c.Get(fiber.HeaderContentType) + if !strings.Contains(contentType, fiber.MIMEApplicationJSON) { + return c.Next() + } + + body := c.Body() + if len(body) == 0 { + return c.Next() + } + + var payload any + if err := json.Unmarshal(body, &payload); err != nil { + return c.Next() + } + + trimStrings(payload) + + buf := bytes.Buffer{} + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(payload); err != nil { + return err + } + + trimmedBody := bytes.TrimSpace(buf.Bytes()) + c.Request().SetBody(trimmedBody) + + return c.Next() + } +} + +func trimStrings(value any) { + switch v := value.(type) { + case map[string]any: + for key, val := range v { + if str, ok := val.(string); ok { + v[key] = strings.TrimSpace(str) + continue + } + trimStrings(val) + } + case []any: + for i, elem := range v { + if str, ok := elem.(string); ok { + v[i] = strings.TrimSpace(str) + continue + } + trimStrings(elem) + } + } +} diff --git a/internal/modules/master/areas/controllers/area.controller.go b/internal/modules/master/areas/controllers/area.controller.go new file mode 100644 index 00000000..e08dba7d --- /dev/null +++ b/internal/modules/master/areas/controllers/area.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type AreaController struct { + AreaService service.AreaService +} + +func NewAreaController(areaService service.AreaService) *AreaController { + return &AreaController{ + AreaService: areaService, + } +} + +func (u *AreaController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.AreaService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.AreaListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all areas successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToAreaListDTOs(result), + }) +} + +func (u *AreaController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.AreaService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get area successfully", + Data: dto.ToAreaListDTO(*result), + }) +} + +func (u *AreaController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.AreaService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create area successfully", + Data: dto.ToAreaListDTO(*result), + }) +} + +func (u *AreaController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.AreaService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update area successfully", + Data: dto.ToAreaListDTO(*result), + }) +} + +func (u *AreaController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.AreaService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete area successfully", + }) +} diff --git a/internal/modules/master/areas/dto/area.dto.go b/internal/modules/master/areas/dto/area.dto.go new file mode 100644 index 00000000..d203d570 --- /dev/null +++ b/internal/modules/master/areas/dto/area.dto.go @@ -0,0 +1,58 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type AreaBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type AreaListDTO struct { + AreaBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AreaDetailDTO struct { + AreaListDTO +} + +// === Mapper Functions === + +func ToAreaBaseDTO(e entity.Area) AreaBaseDTO { + return AreaBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToAreaListDTO(e entity.Area) AreaListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return AreaListDTO{ + AreaBaseDTO: ToAreaBaseDTO(e), + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToAreaListDTOs(e []entity.Area) []AreaListDTO { + result := make([]AreaListDTO, len(e)) + for i, r := range e { + result[i] = ToAreaListDTO(r) + } + return result +} diff --git a/internal/modules/master/areas/module.go b/internal/modules/master/areas/module.go new file mode 100644 index 00000000..0d9d4f4e --- /dev/null +++ b/internal/modules/master/areas/module.go @@ -0,0 +1,26 @@ +package areas + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rArea "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/repositories" + sArea "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type AreaModule struct{} + +func (AreaModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + areaRepo := rArea.NewAreaRepository(db) + userRepo := rUser.NewUserRepository(db) + + areaService := sArea.NewAreaService(areaRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + AreaRoutes(router, userService, areaService) +} + diff --git a/internal/modules/master/areas/repositories/area.repository.go b/internal/modules/master/areas/repositories/area.repository.go new file mode 100644 index 00000000..c600aebe --- /dev/null +++ b/internal/modules/master/areas/repositories/area.repository.go @@ -0,0 +1,31 @@ +package repository + +import ( + "context" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type AreaRepository interface { + repository.BaseRepository[entity.Area] + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) +} + +type AreaRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Area] + db *gorm.DB +} + +func NewAreaRepository(db *gorm.DB) AreaRepository { + return &AreaRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Area](db), + db: db, + } +} + +func (r *AreaRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Area](ctx, r.db, name, excludeID) +} diff --git a/internal/modules/master/areas/route.go b/internal/modules/master/areas/route.go new file mode 100644 index 00000000..71d4980d --- /dev/null +++ b/internal/modules/master/areas/route.go @@ -0,0 +1,28 @@ +package areas + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/controllers" + area "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func AreaRoutes(v1 fiber.Router, u user.UserService, s area.AreaService) { + ctrl := controller.NewAreaController(s) + + route := v1.Group("/areas") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go new file mode 100644 index 00000000..a81fe4ff --- /dev/null +++ b/internal/modules/master/areas/services/area.service.go @@ -0,0 +1,145 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type AreaService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Area, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Area, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Area, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Area, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type areaService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.AreaRepository +} + +func NewAreaService(repo repository.AreaRepository, validate *validator.Validate) AreaService { + return &areaService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s areaService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Area, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + areas, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get areas: %+v", err) + return nil, 0, err + } + return areas, total, nil +} + +func (s areaService) GetOne(c *fiber.Ctx, id uint) (*entity.Area, error) { + area, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Area not found") + } + if err != nil { + s.Log.Errorf("Failed get area by id: %+v", err) + return nil, err + } + return area, nil +} + +func (s *areaService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Area, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { + s.Log.Errorf("Failed to check area name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check area name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Area with name %s already exists", req.Name)) + } + + //TODO: created by dummy + createBody := &entity.Area{ + Name: req.Name, + CreatedBy: 1, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create area: %+v", err) + return nil, err + } + + return s.Repository.GetByID(c.Context(), createBody.Id, s.withRelations) +} + +func (s areaService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Area, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + if exists, err := s.Repository.NameExists(c.Context(), *req.Name, &id); err != nil { + s.Log.Errorf("Failed to check area name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check area name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Area with name %s already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Area not found") + } + s.Log.Errorf("Failed to update area: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s areaService) 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, "Area not found") + } + s.Log.Errorf("Failed to delete area: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/areas/validations/area.validation.go b/internal/modules/master/areas/validations/area.validation.go new file mode 100644 index 00000000..e423aa8c --- /dev/null +++ b/internal/modules/master/areas/validations/area.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/master/customers/controllers/customer.controller.go b/internal/modules/master/customers/controllers/customer.controller.go new file mode 100644 index 00000000..2f9c0ed4 --- /dev/null +++ b/internal/modules/master/customers/controllers/customer.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type CustomerController struct { + CustomerService service.CustomerService +} + +func NewCustomerController(customerService service.CustomerService) *CustomerController { + return &CustomerController{ + CustomerService: customerService, + } +} + +func (u *CustomerController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.CustomerService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.CustomerListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all customers successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToCustomerListDTOs(result), + }) +} + +func (u *CustomerController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.CustomerService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get customer successfully", + Data: dto.ToCustomerListDTO(*result), + }) +} + +func (u *CustomerController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.CustomerService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create customer successfully", + Data: dto.ToCustomerListDTO(*result), + }) +} + +func (u *CustomerController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.CustomerService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update customer successfully", + Data: dto.ToCustomerListDTO(*result), + }) +} + +func (u *CustomerController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.CustomerService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete customer successfully", + }) +} diff --git a/internal/modules/master/customers/dto/customer.dto.go b/internal/modules/master/customers/dto/customer.dto.go new file mode 100644 index 00000000..d73bbece --- /dev/null +++ b/internal/modules/master/customers/dto/customer.dto.go @@ -0,0 +1,79 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type CustomerBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + PicId uint `json:"pic_id"` + Type string `json:"type"` + Address string `json:"address"` + Phone string `json:"phone"` + Email string `json:"email"` + AccountNumber string `json:"account_number"` + + Pic *userDTO.UserBaseDTO `json:"pic"` +} + +type CustomerListDTO struct { + CustomerBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CustomerDetailDTO struct { + CustomerListDTO +} + +// === Mapper Functions === + +func ToCustomerBaseDTO(e entity.Customer) CustomerBaseDTO { + var pic *userDTO.UserBaseDTO + if e.Pic.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.Pic) + pic = &mapped + } + + return CustomerBaseDTO{ + Id: e.Id, + Name: e.Name, + PicId: e.PicId, + Type: e.Type, + Address: e.Address, + Phone: e.Phone, + Email: e.Email, + AccountNumber: e.AccountNumber, + Pic: pic, + } +} + +func ToCustomerListDTO(e entity.Customer) CustomerListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return CustomerListDTO{ + CustomerBaseDTO: ToCustomerBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToCustomerListDTOs(e []entity.Customer) []CustomerListDTO { + result := make([]CustomerListDTO, len(e)) + for i, r := range e { + result[i] = ToCustomerListDTO(r) + } + return result +} diff --git a/internal/modules/master/customers/module.go b/internal/modules/master/customers/module.go new file mode 100644 index 00000000..21262bfa --- /dev/null +++ b/internal/modules/master/customers/module.go @@ -0,0 +1,26 @@ +package customers + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + sCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type CustomerModule struct{} + +func (CustomerModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + customerRepo := rCustomer.NewCustomerRepository(db) + userRepo := rUser.NewUserRepository(db) + + customerService := sCustomer.NewCustomerService(customerRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + CustomerRoutes(router, userService, customerService) +} + diff --git a/internal/modules/master/customers/repositories/customer.repository.go b/internal/modules/master/customers/repositories/customer.repository.go new file mode 100644 index 00000000..13a08211 --- /dev/null +++ b/internal/modules/master/customers/repositories/customer.repository.go @@ -0,0 +1,35 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type CustomerRepository interface { + repository.BaseRepository[entity.Customer] + PicExists(ctx context.Context, areaId uint) (bool, error) + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) +} + +type CustomerRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Customer] + db *gorm.DB +} + +func NewCustomerRepository(db *gorm.DB) CustomerRepository { + return &CustomerRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Customer](db), + db: db, + } +} + +func (r *CustomerRepositoryImpl) PicExists(ctx context.Context, picId uint) (bool, error) { + return repository.Exists[entity.User](ctx, r.db, picId) +} + +func (r *CustomerRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Customer](ctx, r.db, name, excludeID) +} diff --git a/internal/modules/master/customers/route.go b/internal/modules/master/customers/route.go new file mode 100644 index 00000000..54df1345 --- /dev/null +++ b/internal/modules/master/customers/route.go @@ -0,0 +1,28 @@ +package customers + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/controllers" + customer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func CustomerRoutes(v1 fiber.Router, u user.UserService, s customer.CustomerService) { + ctrl := controller.NewCustomerController(s) + + route := v1.Group("/customers") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/customers/services/customer.service.go b/internal/modules/master/customers/services/customer.service.go new file mode 100644 index 00000000..83aff326 --- /dev/null +++ b/internal/modules/master/customers/services/customer.service.go @@ -0,0 +1,179 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type CustomerService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Customer, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Customer, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Customer, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Customer, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type customerService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.CustomerRepository +} + +func NewCustomerService(repo repository.CustomerRepository, validate *validator.Validate) CustomerService { + return &customerService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s customerService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser").Preload("Pic") +} + +func (s customerService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Customer, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + customers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get customers: %+v", err) + return nil, 0, err + } + return customers, total, nil +} + +func (s customerService) GetOne(c *fiber.Ctx, id uint) (*entity.Customer, error) { + customer, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Customer not found") + } + if err != nil { + s.Log.Errorf("Failed get customer by id: %+v", err) + return nil, err + } + return customer, nil +} + +func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Customer, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { + s.Log.Errorf("Failed to check customer name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check customer name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Customer with name %s already exists", req.Name)) + } + + typ := strings.ToUpper(req.Type) + if !utils.IsValidCustomerSupplierType(typ) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid customer type") + } + + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Pic", ID: &req.PicId, Exists: s.Repository.PicExists}, + ); err != nil { + return nil, err + } + + //TODO: created by dummy + createBody := &entity.Customer{ + Name: req.Name, + PicId: req.PicId, + Type: typ, + Address: req.Address, + Phone: req.Phone, + Email: req.Email, + AccountNumber: req.AccountNumber, + CreatedBy: 1, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create customer: %+v", err) + return nil, err + } + + return createBody, nil +} + +func (s customerService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Customer, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + if exists, err := s.Repository.NameExists(c.Context(), *req.Name, &id); err != nil { + s.Log.Errorf("Failed to check customer name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check customer name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Customer with name %s already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + + if req.PicId != nil { + if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Pic", ID: req.PicId, Exists: s.Repository.PicExists}); err != nil { + return nil, err + } + updateBody["pic_id"] = *req.PicId + } + + if req.Type != nil { + typ := strings.ToUpper(*req.Type) + if !utils.IsValidCustomerSupplierType(typ) { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid customer type") + } + updateBody["type"] = typ + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Customer not found") + } + s.Log.Errorf("Failed to update customer: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s customerService) 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, "Customer not found") + } + s.Log.Errorf("Failed to delete customer: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/customers/validations/customer.validation.go b/internal/modules/master/customers/validations/customer.validation.go new file mode 100644 index 00000000..43044d49 --- /dev/null +++ b/internal/modules/master/customers/validations/customer.validation.go @@ -0,0 +1,27 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` + Type string `json:"type" validate:"required_strict"` + Address string `json:"address" validate:"required_strict"` + Phone string `json:"phone" validate:"required_strict,max=20"` + Email string `json:"email" validate:"required_strict,email"` + AccountNumber string `json:"account_number" validate:"required_strict"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` + Type *string `json:"type,omitempty" validate:"omitempty"` + Address *string `json:"address,omitempty" validate:"omitempty"` + Phone *string `json:"phone,omitempty" validate:"omitempty"` + Email *string `json:"email,omitempty" validate:"omitempty"` + AccountNumber *string `json:"account_number,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/master/kandangs/controllers/kandang.controller.go b/internal/modules/master/kandangs/controllers/kandang.controller.go new file mode 100644 index 00000000..45ade39d --- /dev/null +++ b/internal/modules/master/kandangs/controllers/kandang.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type KandangController struct { + KandangService service.KandangService +} + +func NewKandangController(kandangService service.KandangService) *KandangController { + return &KandangController{ + KandangService: kandangService, + } +} + +func (u *KandangController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.KandangService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.KandangListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all kandangs successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToKandangListDTOs(result), + }) +} + +func (u *KandangController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.KandangService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get kandang successfully", + Data: dto.ToKandangListDTO(*result), + }) +} + +func (u *KandangController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.KandangService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create kandang successfully", + Data: dto.ToKandangListDTO(*result), + }) +} + +func (u *KandangController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.KandangService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update kandang successfully", + Data: dto.ToKandangListDTO(*result), + }) +} + +func (u *KandangController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.KandangService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete kandang successfully", + }) +} diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go new file mode 100644 index 00000000..5f5786a3 --- /dev/null +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -0,0 +1,75 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type KandangBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Location *locationDTO.LocationBaseDTO `json:"location"` + Pic *userDTO.UserBaseDTO `json:"pic"` +} + +type KandangListDTO struct { + KandangBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type KandangDetailDTO struct { + KandangListDTO +} + +// === Mapper Functions === + +func ToKandangBaseDTO(e entity.Kandang) KandangBaseDTO { + var location *locationDTO.LocationBaseDTO + if e.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(e.Location) + location = &mapped + } + + var pic *userDTO.UserBaseDTO + if e.Pic.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.Pic) + pic = &mapped + } + + return KandangBaseDTO{ + Id: e.Id, + Name: e.Name, + Location: location, + Pic: pic, + } +} + +func ToKandangListDTO(e entity.Kandang) KandangListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return KandangListDTO{ + KandangBaseDTO: ToKandangBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToKandangListDTOs(e []entity.Kandang) []KandangListDTO { + result := make([]KandangListDTO, len(e)) + for i, r := range e { + result[i] = ToKandangListDTO(r) + } + return result +} diff --git a/internal/modules/master/kandangs/module.go b/internal/modules/master/kandangs/module.go new file mode 100644 index 00000000..b831e322 --- /dev/null +++ b/internal/modules/master/kandangs/module.go @@ -0,0 +1,26 @@ +package kandangs + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + sKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type KandangModule struct{} + +func (KandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + kandangRepo := rKandang.NewKandangRepository(db) + userRepo := rUser.NewUserRepository(db) + + kandangService := sKandang.NewKandangService(kandangRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + KandangRoutes(router, userService, kandangService) +} + diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go new file mode 100644 index 00000000..c72eb87f --- /dev/null +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -0,0 +1,40 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type KandangRepository interface { + repository.BaseRepository[entity.Kandang] + LocationExists(ctx context.Context, areaId uint) (bool, error) + PicExists(ctx context.Context, areaId uint) (bool, error) + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) +} + +type KandangRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Kandang] + db *gorm.DB +} + +func NewKandangRepository(db *gorm.DB) KandangRepository { + return &KandangRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Kandang](db), + db: db, + } +} + +func (r *KandangRepositoryImpl) LocationExists(ctx context.Context, locationId uint) (bool, error) { + return repository.Exists[entity.Location](ctx, r.db, locationId) +} + +func (r *KandangRepositoryImpl) PicExists(ctx context.Context, picId uint) (bool, error) { + return repository.Exists[entity.User](ctx, r.db, picId) +} + +func (r *KandangRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Kandang](ctx, r.db, name, excludeID) +} diff --git a/internal/modules/master/kandangs/route.go b/internal/modules/master/kandangs/route.go new file mode 100644 index 00000000..bf41b4ee --- /dev/null +++ b/internal/modules/master/kandangs/route.go @@ -0,0 +1,28 @@ +package kandangs + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/controllers" + kandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func KandangRoutes(v1 fiber.Router, u user.UserService, s kandang.KandangService) { + ctrl := controller.NewKandangController(s) + + route := v1.Group("/kandangs") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go new file mode 100644 index 00000000..f7dea21f --- /dev/null +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -0,0 +1,169 @@ +package service + +import ( + "errors" + "fmt" + + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type KandangService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Kandang, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Kandang, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Kandang, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Kandang, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type kandangService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.KandangRepository +} + +func NewKandangService(repo repository.KandangRepository, validate *validator.Validate) KandangService { + return &kandangService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s kandangService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser").Preload("Location").Preload("Pic") +} + +func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Kandang, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + kandangs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get kandangs: %+v", err) + return nil, 0, err + } + return kandangs, total, nil +} + +func (s kandangService) GetOne(c *fiber.Ctx, id uint) (*entity.Kandang, error) { + kandang, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + if err != nil { + s.Log.Errorf("Failed get kandang by id: %+v", err) + return nil, err + } + return kandang, nil +} + +func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Kandang, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { + s.Log.Errorf("Failed to check kandang name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check kandang name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang with name %s already exists", req.Name)) + } + + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists}, + common.RelationCheck{Name: "Pic", ID: &req.PicId, Exists: s.Repository.PicExists}, + ); err != nil { + return nil, err + } + + //TODO: created by dummy + createBody := &entity.Kandang{ + Name: req.Name, + LocationId: req.LocationId, + PicId: req.PicId, + CreatedBy: 1, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create kandang: %+v", err) + return nil, err + } + + return s.Repository.GetByID(c.Context(), createBody.Id, s.withRelations) +} + +func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Kandang, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + if exists, err := s.Repository.NameExists(c.Context(), *req.Name, &id); err != nil { + s.Log.Errorf("Failed to check kandang name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check kandang name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Kandang with name %s already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + + if req.LocationId != nil { + if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Location", ID: req.LocationId, Exists: s.Repository.LocationExists}); err != nil { + return nil, err + } + updateBody["location_id"] = *req.LocationId + } + + if req.PicId != nil { + if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Pic", ID: req.PicId, Exists: s.Repository.PicExists}); err != nil { + return nil, err + } + updateBody["pic_id"] = *req.PicId + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Kandang not found") + } + s.Log.Errorf("Failed to update kandang: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s kandangService) 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, "Kandang not found") + } + s.Log.Errorf("Failed to delete kandang: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go new file mode 100644 index 00000000..5b96a4b3 --- /dev/null +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -0,0 +1,19 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` + PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/master/locations/controllers/location.controller.go b/internal/modules/master/locations/controllers/location.controller.go new file mode 100644 index 00000000..37330928 --- /dev/null +++ b/internal/modules/master/locations/controllers/location.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type LocationController struct { + LocationService service.LocationService +} + +func NewLocationController(locationService service.LocationService) *LocationController { + return &LocationController{ + LocationService: locationService, + } +} + +func (u *LocationController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.LocationService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.LocationListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all locations successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToLocationListDTOs(result), + }) +} + +func (u *LocationController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.LocationService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get location successfully", + Data: dto.ToLocationListDTO(*result), + }) +} + +func (u *LocationController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.LocationService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create location successfully", + Data: dto.ToLocationListDTO(*result), + }) +} + +func (u *LocationController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.LocationService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update location successfully", + Data: dto.ToLocationListDTO(*result), + }) +} + +func (u *LocationController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.LocationService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete location successfully", + }) +} diff --git a/internal/modules/master/locations/dto/location.dto.go b/internal/modules/master/locations/dto/location.dto.go new file mode 100644 index 00000000..6da3694b --- /dev/null +++ b/internal/modules/master/locations/dto/location.dto.go @@ -0,0 +1,69 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type LocationBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Area *areaDTO.AreaBaseDTO `json:"area"` +} + +type LocationListDTO struct { + LocationBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type LocationDetailDTO struct { + LocationListDTO +} + +// === Mapper Functions === + +func ToLocationBaseDTO(e entity.Location) LocationBaseDTO { + var area *areaDTO.AreaBaseDTO + if e.Area.Id != 0 { + mapped := areaDTO.ToAreaBaseDTO(e.Area) + area = &mapped + } + + return LocationBaseDTO{ + Id: e.Id, + Name: e.Name, + Address: e.Address, + Area: area, + } +} + +func ToLocationListDTO(e entity.Location) LocationListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return LocationListDTO{ + LocationBaseDTO: ToLocationBaseDTO(e), + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToLocationListDTOs(e []entity.Location) []LocationListDTO { + result := make([]LocationListDTO, len(e)) + for i, r := range e { + result[i] = ToLocationListDTO(r) + } + return result +} diff --git a/internal/modules/master/locations/module.go b/internal/modules/master/locations/module.go new file mode 100644 index 00000000..c8a9303f --- /dev/null +++ b/internal/modules/master/locations/module.go @@ -0,0 +1,26 @@ +package locations + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rLocation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/repositories" + sLocation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type LocationModule struct{} + +func (LocationModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + locationRepo := rLocation.NewLocationRepository(db) + userRepo := rUser.NewUserRepository(db) + + locationService := sLocation.NewLocationService(locationRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + LocationRoutes(router, userService, locationService) +} + diff --git a/internal/modules/master/locations/repositories/location.repository.go b/internal/modules/master/locations/repositories/location.repository.go new file mode 100644 index 00000000..aa3b814f --- /dev/null +++ b/internal/modules/master/locations/repositories/location.repository.go @@ -0,0 +1,35 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type LocationRepository interface { + repository.BaseRepository[entity.Location] + AreaExists(ctx context.Context, areaId uint) (bool, error) + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) +} + +type LocationRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Location] + db *gorm.DB +} + +func NewLocationRepository(db *gorm.DB) LocationRepository { + return &LocationRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Location](db), + db: db, + } +} + +func (r *LocationRepositoryImpl) AreaExists(ctx context.Context, areaId uint) (bool, error) { + return repository.Exists[entity.Area](ctx, r.db, areaId) +} + +func (r *LocationRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Location](ctx, r.db, name, excludeID) +} diff --git a/internal/modules/master/locations/route.go b/internal/modules/master/locations/route.go new file mode 100644 index 00000000..99d22289 --- /dev/null +++ b/internal/modules/master/locations/route.go @@ -0,0 +1,28 @@ +package locations + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/controllers" + location "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func LocationRoutes(v1 fiber.Router, u user.UserService, s location.LocationService) { + ctrl := controller.NewLocationController(s) + + route := v1.Group("/locations") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go new file mode 100644 index 00000000..e7498377 --- /dev/null +++ b/internal/modules/master/locations/services/location.service.go @@ -0,0 +1,171 @@ +package service + +import ( + "errors" + "fmt" + + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type LocationService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Location, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Location, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Location, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Location, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type locationService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.LocationRepository +} + +func NewLocationService(repo repository.LocationRepository, validate *validator.Validate) LocationService { + return &locationService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s locationService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("Area").Preload("CreatedUser") +} + +func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Location, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + locations, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + db = db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get locations: %+v", err) + return nil, 0, err + } + return locations, total, nil +} + +func (s locationService) GetOne(c *fiber.Ctx, id uint) (*entity.Location, error) { + location, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Location not found") + } + if err != nil { + s.Log.Errorf("Failed get location by id: %+v", err) + return nil, err + } + return location, nil +} + +func (s *locationService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Location, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { + s.Log.Errorf("Failed to check location name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check location name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Location with name %s already exists", req.Name)) + } + + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists}, + ); err != nil { + return nil, err + } + + //TODO: created by dummy + createBody := &entity.Location{ + Name: req.Name, + Address: req.Address, + AreaId: req.AreaId, + CreatedBy: 1, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create location: %+v", err) + return nil, err + } + + created, err := s.Repository.GetByID(c.Context(), createBody.Id, s.withRelations) + if err != nil { + s.Log.Errorf("Failed to reload created location: %+v", err) + return nil, err + } + + return created, nil +} + +func (s locationService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Location, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + if exists, err := s.Repository.NameExists(c.Context(), *req.Name, &id); err != nil { + s.Log.Errorf("Failed to check location name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check location name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Location with name %s already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + + if req.Address != nil { + updateBody["address"] = *req.Address + } + + if req.AreaId != nil { + if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Area", ID: req.AreaId, Exists: s.Repository.AreaExists}); err != nil { + return nil, err + } + updateBody["area_id"] = *req.AreaId + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Location not found") + } + s.Log.Errorf("Failed to update location: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s locationService) 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, "Location not found") + } + s.Log.Errorf("Failed to delete location: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/locations/validations/location.validation.go b/internal/modules/master/locations/validations/location.validation.go new file mode 100644 index 00000000..7528d255 --- /dev/null +++ b/internal/modules/master/locations/validations/location.validation.go @@ -0,0 +1,19 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + Address string `json:"address" validate:"required_strict"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Address *string `json:"address,omitempty" validate:"omitempty"` + AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index c407033b..28ae3794 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -7,15 +7,25 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + areas "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas" + customers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers" + kandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs" + locations "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" + warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" // MODULE IMPORTS ) func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - group := router.Group("/master") + group := router.Group("/master-data") allModules := []modules.Module{ uoms.UomModule{}, + areas.AreaModule{}, + locations.LocationModule{}, + kandangs.KandangModule{}, + warehouses.WarehouseModule{}, + customers.CustomerModule{}, // MODULE REGISTRY } diff --git a/internal/modules/master/uoms/dto/uom.dto.go b/internal/modules/master/uoms/dto/uom.dto.go index 305f6e05..726a426d 100644 --- a/internal/modules/master/uoms/dto/uom.dto.go +++ b/internal/modules/master/uoms/dto/uom.dto.go @@ -3,20 +3,22 @@ package dto import ( "time" - model "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/models" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) // === DTO Structs === type UomBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint `json:"id"` + Name string `json:"name"` } type UomListDTO struct { UomBaseDTO - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type UomDetailDTO struct { @@ -25,24 +27,31 @@ type UomDetailDTO struct { // === Mapper Functions === -func ToUomBaseDTO(m model.Uom) UomBaseDTO { +func ToUomBaseDTO(e entity.Uom) UomBaseDTO { return UomBaseDTO{ - Id: m.Id, - Name: m.Name, + Id: e.Id, + Name: e.Name, } } -func ToUomListDTO(m model.Uom) UomListDTO { +func ToUomListDTO(e entity.Uom) UomListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + return UomListDTO{ - UomBaseDTO: ToUomBaseDTO(m), - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, + UomBaseDTO: ToUomBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, } } -func ToUomListDTOs(m []model.Uom) []UomListDTO { - result := make([]UomListDTO, len(m)) - for i, r := range m { +func ToUomListDTOs(e []entity.Uom) []UomListDTO { + result := make([]UomListDTO, len(e)) + for i, r := range e { result[i] = ToUomListDTO(r) } return result diff --git a/internal/modules/master/uoms/models/uom.model.go b/internal/modules/master/uoms/models/uom.model.go deleted file mode 100644 index b6ba141e..00000000 --- a/internal/modules/master/uoms/models/uom.model.go +++ /dev/null @@ -1,19 +0,0 @@ -package model - -import ( - "time" - - model "gitlab.com/mbugroup/lti-api.git/internal/modules/users/models" - "gorm.io/gorm" -) - -type Uom struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` - CreatedBy int64 `gorm:"not null"` - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - CreatedUser model.User `gorm:"foreignKey:CreatedBy;references:Id"` -} diff --git a/internal/modules/master/uoms/repositories/uom.repository.go b/internal/modules/master/uoms/repositories/uom.repository.go index 8554250e..e54d455a 100644 --- a/internal/modules/master/uoms/repositories/uom.repository.go +++ b/internal/modules/master/uoms/repositories/uom.repository.go @@ -1,21 +1,30 @@ package repository import ( - model "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/models" - "gitlab.com/mbugroup/lti-api.git/internal/repository" + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" ) type UomRepository interface { - repository.BaseRepository[model.Uom] + repository.BaseRepository[entity.Uom] + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) } type UomRepositoryImpl struct { - *repository.BaseRepositoryImpl[model.Uom] + *repository.BaseRepositoryImpl[entity.Uom] + db *gorm.DB } func NewUomRepository(db *gorm.DB) UomRepository { return &UomRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[model.Uom](db), + BaseRepositoryImpl: repository.NewBaseRepository[entity.Uom](db), + db: db, } } + +func (r *UomRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Uom](ctx, r.db, name, excludeID) +} diff --git a/internal/modules/master/uoms/services/uom.service.go b/internal/modules/master/uoms/services/uom.service.go index 41460b3b..931740ee 100644 --- a/internal/modules/master/uoms/services/uom.service.go +++ b/internal/modules/master/uoms/services/uom.service.go @@ -2,8 +2,9 @@ package service import ( "errors" + "fmt" - model "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/models" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -15,10 +16,10 @@ import ( ) type UomService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]model.Uom, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*model.Uom, error) - CreateOne(ctx *fiber.Ctx, req *validation.Create) (*model.Uom, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*model.Uom, error) + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Uom, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Uom, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Uom, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Uom, error) DeleteOne(ctx *fiber.Ctx, id uint) error } @@ -35,7 +36,12 @@ func NewUomService(repo repository.UomRepository, validate *validator.Validate) Repository: repo, } } -func (s uomService) GetAll(c *fiber.Ctx, params *validation.Query) ([]model.Uom, int64, error) { + +func (s uomService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s uomService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Uom, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -43,6 +49,7 @@ func (s uomService) GetAll(c *fiber.Ctx, params *validation.Query) ([]model.Uom, offset := (params.Page - 1) * params.Limit uoms, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) if params.Search != "" { return db.Where("name LIKE ?", "%"+params.Search+"%") } @@ -56,8 +63,8 @@ func (s uomService) GetAll(c *fiber.Ctx, params *validation.Query) ([]model.Uom, return uoms, total, nil } -func (s uomService) GetOne(c *fiber.Ctx, id uint) (*model.Uom, error) { - uom, err := s.Repository.GetByID(c.Context(), id, nil) +func (s uomService) GetOne(c *fiber.Ctx, id uint) (*entity.Uom, error) { + uom, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Uom not found") } @@ -68,12 +75,20 @@ func (s uomService) GetOne(c *fiber.Ctx, id uint) (*model.Uom, error) { return uom, nil } -func (s *uomService) CreateOne(c *fiber.Ctx, req *validation.Create) (*model.Uom, error) { +func (s *uomService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Uom, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - createBody := &model.Uom{ + if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { + s.Log.Errorf("Failed to check uom name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check uom name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Uom with name %s already exists", req.Name)) + } + + //TODO: created by dummy + createBody := &entity.Uom{ Name: req.Name, CreatedBy: 1, } @@ -83,10 +98,10 @@ func (s *uomService) CreateOne(c *fiber.Ctx, req *validation.Create) (*model.Uom return nil, err } - return createBody, nil + return s.Repository.GetByID(c.Context(), createBody.Id, s.withRelations) } -func (s uomService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*model.Uom, error) { +func (s uomService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Uom, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -94,9 +109,19 @@ func (s uomService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*m updateBody := make(map[string]any) if req.Name != nil { + if exists, err := s.Repository.NameExists(c.Context(), *req.Name, &id); err != nil { + s.Log.Errorf("Failed to check uom name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check uom name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Uom with name %s already exists", *req.Name)) + } updateBody["name"] = *req.Name } + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Uom not found") diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go new file mode 100644 index 00000000..b1813ef2 --- /dev/null +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type WarehouseController struct { + WarehouseService service.WarehouseService +} + +func NewWarehouseController(warehouseService service.WarehouseService) *WarehouseController { + return &WarehouseController{ + WarehouseService: warehouseService, + } +} + +func (u *WarehouseController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.WarehouseService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.WarehouseListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all warehouses successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToWarehouseListDTOs(result), + }) +} + +func (u *WarehouseController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.WarehouseService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get warehouse successfully", + Data: dto.ToWarehouseListDTO(*result), + }) +} + +func (u *WarehouseController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.WarehouseService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create warehouse successfully", + Data: dto.ToWarehouseListDTO(*result), + }) +} + +func (u *WarehouseController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.WarehouseService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update warehouse successfully", + Data: dto.ToWarehouseListDTO(*result), + }) +} + +func (u *WarehouseController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.WarehouseService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete warehouse successfully", + }) +} diff --git a/internal/modules/master/warehouses/dto/warehouse.dto.go b/internal/modules/master/warehouses/dto/warehouse.dto.go new file mode 100644 index 00000000..c46a9ecb --- /dev/null +++ b/internal/modules/master/warehouses/dto/warehouse.dto.go @@ -0,0 +1,87 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/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" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type WarehouseBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Area *areaDTO.AreaBaseDTO `json:"area"` + Location *locationDTO.LocationBaseDTO `json:"location"` + Kandang *kandangDTO.KandangBaseDTO `json:"kandang"` +} + +type WarehouseListDTO struct { + WarehouseBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type WarehouseDetailDTO struct { + WarehouseListDTO +} + +// === Mapper Functions === + +func ToWarehouseBaseDTO(e entity.Warehouse) WarehouseBaseDTO { + var area *areaDTO.AreaBaseDTO + if e.Area.Id != 0 { + mapped := areaDTO.ToAreaBaseDTO(e.Area) + area = &mapped + } + + var location *locationDTO.LocationBaseDTO + if e.Location != nil && e.Location.Id != 0 { + mapped := locationDTO.ToLocationBaseDTO(*e.Location) + location = &mapped + } + + var kandang *kandangDTO.KandangBaseDTO + if e.Kandang != nil && e.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangBaseDTO(*e.Kandang) + kandang = &mapped + } + + return WarehouseBaseDTO{ + Id: e.Id, + Name: e.Name, + Type: e.Type, + Area: area, + Location: location, + Kandang: kandang, + } +} + +func ToWarehouseListDTO(e entity.Warehouse) WarehouseListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return WarehouseListDTO{ + WarehouseBaseDTO: ToWarehouseBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToWarehouseListDTOs(e []entity.Warehouse) []WarehouseListDTO { + result := make([]WarehouseListDTO, len(e)) + for i, r := range e { + result[i] = ToWarehouseListDTO(r) + } + return result +} diff --git a/internal/modules/master/warehouses/module.go b/internal/modules/master/warehouses/module.go new file mode 100644 index 00000000..bb331862 --- /dev/null +++ b/internal/modules/master/warehouses/module.go @@ -0,0 +1,26 @@ +package warehouses + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + sWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type WarehouseModule struct{} + +func (WarehouseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + userRepo := rUser.NewUserRepository(db) + + warehouseService := sWarehouse.NewWarehouseService(warehouseRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + WarehouseRoutes(router, userService, warehouseService) +} + diff --git a/internal/modules/master/warehouses/repositories/warehouse.repository.go b/internal/modules/master/warehouses/repositories/warehouse.repository.go new file mode 100644 index 00000000..6a4e6c16 --- /dev/null +++ b/internal/modules/master/warehouses/repositories/warehouse.repository.go @@ -0,0 +1,45 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type WarehouseRepository interface { + repository.BaseRepository[entity.Warehouse] + AreaExists(ctx context.Context, areaId uint) (bool, error) + LocationExists(ctx context.Context, locationId uint) (bool, error) + KandangExists(ctx context.Context, kandangId uint) (bool, error) + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) +} + +type WarehouseRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Warehouse] + db *gorm.DB +} + +func NewWarehouseRepository(db *gorm.DB) WarehouseRepository { + return &WarehouseRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Warehouse](db), + db: db, + } +} + +func (r *WarehouseRepositoryImpl) AreaExists(ctx context.Context, areaId uint) (bool, error) { + return repository.Exists[entity.Area](ctx, r.db, areaId) +} + +func (r *WarehouseRepositoryImpl) LocationExists(ctx context.Context, locationId uint) (bool, error) { + return repository.Exists[entity.Location](ctx, r.db, locationId) +} + +func (r *WarehouseRepositoryImpl) KandangExists(ctx context.Context, kandangId uint) (bool, error) { + return repository.Exists[entity.Kandang](ctx, r.db, kandangId) +} + +func (r *WarehouseRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Warehouse](ctx, r.db, name, excludeID) +} diff --git a/internal/modules/master/warehouses/route.go b/internal/modules/master/warehouses/route.go new file mode 100644 index 00000000..b19657cb --- /dev/null +++ b/internal/modules/master/warehouses/route.go @@ -0,0 +1,28 @@ +package warehouses + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/controllers" + warehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func WarehouseRoutes(v1 fiber.Router, u user.UserService, s warehouse.WarehouseService) { + ctrl := controller.NewWarehouseController(s) + + route := v1.Group("/warehouses") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go new file mode 100644 index 00000000..ea8626a9 --- /dev/null +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -0,0 +1,268 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type WarehouseService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Warehouse, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Warehouse, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Warehouse, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Warehouse, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type warehouseService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.WarehouseRepository +} + +func NewWarehouseService(repo repository.WarehouseRepository, validate *validator.Validate) WarehouseService { + return &warehouseService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s warehouseService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser").Preload("Area").Preload("Location").Preload("Kandang") +} + +func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Warehouse, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get warehouses: %+v", err) + return nil, 0, err + } + return warehouses, total, nil +} + +func (s warehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.Warehouse, error) { + warehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + if err != nil { + s.Log.Errorf("Failed get warehouse by id: %+v", err) + return nil, err + } + return warehouse, nil +} + +func (s *warehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Warehouse, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { + s.Log.Errorf("Failed to check warehouse name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check warehouse name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Warehouse with name %s already exists", req.Name)) + } + + typ := strings.ToUpper(req.Type) + if err := validateWarehouseTypeRequirements(typ, &req.AreaId, req.LocationId, req.KandangId); err != nil { + return nil, err + } + + //? Check relation area, location, and kandang + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists}, + common.RelationCheck{Name: "Location", ID: req.LocationId, Exists: s.Repository.LocationExists}, + common.RelationCheck{Name: "Kandang", ID: req.KandangId, Exists: s.Repository.KandangExists}, + ); err != nil { + return nil, err + } + + //TODO: created by dummy + createBody := &entity.Warehouse{ + Name: req.Name, + Type: typ, + AreaId: req.AreaId, + CreatedBy: 1, + } + if req.LocationId != nil { + createBody.LocationId = req.LocationId + } + if req.KandangId != nil { + createBody.KandangId = req.KandangId + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create warehouse: %+v", err) + return nil, err + } + + return s.Repository.GetByID(c.Context(), createBody.Id, s.withRelations) +} + +func (s warehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Warehouse, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + existing, err := s.GetOne(c, id) + if err != nil { + s.Log.Errorf("Failed to get warehouse for update: %+v", err) + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + if exists, err := s.Repository.NameExists(c.Context(), *req.Name, &id); err != nil { + s.Log.Errorf("Failed to check warehouse name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check warehouse name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Warehouse with name %s already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + if req.Type != nil { + normalizedType := strings.ToUpper(*req.Type) + updateBody["type"] = normalizedType + req.Type = &normalizedType + } + if req.AreaId != nil { + if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Area", ID: req.AreaId, Exists: s.Repository.AreaExists}); err != nil { + return nil, err + } + updateBody["area_id"] = *req.AreaId + } + if req.LocationId != nil { + if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Location", ID: req.LocationId, Exists: s.Repository.LocationExists}); err != nil { + return nil, err + } + updateBody["location_id"] = req.LocationId + } + if req.KandangId != nil { + if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Kandang", ID: req.KandangId, Exists: s.Repository.KandangExists}); err != nil { + return nil, err + } + updateBody["kandang_id"] = req.KandangId + } + + finalType := strings.ToUpper(existing.Type) + if req.Type != nil { + finalType = *req.Type + } + finalAreaId := existing.AreaId + if req.AreaId != nil { + finalAreaId = *req.AreaId + } + finalLocationId := existing.LocationId + if req.LocationId != nil { + finalLocationId = req.LocationId + } + finalKandangId := existing.KandangId + if req.KandangId != nil { + finalKandangId = req.KandangId + } + + if err := validateWarehouseTypeRequirements(finalType, &finalAreaId, finalLocationId, finalKandangId); err != nil { + return nil, err + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + s.Log.Errorf("Failed to update warehouse: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s warehouseService) 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, "Warehouse not found") + } + s.Log.Errorf("Failed to delete warehouse: %+v", err) + return err + } + return nil +} + +func validateWarehouseTypeRequirements(typ string, areaID *uint, locationID *uint, kandangID *uint) error { + switch utils.WarehouseType(typ) { + case utils.WarehouseTypeArea: + if areaID == nil || *areaID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is AREA") + } + if locationID != nil { + return fiber.NewError(fiber.StatusBadRequest, "location_id must not be provided when type is AREA") + } + if kandangID != nil { + return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is AREA") + } + return nil + case utils.WarehouseTypeLokasi: + if areaID == nil || *areaID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is LOCATION") + } + if locationID == nil { + return fiber.NewError(fiber.StatusBadRequest, "location_id is required when type is LOCATION") + } + if *locationID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "location_id must be greater than 0 when type is LOCATION") + } + if kandangID != nil { + return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is LOCATION") + } + return nil + case utils.WarehouseTypeKandang: + if areaID == nil || *areaID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is KANDANG") + } + if locationID == nil { + return fiber.NewError(fiber.StatusBadRequest, "location_id is required when type is KANDANG") + } + if *locationID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "location_id must be greater than 0 when type is KANDANG") + } + if kandangID == nil { + return fiber.NewError(fiber.StatusBadRequest, "kandang_id is required when type is KANDANG") + } + if *kandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "kandang_id must be greater than 0 when type is KANDANG") + } + return nil + default: + return fiber.NewError(fiber.StatusBadRequest, "Invalid warehouse type") + } +} diff --git a/internal/modules/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go new file mode 100644 index 00000000..d5513652 --- /dev/null +++ b/internal/modules/master/warehouses/validations/warehouse.validation.go @@ -0,0 +1,23 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + Type string `json:"type" validate:"required_strict"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` + LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` + KandangId *uint `json:"kandang_id,omitempty" validate:"omitempty,number,gt=0"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Type *string `json:"type,omitempty" validate:"omitempty"` + AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` + LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` + KandangId *uint `json:"kandang_id,omitempty" validate:"omitempty,number,gt=0"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/users/dto/user.dto.go b/internal/modules/users/dto/user.dto.go index 3fc030b1..670a2810 100644 --- a/internal/modules/users/dto/user.dto.go +++ b/internal/modules/users/dto/user.dto.go @@ -3,7 +3,7 @@ package dto import ( "time" - model "gitlab.com/mbugroup/lti-api.git/internal/modules/users/models" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" ) // === DTO Structs === @@ -27,14 +27,16 @@ type UserDetailDTO struct { // === Mapper Functions === -func ToUserBaseDTO(m model.User) UserBaseDTO { +func ToUserBaseDTO(m entity.User) UserBaseDTO { return UserBaseDTO{ - Id: m.Id, - Name: m.Name, + Id: m.Id, + IdUser: m.IdUser, + Email: m.Email, + Name: m.Name, } } -func ToUserListDTO(m model.User) UserListDTO { +func ToUserListDTO(m entity.User) UserListDTO { return UserListDTO{ UserBaseDTO: ToUserBaseDTO(m), CreatedAt: m.CreatedAt, @@ -42,7 +44,7 @@ func ToUserListDTO(m model.User) UserListDTO { } } -func ToUserListDTOs(m []model.User) []UserListDTO { +func ToUserListDTOs(m []entity.User) []UserListDTO { result := make([]UserListDTO, len(m)) for i, r := range m { result[i] = ToUserListDTO(r) diff --git a/internal/modules/users/models/user.model.go b/internal/modules/users/models/user.model.go deleted file mode 100644 index 17bf4080..00000000 --- a/internal/modules/users/models/user.model.go +++ /dev/null @@ -1,17 +0,0 @@ -package model - -import ( - "time" - - "gorm.io/gorm" -) - -type User struct { - Id uint `gorm:"primaryKey"` - IdUser int64 `gorm:"uniqueIndex"` - Email string `gorm:"uniqueIndex"` - Name string `gorm:"not null"` - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` -} diff --git a/internal/modules/users/repositories/user.repository.go b/internal/modules/users/repositories/user.repository.go index 7e5cef9c..8472db13 100644 --- a/internal/modules/users/repositories/user.repository.go +++ b/internal/modules/users/repositories/user.repository.go @@ -1,21 +1,21 @@ package repository import ( - model "gitlab.com/mbugroup/lti-api.git/internal/modules/users/models" - "gitlab.com/mbugroup/lti-api.git/internal/repository" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" ) type UserRepository interface { - repository.BaseRepository[model.User] + repository.BaseRepository[entity.User] } type UserRepositoryImpl struct { - *repository.BaseRepositoryImpl[model.User] + *repository.BaseRepositoryImpl[entity.User] } func NewUserRepository(db *gorm.DB) UserRepository { return &UserRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[model.User](db), + BaseRepositoryImpl: repository.NewBaseRepository[entity.User](db), } } diff --git a/internal/modules/users/services/user.service.go b/internal/modules/users/services/user.service.go index b1c5781d..f8e053e4 100644 --- a/internal/modules/users/services/user.service.go +++ b/internal/modules/users/services/user.service.go @@ -3,7 +3,7 @@ package service import ( "errors" - model "gitlab.com/mbugroup/lti-api.git/internal/modules/users/models" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/users/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -15,10 +15,10 @@ import ( ) type UserService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]model.User, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*model.User, error) - CreateOne(ctx *fiber.Ctx, req *validation.Create) (*model.User, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*model.User, error) + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.User, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.User, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.User, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.User, error) DeleteOne(ctx *fiber.Ctx, id uint) error } @@ -35,7 +35,7 @@ func NewUserService(repo repository.UserRepository, validate *validator.Validate Repository: repo, } } -func (s userService) GetAll(c *fiber.Ctx, params *validation.Query) ([]model.User, int64, error) { +func (s userService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.User, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -56,7 +56,7 @@ func (s userService) GetAll(c *fiber.Ctx, params *validation.Query) ([]model.Use return users, total, nil } -func (s userService) GetOne(c *fiber.Ctx, id uint) (*model.User, error) { +func (s userService) GetOne(c *fiber.Ctx, id uint) (*entity.User, error) { user, err := s.Repository.GetByID(c.Context(), id, nil) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "User not found") @@ -68,13 +68,13 @@ func (s userService) GetOne(c *fiber.Ctx, id uint) (*model.User, error) { return user, nil } -func (s *userService) CreateOne(c *fiber.Ctx, req *validation.Create) (*model.User, error) { +func (s *userService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.User, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - createBody := &model.User{ - Name: req.Name, + createBody := &entity.User{ + Name: req.Name, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { @@ -85,7 +85,7 @@ func (s *userService) CreateOne(c *fiber.Ctx, req *validation.Create) (*model.Us return createBody, nil } -func (s userService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*model.User, error) { +func (s userService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.User, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -96,6 +96,10 @@ func (s userService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (* updateBody["name"] = *req.Name } + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "User not found") diff --git a/internal/route/route.go b/internal/route/route.go index b5769041..627be6e4 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -1,8 +1,9 @@ package route import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/validation" "gitlab.com/mbugroup/lti-api.git/internal/modules" - "gitlab.com/mbugroup/lti-api.git/internal/validation" + trim "gitlab.com/mbugroup/lti-api.git/internal/middleware/trim" "github.com/gofiber/fiber/v2" "gorm.io/gorm" @@ -15,6 +16,7 @@ import ( func Routes(app *fiber.App, db *gorm.DB) { validate := validation.Validator() api := app.Group("/api") + api.Use(trim.JSONBody()) // root modules di sini allModules := []modules.Module{ diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 129eb1bf..c8ee5fc8 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -9,6 +9,29 @@ const ( FlagIsActive FlagType = "IS_ACTIVE" ) +// ------------------------------------------------------------------- +// WarehouseType +// ------------------------------------------------------------------- + +type WarehouseType string + +const ( + WarehouseTypeArea WarehouseType = "AREA" + WarehouseTypeLokasi WarehouseType = "LOKASI" + WarehouseTypeKandang WarehouseType = "KANDANG" +) + +// ------------------------------------------------------------------- +// WarehouseType +// ------------------------------------------------------------------- + +type CustomerSupplierType string + +const ( + CustomerSupplierTypeBisnis CustomerSupplierType = "BISNIS" + CustomerSupplierTypeIndividual CustomerSupplierType = "INDIVIDUAL" +) + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -21,6 +44,22 @@ func IsValidFlagType(v string) bool { return false } +func IsValidWarehouseType(v string) bool { + switch WarehouseType(v) { + case WarehouseTypeArea, WarehouseTypeLokasi, WarehouseTypeKandang: + return true + } + return false +} + +func IsValidCustomerSupplierType(v string) bool { + switch CustomerSupplierType(v) { + case CustomerSupplierTypeBisnis, CustomerSupplierTypeIndividual: + return true + } + return false +} + // example use /** diff --git a/internal/utils/error.go b/internal/utils/error.go index 2e5223bd..e63e81a2 100644 --- a/internal/utils/error.go +++ b/internal/utils/error.go @@ -3,8 +3,8 @@ package utils import ( "errors" + "gitlab.com/mbugroup/lti-api.git/internal/common/validation" "gitlab.com/mbugroup/lti-api.git/internal/response" - "gitlab.com/mbugroup/lti-api.git/internal/validation" "github.com/gofiber/fiber/v2" ) diff --git a/internal/utils/strings.go b/internal/utils/strings.go new file mode 100644 index 00000000..f6560191 --- /dev/null +++ b/internal/utils/strings.go @@ -0,0 +1,13 @@ +package utils + +import "strings" + +// NormalizeTrim returns the input string without leading/trailing whitespace. +func NormalizeTrim(value string) string { + return strings.TrimSpace(value) +} + +// NormalizeUpper returns the trimmed, upper-case version of value. +func NormalizeUpper(value string) string { + return strings.ToUpper(NormalizeTrim(value)) +} diff --git a/test/integration/master_data/area_test.go b/test/integration/master_data/area_test.go new file mode 100644 index 00000000..ad8a17ad --- /dev/null +++ b/test/integration/master_data/area_test.go @@ -0,0 +1,27 @@ +package test + +import ( + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestAreaIntegration(t *testing.T) { + app, db := setupIntegrationApp(t) + + t.Run("create area trims name", func(t *testing.T) { + areaID := createArea(t, app, " Area Trim ") + if got := fetchAreaName(t, db, areaID); got != "Area Trim" { + t.Fatalf("expected trimmed name, got %q", got) + } + }) + + t.Run("duplicate area returns conflict", func(t *testing.T) { + createArea(t, app, "Duplicate Area") + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/areas", map[string]any{"name": "Duplicate Area"}) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 conflict, got %d: %s", resp.StatusCode, string(body)) + } + }) +} diff --git a/test/integration/master_data/customer_test.go b/test/integration/master_data/customer_test.go new file mode 100644 index 00000000..737ff109 --- /dev/null +++ b/test/integration/master_data/customer_test.go @@ -0,0 +1,189 @@ +package test + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +func TestCustomerIntegration(t *testing.T) { + app, db := setupIntegrationApp(t) + + t.Run("creating customer without existing pic fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/customers", map[string]any{ + "name": "Invalid Customer", + "pic_id": 9999, + "type": utils.CustomerSupplierTypeBisnis, + "address": "Somewhere", + "phone": "0800000000", + "email": "invalid@example.com", + "account_number": "ACC-INVALID", + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when pic is missing, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("creating customer with invalid type fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/customers", map[string]any{ + "name": "Invalid Type", + "pic_id": 1, + "type": "UNKNOWN", + "address": "Somewhere", + "phone": "081234567891", + "email": "invalid-type@example.com", + "account_number": "ACC-INVALID-TYPE", + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when type is invalid, got %d: %s", resp.StatusCode, string(body)) + } + }) + + const customerName = "Customer Alpha" + var customerID uint + + t.Run("creating customer succeeds", func(t *testing.T) { + customerID = createCustomer(t, app, customerName, 1) + customer := fetchCustomer(t, db, customerID) + if customer.Name != customerName { + t.Fatalf("expected name %q, got %q", customerName, customer.Name) + } + if customer.PicId != 1 { + t.Fatalf("expected pic id 1, got %d", customer.PicId) + } + if customer.Type != string(utils.CustomerSupplierTypeBisnis) { + t.Fatalf("expected type %q, got %q", utils.CustomerSupplierTypeBisnis, customer.Type) + } + }) + + t.Run("creating customer with duplicate name fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/customers", map[string]any{ + "name": customerName, + "pic_id": 1, + "type": utils.CustomerSupplierTypeBisnis, + "address": "Duplicate address", + "phone": "0811111111", + "email": "duplicate@example.com", + "account_number": "ACC-DUP", + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when creating duplicate customer, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("getting existing customer succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/customers/%d", customerID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching customer, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Id uint `json:"id"` + Name string `json:"name"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse customer response: %v", err) + } + if payload.Data.Id != customerID { + t.Fatalf("expected id %d, got %d", customerID, payload.Data.Id) + } + if payload.Data.Name != customerName { + t.Fatalf("expected name %q, got %q", customerName, payload.Data.Name) + } + }) + + const updatedName = "Customer Gamma" + + t.Run("updating customer name succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/customers/%d", customerID), map[string]any{ + "name": updatedName, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating customer, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Name string `json:"name"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse update response: %v", err) + } + if payload.Data.Name != updatedName { + t.Fatalf("expected updated name %q, got %q", updatedName, payload.Data.Name) + } + customer := fetchCustomer(t, db, customerID) + if customer.Name != updatedName { + t.Fatalf("expected persisted name %q, got %q", updatedName, customer.Name) + } + }) + + t.Run("updating customer type succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/customers/%d", customerID), map[string]any{ + "type": utils.CustomerSupplierTypeIndividual, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating customer type, got %d: %s", resp.StatusCode, string(body)) + } + customer := fetchCustomer(t, db, customerID) + if customer.Type != string(utils.CustomerSupplierTypeIndividual) { + t.Fatalf("expected persisted type %q, got %q", utils.CustomerSupplierTypeIndividual, customer.Type) + } + }) + + t.Run("updating customer with invalid type fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/customers/%d", customerID), map[string]any{ + "type": "random-type", + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when updating with invalid type, got %d: %s", resp.StatusCode, string(body)) + } + customer := fetchCustomer(t, db, customerID) + if customer.Type != string(utils.CustomerSupplierTypeIndividual) { + t.Fatalf("expected type to remain %q after invalid update, got %q", utils.CustomerSupplierTypeIndividual, customer.Type) + } + }) + + t.Run("updating non existent customer returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, "/api/master-data/customers/9999", map[string]any{ + "name": "Does Not Matter", + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when updating missing customer, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("deleting customer succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/customers/%d", customerID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting customer, got %d: %s", resp.StatusCode, string(body)) + } + var customer entities.Customer + if err := db.First(&customer, customerID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected customer to be deleted, got error %v", err) + } + }) + + t.Run("deleting non existent customer returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/customers/%d", customerID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when deleting missing customer, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("getting deleted customer returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/customers/%d", customerID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when fetching deleted customer, got %d: %s", resp.StatusCode, string(body)) + } + }) +} diff --git a/test/integration/master_data/kandang_test.go b/test/integration/master_data/kandang_test.go new file mode 100644 index 00000000..e17c8ad5 --- /dev/null +++ b/test/integration/master_data/kandang_test.go @@ -0,0 +1,36 @@ +package test + +import ( + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestKandangIntegration(t *testing.T) { + app, _ := setupIntegrationApp(t) + areaID := createArea(t, app, "Area Kandang") + locationID := createLocation(t, app, "Location For Kandang", "Address", areaID) + + t.Run("create kandang success", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ + "name": "Kandang OK", + "location_id": locationID, + "pic_id": 1, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("create kandang with unknown location fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ + "name": "Kandang Fail", + "location_id": 999, + "pic_id": 1, + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", resp.StatusCode, string(body)) + } + }) +} diff --git a/test/integration/master_data/location_test.go b/test/integration/master_data/location_test.go new file mode 100644 index 00000000..0c78ac2a --- /dev/null +++ b/test/integration/master_data/location_test.go @@ -0,0 +1,35 @@ +package test + +import ( + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestLocationIntegration(t *testing.T) { + app, _ := setupIntegrationApp(t) + + t.Run("creating location without existing area fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/locations", map[string]any{ + "name": "Loc A", + "address": "Address", + "area_id": 999, // non-existent + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("creating location succeeds", func(t *testing.T) { + areaID := createArea(t, app, "Area For Location") + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/locations", map[string]any{ + "name": "Location OK", + "address": "Addr", + "area_id": areaID, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(body)) + } + }) +} diff --git a/test/integration/master_data/master_data.go b/test/integration/master_data/master_data.go new file mode 100644 index 00000000..ca4b3745 --- /dev/null +++ b/test/integration/master_data/master_data.go @@ -0,0 +1,188 @@ +package test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/route" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +func setupIntegrationApp(t *testing.T) (*fiber.App, *gorm.DB) { + t.Helper() + dir := t.TempDir() + dsn := filepath.Join(dir, "integration.db") + + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("failed to open sqlite database: %v", err) + } + + if err := db.Exec("PRAGMA foreign_keys = ON").Error; err != nil { + t.Fatalf("failed to enable foreign keys: %v", err) + } + + if err := db.AutoMigrate( + &entities.User{}, + &entities.Area{}, + &entities.Location{}, + &entities.Kandang{}, + &entities.Warehouse{}, + &entities.Uom{}, + &entities.Customer{}, + ); err != nil { + t.Fatalf("auto migrate failed: %v", err) + } + + seedUser := entities.User{ + Id: 1, + IdUser: 1001, + Email: "tester@example.com", + Name: "Tester", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := db.Create(&seedUser).Error; err != nil { + t.Fatalf("failed to seed user: %v", err) + } + + app := fiber.New() + route.Routes(app, db) + return app, db +} + +func doJSONRequest(t *testing.T, app *fiber.App, method, path string, payload any) (*http.Response, []byte) { + t.Helper() + + var body io.Reader + if payload != nil { + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(payload); err != nil { + t.Fatalf("failed to encode payload: %v", err) + } + body = buf + } + + req := httptest.NewRequest(method, path, body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := app.Test(req, -1) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + return resp, data +} + +func parseID(t *testing.T, body []byte) uint { + t.Helper() + var resp struct { + Data struct { + Id uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + return resp.Data.Id +} + +func createArea(t *testing.T, app *fiber.App, name string) uint { + t.Helper() + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/areas", map[string]any{"name": name}) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating area, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + +func createLocation(t *testing.T, app *fiber.App, name, address string, areaID uint) uint { + t.Helper() + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/locations", map[string]any{ + "name": name, + "address": address, + "area_id": areaID, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating location, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + +func createKandang(t *testing.T, app *fiber.App, name string, locationID, picID uint) uint { + t.Helper() + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ + "name": name, + "location_id": locationID, + "pic_id": picID, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating kandang, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + +func createCustomer(t *testing.T, app *fiber.App, name string, picID uint) uint { + t.Helper() + identifier := strings.ToLower(strings.ReplaceAll(name, " ", "_")) + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/customers", map[string]any{ + "name": name, + "pic_id": picID, + "type": utils.CustomerSupplierTypeBisnis, + "address": "Customer address", + "phone": "081234567890", + "email": fmt.Sprintf("%s@example.com", identifier), + "account_number": fmt.Sprintf("ACC-%s", identifier), + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating customer, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + +func fetchCustomer(t *testing.T, db *gorm.DB, id uint) entities.Customer { + t.Helper() + var customer entities.Customer + if err := db.Preload("Pic").Preload("CreatedUser").First(&customer, id).Error; err != nil { + t.Fatalf("failed to fetch customer: %v", err) + } + return customer +} + +func fetchAreaName(t *testing.T, db *gorm.DB, id uint) string { + t.Helper() + var area entities.Area + if err := db.First(&area, id).Error; err != nil { + t.Fatalf("failed to fetch area: %v", err) + } + return area.Name +} + +func fetchWarehouse(t *testing.T, db *gorm.DB, id uint) entities.Warehouse { + t.Helper() + var wh entities.Warehouse + if err := db.First(&wh, id).Error; err != nil { + t.Fatalf("failed to fetch warehouse: %v", err) + } + return wh +} diff --git a/test/integration/master_data/warehouse_test.go b/test/integration/master_data/warehouse_test.go new file mode 100644 index 00000000..72b267c3 --- /dev/null +++ b/test/integration/master_data/warehouse_test.go @@ -0,0 +1,96 @@ +package test + +import ( + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" +) + +func TestWarehouseIntegration(t *testing.T) { + app, db := setupIntegrationApp(t) + areaID := createArea(t, app, "Warehouse Area") + locationID := createLocation(t, app, "Location WH", "Addr", areaID) + kandangID := createKandang(t, app, "Kandang WH", locationID, 1) + + t.Run("type AREA only needs area_id", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/warehouses", map[string]any{ + "name": "WH Area", + "type": "AREA", + "area_id": areaID, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("type AREA rejects location_id", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/warehouses", map[string]any{ + "name": "WH Area Invalid", + "type": "AREA", + "area_id": areaID, + "location_id": locationID, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("type LOKASI requires location_id", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/warehouses", map[string]any{ + "name": "WH Lokasi Fail", + "type": "LOKASI", + "area_id": areaID, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("type LOKASI success", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/warehouses", map[string]any{ + "name": "WH Lokasi", + "type": "LOKASI", + "area_id": areaID, + "location_id": locationID, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(body)) + } + whID := parseID(t, body) + wh := fetchWarehouse(t, db, whID) + if wh.LocationId == nil || *wh.LocationId != locationID { + t.Fatalf("expected location_id %d, got %v", locationID, wh.LocationId) + } + }) + + t.Run("type KANDANG requires all ids", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/warehouses", map[string]any{ + "name": "WH Kandang Fail", + "type": "KANDANG", + "area_id": areaID, + "location_id": locationID, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("type KANDANG success", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/warehouses", map[string]any{ + "name": "WH Kandang", + "type": "KANDANG", + "area_id": areaID, + "location_id": locationID, + "kandang_id": kandangID, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(body)) + } + whID := parseID(t, body) + wh := fetchWarehouse(t, db, whID) + if wh.KandangId == nil || *wh.KandangId != kandangID { + t.Fatalf("expected kandang_id %d, got %v", kandangID, wh.KandangId) + } + }) +} diff --git a/tools/gen.go b/tools/gen.go index 6bae2a0d..9906b8fa 100644 --- a/tools/gen.go +++ b/tools/gen.go @@ -44,10 +44,11 @@ func main() { TplName string }{ { - TplPath: "tools/templates/model.tmpl", - OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "models"), - OutSuffix: ".model.go", - TplName: "model", + TplPath: "tools/templates/entity.tmpl", + // Centralize entities at internal/entities + OutDir: filepath.Join("internal", "entities"), + OutSuffix: ".go", + TplName: "entity", }, { TplPath: "tools/templates/validation.tmpl", diff --git a/tools/templates/dto.tmpl b/tools/templates/dto.tmpl index a3cdf89e..3780b080 100644 --- a/tools/templates/dto.tmpl +++ b/tools/templates/dto.tmpl @@ -3,7 +3,8 @@ import ( "time" - model "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/models" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) // === DTO Structs === @@ -15,6 +16,7 @@ type {{Pascal .Entity}}BaseDTO struct { type {{Pascal .Entity}}ListDTO struct { {{Pascal .Entity}}BaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -25,24 +27,31 @@ type {{Pascal .Entity}}DetailDTO struct { // === Mapper Functions === -func To{{Pascal .Entity}}BaseDTO(m model.{{Pascal .Entity}}) {{Pascal .Entity}}BaseDTO { +func To{{Pascal .Entity}}BaseDTO(e entity.{{Pascal .Entity}}) {{Pascal .Entity}}BaseDTO { return {{Pascal .Entity}}BaseDTO{ - Id: m.Id, - Name: m.Name, + Id: e.Id, + Name: e.Name, } } -func To{{Pascal .Entity}}ListDTO(m model.{{Pascal .Entity}}) {{Pascal .Entity}}ListDTO { +func To{{Pascal .Entity}}ListDTO(e entity.{{Pascal .Entity}}) {{Pascal .Entity}}ListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + return {{Pascal .Entity}}ListDTO{ - {{Pascal .Entity}}BaseDTO: To{{Pascal .Entity}}BaseDTO(m), - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, + {{Pascal .Entity}}BaseDTO: To{{Pascal .Entity}}BaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, } } -func To{{Pascal .Entity}}ListDTOs(m []model.{{Pascal .Entity}}) []{{Pascal .Entity}}ListDTO { - result := make([]{{Pascal .Entity}}ListDTO, len(m)) - for i, r := range m { +func To{{Pascal .Entity}}ListDTOs(e []entity.{{Pascal .Entity}}) []{{Pascal .Entity}}ListDTO { + result := make([]{{Pascal .Entity}}ListDTO, len(e)) + for i, r := range e { result[i] = To{{Pascal .Entity}}ListDTO(r) } return result diff --git a/tools/templates/entity.tmpl b/tools/templates/entity.tmpl new file mode 100644 index 00000000..87da6f9d --- /dev/null +++ b/tools/templates/entity.tmpl @@ -0,0 +1,19 @@ +{{define "entity"}}package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type {{Pascal .Entity}} struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not 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"` +} +{{end}} diff --git a/tools/templates/model.tmpl b/tools/templates/model.tmpl deleted file mode 100644 index efc2af69..00000000 --- a/tools/templates/model.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -{{define "model"}}package model - -import ( - "time" -) - -type {{Pascal .Entity}} struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` - CreatedBy int64 `gorm:"not null"` - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - CreatedUser model.User `gorm:"foreignKey:CreatedBy;references:Id"` -} -{{end}} diff --git a/tools/templates/repository.tmpl b/tools/templates/repository.tmpl index ee6d625e..2577312a 100644 --- a/tools/templates/repository.tmpl +++ b/tools/templates/repository.tmpl @@ -1,22 +1,22 @@ {{define "repository"}}package repository import ( - model "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/models" - "gitlab.com/mbugroup/lti-api.git/internal/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gorm.io/gorm" ) type {{Pascal .Entity}}Repository interface { - repository.BaseRepository[model.{{Pascal .Entity}}] + repository.BaseRepository[entity.{{Pascal .Entity}}] } type {{Pascal .Entity}}RepositoryImpl struct { - *repository.BaseRepositoryImpl[model.{{Pascal .Entity}}] + *repository.BaseRepositoryImpl[entity.{{Pascal .Entity}}] } func New{{Pascal .Entity}}Repository(db *gorm.DB) {{Pascal .Entity}}Repository { return &{{Pascal .Entity}}RepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[model.{{Pascal .Entity}}](db), + BaseRepositoryImpl: repository.NewBaseRepository[entity.{{Pascal .Entity}}](db), } } {{end}} diff --git a/tools/templates/service.tmpl b/tools/templates/service.tmpl index 451c315f..8977a921 100644 --- a/tools/templates/service.tmpl +++ b/tools/templates/service.tmpl @@ -3,7 +3,7 @@ import ( "errors" - model "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/models" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -15,10 +15,10 @@ import ( ) type {{Pascal .Entity}}Service interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]model.{{Pascal .Entity}}, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*model.{{Pascal .Entity}}, error) - CreateOne(ctx *fiber.Ctx, req *validation.Create) (*model.{{Pascal .Entity}}, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*model.{{Pascal .Entity}}, error) + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.{{Pascal .Entity}}, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.{{Pascal .Entity}}, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.{{Pascal .Entity}}, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.{{Pascal .Entity}}, error) DeleteOne(ctx *fiber.Ctx, id uint) error } @@ -35,7 +35,12 @@ func New{{Pascal .Entity}}Service(repo repository.{{Pascal .Entity}}Repository, Repository: repo, } } -func (s {{Camel .Entity}}Service) GetAll(c *fiber.Ctx, params *validation.Query) ([]model.{{Pascal .Entity}}, int64, error) { + +func (s {{Camel .Entity}}Service) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s {{Camel .Entity}}Service) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.{{Pascal .Entity}}, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -43,6 +48,7 @@ func (s {{Camel .Entity}}Service) GetAll(c *fiber.Ctx, params *validation.Query) offset := (params.Page - 1) * params.Limit {{Camel .Entity}}s, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) if params.Search != "" { return db.Where("name LIKE ?", "%"+params.Search+"%") } @@ -56,8 +62,8 @@ func (s {{Camel .Entity}}Service) GetAll(c *fiber.Ctx, params *validation.Query) return {{Camel .Entity}}s, total, nil } -func (s {{Camel .Entity}}Service) GetOne(c *fiber.Ctx, id uint) (*model.{{Pascal .Entity}}, error) { - {{Camel .Entity}}, err := s.Repository.GetByID(c.Context(), id, nil) +func (s {{Camel .Entity}}Service) GetOne(c *fiber.Ctx, id uint) (*entity.{{Pascal .Entity}}, error) { + {{Camel .Entity}}, err := s.Repository.GetByID(c.Context(), id, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "{{Pascal .Entity}} not found") } @@ -68,12 +74,12 @@ func (s {{Camel .Entity}}Service) GetOne(c *fiber.Ctx, id uint) (*model.{{Pascal return {{Camel .Entity}}, nil } -func (s *{{Camel .Entity}}Service) CreateOne(c *fiber.Ctx, req *validation.Create) (*model.{{Pascal .Entity}}, error) { +func (s *{{Camel .Entity}}Service) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.{{Pascal .Entity}}, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - createBody := &model.{{Pascal .Entity}}{ + createBody := &entity.{{Pascal .Entity}}{ Name: req.Name, } @@ -85,7 +91,7 @@ func (s *{{Camel .Entity}}Service) CreateOne(c *fiber.Ctx, req *validation.Creat return createBody, nil } -func (s {{Camel .Entity}}Service) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*model.{{Pascal .Entity}}, error) { +func (s {{Camel .Entity}}Service) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.{{Pascal .Entity}}, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -96,6 +102,10 @@ func (s {{Camel .Entity}}Service) UpdateOne(c *fiber.Ctx, req *validation.Update updateBody["name"] = *req.Name } + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "{{Pascal .Entity}} not found")