diff --git a/.gitignore b/.gitignore index 82524f71..d6a26e97 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ coverage/ .vscode/ .idea/ *.swp +.DS_Store diff --git a/go.mod b/go.mod index 517bcdc1..abb6d004 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,22 @@ go 1.23 require ( github.com/MicahParks/keyfunc/v2 v2.1.0 + github.com/aws/aws-sdk-go-v2 v1.40.0 + github.com/aws/aws-sdk-go-v2/config v1.32.2 + github.com/aws/aws-sdk-go-v2/credentials v1.19.2 + github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 github.com/bytedance/sonic v1.12.1 + github.com/glebarez/sqlite v1.11.0 github.com/go-playground/validator/v10 v10.27.0 github.com/gofiber/contrib/jwt v1.0.10 github.com/gofiber/fiber/v2 v2.52.5 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgconn v1.14.1 github.com/redis/go-redis/v9 v9.14.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/viper v1.19.0 + github.com/xuri/excelize/v2 v2.9.0 golang.org/x/crypto v0.33.0 gorm.io/driver/postgres v1.5.9 gorm.io/gorm v1.25.11 @@ -20,17 +27,33 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect 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/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 - github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgio v1.0.0 // indirect @@ -49,8 +72,12 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.1.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect @@ -59,12 +86,15 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tinylib/msgp v1.1.8 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect + github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect + github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect @@ -75,4 +105,8 @@ 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 + 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 c07e37e3..73b36464 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,44 @@ github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+G github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= +github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk= +github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w= +github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -27,12 +65,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= @@ -50,6 +94,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -136,6 +182,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= @@ -146,6 +194,14 @@ 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/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -189,8 +245,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= @@ -203,6 +260,16 @@ github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8 github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY= +github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE= +github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE= +github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A= +github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -229,6 +296,8 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -306,4 +375,12 @@ gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= 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/capabilities/capabilities.go b/internal/capabilities/capabilities.go deleted file mode 100644 index 742d7acb..00000000 --- a/internal/capabilities/capabilities.go +++ /dev/null @@ -1,44 +0,0 @@ -package capabilities - -import ( - "strings" - - recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" -) - -// FromPermissions returns a filtered map of capabilities that the frontend can use -// to toggle features. Only permissions recognized by the application are exposed. -func FromPermissions(perms []string) map[string]bool { - if len(perms) == 0 { - return nil - } - - out := make(map[string]bool) - for _, perm := range perms { - if key, ok := normalizeAndAllow(perm); ok { - out[key] = true - } - } - if len(out) == 0 { - return nil - } - return out -} - -func normalizeAndAllow(perm string) (string, bool) { - perm = strings.ToLower(strings.TrimSpace(perm)) - if perm == "" { - return "", false - } - if _, ok := allowed[perm]; !ok { - return "", false - } - return perm, true -} - -var allowed = map[string]struct{}{ - recordings.PermissionRecordingRead: {}, - recordings.PermissionRecordingCreate: {}, - recordings.PermissionRecordingUpdate: {}, - recordings.PermissionRecordingDelete: {}, -} diff --git a/internal/common/repository/common.approval.repository..go b/internal/common/repository/common.approval.repository..go index dc517f21..8c045084 100644 --- a/internal/common/repository/common.approval.repository..go +++ b/internal/common/repository/common.approval.repository..go @@ -84,8 +84,9 @@ func (r *approvalRepositoryImpl) LatestByTargets( result := make(map[uint]entity.Approval, len(approvableIDs)) q := r.DB().WithContext(ctx). + Select("DISTINCT ON (approvable_id) *"). Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs). - Order("action_at DESC") + Order("approvable_id, action_at DESC") if modifier != nil { q = modifier(q) diff --git a/internal/common/repository/common.base.repository.go b/internal/common/repository/common.base.repository.go index fa58fcd7..27eea03a 100644 --- a/internal/common/repository/common.base.repository.go +++ b/internal/common/repository/common.base.repository.go @@ -187,10 +187,11 @@ func (r *BaseRepositoryImpl[T]) PatchOne( updates map[string]any, modifier func(*gorm.DB) *gorm.DB, ) error { - q := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id) + q := r.db.WithContext(ctx) if modifier != nil { q = modifier(q) } + q = q.Model(new(T)).Where("id = ?", id) result := q.Updates(updates) if result.Error != nil { diff --git a/internal/common/repository/common.document.repository.go b/internal/common/repository/common.document.repository.go new file mode 100644 index 00000000..79e8a04d --- /dev/null +++ b/internal/common/repository/common.document.repository.go @@ -0,0 +1,62 @@ +package repository + +import ( + "context" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type DocumentRepository interface { + BaseRepository[entity.Document] + ListByTarget(ctx context.Context, documentableType string, documentableID uint64, modifier func(*gorm.DB) *gorm.DB) ([]entity.Document, error) + DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, modifier func(*gorm.DB) *gorm.DB) error +} + +type documentRepositoryImpl struct { + *BaseRepositoryImpl[entity.Document] +} + +func NewDocumentRepository(db *gorm.DB) DocumentRepository { + return &documentRepositoryImpl{ + BaseRepositoryImpl: NewBaseRepository[entity.Document](db), + } +} + +func (r *documentRepositoryImpl) ListByTarget( + ctx context.Context, + documentableType string, + documentableID uint64, + modifier func(*gorm.DB) *gorm.DB, +) ([]entity.Document, error) { + var documents []entity.Document + + q := r.DB().WithContext(ctx). + Where("documentable_type = ? AND documentable_id = ?", documentableType, documentableID) + + if modifier != nil { + q = modifier(q) + } + + if err := q.Order("created_at ASC").Find(&documents).Error; err != nil { + return nil, err + } + + return documents, nil +} + +func (r *documentRepositoryImpl) DeleteByTarget( + ctx context.Context, + documentableType string, + documentableID uint64, + modifier func(*gorm.DB) *gorm.DB, +) error { + q := r.DB().WithContext(ctx). + Where("documentable_type = ? AND documentable_id = ?", documentableType, documentableID) + + if modifier != nil { + q = modifier(q) + } + + return q.Delete(&entity.Document{}).Error +} diff --git a/internal/common/repository/common.exists.repository.go b/internal/common/repository/common.exists.repository.go index c6bc11f0..b8206eb9 100644 --- a/internal/common/repository/common.exists.repository.go +++ b/internal/common/repository/common.exists.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "gorm.io/gorm" @@ -9,45 +10,59 @@ import ( // Exists reports whether a record with the given ID exists for type T. func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) { - var count int64 - if err := db.WithContext(ctx). + var marker int + err := db.WithContext(ctx). Model(new(T)). + Select("1"). Where("id = ?", id). - Count(&count).Error; err != nil { + Limit(1). + Take(&marker).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { return false, err } - return count > 0, nil + return true, nil } func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) { - var count int64 q := db.WithContext(ctx). Model(new(T)). + Select("1"). Where("name = ?", name). Where("deleted_at IS NULL") if excludeID != nil { q = q.Where("id <> ?", *excludeID) } - if err := q.Count(&count).Error; err != nil { + var marker int + if err := q.Limit(1).Take(&marker).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } return false, err } - return count > 0, nil + return true, nil } func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) { if field == "" { return false, fmt.Errorf("field is required") } - var count int64 q := db.WithContext(ctx). Model(new(T)). + Select("1"). Where(fmt.Sprintf("%s = ?", field), value). Where("deleted_at IS NULL") if excludeID != nil { q = q.Where("id <> ?", *excludeID) } - if err := q.Count(&count).Error; err != nil { + var marker int + if err := q.Limit(1).Take(&marker).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } return false, err } - return count > 0, nil + return true, nil } diff --git a/internal/common/repository/common.stock_allocation.repository.go b/internal/common/repository/common.stock_allocation.repository.go new file mode 100644 index 00000000..466fbe4a --- /dev/null +++ b/internal/common/repository/common.stock_allocation.repository.go @@ -0,0 +1,76 @@ +package repository + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockAllocationRepository interface { + BaseRepository[entity.StockAllocation] + FindActiveByUsable(ctx context.Context, usableType string, usableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.StockAllocation, error) + ReleaseByUsable(ctx context.Context, usableType string, usableID uint, note *string, modifier func(*gorm.DB) *gorm.DB) error +} + +type StockAllocationRepositoryImpl struct { + *BaseRepositoryImpl[entity.StockAllocation] +} + +func NewStockAllocationRepository(db *gorm.DB) StockAllocationRepository { + return &StockAllocationRepositoryImpl{ + BaseRepositoryImpl: NewBaseRepository[entity.StockAllocation](db), + } +} + +func (r *StockAllocationRepositoryImpl) FindActiveByUsable( + ctx context.Context, + usableType string, + usableID uint, + modifier func(*gorm.DB) *gorm.DB, +) ([]entity.StockAllocation, error) { + var allocations []entity.StockAllocation + + q := r.DB().WithContext(ctx). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + + if modifier != nil { + q = modifier(q) + } + + if err := q.Order("created_at ASC").Find(&allocations).Error; err != nil { + return nil, err + } + + return allocations, nil +} + +func (r *StockAllocationRepositoryImpl) ReleaseByUsable( + ctx context.Context, + usableType string, + usableID uint, + note *string, + modifier func(*gorm.DB) *gorm.DB, +) error { + now := time.Now() + + updates := map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + } + if note != nil { + updates["note"] = *note + } + + baseDB := r.DB() + if modifier != nil { + baseDB = modifier(baseDB) + } + + q := baseDB.WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + + return q.Updates(updates).Error +} diff --git a/internal/common/service/common.closing.service.go b/internal/common/service/common.closing.service.go new file mode 100644 index 00000000..3e5e88f8 --- /dev/null +++ b/internal/common/service/common.closing.service.go @@ -0,0 +1,120 @@ +package service + +import ( + "context" + "errors" + "fmt" + + productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// Dipakai untuk semua module yang butuh cek: +// "PW ini → warehouse → kandang → project_flock_kandang sudah closing atau belum" +func EnsureProjectFlockNotClosedForProductWarehouses( + ctx context.Context, + db *gorm.DB, + productWarehouseIDs []uint, +) error { + if len(productWarehouseIDs) == 0 { + return nil + } + + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(db) + wRepo := warehouseRepo.NewWarehouseRepository(db) + pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) + + seenPW := make(map[uint]struct{}) + seenKandang := make(map[uint]struct{}) + + for _, pwID := range productWarehouseIDs { + if pwID == 0 { + continue + } + if _, ok := seenPW[pwID]; ok { + continue + } + seenPW[pwID] = struct{}{} + + pw, err := pwRepo.GetByID(ctx, pwID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d tidak ditemukan", pwID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + } + + wh, err := wRepo.GetByID(ctx, uint(pw.WarehouseId), nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Warehouse %d tidak ditemukan", pw.WarehouseId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") + } + + // Warehouse tanpa kandang → bukan kandang produksi → skip + if wh.KandangId == nil || *wh.KandangId == 0 { + continue + } + + kandangID := uint(*wh.KandangId) + if _, ok := seenKandang[kandangID]; ok { + continue + } + seenKandang[kandangID] = struct{}{} + + pfk, err := pfkRepo.GetActiveByKandangID(ctx, kandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // nggak ada project aktif untuk kandang ini → aman + continue + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + } + // INTI RULE: kalau aktif tapi sudah punya ClosedAt → anggap "project sudah closing" + if pfk != nil && pfk.ClosedAt != nil { + return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing") + } + } + + return nil +} + +func EnsureProjectFlockNotClosedByProjectFlockKandangID( + ctx context.Context, + db *gorm.DB, + pfkIDs []uint, +) error { + pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) + + seen := make(map[uint]struct{}) + for _, id := range pfkIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + + pfk, err := pfkRepo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Project flock kandang %d tidak ditemukan", id)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + } + + if pfk.ClosedAt != nil { + return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing") + } + } + return nil +} diff --git a/internal/common/service/common.document.service.go b/internal/common/service/common.document.service.go new file mode 100644 index 00000000..44f2c116 --- /dev/null +++ b/internal/common/service/common.document.service.go @@ -0,0 +1,474 @@ +package service + +import ( + "context" + "errors" + "fmt" + "mime" + "mime/multipart" + "net/url" + "path/filepath" + "strings" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/config" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/google/uuid" +) + +const ( + defaultDocumentPathLimit = 50 + defaultDocumentKeyPrefix = "docs" + maxDocumentNameLength = 50 +) + +type DocumentService interface { + UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error) + ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error) + DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error + DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error + PublicURL(document entity.Document) string + PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error) +} + +type DocumentUploadRequest struct { + DocumentableType string + DocumentableID uint64 + CreatedBy *uint + Files []DocumentFile +} + +type DocumentFile struct { + File *multipart.FileHeader + Type string + Index *int +} + +type DocumentUploadResult struct { + Document entity.Document + URL string + Index *int +} + +type DocumentServiceOption func(*documentService) + +type documentService struct { + repo commonRepo.DocumentRepository + storage DocumentStorage + keyPrefix string + maxPathLength int +} + +func NewDocumentService(repo commonRepo.DocumentRepository, storage DocumentStorage, opts ...DocumentServiceOption) DocumentService { + svc := &documentService{ + repo: repo, + storage: storage, + keyPrefix: defaultDocumentKeyPrefix, + maxPathLength: defaultDocumentPathLimit, + } + + for _, opt := range opts { + opt(svc) + } + + return svc +} + +func NewDocumentServiceFromConfig(ctx context.Context, repo commonRepo.DocumentRepository) (DocumentService, error) { + if repo == nil { + return nil, errors.New("document repository is required") + } + if strings.TrimSpace(config.S3Bucket) == "" { + return nil, errors.New("S3_BUCKET is not configured") + } + + storage, err := NewS3DocumentStorage(ctx, S3DocumentStorageConfig{ + Region: config.S3Region, + Bucket: config.S3Bucket, + AccessKey: config.S3AccessKey, + SecretKey: config.S3SecretKey, + Endpoint: config.S3Endpoint, + BaseURL: config.S3PublicBaseURL, + ForcePathStyle: config.S3ForcePathStyle, + }) + if err != nil { + return nil, err + } + + prefix := config.S3DocumentKeyPrefix + if prefix == "" { + prefix = defaultDocumentKeyPrefix + } + + return NewDocumentService( + repo, + storage, + WithDocumentKeyPrefix(prefix), + WithDocumentPathLimit(defaultDocumentPathLimit), + ), nil +} + +func WithDocumentKeyPrefix(prefix string) DocumentServiceOption { + return func(svc *documentService) { + prefix = strings.Trim(prefix, "/") + if prefix == "" { + prefix = defaultDocumentKeyPrefix + } + svc.keyPrefix = prefix + } +} + +func WithDocumentPathLimit(limit int) DocumentServiceOption { + return func(svc *documentService) { + if limit > 0 { + svc.maxPathLength = limit + } + } +} + +func (s *documentService) UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error) { + if s.repo == nil { + return nil, errors.New("document repository not configured") + } + if s.storage == nil { + return nil, errors.New("document storage not configured") + } + + documentableType := strings.ToUpper(strings.TrimSpace(req.DocumentableType)) + if documentableType == "" { + return nil, errors.New("documentable type is required") + } + if req.DocumentableID == 0 { + return nil, errors.New("documentable id is required") + } + if len(req.Files) == 0 { + return nil, errors.New("no files to upload") + } + + var createdBy *uint + if req.CreatedBy != nil && *req.CreatedBy != 0 { + idCopy := *req.CreatedBy + createdBy = &idCopy + } + + results := make([]DocumentUploadResult, 0, len(req.Files)) + createdDocs := make([]entity.Document, 0, len(req.Files)) + + for _, file := range req.Files { + if file.File == nil { + return nil, errors.New("file header is required") + } + + originalName := sanitizeDocumentName(file.File.Filename) + contentType := detectContentType(file.File, originalName) + ext := detectExtension(file.File.Filename, contentType) + key, err := s.generateObjectKey(ext) + if err != nil { + s.rollbackDocuments(ctx, createdDocs) + return nil, err + } + + reader, err := file.File.Open() + if err != nil { + s.rollbackDocuments(ctx, createdDocs) + return nil, err + } + uploadRes, err := s.storage.Upload(ctx, key, reader, file.File.Size, contentType) + _ = reader.Close() + if err != nil { + s.rollbackDocuments(ctx, createdDocs) + return nil, err + } + + docType := resolveDocumentType(file.Type, documentableType) + doc := entity.Document{ + DocumentableType: documentableType, + DocumentableId: req.DocumentableID, + Type: docType, + Path: uploadRes.Key, + Name: originalName, + Ext: strings.TrimPrefix(ext, "."), + Size: float64(file.File.Size), + CreatedBy: createdBy, + } + + if err := s.repo.CreateOne(ctx, &doc, nil); err != nil { + _ = s.storage.Delete(ctx, uploadRes.Key) + s.rollbackDocuments(ctx, createdDocs) + return nil, err + } + + createdDocs = append(createdDocs, doc) + results = append(results, DocumentUploadResult{ + Document: doc, + URL: uploadRes.URL, + Index: cloneIndex(file.Index), + }) + } + + return results, nil +} + +func (s *documentService) ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error) { + if s.repo == nil { + return nil, errors.New("document repository not configured") + } + + documentableType = strings.ToUpper(strings.TrimSpace(documentableType)) + if documentableType == "" { + return nil, errors.New("documentable type is required") + } + if documentableID == 0 { + return nil, errors.New("documentable id is required") + } + + return s.repo.ListByTarget(ctx, documentableType, documentableID, nil) +} + +func (s *documentService) DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error { + if s.repo == nil { + return errors.New("document repository not configured") + } + if len(ids) == 0 { + return nil + } + + docs, err := s.repo.GetByIDs(ctx, ids, nil) + if err != nil { + return err + } + + for _, doc := range docs { + if err := s.repo.DeleteOne(ctx, doc.Id); err != nil { + return err + } + if removeFromStorage && s.storage != nil { + if err := s.storage.Delete(ctx, doc.Path); err != nil { + utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path) + } + } + } + + return nil +} + +func (s *documentService) DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error { + if s.repo == nil { + return errors.New("document repository not configured") + } + + documentableType = strings.ToUpper(strings.TrimSpace(documentableType)) + if documentableType == "" || documentableID == 0 { + return errors.New("documentable type and id are required") + } + + var docs []entity.Document + if removeFromStorage && s.storage != nil { + var err error + docs, err = s.repo.ListByTarget(ctx, documentableType, documentableID, nil) + if err != nil { + return err + } + } + + if err := s.repo.DeleteByTarget(ctx, documentableType, documentableID, nil); err != nil { + return err + } + + if removeFromStorage && len(docs) > 0 { + for _, doc := range docs { + if err := s.storage.Delete(ctx, doc.Path); err != nil { + utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path) + } + } + } + + return nil +} + +func (s *documentService) PublicURL(document entity.Document) string { + if s.storage == nil || strings.TrimSpace(document.Path) == "" { + return "" + } + return s.storage.URL(document.Path) +} + +func (s *documentService) PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error) { + if s.storage == nil { + return "", errors.New("document storage not configured") + } + if strings.TrimSpace(document.Path) == "" { + return "", errors.New("document path is required") + } + return s.storage.PresignURL(ctx, document.Path, expires) +} + +// ResolveDocumentURL normalizes a stored path or URL into a presigned URL. +func ResolveDocumentURL( + ctx context.Context, + svc DocumentService, + rawPath string, + expires time.Duration, +) (string, error) { + if svc == nil { + return "", nil + } + + rawPath = strings.TrimSpace(rawPath) + if rawPath == "" { + return "", nil + } + + key := rawPath + lower := strings.ToLower(rawPath) + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + key = extractS3KeyFromURL(rawPath) + if key == "" { + return "", nil + } + } + + return svc.PresignURL(ctx, entity.Document{Path: key}, expires) +} + +func extractS3KeyFromURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "" + } + path := strings.TrimPrefix(parsed.Path, "/") + if path == "" { + return "" + } + + host := strings.ToLower(strings.TrimSpace(parsed.Host)) + if strings.HasPrefix(host, "s3.") || strings.HasPrefix(host, "s3-") { + parts := strings.SplitN(path, "/", 2) + if len(parts) == 2 { + return parts[1] + } + return "" + } + + return path +} + +func (s *documentService) generateObjectKey(ext string) (string, error) { + normalizedExt := strings.TrimSpace(ext) + if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") { + normalizedExt = "." + normalizedExt + } + + u := uuid.New().String() + key := fmt.Sprintf("%s/%s%s", strings.Trim(s.keyPrefix, "/"), u, normalizedExt) + if s.keyPrefix == "" { + key = fmt.Sprintf("%s%s", u, normalizedExt) + } + + if len(key) > s.maxPathLength { + key = fmt.Sprintf("%s%s", u, normalizedExt) + } + + if len(key) > s.maxPathLength { + return "", fmt.Errorf("object key exceeds maximum length (%d)", s.maxPathLength) + } + + return key, nil +} + +func (s *documentService) rollbackDocuments(ctx context.Context, docs []entity.Document) { + if len(docs) == 0 { + return + } + + for i := len(docs) - 1; i >= 0; i-- { + doc := docs[i] + if s.repo != nil && doc.Id != 0 { + if err := s.repo.DeleteOne(ctx, doc.Id); err != nil { + utils.Log.WithError(err).Warnf("failed to rollback document #%d", doc.Id) + } + } + if s.storage != nil && strings.TrimSpace(doc.Path) != "" { + if err := s.storage.Delete(ctx, doc.Path); err != nil { + utils.Log.WithError(err).Warnf("failed to rollback document object %s", doc.Path) + } + } + } +} + +func sanitizeDocumentName(name string) string { + name = filepath.Base(strings.TrimSpace(name)) + if name == "." || name == "" { + name = "document" + } + name = strings.Map(func(r rune) rune { + if r < 32 { + return -1 + } + switch r { + case '\\', '/', ':', '*', '?', '"', '<', '>', '|': + return '-' + default: + return r + } + }, name) + + if len(name) > maxDocumentNameLength { + runes := []rune(name) + if len(runes) > maxDocumentNameLength { + name = string(runes[:maxDocumentNameLength]) + } + } + return name +} + +func detectExtension(filename, contentType string) string { + ext := strings.ToLower(strings.TrimSpace(filepath.Ext(filename))) + if ext == "" && contentType != "" { + if exts, _ := mime.ExtensionsByType(contentType); len(exts) > 0 { + ext = exts[0] + } + } + if ext == "" { + return ".bin" + } + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + return ext +} + +func detectContentType(file *multipart.FileHeader, filename string) string { + if file == nil { + return "application/octet-stream" + } + contentType := strings.TrimSpace(file.Header.Get("Content-Type")) + if contentType != "" { + return contentType + } + if ext := filepath.Ext(filename); ext != "" { + if guess := mime.TypeByExtension(ext); guess != "" { + return guess + } + } + return "application/octet-stream" +} + +func resolveDocumentType(fileType, fallback string) string { + value := strings.ToUpper(strings.TrimSpace(fileType)) + if value == "" { + return fallback + } + return value +} + +func cloneIndex(index *int) *int { + if index == nil { + return nil + } + value := *index + return &value +} diff --git a/internal/common/service/common.document.service_test.go b/internal/common/service/common.document.service_test.go new file mode 100644 index 00000000..8b7d248d --- /dev/null +++ b/internal/common/service/common.document.service_test.go @@ -0,0 +1,101 @@ +package service + +import ( + "bytes" + "context" + "mime/multipart" + "net/http/httptest" + "strings" + "testing" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +func TestDocumentServiceUpload(t *testing.T) { + if strings.TrimSpace(config.S3Bucket) == "" { + t.Fatal("S3 bucket is not configured; set S3_* env vars to run this test") + } + + ctx := context.Background() + db := setupDocumentTestDB(t) + repo := commonRepo.NewDocumentRepository(db) + + svc, err := NewDocumentServiceFromConfig(ctx, repo) + if err != nil { + t.Fatalf("failed to create document service from config: %v", err) + } + + file := newTestFileHeader(t, "integration-proof.txt", "text/plain", []byte("document integration test")) + userID := uint(100) + + results, err := svc.UploadDocuments(ctx, DocumentUploadRequest{ + DocumentableType: "INVENTORY_TRANSFER", + DocumentableID: 99, + CreatedBy: &userID, + Files: []DocumentFile{ + {File: file, Type: "integration"}, + }, + }) + if err != nil { + t.Fatalf("upload to S3 failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 uploaded document, got %d", len(results)) + } + + doc := results[0].Document + if doc.Path == "" { + t.Fatalf("expected non-empty storage path") + } + if results[0].URL == "" { + t.Fatalf("expected public URL for uploaded document") + } + + t.Logf("uploaded document #%d to %s (path=%s)", doc.Id, results[0].URL, doc.Path) +} + +func setupDocumentTestDB(t *testing.T) *gorm.DB { + t.Helper() + if strings.TrimSpace(config.DBHost) == "" || strings.TrimSpace(config.DBName) == "" { + t.Fatal("database configuration missing; ensure DB_HOST and DB_NAME are set") + } + db := database.Connect(config.DBHost, config.DBName) + if db == nil { + t.Fatal("failed to create database connection") + } + if err := db.AutoMigrate(&entity.Document{}); err != nil { + t.Fatalf("failed to migrate document table: %v", err) + } + return db +} + +func newTestFileHeader(t *testing.T, filename, contentType string, data []byte) *multipart.FileHeader { + t.Helper() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("documents", filename) + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + if _, err := part.Write(data); err != nil { + t.Fatalf("failed to write file data: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("failed to close writer: %v", err) + } + + req := httptest.NewRequest("POST", "http://example.com/upload", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + _, fileHeader, err := req.FormFile("documents") + if err != nil { + t.Fatalf("failed to parse form file: %v", err) + } + fileHeader.Header.Set("Content-Type", contentType) + return fileHeader +} diff --git a/internal/common/service/common.document.storage.go b/internal/common/service/common.document.storage.go new file mode 100644 index 00000000..42909dbd --- /dev/null +++ b/internal/common/service/common.document.storage.go @@ -0,0 +1,185 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type DocumentStorage interface { + Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error) + Delete(ctx context.Context, key string) error + URL(key string) string + PresignURL(ctx context.Context, key string, expires time.Duration) (string, error) +} + +type DocumentStorageUploadResult struct { + Key string + URL string + ETag string +} + +type S3DocumentStorageConfig struct { + Region string + Bucket string + AccessKey string + SecretKey string + Endpoint string + BaseURL string + ForcePathStyle bool +} + +type s3DocumentStorage struct { + client *s3.Client + presignClient *s3.PresignClient + bucket string + base string +} + +func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (DocumentStorage, error) { + bucket := strings.TrimSpace(cfg.Bucket) + if bucket == "" { + return nil, errors.New("s3 bucket is required") + } + region := strings.TrimSpace(cfg.Region) + if region == "" { + region = "us-east-1" + } + + options := []func(*awsconfig.LoadOptions) error{ + awsconfig.WithRegion(region), + } + + endpoint := strings.TrimSpace(cfg.Endpoint) + if endpoint != "" { + resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) { + if service == s3.ServiceID { + return aws.Endpoint{ + URL: endpoint, + SigningRegion: region, + HostnameImmutable: true, + }, nil + } + return aws.Endpoint{}, &aws.EndpointNotFoundError{} + }) + options = append(options, awsconfig.WithEndpointResolverWithOptions(resolver)) + } + + accessKey := strings.TrimSpace(cfg.AccessKey) + secretKey := strings.TrimSpace(cfg.SecretKey) + if accessKey != "" && secretKey != "" { + options = append(options, awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), + )) + } + + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, options...) + if err != nil { + return nil, err + } + + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.UsePathStyle = cfg.ForcePathStyle + }) + presignClient := s3.NewPresignClient(client) + + baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/") + if baseURL == "" { + if endpoint != "" { + baseURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(endpoint, "/"), bucket) + } else { + baseURL = fmt.Sprintf("https://%s.s3.%s.amazonaws.com", bucket, region) + } + } + + return &s3DocumentStorage{ + client: client, + presignClient: presignClient, + bucket: bucket, + base: baseURL, + }, nil +} + +func (s *s3DocumentStorage) Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error) { + if strings.TrimSpace(key) == "" { + return DocumentStorageUploadResult{}, errors.New("storage key is required") + } + if size < 0 { + size = 0 + } + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: body, + } + input.ContentLength = aws.Int64(size) + if ct := strings.TrimSpace(contentType); ct != "" { + input.ContentType = aws.String(ct) + } + + out, err := s.client.PutObject(ctx, input) + if err != nil { + return DocumentStorageUploadResult{}, err + } + + var etag string + if out.ETag != nil { + etag = strings.Trim(*out.ETag, "\"") + } + + return DocumentStorageUploadResult{ + Key: key, + URL: s.URL(key), + ETag: etag, + }, nil +} + +func (s *s3DocumentStorage) Delete(ctx context.Context, key string) error { + if strings.TrimSpace(key) == "" { + return nil + } + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + return err +} + +func (s *s3DocumentStorage) URL(key string) string { + key = strings.TrimPrefix(strings.TrimSpace(key), "/") + if key == "" { + return s.base + } + if s.base == "" { + return key + } + return fmt.Sprintf("%s/%s", s.base, key) +} + +func (s *s3DocumentStorage) PresignURL(ctx context.Context, key string, expires time.Duration) (string, error) { + key = strings.TrimPrefix(strings.TrimSpace(key), "/") + if key == "" { + return "", errors.New("storage key is required") + } + if expires <= 0 { + expires = 15 * time.Minute + } + + out, err := s.presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(expires)) + if err != nil { + return "", err + } + + return out.URL, nil +} diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go new file mode 100644 index 00000000..2a65c1b4 --- /dev/null +++ b/internal/common/service/common.fifo.service.go @@ -0,0 +1,828 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "sort" + "strings" + "time" + + "github.com/sirupsen/logrus" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gitlab.com/mbugroup/lti-api.git/internal/entities" + productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type FifoService interface { + RegisterStockable(cfg fifo.StockableConfig) error + RegisterUsable(cfg fifo.UsableConfig) error + + Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) + Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) + ReleaseUsage(ctx context.Context, req StockReleaseRequest) error +} + +type fifoService struct { + db *gorm.DB + logger *logrus.Logger + allocations commonRepo.StockAllocationRepository + productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository + defaultOrderBy []string + pendingBatchPerUsable int + maxLotsPerStockable int + defaultAllocationNotes string +} + +func NewFifoService( + db *gorm.DB, + allocations commonRepo.StockAllocationRepository, + productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, + logger *logrus.Logger, +) FifoService { + if logger == nil { + logger = logrus.StandardLogger() + } + return &fifoService{ + db: db, + logger: logger, + allocations: allocations, + productWarehouseRepo: productWarehouseRepo, + defaultOrderBy: []string{"created_at ASC", "id ASC"}, + pendingBatchPerUsable: 25, + maxLotsPerStockable: 50, + } +} + +func (s *fifoService) withTransaction( + ctx context.Context, + tx *gorm.DB, + fn func(*gorm.DB) error, +) error { + if tx != nil { + return fn(tx.WithContext(ctx)) + } + return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error { + return fn(inner) + }) +} + +func (s *fifoService) txOrDB(tx, db *gorm.DB) *gorm.DB { + if tx != nil { + return tx + } + return db +} + +func (s *fifoService) RegisterStockable(cfg fifo.StockableConfig) error { + return fifo.RegisterStockable(cfg) +} + +func (s *fifoService) RegisterUsable(cfg fifo.UsableConfig) error { + return fifo.RegisterUsable(cfg) +} + +type StockReplenishRequest struct { + StockableKey fifo.StockableKey + StockableID uint + ProductWarehouseID uint + Quantity float64 + Note *string + Tx *gorm.DB +} + +type PendingResolution struct { + UsableKey fifo.UsableKey + UsableID uint + Quantity float64 +} + +type StockReplenishResult struct { + AddedQuantity float64 + PendingResolved []PendingResolution + RemainingPending float64 +} + +type StockConsumeRequest struct { + UsableKey fifo.UsableKey + UsableID uint + ProductWarehouseID uint + Quantity float64 + AllowPending bool + Note *string + Tx *gorm.DB +} + +type AllocationDetail struct { + StockableKey fifo.StockableKey + StockableID uint + Quantity float64 +} + +type StockConsumeResult struct { + RequestedQuantity float64 + UsageQuantity float64 + PendingQuantity float64 + AddedAllocations []AllocationDetail + ReleasedQuantity float64 +} + +type StockReleaseRequest struct { + UsableKey fifo.UsableKey + UsableID uint + Reason *string + Tx *gorm.DB +} + +func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) { + if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { + return nil, errors.New("stockable key and id are required") + } + if req.ProductWarehouseID == 0 { + return nil, errors.New("product warehouse id is required") + } + if req.Quantity <= 0 { + return nil, errors.New("quantity must be greater than zero") + } + + cfg, ok := fifo.Stockable(req.StockableKey) + if !ok { + return nil, fmt.Errorf("stockable %q is not registered", req.StockableKey) + } + + result := &StockReplenishResult{ + AddedQuantity: req.Quantity, + } + + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil { + return err + } + + if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + req.ProductWarehouseID: req.Quantity, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return err + } + + resolved, err := s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID) + if err != nil { + return err + } + result.PendingResolved = resolved + return nil + }) + if err != nil { + return nil, err + } + + return result, nil +} + +func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) { + if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { + return nil, errors.New("usable key and id are required") + } + if req.Quantity < 0 { + return nil, errors.New("quantity must be zero or greater") + } + cfg, ok := fifo.Usable(req.UsableKey) + if !ok { + return nil, fmt.Errorf("usable %q is not registered", req.UsableKey) + } + + result := &StockConsumeResult{ + RequestedQuantity: req.Quantity, + } + + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID) + if err != nil { + return err + } + + productWarehouseID := ctxRow.ProductWarehouseID + if productWarehouseID == 0 { + return fmt.Errorf("usable %q (id: %d) has no product warehouse reference", req.UsableKey, req.UsableID) + } + if req.ProductWarehouseID != 0 && req.ProductWarehouseID != productWarehouseID { + return fmt.Errorf("usable %q (id: %d) references product warehouse %d but %d was provided", req.UsableKey, req.UsableID, productWarehouseID, req.ProductWarehouseID) + } + + currentUsage := ctxRow.UsageQty + currentPending := ctxRow.PendingQty + currentTotal := currentUsage + currentPending + delta := req.Quantity - currentTotal + var ( + usageDelta float64 + pendingDelta float64 + addedAlloc []AllocationDetail + releasedAmount float64 + ) + + switch { + case delta > 0: + allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta) + if err != nil { + return err + } + if allocationRes.pending > 0 && !req.AllowPending { + return fmt.Errorf("insufficient stock: requested %.3f, allocated %.3f", req.Quantity, currentUsage+allocationRes.allocated) + } + + usageDelta += allocationRes.allocated + pendingDelta += allocationRes.pending + addedAlloc = allocationRes.allocations + + if allocationRes.allocated > 0 { + if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + productWarehouseID: -allocationRes.allocated, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return err + } + } + case delta < 0: + reductionTarget := -delta + + if currentPending > 0 { + pendingReduction := math.Min(currentPending, reductionTarget) + if pendingReduction > 0 { + pendingDelta -= pendingReduction + reductionTarget -= pendingReduction + } + } + + if reductionTarget > 0 { + released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget) + if err != nil { + return err + } + if released+1e-6 < reductionTarget { + return fmt.Errorf("unable to release %.3f from usable %d, only %.3f available", reductionTarget, req.UsableID, released) + } + usageDelta -= released + releasedAmount = released + } + default: + // no change + } + + if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil { + return err + } + + result.AddedAllocations = addedAlloc + result.ReleasedQuantity = releasedAmount + result.UsageQuantity = currentUsage + usageDelta + result.PendingQuantity = currentPending + pendingDelta + return nil + }) + if err != nil { + return nil, err + } + + return result, nil +} + +func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error { + if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { + return errors.New("usable key and id are required") + } + return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + cfg, ok := fifo.Usable(req.UsableKey) + if !ok { + return fmt.Errorf("usable %q is not registered", req.UsableKey) + } + + ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID) + if err != nil { + return err + } + var usageDelta, pendingDelta float64 + if ctxRow.UsageQty > 0 { + if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil { + return err + } + usageDelta -= ctxRow.UsageQty + } + if ctxRow.PendingQty > 0 { + pendingDelta -= ctxRow.PendingQty + } + + if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil { + return err + } + + return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }) + }) +} + +// --- helpers --- + +type usableContextRow struct { + ProductWarehouseID uint + UsageQty float64 + PendingQty float64 +} + +func (s *fifoService) loadUsableContext(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint) (*usableContextRow, error) { + var row usableContextRow + + query := tx.Table(cfg.Table). + Select(fmt.Sprintf("%s AS product_warehouse_id, COALESCE(%s,0) AS usage_qty, COALESCE(%s,0) AS pending_qty", cfg.Columns.ProductWarehouseID, cfg.Columns.UsageQuantity, cfg.Columns.PendingQuantity)). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id). + Clauses(clause.Locking{Strength: "UPDATE"}) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + if err := query.Take(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("usable record %d not found", id) + } + return nil, err + } + + return &row, nil +} + +func (s *fifoService) incrementStockableQty(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error { + column := cfg.Columns.TotalQuantity + + query := tx.Table(cfg.Table). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id) + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + updates := map[string]any{ + column: gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty), + } + if cfg.Columns.TotalUsedQuantity != "" { + updates[cfg.Columns.TotalUsedQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0)", cfg.Columns.TotalUsedQuantity)) + } + + return query.Updates(updates).Error +} + +func (s *fifoService) incrementStockableUsage(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error { + if qty == 0 { + return nil + } + column := cfg.Columns.TotalUsedQuantity + query := tx.Table(cfg.Table). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id) + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + return query.Update(column, gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty)).Error +} + +type allocationOutcome struct { + allocated float64 + pending float64 + allocations []AllocationDetail +} + +type stockLot struct { + StockableKey fifo.StockableKey + RecordID uint + AvailableQty float64 + CreatedAt time.Time +} + +func (s *fifoService) allocateFromStock( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + usableKey fifo.UsableKey, + usableID uint, + requestQty float64, +) (*allocationOutcome, error) { + lots, err := s.fetchStockLots(ctx, tx, productWarehouseID) + if err != nil { + return nil, err + } + if len(lots) == 0 { + return &allocationOutcome{pending: requestQty}, nil + } + + var ( + remaining = requestQty + applied float64 + allocations []*entities.StockAllocation + allocationSummaries []AllocationDetail + usageAdjustments = make(map[fifo.StockableKey]map[uint]float64) + ) + + for _, lot := range lots { + if remaining <= 0 { + break + } + if lot.AvailableQty <= 0 { + continue + } + + portion := lot.AvailableQty + if portion > remaining { + portion = remaining + } + + applied += portion + remaining -= portion + + allocationSummaries = append(allocationSummaries, AllocationDetail{ + StockableKey: lot.StockableKey, + StockableID: lot.RecordID, + Quantity: portion, + }) + + allocations = append(allocations, &entities.StockAllocation{ + ProductWarehouseId: productWarehouseID, + StockableType: lot.StockableKey.String(), + StockableId: lot.RecordID, + UsableType: usableKey.String(), + UsableId: usableID, + Qty: portion, + Status: entities.StockAllocationStatusActive, + }) + + if _, ok := usageAdjustments[lot.StockableKey]; !ok { + usageAdjustments[lot.StockableKey] = make(map[uint]float64) + } + usageAdjustments[lot.StockableKey][lot.RecordID] += portion + } + + if len(allocations) > 0 { + if err := s.allocations.CreateMany(ctx, allocations, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return nil, err + } + + for key, deltas := range usageAdjustments { + cfg, ok := fifo.Stockable(key) + if !ok { + continue + } + for id, qty := range deltas { + if err := s.incrementStockableUsage(ctx, tx, cfg, id, qty); err != nil { + return nil, err + } + } + } + } + + return &allocationOutcome{ + allocated: applied, + pending: remaining, + allocations: allocationSummaries, + }, nil +} + +func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]stockLot, error) { + configs := fifo.Stockables() + if len(configs) == 0 { + return nil, nil + } + + var lots []stockLot + for key, cfg := range configs { + + usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID + + var selectStmt string + if usesNumericTime { + + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + ) + } else { + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, %s AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + cfg.Columns.CreatedAt, + ) + } + + var rows []struct { + ID uint + AvailableQty float64 + CreatedAt time.Time + } + + query := tx.Table(cfg.Table). + Select(selectStmt). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). + Where(fmt.Sprintf("%s > %s", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity)) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + for _, order := range s.orderClauses(cfg.OrderBy) { + query = query.Order(order) + } + query = query.Limit(s.maxLotsPerStockable) + + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.AvailableQty <= 0 { + continue + } + lots = append(lots, stockLot{ + StockableKey: key, + RecordID: row.ID, + AvailableQty: row.AvailableQty, + CreatedAt: row.CreatedAt, + }) + } + } + + if len(lots) == 0 { + return nil, nil + } + + sort.SliceStable(lots, func(i, j int) bool { + if lots[i].CreatedAt.Equal(lots[j].CreatedAt) { + return lots[i].RecordID < lots[j].RecordID + } + return lots[i].CreatedAt.Before(lots[j].CreatedAt) + }) + + return lots, nil +} + +func (s *fifoService) applyUsableDeltas(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint, usageDelta, pendingDelta float64) error { + if usageDelta == 0 && pendingDelta == 0 { + return nil + } + + updates := map[string]any{} + if usageDelta != 0 { + updates[cfg.Columns.UsageQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.UsageQuantity), usageDelta) + } + if pendingDelta != 0 { + updates[cfg.Columns.PendingQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.PendingQuantity), pendingDelta) + } + + query := tx.Table(cfg.Table).Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id) + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + return query.Updates(updates).Error +} + +type pendingCandidate struct { + UsableKey fifo.UsableKey + Config fifo.UsableConfig + UsableID uint + Pending float64 + CreatedAt time.Time +} + +func (s *fifoService) resolvePendingForWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]PendingResolution, error) { + candidates, err := s.fetchPendingCandidates(ctx, tx, productWarehouseID) + if err != nil { + return nil, err + } + if len(candidates) == 0 { + return nil, nil + } + + var resolutions []PendingResolution + + for _, candidate := range candidates { + if candidate.Pending <= 0 { + continue + } + + outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending) + if err != nil { + return nil, err + } + if outcome.allocated <= 0 { + break + } + + if err := s.applyUsableDeltas(ctx, tx, candidate.Config, candidate.UsableID, outcome.allocated, -outcome.allocated); err != nil { + return nil, err + } + + if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + productWarehouseID: -outcome.allocated, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return nil, err + } + + resolutions = append(resolutions, PendingResolution{ + UsableKey: candidate.UsableKey, + UsableID: candidate.UsableID, + Quantity: outcome.allocated, + }) + + if outcome.pending > 0 { + // No more stock available for this warehouse at the moment. + break + } + } + + return resolutions, nil +} + +func (s *fifoService) releaseUsagePortion( + ctx context.Context, + tx *gorm.DB, + usableKey fifo.UsableKey, + usableID uint, + target float64, +) (float64, error) { + if target <= 0 { + return 0, nil + } + + allocations, err := s.allocations.FindActiveByUsable(ctx, usableKey.String(), usableID, func(db *gorm.DB) *gorm.DB { + target := s.txOrDB(tx, db) + return target.Clauses(clause.Locking{Strength: "UPDATE"}) + }) + if err != nil { + return 0, err + } + if len(allocations) == 0 { + return 0, nil + } + + var ( + remaining = target + totalReleased float64 + warehouseAdjustments = make(map[uint]float64) + stockableAdjustments = make(map[fifo.StockableKey]map[uint]float64) + ) + + now := time.Now() + + for i := len(allocations) - 1; i >= 0 && remaining > 0; i-- { + allocation := allocations[i] + releaseAmt := allocation.Qty + if releaseAmt > remaining { + releaseAmt = remaining + } + + remaining -= releaseAmt + totalReleased += releaseAmt + warehouseAdjustments[allocation.ProductWarehouseId] += releaseAmt + + key := fifo.StockableKey(allocation.StockableType) + if _, ok := stockableAdjustments[key]; !ok { + stockableAdjustments[key] = make(map[uint]float64) + } + stockableAdjustments[key][allocation.StockableId] += releaseAmt + + if releaseAmt == allocation.Qty { + if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{ + "status": entities.StockAllocationStatusReleased, + "released_at": now, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return 0, err + } + } else { + if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{ + "qty": allocation.Qty - releaseAmt, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return 0, err + } + } + } + + if totalReleased == 0 { + return 0, nil + } + + for key, deltas := range stockableAdjustments { + cfg, ok := fifo.Stockable(key) + if !ok { + continue + } + for id, qty := range deltas { + if err := s.incrementStockableUsage(ctx, tx, cfg, id, -qty); err != nil { + return 0, err + } + } + } + + if len(warehouseAdjustments) > 0 { + if err := s.productWarehouseRepo.AdjustQuantities(ctx, warehouseAdjustments, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }); err != nil { + return 0, err + } + + for warehouseID := range warehouseAdjustments { + if _, err := s.resolvePendingForWarehouse(ctx, tx, warehouseID); err != nil { + return 0, err + } + } + } + + return totalReleased, nil +} + +func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]pendingCandidate, error) { + configs := fifo.Usables() + if len(configs) == 0 { + return nil, nil + } + + var candidates []pendingCandidate + + for key, cfg := range configs { + selectStmt := fmt.Sprintf( + "%s AS id, %s AS pending_qty, %s AS created_at", + cfg.Columns.ID, + cfg.Columns.PendingQuantity, + cfg.Columns.CreatedAt, + ) + + var rows []struct { + ID uint + Pending float64 + CreatedAt time.Time + } + + query := tx.Table(cfg.Table). + Select(selectStmt). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). + Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)). + Limit(s.pendingBatchPerUsable) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + for _, order := range s.orderClauses(cfg.OrderBy) { + query = query.Order(order) + } + + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.Pending <= 0 { + continue + } + candidates = append(candidates, pendingCandidate{ + UsableKey: key, + Config: cfg, + UsableID: row.ID, + Pending: row.Pending, + CreatedAt: row.CreatedAt, + }) + } + } + + if len(candidates) == 0 { + return nil, nil + } + + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].CreatedAt.Equal(candidates[j].CreatedAt) { + return candidates[i].UsableID < candidates[j].UsableID + } + return candidates[i].CreatedAt.Before(candidates[j].CreatedAt) + }) + + return candidates, nil +} + +func (s *fifoService) orderClauses(custom []string) []string { + if len(custom) > 0 { + return custom + } + return s.defaultOrderBy +} diff --git a/internal/config/config.go b/internal/config/config.go index 2554bf57..8660704b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,7 @@ var ( SSOAuthorizeURL string SSOTokenURL string SSOGetMeURL string + SSOPortalURL string SSOClients map[string]SSOClientConfig SSOAccessCookieName string SSORefreshCookieName string @@ -65,6 +66,14 @@ var ( SSOUserSyncDrift time.Duration SSOUserSyncNonceTTL time.Duration SSOUserSyncMaxBodyBytes int + S3Endpoint string + S3Region string + S3Bucket string + S3AccessKey string + S3SecretKey string + S3ForcePathStyle bool + S3PublicBaseURL string + S3DocumentKeyPrefix string ) func init() { @@ -106,6 +115,16 @@ func init() { // Redis RedisURL = viper.GetString("REDIS_URL") + // Object storage + S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT")) + S3Region = strings.TrimSpace(viper.GetString("S3_REGION")) + S3Bucket = strings.TrimSpace(viper.GetString("S3_BUCKET")) + S3AccessKey = strings.TrimSpace(viper.GetString("S3_ACCESS_KEY")) + S3SecretKey = strings.TrimSpace(viper.GetString("S3_SECRET_KEY")) + S3ForcePathStyle = viper.GetBool("S3_FORCE_PATH_STYLE") + S3PublicBaseURL = strings.TrimSuffix(strings.TrimSpace(viper.GetString("S3_PUBLIC_BASE_URL")), "/") + S3DocumentKeyPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/"), "docs") + // SSO integration SSOIssuer = viper.GetString("SSO_ISSUER") SSOJWKSURL = viper.GetString("SSO_JWKS_URL") @@ -113,6 +132,7 @@ func init() { SSOAuthorizeURL = viper.GetString("SSO_AUTHORIZE_URL") SSOTokenURL = viper.GetString("SSO_TOKEN_URL") SSOGetMeURL = viper.GetString("SSO_GETME_URL") + SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL")) SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access") SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") diff --git a/internal/database/migrations/20250925040409_create_master_tables.up.sql b/internal/database/migrations/20250925040409_create_master_tables.up.sql index eabc78b5..7a1a6bf1 100644 --- a/internal/database/migrations/20250925040409_create_master_tables.up.sql +++ b/internal/database/migrations/20250925040409_create_master_tables.up.sql @@ -2,42 +2,42 @@ CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, id_user BIGINT NOT NULL, - name VARCHAR NOT NULL, - email VARCHAR NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + name VARCHAR(50) NOT NULL, + email VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ ); -CREATE UNIQUE INDEX users_id_user_unique ON users (id_user) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX users_id_user_unique ON users (id_user) +WHERE + deleted_at IS NULL; -CREATE UNIQUE INDEX users_email_unique ON users (email) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX users_email_unique ON users (email) +WHERE + deleted_at IS NULL; -- FLAGS CREATE TABLE flags ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, flagable_id BIGINT NOT NULL, flagable_type VARCHAR(50) NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW () ); -CREATE UNIQUE INDEX flags_unique_flagable ON flags ( - name, - flagable_id, - flagable_type -); +CREATE UNIQUE INDEX flags_unique_flagable ON flags (name, flagable_id, flagable_type); CREATE INDEX flags_flagable_lookup ON flags (flagable_type, flagable_id); -- PRODUCT CATEGORIES CREATE TABLE product_categories ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, code VARCHAR(10) NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -53,9 +53,9 @@ WHERE -- UOM CREATE TABLE uoms ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + name VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -67,12 +67,12 @@ WHERE -- BANKS CREATE TABLE banks ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, alias VARCHAR(5) NOT NULL, - owner VARCHAR, + owner VARCHAR(50), account_number VARCHAR(50) NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -84,9 +84,9 @@ WHERE -- AREAS CREATE TABLE areas ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + name VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -98,11 +98,11 @@ WHERE -- LOCATIONS CREATE TABLE locations ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, address TEXT NOT NULL, area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -114,11 +114,11 @@ WHERE -- KANDANG CREATE TABLE kandangs ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) 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(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -130,13 +130,13 @@ WHERE -- WAREHOUSES CREATE TABLE warehouses ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, type VARCHAR(50) NOT NULL, 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(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -148,16 +148,16 @@ WHERE -- CUSTOMERS CREATE TABLE customers ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, 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, - email VARCHAR NOT NULL, + email VARCHAR(50) NOT NULL, account_number VARCHAR(50) NOT NULL, balance NUMERIC(15, 3) DEFAULT 0, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -169,10 +169,10 @@ WHERE -- NONSTOCK CREATE TABLE nonstocks ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -184,9 +184,9 @@ WHERE -- FCR CREATE TABLE fcrs ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + name VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -201,29 +201,29 @@ CREATE TABLE fcr_standards ( weight NUMERIC(15, 3) NOT NULL, fcr_number NUMERIC(15, 3) NOT NULL, mortality NUMERIC(15, 3) NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ ); -- SUPPLIERS CREATE TABLE suppliers ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, alias VARCHAR(5) NOT NULL, - pic VARCHAR NOT NULL, + pic VARCHAR(50) NOT NULL, type VARCHAR(50) NOT NULL, category VARCHAR(20) NOT NULL, - hatchery VARCHAR, + hatchery VARCHAR(50), phone VARCHAR(20) NOT NULL, - email VARCHAR NOT NULL, + email VARCHAR(50) NOT NULL, address TEXT NOT NULL, npwp VARCHAR(50), account_number VARCHAR(50), balance NUMERIC(15, 3) DEFAULT 0, due_date INT NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -235,15 +235,15 @@ WHERE CREATE TABLE nonstock_suppliers ( nonstock_id BIGINT NOT NULL REFERENCES nonstocks (id) ON DELETE CASCADE ON UPDATE CASCADE, supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), PRIMARY KEY (nonstock_id, supplier_id) ); -- PRODUCTS CREATE TABLE products ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - brand VARCHAR NOT NULL, + name VARCHAR(50) NOT NULL, + brand VARCHAR(50) NOT NULL, sku VARCHAR(100), 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, @@ -251,8 +251,8 @@ CREATE TABLE products ( selling_price NUMERIC(15, 3), tax NUMERIC(15, 3), expiry_period INT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -268,15 +268,15 @@ WHERE CREATE TABLE product_suppliers ( product_id BIGINT NOT NULL REFERENCES products (id) ON DELETE CASCADE ON UPDATE CASCADE, supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), PRIMARY KEY (product_id, supplier_id) ); -- PROJECTS CREATE TABLE projects ( id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ, created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE ); @@ -288,8 +288,8 @@ CREATE TABLE product_warehouses ( warehouse_id BIGINT NOT NULL REFERENCES warehouses (id), quantity INTEGER NOT NULL DEFAULT 0, created_by BIGINT NOT NULL REFERENCES users (id), - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ ); @@ -316,8 +316,8 @@ CREATE TABLE stock_logs ( note TEXT, product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE, created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW (), + updated_at TIMESTAMPTZ DEFAULT NOW (), deleted_at TIMESTAMPTZ ); @@ -330,4 +330,4 @@ CREATE INDEX stock_logs_created_by_idx ON stock_logs (created_by); CREATE INDEX stock_logs_created_at_idx ON stock_logs (created_at); -CREATE INDEX stock_logs_deleted_at_idx ON stock_logs (deleted_at); +CREATE INDEX stock_logs_deleted_at_idx ON stock_logs (deleted_at); \ No newline at end of file diff --git a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql index e029646b..4ece8942 100644 --- a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql +++ b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql @@ -29,7 +29,7 @@ ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id -- Relasi ke product_warehouses ALTER TABLE project_chickins -ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE; +ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE; -- Relasi ke users ALTER TABLE project_chickins diff --git a/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql b/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql new file mode 100644 index 00000000..955610e9 --- /dev/null +++ b/internal/database/migrations/20251110070219_create_stock_allocations_table.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS stock_allocations_released_at_idx; +DROP INDEX IF EXISTS stock_allocations_status_idx; +DROP INDEX IF EXISTS stock_allocations_usage_lookup; +DROP INDEX IF EXISTS stock_allocations_lookup; +DROP INDEX IF EXISTS stock_allocations_product_warehouse_id_idx; + +DROP TABLE IF EXISTS stock_allocations; diff --git a/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql b/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql new file mode 100644 index 00000000..b2a8b053 --- /dev/null +++ b/internal/database/migrations/20251110070219_create_stock_allocations_table.up.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS stock_allocations ( + id BIGSERIAL PRIMARY KEY, + product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses(id), + stockable_type VARCHAR(100) NOT NULL, + stockable_id BIGINT NOT NULL, + usable_type VARCHAR(100) NOT NULL, + usable_id BIGINT NOT NULL, + qty NUMERIC(15,3) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + note TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + released_at TIMESTAMPTZ NULL, + deleted_at TIMESTAMPTZ NULL +); + +CREATE INDEX IF NOT EXISTS stock_allocations_product_warehouse_id_idx + ON stock_allocations (product_warehouse_id); + +CREATE INDEX IF NOT EXISTS stock_allocations_lookup + ON stock_allocations (stockable_type, stockable_id); + +CREATE INDEX IF NOT EXISTS stock_allocations_usage_lookup + ON stock_allocations (usable_type, usable_id); + +CREATE INDEX IF NOT EXISTS stock_allocations_status_idx + ON stock_allocations (status); + +CREATE INDEX IF NOT EXISTS stock_allocations_released_at_idx + ON stock_allocations (released_at); diff --git a/internal/database/migrations/20251117034511_create_expenses_table.up.sql b/internal/database/migrations/20251117034511_create_expenses_table.up.sql index 8949d931..f5f20f2c 100644 --- a/internal/database/migrations/20251117034511_create_expenses_table.up.sql +++ b/internal/database/migrations/20251117034511_create_expenses_table.up.sql @@ -1,7 +1,7 @@ CREATE TABLE expenses ( id BIGSERIAL PRIMARY KEY, reference_number VARCHAR(50) UNIQUE NOT NULL, - supplier_id BIGINT NULL, + supplier_id BIGINT NOT NULL, category VARCHAR(50) NOT NULL CHECK ( category IN ('BOP', 'NON-BOP') ), diff --git a/internal/database/migrations/20251125055613_update_expneses_expense_nostock_and_expense_ralization.down.sql b/internal/database/migrations/20251125055613_update_expneses_expense_nostock_and_expense_ralization.down.sql new file mode 100644 index 00000000..e69de29b diff --git a/internal/database/migrations/20251125055613_update_expneses_expense_nostock_and_expense_ralization.up.sql b/internal/database/migrations/20251125055613_update_expneses_expense_nostock_and_expense_ralization.up.sql new file mode 100644 index 00000000..ce71256b --- /dev/null +++ b/internal/database/migrations/20251125055613_update_expneses_expense_nostock_and_expense_ralization.up.sql @@ -0,0 +1,44 @@ +-- ============================ +-- EXPENSES +-- ============================ +ALTER TABLE expenses DROP COLUMN IF EXISTS grand_total; + +ALTER TABLE expenses RENAME COLUMN note TO notes; + +ALTER TABLE expenses RENAME COLUMN expense_date TO transaction_date; + +-- ============================ +-- EXPENSE_REALIZATIONS +-- ============================ +ALTER TABLE expense_realizations +RENAME COLUMN realization_qty TO qty; + +ALTER TABLE expense_realizations +RENAME COLUMN realization_unit_price TO price; + +ALTER TABLE expense_realizations RENAME COLUMN note TO notes; + +ALTER TABLE expense_realizations +DROP COLUMN IF EXISTS realization_total_price; + +ALTER TABLE expense_realizations +DROP COLUMN IF EXISTS realization_date; + +ALTER TABLE expense_realizations DROP COLUMN IF EXISTS created_by; + +ALTER TABLE expense_realizations +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); + +-- ============================ +-- EXPENSE_NONSTOCKS +-- ============================ +ALTER TABLE expense_nonstocks RENAME COLUMN note TO notes; + +ALTER TABLE expense_nonstocks DROP COLUMN IF EXISTS total_price; + +ALTER TABLE expense_nonstocks RENAME COLUMN unit_price TO price; + +ALTER TABLE expense_nonstocks DROP COLUMN IF EXISTS created_by; + +ALTER TABLE expense_nonstocks +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); \ No newline at end of file diff --git a/internal/database/migrations/20251127070744_create_project_budgets.down.sql b/internal/database/migrations/20251127070744_create_project_budgets.down.sql new file mode 100644 index 00000000..55bfdb3d --- /dev/null +++ b/internal/database/migrations/20251127070744_create_project_budgets.down.sql @@ -0,0 +1,2 @@ +DROP Table IF EXISTS project_budgets; + diff --git a/internal/database/migrations/20251127070744_create_project_budgets.up.sql b/internal/database/migrations/20251127070744_create_project_budgets.up.sql new file mode 100644 index 00000000..db4a713b --- /dev/null +++ b/internal/database/migrations/20251127070744_create_project_budgets.up.sql @@ -0,0 +1,31 @@ +CREATE TABLE project_budgets ( + id BIGSERIAL PRIMARY KEY, + project_flock_id BIGINT NOT NULL, + nonstock_id BIGINT NOT NULL, + qty NUMERIC(15, 3) NOT NULL, + price NUMERIC(15, 3) NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +-- Tambahkan Foreign Key ke project_flocks +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flocks') THEN + ALTER TABLE project_budgets + ADD CONSTRAINT fk_project_budgets_project_flock_id + FOREIGN KEY (project_flock_id) REFERENCES project_flocks(id); + END IF; +END $$; +-- Tambahkan Foreign Key ke nonstocks +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'nonstocks') THEN + ALTER TABLE project_budgets + ADD CONSTRAINT fk_project_budgets_nonstock_id + FOREIGN KEY (nonstock_id) REFERENCES nonstocks(id); + END IF; +END $$; +-- Index +CREATE INDEX idx_project_budgets_project_flock_id ON project_budgets (project_flock_id); + +CREATE INDEX idx_project_budgets_nonstock_id ON project_budgets (nonstock_id); \ No newline at end of file diff --git a/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.down.sql b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.down.sql new file mode 100644 index 00000000..2003bc61 --- /dev/null +++ b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_project_flock_kandangs_closed_at; +ALTER TABLE project_flock_kandangs + DROP COLUMN IF EXISTS closed_at; diff --git a/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.up.sql b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.up.sql new file mode 100644 index 00000000..dc2114b1 --- /dev/null +++ b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE project_flock_kandangs + ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandangs_closed_at + ON project_flock_kandangs (closed_at); diff --git a/internal/database/migrations/20251201140316_add_is_visible_to_products.down.sql b/internal/database/migrations/20251201140316_add_is_visible_to_products.down.sql new file mode 100644 index 00000000..64964c85 --- /dev/null +++ b/internal/database/migrations/20251201140316_add_is_visible_to_products.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE products +DROP COLUMN IF EXISTS is_visible; diff --git a/internal/database/migrations/20251201140316_add_is_visible_to_products.up.sql b/internal/database/migrations/20251201140316_add_is_visible_to_products.up.sql new file mode 100644 index 00000000..965e4f39 --- /dev/null +++ b/internal/database/migrations/20251201140316_add_is_visible_to_products.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE products +ADD COLUMN IF NOT EXISTS is_visible BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/internal/database/migrations/20251202103838_create_document_table.down.sql b/internal/database/migrations/20251202103838_create_document_table.down.sql new file mode 100644 index 00000000..68c3a98a --- /dev/null +++ b/internal/database/migrations/20251202103838_create_document_table.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS documents_documentable_polymorphic; +DROP TABLE IF EXISTS documents; diff --git a/internal/database/migrations/20251202103838_create_document_table.up.sql b/internal/database/migrations/20251202103838_create_document_table.up.sql new file mode 100644 index 00000000..cec686a4 --- /dev/null +++ b/internal/database/migrations/20251202103838_create_document_table.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE documents ( + id BIGSERIAL PRIMARY KEY, + documentable_type VARCHAR(50) NOT NULL, + documentable_id BIGINT NOT NULL, + type VARCHAR(50) NOT NULL, + path VARCHAR(50) NOT NULL, + name VARCHAR(50) NOT NULL, + ext VARCHAR(50) NOT NULL, + size NUMERIC(15, 3) NOT NULL, + created_by BIGINT REFERENCES users (id) ON DELETE RESTRICT ON UPDATE CASCADE, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX documents_documentable_polymorphic ON documents (documentable_type, documentable_id); diff --git a/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql new file mode 100644 index 00000000..059e8ca5 --- /dev/null +++ b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql @@ -0,0 +1,35 @@ +BEGIN; + +-- Drop new indexes and FK +DROP INDEX IF EXISTS idx_product_warehouses_project_flock_kandang_id; +DROP INDEX IF EXISTS idx_product_warehouses_unique; + +ALTER TABLE product_warehouses + DROP CONSTRAINT IF EXISTS fk_product_warehouses_project_flock_kandang_id, + ALTER COLUMN project_flock_kandang_id DROP NOT NULL, + DROP COLUMN IF EXISTS project_flock_kandang_id; + +-- Revert qty to integer quantity +ALTER TABLE product_warehouses + RENAME COLUMN qty TO quantity; + +ALTER TABLE product_warehouses + ALTER COLUMN quantity TYPE INTEGER USING quantity::integer, + ALTER COLUMN quantity SET DEFAULT 0, + ALTER COLUMN quantity SET NOT NULL; + +-- Restore audit/soft-delete columns +ALTER TABLE product_warehouses + ADD COLUMN IF NOT EXISTS created_by BIGINT REFERENCES users (id), + ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +-- Recreate prior indexes +CREATE INDEX IF NOT EXISTS idx_product_warehouses_deleted_at ON product_warehouses (deleted_at); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_product_warehouses_unique + ON product_warehouses (product_id, warehouse_id) + WHERE deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.up.sql b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.up.sql new file mode 100644 index 00000000..cb1e16bc --- /dev/null +++ b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.up.sql @@ -0,0 +1,41 @@ +BEGIN; + +-- Drop indexes that depend on deleted_at or old uniqueness +DROP INDEX IF EXISTS idx_product_warehouses_deleted_at; +DROP INDEX IF EXISTS idx_product_warehouses_unique; + +-- Add new relation and adjust quantity column +ALTER TABLE product_warehouses + ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT; + +ALTER TABLE product_warehouses + RENAME COLUMN quantity TO qty; + +-- Enforce numeric quantity with precision and default +ALTER TABLE product_warehouses + ALTER COLUMN qty TYPE NUMERIC(15, 3) USING qty::numeric(15, 3), + ALTER COLUMN qty SET DEFAULT 0, + ALTER COLUMN qty SET NOT NULL; + +-- Remove audit/soft-delete columns no longer used +ALTER TABLE product_warehouses + DROP COLUMN IF EXISTS created_by, + DROP COLUMN IF EXISTS created_at, + DROP COLUMN IF EXISTS updated_at, + DROP COLUMN IF EXISTS deleted_at; + +-- Enforce FK and not-null for project_flock_kandang_id +ALTER TABLE product_warehouses + ADD CONSTRAINT fk_product_warehouses_project_flock_kandang_id + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs (id) + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- New indexes +CREATE INDEX IF NOT EXISTS idx_product_warehouses_project_flock_kandang_id + ON product_warehouses (project_flock_kandang_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_product_warehouses_unique + ON product_warehouses (product_id, warehouse_id, project_flock_kandang_id); + +COMMIT; diff --git a/internal/database/migrations/20251203103853_update_stock_logs_schema.down.sql b/internal/database/migrations/20251203103853_update_stock_logs_schema.down.sql new file mode 100644 index 00000000..9f9b7aa4 --- /dev/null +++ b/internal/database/migrations/20251203103853_update_stock_logs_schema.down.sql @@ -0,0 +1,44 @@ +BEGIN; + +-- Drop new indexes +DROP INDEX IF EXISTS stock_logs_loggable_type_loggable_id_idx; +DROP INDEX IF EXISTS stock_logs_product_warehouse_id_idx; +DROP INDEX IF EXISTS stock_logs_created_by_idx; +DROP INDEX IF EXISTS stock_logs_created_at_idx; + +-- Restore obsolete columns +ALTER TABLE stock_logs + ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(20) DEFAULT '' NOT NULL, + ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(), + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +-- Rename columns back +ALTER TABLE stock_logs + RENAME COLUMN loggable_type TO log_type; + +ALTER TABLE stock_logs + RENAME COLUMN loggable_id TO log_id; + +ALTER TABLE stock_logs + RENAME COLUMN notes TO note; + +-- Drop new columns +ALTER TABLE stock_logs + DROP COLUMN IF EXISTS increase, + DROP COLUMN IF EXISTS decrease; + +-- Restore indexes for old structure +CREATE INDEX IF NOT EXISTS stock_logs_product_warehouse_id_idx ON stock_logs (product_warehouse_id); + +CREATE INDEX IF NOT EXISTS stock_logs_log_type_log_id_idx ON stock_logs (log_type, log_id); + +CREATE INDEX IF NOT EXISTS stock_logs_created_by_idx ON stock_logs (created_by); + +CREATE INDEX IF NOT EXISTS stock_logs_created_at_idx ON stock_logs (created_at); + +CREATE INDEX IF NOT EXISTS stock_logs_deleted_at_idx ON stock_logs (deleted_at); + +COMMIT; diff --git a/internal/database/migrations/20251203103853_update_stock_logs_schema.up.sql b/internal/database/migrations/20251203103853_update_stock_logs_schema.up.sql new file mode 100644 index 00000000..0501140f --- /dev/null +++ b/internal/database/migrations/20251203103853_update_stock_logs_schema.up.sql @@ -0,0 +1,50 @@ +BEGIN; + +-- Drop old indexes tied to removed columns +DROP INDEX IF EXISTS stock_logs_log_type_log_id_idx; +DROP INDEX IF EXISTS stock_logs_deleted_at_idx; + +-- Rename columns to new naming +ALTER TABLE stock_logs + RENAME COLUMN log_type TO loggable_type; + +ALTER TABLE stock_logs + RENAME COLUMN log_id TO loggable_id; + +ALTER TABLE stock_logs + RENAME COLUMN note TO notes; + +-- Add new increase/decrease columns +ALTER TABLE stock_logs + ADD COLUMN IF NOT EXISTS increase NUMERIC(15, 3) DEFAULT 0, + ADD COLUMN IF NOT EXISTS decrease NUMERIC(15, 3) DEFAULT 0; + +-- Adjust column definitions +ALTER TABLE stock_logs + ALTER COLUMN loggable_type TYPE VARCHAR(50), + ALTER COLUMN loggable_type SET NOT NULL, + ALTER COLUMN loggable_id SET NOT NULL, + ALTER COLUMN increase SET DEFAULT 0, + ALTER COLUMN increase SET NOT NULL, + ALTER COLUMN decrease SET DEFAULT 0, + ALTER COLUMN decrease SET NOT NULL; + +-- Remove obsolete columns +ALTER TABLE stock_logs + DROP COLUMN IF EXISTS transaction_type, + DROP COLUMN IF EXISTS quantity, + DROP COLUMN IF EXISTS before_quantity, + DROP COLUMN IF EXISTS after_quantity, + DROP COLUMN IF EXISTS updated_at, + DROP COLUMN IF EXISTS deleted_at; + +-- Recreate indexes for new structure +CREATE INDEX IF NOT EXISTS stock_logs_product_warehouse_id_idx ON stock_logs (product_warehouse_id); + +CREATE INDEX IF NOT EXISTS stock_logs_loggable_type_loggable_id_idx ON stock_logs (loggable_type, loggable_id); + +CREATE INDEX IF NOT EXISTS stock_logs_created_by_idx ON stock_logs (created_by); + +CREATE INDEX IF NOT EXISTS stock_logs_created_at_idx ON stock_logs (created_at); + +COMMIT; diff --git a/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.down.sql b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.down.sql new file mode 100644 index 00000000..294d5e40 --- /dev/null +++ b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.down.sql @@ -0,0 +1,33 @@ +BEGIN; + +-- Remove grading details from recording_eggs +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; + +ALTER TABLE recording_eggs + DROP COLUMN IF EXISTS weight; + +ALTER TABLE recording_eggs + ADD CONSTRAINT chk_recording_eggs_qty CHECK (qty >= 0); + +-- Restore grading_eggs table for rollback scenarios +CREATE TABLE grading_eggs ( + id BIGSERIAL PRIMARY KEY, + recording_egg_id BIGINT NOT NULL, + qty NUMERIC(15,3) NOT NULL, + grade VARCHAR, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_grading_eggs_recording_egg + FOREIGN KEY (recording_egg_id) REFERENCES recording_eggs(id) ON DELETE CASCADE, + CONSTRAINT fk_grading_eggs_created_by + FOREIGN KEY (created_by) REFERENCES users(id), + CONSTRAINT chk_grading_eggs_qty CHECK (qty >= 0) +); + +CREATE INDEX idx_grading_eggs_recording_egg + ON grading_eggs (recording_egg_id); + +COMMIT; diff --git a/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.up.sql b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.up.sql new file mode 100644 index 00000000..4da8c647 --- /dev/null +++ b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.up.sql @@ -0,0 +1,18 @@ +BEGIN; + +-- Remove separate grading table and move grading details into recording_eggs +DROP INDEX IF EXISTS idx_grading_eggs_recording_egg; +DROP TABLE IF EXISTS grading_eggs; + +ALTER TABLE recording_eggs + ADD COLUMN IF NOT EXISTS weight NUMERIC(10,3); + +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; + +ALTER TABLE recording_eggs + ADD CONSTRAINT chk_recording_eggs_qty CHECK ( + qty >= 0 AND (weight IS NULL OR weight >= 0) + ); + +COMMIT; diff --git a/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql new file mode 100644 index 00000000..022e3a36 --- /dev/null +++ b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql @@ -0,0 +1,38 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock' + ) THEN + ALTER TABLE purchase_items + DROP CONSTRAINT fk_purchase_items_expense_nonstock; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang' + ) THEN + ALTER TABLE purchase_items + DROP CONSTRAINT fk_purchase_items_project_flock_kandang; + END IF; +END $$; + +DROP INDEX IF EXISTS idx_purchase_items_expense_nonstock_id; +DROP INDEX IF EXISTS idx_purchase_items_project_flock_kandang_id; + +ALTER TABLE purchase_items + DROP COLUMN IF EXISTS expense_nonstock_id, + DROP COLUMN IF EXISTS project_flock_kandang_id, + ALTER COLUMN vehicle_number DROP NOT NULL, + ALTER COLUMN vehicle_number TYPE VARCHAR USING vehicle_number; + +ALTER TABLE purchases + ALTER COLUMN pr_number TYPE VARCHAR USING pr_number, + ALTER COLUMN po_number TYPE VARCHAR USING po_number, + ALTER COLUMN created_at DROP DEFAULT, + ALTER COLUMN updated_at DROP DEFAULT; + +ALTER TABLE purchases + ADD COLUMN credit_term INT NOT NULL DEFAULT 0, + ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0; + +ALTER TABLE purchases + ALTER COLUMN credit_term DROP DEFAULT, + ALTER COLUMN grand_total DROP DEFAULT; diff --git a/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql new file mode 100644 index 00000000..c8d5748f --- /dev/null +++ b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql @@ -0,0 +1,57 @@ +-- Adjust purchases table to new purchasing schema +ALTER TABLE purchases + ALTER COLUMN pr_number TYPE VARCHAR(50) USING LEFT(pr_number, 50), + ALTER COLUMN po_number TYPE VARCHAR(50) USING LEFT(po_number, 50), + ALTER COLUMN created_at SET DEFAULT now(), + ALTER COLUMN updated_at SET DEFAULT now(); + +ALTER TABLE purchases + DROP COLUMN IF EXISTS credit_term, + DROP COLUMN IF EXISTS grand_total; + +-- Bring purchase_items in line with new requirements +ALTER TABLE purchase_items + ADD COLUMN IF NOT EXISTS expense_nonstock_id BIGINT, + ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT; + +UPDATE purchase_items +SET vehicle_number = '' +WHERE vehicle_number IS NULL; + +ALTER TABLE purchase_items + ALTER COLUMN vehicle_number TYPE VARCHAR(10) USING LEFT(vehicle_number, 10), + ALTER COLUMN vehicle_number SET NOT NULL; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'expense_nonstocks') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock' + ) THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_expense_nonstock + FOREIGN KEY (expense_nonstock_id) + REFERENCES expense_nonstocks(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang' + ) THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_purchase_items_expense_nonstock_id + ON purchase_items (expense_nonstock_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_project_flock_kandang_id + ON purchase_items (project_flock_kandang_id); diff --git a/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql b/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql new file mode 100644 index 00000000..4d80dd2c --- /dev/null +++ b/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql @@ -0,0 +1,3 @@ +-- Drop function and sequence for sales order numbers +DROP FUNCTION IF EXISTS generate_so_number(); +DROP SEQUENCE IF EXISTS so_number_seq; diff --git a/internal/database/migrations/20251210044651_create_so_number_sequence.up.sql b/internal/database/migrations/20251210044651_create_so_number_sequence.up.sql new file mode 100644 index 00000000..833a8323 --- /dev/null +++ b/internal/database/migrations/20251210044651_create_so_number_sequence.up.sql @@ -0,0 +1,12 @@ +-- Create sequence for sales order numbers +CREATE SEQUENCE so_number_seq START WITH 1 INCREMENT BY 1; + +CREATE OR REPLACE FUNCTION generate_so_number() +RETURNS VARCHAR AS $$ +DECLARE + next_val INTEGER; +BEGIN + next_val := nextval('so_number_seq'); + RETURN 'SO-' || LPAD(next_val::TEXT, 5, '0'); +END; +$$ LANGUAGE plpgsql; diff --git a/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.down.sql b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.down.sql new file mode 100644 index 00000000..866c12b9 --- /dev/null +++ b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE purchases + DROP COLUMN IF EXISTS credit_term; diff --git a/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.up.sql b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.up.sql new file mode 100644 index 00000000..2cae8d6a --- /dev/null +++ b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE purchases + ADD COLUMN IF NOT EXISTS credit_term INT NOT NULL DEFAULT 0; + +ALTER TABLE purchases + ALTER COLUMN credit_term DROP DEFAULT; diff --git a/internal/database/migrations/20251224033033_create_payment_table.down.sql b/internal/database/migrations/20251224033033_create_payment_table.down.sql new file mode 100644 index 00000000..14bc4ca1 --- /dev/null +++ b/internal/database/migrations/20251224033033_create_payment_table.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_payments_bank_id; +DROP INDEX IF EXISTS payments_party_polymorphic; +DROP TABLE IF EXISTS payments; diff --git a/internal/database/migrations/20251224033033_create_payment_table.up.sql b/internal/database/migrations/20251224033033_create_payment_table.up.sql new file mode 100644 index 00000000..d27c55f4 --- /dev/null +++ b/internal/database/migrations/20251224033033_create_payment_table.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS payments ( + id BIGSERIAL PRIMARY KEY, + payment_code VARCHAR(50) NOT NULL, + reference_number VARCHAR(100) NULL, + transaction_type VARCHAR(50), + party_type VARCHAR(50) NOT NULL, + party_id BIGINT NOT NULL, + payment_date TIMESTAMPTZ NOT NULL, + payment_method VARCHAR(20) NOT NULL, + bank_id BIGINT NULL REFERENCES banks(id) ON DELETE RESTRICT ON UPDATE CASCADE, + direction VARCHAR(5) NOT NULL, + nominal NUMERIC(15, 3) NOT NULL, + notes TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +-- Indexes +CREATE INDEX payments_party_polymorphic ON payments (party_type, party_id); +CREATE INDEX idx_payments_bank_id ON payments (bank_id); diff --git a/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql new file mode 100644 index 00000000..1d55147b --- /dev/null +++ b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql @@ -0,0 +1,18 @@ +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + END LOOP; +END $$; + +DROP FUNCTION IF EXISTS soft_delete_handle_fk(); diff --git a/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql new file mode 100644 index 00000000..161d3d89 --- /dev/null +++ b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql @@ -0,0 +1,126 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)', + fk.child_table, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql b/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql new file mode 100644 index 00000000..50996e8f --- /dev/null +++ b/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql @@ -0,0 +1 @@ +DROP SEQUENCE IF EXISTS payments_code_seq; diff --git a/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql b/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql new file mode 100644 index 00000000..875b0697 --- /dev/null +++ b/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql @@ -0,0 +1 @@ +CREATE SEQUENCE IF NOT EXISTS payments_code_seq START WITH 1 INCREMENT BY 1; diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql new file mode 100644 index 00000000..59e54379 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql @@ -0,0 +1,3 @@ +-- Rollback: restore document columns to expenses table +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS document_path JSON; +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS realization_document_path JSON; diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql new file mode 100644 index 00000000..c75bc307 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql @@ -0,0 +1,3 @@ +-- Delete document columns from expenses table since we now use Document service with polymorphic relations +ALTER TABLE expenses DROP COLUMN IF EXISTS document_path; +ALTER TABLE expenses DROP COLUMN IF EXISTS realization_document_path; diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql new file mode 100644 index 00000000..b9637077 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql @@ -0,0 +1,28 @@ +-- ============================================ +-- Rollback: Remove FIFO fields and restore qty column +-- ============================================ + +-- STEP 1: Drop indexes +DROP INDEX IF EXISTS idx_marketing_delivery_products_fifo_lookup; +DROP INDEX IF EXISTS idx_marketing_delivery_products_pending_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_usage_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_created_at; + +-- STEP 2: Drop constraints +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS chk_marketing_delivery_products_fifo_nonneg; + +-- STEP 3: Restore qty column from usage_qty data +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- Migrate data back from usage_qty to qty +UPDATE marketing_delivery_products +SET qty = usage_qty +WHERE qty = 0; + +-- STEP 4: Drop FIFO columns +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS created_at; diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql new file mode 100644 index 00000000..160c1f05 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql @@ -0,0 +1,58 @@ +-- ============================================ +-- Add FIFO fields to marketing_delivery_products +-- This migration adds fields needed for FIFO stock management +-- and removes the old qty field in favor of FIFO-based allocation +-- ============================================ + +-- STEP 0: Drop orphan indexes from previous migration +DROP INDEX IF EXISTS idx_marketing_delivery_products_deleted_at; + +-- STEP 1: Add created_at column (required for FIFO ordering) +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); + +-- STEP 2: Add FIFO tracking fields +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0; + +-- STEP 3: Migrate data from old qty to usage_qty for existing records +-- This preserves existing quantity data as allocated quantity +UPDATE marketing_delivery_products +SET + usage_qty = COALESCE(qty, 0), + pending_qty = 0 +WHERE usage_qty = 0; + +-- STEP 4: Drop the old qty column (replaced by usage_qty + pending_qty) +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS qty; + +-- STEP 5: Make FIFO fields NOT NULL +ALTER TABLE marketing_delivery_products +ALTER COLUMN usage_qty SET NOT NULL, +ALTER COLUMN pending_qty SET NOT NULL, +ALTER COLUMN created_at SET NOT NULL; + +-- STEP 6: Add constraints to ensure non-negative values +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT chk_marketing_delivery_products_fifo_nonneg CHECK ( + usage_qty >= 0 AND + pending_qty >= 0 +); + +-- STEP 7: Create indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_created_at +ON marketing_delivery_products(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_usage_qty +ON marketing_delivery_products(usage_qty) +WHERE usage_qty > 0; + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_pending_qty +ON marketing_delivery_products(pending_qty) +WHERE pending_qty > 0; + +-- Composite index for FIFO lookups +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_fifo_lookup +ON marketing_delivery_products(marketing_product_id, created_at DESC); diff --git a/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql new file mode 100644 index 00000000..15f3368a --- /dev/null +++ b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql @@ -0,0 +1,7 @@ +-- Remove foreign key constraint +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_product_warehouse; + +-- Drop product_warehouse_id column +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS product_warehouse_id; diff --git a/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql new file mode 100644 index 00000000..97e5b4ee --- /dev/null +++ b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql @@ -0,0 +1,19 @@ +-- Add product_warehouse_id column to marketing_delivery_products +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS product_warehouse_id INT NOT NULL DEFAULT 0; + +-- Fill product_warehouse_id from marketing_products +UPDATE marketing_delivery_products mdp +SET product_warehouse_id = mp.product_warehouse_id +FROM marketing_products mp +WHERE mdp.marketing_product_id = mp.id + AND mdp.product_warehouse_id = 0; + +-- Set NOT NULL constraint +ALTER TABLE marketing_delivery_products +ALTER COLUMN product_warehouse_id SET NOT NULL; + +-- Add foreign key constraint +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT fk_marketing_delivery_products_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id); diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql new file mode 100644 index 00000000..f5cc2237 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql @@ -0,0 +1,10 @@ +-- Drop indexes +DROP INDEX IF EXISTS idx_standard_growth_details_standard_week; +DROP INDEX IF EXISTS idx_production_standard_details_standard_week; +DROP INDEX IF EXISTS idx_production_standards_project_category; +DROP INDEX IF EXISTS idx_production_standards_deleted_at; + +-- Drop tables (in reverse order due to foreign keys) +DROP TABLE IF EXISTS standard_growth_details; +DROP TABLE IF EXISTS production_standard_details; +DROP TABLE IF EXISTS production_standards; diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql new file mode 100644 index 00000000..2af43d20 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql @@ -0,0 +1,96 @@ +-- Create production_standards table +CREATE TABLE IF NOT EXISTS production_standards ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + project_category VARCHAR(20) NOT NULL CHECK (project_category IN ('GROWING', 'LAYING')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT +); + +-- Create index for deleted_at (soft delete) +CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at); + +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE production_standards + ADD CONSTRAINT fk_production_standards_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- Index +CREATE INDEX idx_production_standards_created_by ON production_standards(created_by); + +-- Create production_standard_details table +CREATE TABLE IF NOT EXISTS production_standard_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + week INT NOT NULL, + target_hen_day_production NUMERIC(15, 3), + target_hen_house_production NUMERIC(15, 3), + target_egg_weight NUMERIC(15, 3), + target_egg_mass NUMERIC(15, 3), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE production_standard_details + ADD CONSTRAINT fk_production_standard_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_production_standard_details_standard_week + ON production_standard_details(production_standard_id, week); + +-- Create standard_growth_details table +CREATE TABLE IF NOT EXISTS standard_growth_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + target_mean_bw NUMERIC(15, 3), + max_depletion NUMERIC(15, 3), + min_uniformity NUMERIC(15, 3) NOT NULL, + week INT NOT NULL, + feed_intake NUMERIC(15, 3), + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by BIGINT +); + +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_standard_growth_details_standard_week + ON standard_growth_details(production_standard_id, week); + +-- Index +CREATE INDEX idx_standard_growth_details_created_by ON standard_growth_details(created_by); + +-- Create index for project_category +CREATE INDEX idx_production_standards_project_category ON production_standards(project_category); diff --git a/internal/database/migrations/20251227100000_update_expense_table.down.sql b/internal/database/migrations/20251227100000_update_expense_table.down.sql new file mode 100644 index 00000000..fbaff587 --- /dev/null +++ b/internal/database/migrations/20251227100000_update_expense_table.down.sql @@ -0,0 +1,24 @@ +-- Rollback: Update expense and expense_nonstocks tables + +-- Drop indexes +DROP INDEX IF EXISTS idx_expenses_project_flock_id; +DROP INDEX IF EXISTS idx_expenses_location_id; + +-- Drop Foreign Key constraint +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_expenses_location_id' + ) THEN + ALTER TABLE expenses + DROP CONSTRAINT fk_expenses_location_id; + END IF; +END $$; + +-- Drop columns from expenses table +ALTER TABLE expenses +DROP COLUMN IF EXISTS project_flock_id; + +ALTER TABLE expenses +DROP COLUMN IF EXISTS location_id; diff --git a/internal/database/migrations/20251227100000_update_expense_table.up.sql b/internal/database/migrations/20251227100000_update_expense_table.up.sql new file mode 100644 index 00000000..6415ac98 --- /dev/null +++ b/internal/database/migrations/20251227100000_update_expense_table.up.sql @@ -0,0 +1,29 @@ +-- Migration: Update expense and expense_nonstocks tables + +-- Add location_id column to expenses table +ALTER TABLE expenses +ADD COLUMN IF NOT EXISTS location_id BIGINT NOT NULL DEFAULT 1; + +-- Add project_flock_id column to expenses table (JSON type) +ALTER TABLE expenses +ADD COLUMN IF NOT EXISTS project_flock_id JSON NULL; + +-- Add Foreign Key constraint to locations table +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'locations') THEN + ALTER TABLE expenses + ADD CONSTRAINT fk_expenses_location_id + FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- Create index for location_id +CREATE INDEX IF NOT EXISTS idx_expenses_location_id ON expenses (location_id); + +-- Create index for project_flock_id +CREATE INDEX IF NOT EXISTS idx_expenses_project_flock_id ON expenses ((project_flock_id::text)); + +-- Ensure kandang_id is nullable in expense_nonstocks table +ALTER TABLE expense_nonstocks +ALTER COLUMN kandang_id DROP NOT NULL; diff --git a/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.down.sql b/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.down.sql new file mode 100644 index 00000000..19eaba80 --- /dev/null +++ b/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_deleted_at; +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_created_by; +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_week; +DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_id; + +DROP TABLE IF EXISTS project_flock_kandang_uniformity; diff --git a/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.up.sql b/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.up.sql new file mode 100644 index 00000000..86bc5ed5 --- /dev/null +++ b/internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.up.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS project_flock_kandang_uniformity ( + id BIGSERIAL PRIMARY KEY, + uniformity NUMERIC(15, 3), + week INT NOT NULL, + cv NUMERIC(15, 3), + chick_qty_of_weight NUMERIC(15, 3), + mean_up NUMERIC(15, 3), + mean_down NUMERIC(15, 3), + project_flock_kandang_id BIGINT NOT NULL, + uniform_qty NUMERIC(15, 3), + not_uniform_qty NUMERIC(15, 3), + uniform_date TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT NOT NULL +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang' + ) THEN + EXECUTE + 'ALTER TABLE project_flock_kandang_uniformity + ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_project_flock_kandang_uniformity_created_by' + ) THEN + EXECUTE + 'ALTER TABLE project_flock_kandang_uniformity + ADD CONSTRAINT fk_project_flock_kandang_uniformity_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_id + ON project_flock_kandang_uniformity (project_flock_kandang_id); + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_week + ON project_flock_kandang_uniformity (project_flock_kandang_id, week); + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_created_by + ON project_flock_kandang_uniformity (created_by); + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_deleted_at + ON project_flock_kandang_uniformity (deleted_at); diff --git a/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql new file mode 100644 index 00000000..9b5b8164 --- /dev/null +++ b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql @@ -0,0 +1,42 @@ +-- =============================================================== +-- ROLLBACK: Remove FIFO fields from STOCK_TRANSFER_DETAILS +-- =============================================================== + +-- Drop indexes +DROP INDEX IF EXISTS idx_stock_transfer_details_dest_pw; +DROP INDEX IF EXISTS idx_stock_transfer_details_source_pw; + +-- Drop foreign keys +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_source_pw' + ) THEN + EXECUTE 'ALTER TABLE stock_transfer_details + DROP CONSTRAINT fk_stock_transfer_details_source_pw'; + END IF; + + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_dest_pw' + ) THEN + EXECUTE 'ALTER TABLE stock_transfer_details + DROP CONSTRAINT fk_stock_transfer_details_dest_pw'; + END IF; +END $$; + +-- Drop FIFO columns +ALTER TABLE stock_transfer_details +DROP COLUMN IF EXISTS total_used, +DROP COLUMN IF EXISTS total_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS dest_product_warehouse_id, +DROP COLUMN IF EXISTS source_product_warehouse_id; + +-- Restore original columns (in case rollback) +ALTER TABLE stock_transfer_details +ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3), +ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3); diff --git a/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql new file mode 100644 index 00000000..7f6ad5cb --- /dev/null +++ b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql @@ -0,0 +1,83 @@ +-- =============================================================== +-- ADD FIFO FIELDS TO STOCK_TRANSFER_DETAILS +-- Enable transfer module to work with FIFO stock system +-- +-- Notes: +-- - Field 'quantity' will be removed (replaced by usage_qty + pending_qty) +-- - Fields 'before_quantity' & 'after_quantity' will be removed (unused legacy) +-- - New FIFO fields track actual allocation instead of requested quantity +-- =============================================================== + +-- Add FIFO tracking fields +ALTER TABLE stock_transfer_details +ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT, +ADD COLUMN IF NOT EXISTS dest_product_warehouse_id BIGINT, +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS total_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS total_used NUMERIC(15, 3) DEFAULT 0; + +-- Remove obsolete columns (quantity replaced by FIFO fields, legacy fields never used) +ALTER TABLE stock_transfer_details +DROP COLUMN IF EXISTS quantity, +DROP COLUMN IF EXISTS before_quantity, +DROP COLUMN IF EXISTS after_quantity; + +-- Add foreign keys for product warehouse references +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + -- Source warehouse foreign key + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_source_pw' + ) THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_source_pw + FOREIGN KEY (source_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + + -- Destination warehouse foreign key + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_dest_pw' + ) THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_dest_pw + FOREIGN KEY (dest_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; +END $$; + +-- Add indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_source_pw +ON stock_transfer_details (source_product_warehouse_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_dest_pw +ON stock_transfer_details (dest_product_warehouse_id); + +-- Add comments for documentation +COMMENT ON COLUMN stock_transfer_details.source_product_warehouse_id IS +'Source product warehouse ID - referensi warehouse asal (FIFO usable)'; + +COMMENT ON COLUMN stock_transfer_details.dest_product_warehouse_id IS +'Destination product warehouse ID - referensi warehouse tujuan (FIFO stockable)'; + +COMMENT ON COLUMN stock_transfer_details.usage_qty IS +'Actual quantity successfully taken from source warehouse (FIFO usable tracking) - replaces quantity field'; + +COMMENT ON COLUMN stock_transfer_details.pending_qty IS +'Quantity waiting for stock availability (FIFO usable tracking)'; + +COMMENT ON COLUMN stock_transfer_details.total_qty IS +'Total lot quantity available at destination warehouse (FIFO stockable tracking)'; + +COMMENT ON COLUMN stock_transfer_details.total_used IS +'Quantity already consumed from this lot at destination warehouse (FIFO stockable tracking)'; + diff --git a/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql new file mode 100644 index 00000000..9941a992 --- /dev/null +++ b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql @@ -0,0 +1,16 @@ +-- Rollback: Drop adjustment_stocks table + +BEGIN; + +DROP INDEX IF EXISTS idx_adjustment_stocks_product_warehouse; +DROP INDEX IF EXISTS idx_adjustment_stocks_stock_log; + +ALTER TABLE adjustment_stocks +DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_product_warehouse; + +ALTER TABLE adjustment_stocks +DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_stock_log; + +DROP TABLE IF EXISTS adjustment_stocks; + +COMMIT; diff --git a/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql new file mode 100644 index 00000000..1c79439b --- /dev/null +++ b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql @@ -0,0 +1,40 @@ +-- Migration: Create adjustment_stocks table for FIFO tracking +-- This table tracks FIFO allocation for stock adjustments (both increase and decrease) + +BEGIN; + +CREATE TABLE IF NOT EXISTS adjustment_stocks ( + id BIGSERIAL PRIMARY KEY, + stock_log_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + + -- FIFO fields for Adjustment INCREASE (Stockable) + -- Tracks stock added to warehouse via adjustment + total_qty NUMERIC(15, 3) DEFAULT 0, + total_used NUMERIC(15, 3) DEFAULT 0, + + -- FIFO fields for Adjustment DECREASE (Usable) + -- Tracks stock consumed from warehouse via adjustment + usage_qty NUMERIC(15, 3) DEFAULT 0, + pending_qty NUMERIC(15, 3) DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Foreign keys +ALTER TABLE adjustment_stocks +ADD CONSTRAINT fk_adjustment_stocks_stock_log +FOREIGN KEY (stock_log_id) REFERENCES stock_logs(id) +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE adjustment_stocks +ADD CONSTRAINT fk_adjustment_stocks_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id) +ON DELETE CASCADE ON UPDATE CASCADE; + +-- Indexes +CREATE INDEX idx_adjustment_stocks_stock_log ON adjustment_stocks(stock_log_id); +CREATE INDEX idx_adjustment_stocks_product_warehouse ON adjustment_stocks(product_warehouse_id); + +COMMIT; diff --git a/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql new file mode 100644 index 00000000..c42fd7d6 --- /dev/null +++ b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.down.sql @@ -0,0 +1,54 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS recording_bws ( + id BIGSERIAL PRIMARY KEY, + recording_id BIGINT NOT NULL, + avg_weight NUMERIC(8,2) NOT NULL, + qty NUMERIC(15,3) NOT NULL DEFAULT 1, + total_weight NUMERIC(10,3) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_recording_bws_recording + FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE, + + CONSTRAINT chk_recording_bws_nonneg + CHECK (avg_weight >= 0 AND qty >= 0 AND total_weight >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_recording_bws_recording + ON recording_bws (recording_id); + +ALTER TABLE recordings + DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v3; + +ALTER TABLE recordings + DROP COLUMN IF EXISTS hand_day, + DROP COLUMN IF EXISTS hand_house, + DROP COLUMN IF EXISTS feed_intake, + DROP COLUMN IF EXISTS egg_mesh, + DROP COLUMN IF EXISTS egg_weight; + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_nonnegatives_v2 CHECK ( + (total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (fcr_value IS NULL OR fcr_value >= 0) AND + (total_chick_qty IS NULL OR total_chick_qty >= 0) + ); + +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; + +ALTER TABLE recording_eggs + ALTER COLUMN weight TYPE NUMERIC(10,3) USING weight::NUMERIC(10,3); + +ALTER TABLE recording_eggs + ADD CONSTRAINT chk_recording_eggs_qty CHECK ( + qty >= 0 AND (weight IS NULL OR weight >= 0) + ); + +COMMIT; diff --git a/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql new file mode 100644 index 00000000..032d77b5 --- /dev/null +++ b/internal/database/migrations/20251228173112_adjustment_recording_egg_and_deleting_recording_bws.up.sql @@ -0,0 +1,44 @@ +BEGIN; + +ALTER TABLE recordings + DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v2; + +ALTER TABLE recordings + ADD COLUMN IF NOT EXISTS hand_day NUMERIC(15,3), + ADD COLUMN IF NOT EXISTS hand_house NUMERIC(15,3), + ADD COLUMN IF NOT EXISTS feed_intake NUMERIC(15,3), + ADD COLUMN IF NOT EXISTS egg_mesh NUMERIC(15,3), + ADD COLUMN IF NOT EXISTS egg_weight NUMERIC(15,3); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_nonnegatives_v3 CHECK ( + (total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (fcr_value IS NULL OR fcr_value >= 0) AND + (total_chick_qty IS NULL OR total_chick_qty >= 0) AND + (hand_day IS NULL OR hand_day >= 0) AND + (hand_house IS NULL OR hand_house >= 0) AND + (feed_intake IS NULL OR feed_intake >= 0) AND + (egg_mesh IS NULL OR egg_mesh >= 0) AND + (egg_weight IS NULL OR egg_weight >= 0) + ); + +ALTER TABLE recording_eggs + ALTER COLUMN weight TYPE NUMERIC(15,3) USING weight::NUMERIC(15,3); + +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; + +ALTER TABLE recording_eggs + ADD CONSTRAINT chk_recording_eggs_qty CHECK ( + qty >= 0 AND + (weight IS NULL OR weight >= 0) + ); + +DROP INDEX IF EXISTS idx_recording_bws_recording; +DROP TABLE IF EXISTS recording_bws; + +COMMIT; diff --git a/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.down.sql b/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.down.sql new file mode 100644 index 00000000..7433c93d --- /dev/null +++ b/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_project_flocks_production_standard_id; + +ALTER TABLE project_flocks + DROP CONSTRAINT IF EXISTS fk_project_flocks_production_standard_id; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS production_standard_id; diff --git a/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.up.sql b/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.up.sql new file mode 100644 index 00000000..f513c447 --- /dev/null +++ b/internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.up.sql @@ -0,0 +1,15 @@ +-- Add production_standard_id to project_flocks +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS production_standard_id BIGINT; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE project_flocks + ADD CONSTRAINT fk_project_flocks_production_standard_id + FOREIGN KEY (production_standard_id) REFERENCES production_standards (id) ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_project_flocks_production_standard_id + ON project_flocks (production_standard_id); diff --git a/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.down.sql b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.down.sql new file mode 100644 index 00000000..b686a59a --- /dev/null +++ b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.down.sql @@ -0,0 +1,3 @@ +-- Remove standard_fcr column from production_standard_details table +ALTER TABLE production_standard_details +DROP COLUMN IF EXISTS standard_fcr; diff --git a/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.up.sql b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.up.sql new file mode 100644 index 00000000..560a24ac --- /dev/null +++ b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.up.sql @@ -0,0 +1,3 @@ +-- Add standard_fcr column to production_standard_details table +ALTER TABLE production_standard_details +ADD COLUMN standard_fcr NUMERIC(15, 3); diff --git a/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.down.sql b/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.down.sql new file mode 100644 index 00000000..1314087c --- /dev/null +++ b/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.down.sql @@ -0,0 +1,20 @@ +-- Drop CASCADE constraint +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_chickins_kandang' + AND conrelid = 'project_chickins'::regclass + ) THEN + ALTER TABLE project_chickins + DROP CONSTRAINT fk_project_chickins_kandang; + END IF; +END $$; + +-- Recreate foreign key constraint with RESTRICT (original behavior) +ALTER TABLE project_chickins +ADD CONSTRAINT fk_project_chickins_kandang +FOREIGN KEY (project_flock_kandang_id) +REFERENCES project_flock_kandangs(id) +ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.up.sql b/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.up.sql new file mode 100644 index 00000000..ad07b8e0 --- /dev/null +++ b/internal/database/migrations/20251230201439_alter_project_chickins_fk_cascade.up.sql @@ -0,0 +1,20 @@ +-- Drop existing foreign key constraint with RESTRICT +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_chickins_kandang' + AND conrelid = 'project_chickins'::regclass + ) THEN + ALTER TABLE project_chickins + DROP CONSTRAINT fk_project_chickins_kandang; + END IF; +END $$; + +-- Add new foreign key constraint with CASCADE delete +ALTER TABLE project_chickins +ADD CONSTRAINT fk_project_chickins_kandang +FOREIGN KEY (project_flock_kandang_id) +REFERENCES project_flock_kandangs(id) +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql new file mode 100644 index 00000000..161d3d89 --- /dev/null +++ b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.down.sql @@ -0,0 +1,126 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)', + fk.child_table, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql new file mode 100644 index 00000000..2801ac2e --- /dev/null +++ b/internal/database/migrations/20260102045853_fix_soft_delete_fk_casts.up.sql @@ -0,0 +1,142 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; + child_type text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + SELECT format_type(atttypid, atttypmod) INTO child_type + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = child_column + AND NOT attisdropped; + + IF child_type IS NULL THEN + child_type := 'text'; + END IF; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1::%s %s)', + fk.child_table, + child_column, + child_type, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1::%s %s', + fk.child_table, + child_column, + child_column, + child_type, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1::%s AND deleted_at IS NULL', + fk.child_table, + child_column, + child_type + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1::%s', + fk.child_table, + child_column, + child_type + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1::%s %s', + fk.child_table, + child_column, + child_column, + child_type, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql new file mode 100644 index 00000000..b702016c --- /dev/null +++ b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.down.sql @@ -0,0 +1,86 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang; + END IF; +END $$; + +ALTER TABLE project_flock_kandang_uniformity + ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs (id) + ON DELETE RESTRICT ON UPDATE CASCADE; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = 'project_budgets' + ) THEN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_budgets_project_flock_id' + ) THEN + ALTER TABLE project_budgets + DROP CONSTRAINT fk_project_budgets_project_flock_id; + END IF; + + ALTER TABLE project_budgets + ADD CONSTRAINT fk_project_budgets_project_flock_id + FOREIGN KEY (project_flock_id) + REFERENCES project_flocks(id); + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = 'project_flock_kandang_uniformity' + ) THEN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'created_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW(); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'updated_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW(); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'deleted_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + ADD COLUMN deleted_at TIMESTAMPTZ; + END IF; + END IF; +END $$; + +COMMIT; diff --git a/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql new file mode 100644 index 00000000..7a092012 --- /dev/null +++ b/internal/database/migrations/20260102092243_update_uniformity_project_budget_cascade.up.sql @@ -0,0 +1,90 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang; + END IF; +END $$; + +ALTER TABLE project_flock_kandang_uniformity + ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs (id) + ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_tables + WHERE tablename = 'project_budgets' + ) THEN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_project_budgets_project_flock_id' + ) THEN + ALTER TABLE project_budgets + DROP CONSTRAINT fk_project_budgets_project_flock_id; + END IF; + + ALTER TABLE project_budgets + ADD CONSTRAINT fk_project_budgets_project_flock_id + FOREIGN KEY (project_flock_id) + REFERENCES project_flocks(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_trigger + WHERE tgname = 'trg_soft_delete_fk_project_flock_kandang_uniformity' + ) THEN + DROP TRIGGER trg_soft_delete_fk_project_flock_kandang_uniformity + ON project_flock_kandang_uniformity; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'created_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN created_at; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'updated_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN updated_at; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flock_kandang_uniformity' + AND column_name = 'deleted_at' + ) THEN + ALTER TABLE project_flock_kandang_uniformity + DROP COLUMN deleted_at; + END IF; +END $$; + +COMMIT; diff --git a/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql b/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql new file mode 100644 index 00000000..7be30be1 --- /dev/null +++ b/internal/database/migrations/20260105131644_create_daily_checklists_tables.down.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS daily_checklist_tasks; +DROP TABLE IF EXISTS daily_checklist_activity_task_assignees; +DROP TABLE IF EXISTS daily_checklist_activity_tasks; +DROP TABLE IF EXISTS daily_checklist_phases; +DROP TABLE IF EXISTS daily_checklists; +DROP TABLE IF EXISTS checklists; +DROP TABLE IF EXISTS phase_activities; +DROP TABLE IF EXISTS phases; +DROP TABLE IF EXISTS employee_kandangs; +DROP TABLE IF EXISTS employees; + +DROP TYPE IF EXISTS category_code; diff --git a/internal/database/migrations/20260105131644_create_daily_checklists_tables.up.sql b/internal/database/migrations/20260105131644_create_daily_checklists_tables.up.sql new file mode 100644 index 00000000..6074fa8c --- /dev/null +++ b/internal/database/migrations/20260105131644_create_daily_checklists_tables.up.sql @@ -0,0 +1,194 @@ +CREATE TYPE category_code AS ENUM ( + 'pullet_open', + 'pullet_close', + 'produksi_open', + 'produksi_close' +); + +-- MASTER TABLES + +CREATE TABLE employees ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE employee_kandangs ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + employee_id bigint NOT NULL, + kandang_id bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_employee_kandangs_employee + FOREIGN KEY (employee_id) REFERENCES employees(id) + ON DELETE CASCADE, + + CONSTRAINT fk_employee_kandangs_kandang + FOREIGN KEY (kandang_id) REFERENCES kandangs(id) + ON DELETE CASCADE, + + CONSTRAINT uq_employee_kandangs UNIQUE (employee_id, kandang_id) +); + +-- PHASE & CHECKLIST + +CREATE TABLE phases ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar NOT NULL, + is_active boolean NOT NULL DEFAULT true, + category category_code NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE phase_activities ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + phase_id bigint NOT NULL, + name varchar NOT NULL, + description text, + time_type text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_phase_activities_phase + FOREIGN KEY (phase_id) REFERENCES phases(id) + ON DELETE CASCADE +); + +CREATE TABLE checklists ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name varchar NOT NULL, + description text, + phase_id bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + + CONSTRAINT fk_checklists_phase + FOREIGN KEY (phase_id) REFERENCES phases(id) + ON DELETE SET NULL +); + + +-- DAILY CHECKLISTS +CREATE TABLE daily_checklists ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + kandang_id bigint NOT NULL, + checklist_id bigint NOT NULL, + date date NOT NULL, + name varchar, + status varchar, + category category_code NOT NULL, + total_score integer, + document_path varchar, + reject_reason text, + created_by bigint, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_daily_checklists_kandang + FOREIGN KEY (kandang_id) REFERENCES kandangs(id) + ON DELETE CASCADE, + + CONSTRAINT fk_daily_checklists_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) + ON DELETE RESTRICT, + + CONSTRAINT fk_daily_checklists_created_by + FOREIGN KEY (created_by) REFERENCES users(id) + ON DELETE SET NULL +); + + +--RELASI CHECKLIST ⇄ PHASE + +CREATE TABLE daily_checklist_phases ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + checklist_id bigint NOT NULL, + phase_id bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_dcp_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) + ON DELETE CASCADE, + + CONSTRAINT fk_dcp_phase + FOREIGN KEY (phase_id) REFERENCES phases(id) + ON DELETE CASCADE, + + CONSTRAINT uq_daily_checklist_phases UNIQUE (checklist_id, phase_id) +); + + +--ACTIVITY TASKS & ASSIGNMENT + + +CREATE TABLE daily_checklist_activity_tasks ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + checklist_id bigint NOT NULL, + phase_id bigint NOT NULL, + phase_activity_id bigint NOT NULL, + time_type text, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_dcat_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) + ON DELETE CASCADE, + + CONSTRAINT fk_dcat_phase + FOREIGN KEY (phase_id) REFERENCES phases(id) + ON DELETE CASCADE, + + CONSTRAINT fk_dcat_phase_activity + FOREIGN KEY (phase_activity_id) REFERENCES phase_activities(id) + ON DELETE CASCADE +); + +CREATE TABLE daily_checklist_activity_task_assignments ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_id bigint NOT NULL, + employee_id bigint NOT NULL, + checked boolean NOT NULL DEFAULT false, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_assignment_task + FOREIGN KEY (task_id) REFERENCES daily_checklist_activity_tasks(id) + ON DELETE CASCADE, + + CONSTRAINT fk_assignment_employee + FOREIGN KEY (employee_id) REFERENCES employees(id) + ON DELETE CASCADE +); + +--DAILY CHECKLIST TASK RESULT +CREATE TABLE daily_checklist_tasks ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + daily_checklist_id bigint NOT NULL, + checklist_id bigint NOT NULL, + checklist_item_id bigint, + is_completed boolean NOT NULL DEFAULT false, + score_value integer, + notes text, + photo_proof varchar, + status varchar, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CONSTRAINT fk_dct_daily + FOREIGN KEY (daily_checklist_id) REFERENCES daily_checklists(id) + ON DELETE CASCADE, + + CONSTRAINT fk_dct_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) + ON DELETE CASCADE, + + CONSTRAINT fk_dct_checklist_item + FOREIGN KEY (checklist_item_id) REFERENCES phase_activities(id) + ON DELETE SET NULL +); diff --git a/internal/database/migrations/20260106090725_fk_recording_cascade.down.sql b/internal/database/migrations/20260106090725_fk_recording_cascade.down.sql new file mode 100644 index 00000000..efe3954a --- /dev/null +++ b/internal/database/migrations/20260106090725_fk_recording_cascade.down.sql @@ -0,0 +1,21 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_recordings_project_flock_kandang' + ) THEN + ALTER TABLE recordings + DROP CONSTRAINT fk_recordings_project_flock_kandang; + END IF; +END $$; + +ALTER TABLE recordings + ADD CONSTRAINT fk_recordings_project_flock_kandang + FOREIGN KEY (project_flock_kandangs_id) + REFERENCES project_flock_kandangs (id) + ON DELETE RESTRICT ON UPDATE CASCADE; + +COMMIT; diff --git a/internal/database/migrations/20260106090725_fk_recording_cascade.up.sql b/internal/database/migrations/20260106090725_fk_recording_cascade.up.sql new file mode 100644 index 00000000..2600827d --- /dev/null +++ b/internal/database/migrations/20260106090725_fk_recording_cascade.up.sql @@ -0,0 +1,21 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_recordings_project_flock_kandang' + ) THEN + ALTER TABLE recordings + DROP CONSTRAINT fk_recordings_project_flock_kandang; + END IF; +END $$; + +ALTER TABLE recordings + ADD CONSTRAINT fk_recordings_project_flock_kandang + FOREIGN KEY (project_flock_kandangs_id) + REFERENCES project_flock_kandangs (id) + ON DELETE CASCADE ON UPDATE CASCADE; + +COMMIT; diff --git a/internal/database/migrations/20260106101434_add_unique_daily_checklists.down.sql b/internal/database/migrations/20260106101434_add_unique_daily_checklists.down.sql new file mode 100644 index 00000000..f33ea629 --- /dev/null +++ b/internal/database/migrations/20260106101434_add_unique_daily_checklists.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE daily_checklists + DROP CONSTRAINT IF EXISTS daily_checklists_date_kandang_category_key; diff --git a/internal/database/migrations/20260106101434_add_unique_daily_checklists.up.sql b/internal/database/migrations/20260106101434_add_unique_daily_checklists.up.sql new file mode 100644 index 00000000..6566083b --- /dev/null +++ b/internal/database/migrations/20260106101434_add_unique_daily_checklists.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE daily_checklists + ADD CONSTRAINT daily_checklists_date_kandang_category_key + UNIQUE (date, kandang_id, category); diff --git a/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.down.sql b/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.down.sql new file mode 100644 index 00000000..a1095689 --- /dev/null +++ b/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE daily_checklists + ALTER COLUMN checklist_id SET NOT NULL; diff --git a/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.up.sql b/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.up.sql new file mode 100644 index 00000000..2f804e4b --- /dev/null +++ b/internal/database/migrations/20260106102657_alter_daily_checklists_checklist_id_nullable.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE daily_checklists + ALTER COLUMN checklist_id DROP NOT NULL; diff --git a/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.down.sql b/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.down.sql new file mode 100644 index 00000000..e2b34f4e --- /dev/null +++ b/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE daily_checklist_phases + DROP CONSTRAINT IF EXISTS fk_dcp_daily_checklist, + ADD CONSTRAINT fk_dcp_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) ON DELETE CASCADE; diff --git a/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.up.sql b/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.up.sql new file mode 100644 index 00000000..5f4384b4 --- /dev/null +++ b/internal/database/migrations/20260106111217_update_daily_checklist_phases_fk.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE daily_checklist_phases + DROP CONSTRAINT IF EXISTS fk_dcp_checklist, + ADD CONSTRAINT fk_dcp_daily_checklist + FOREIGN KEY (checklist_id) REFERENCES daily_checklists(id) ON DELETE CASCADE; diff --git a/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.down.sql b/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.down.sql new file mode 100644 index 00000000..e37f1ad0 --- /dev/null +++ b/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE daily_checklist_activity_tasks + DROP CONSTRAINT IF EXISTS fk_dcat_daily_checklist, + ADD CONSTRAINT fk_dcat_checklist + FOREIGN KEY (checklist_id) REFERENCES checklists(id) ON DELETE CASCADE; diff --git a/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.up.sql b/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.up.sql new file mode 100644 index 00000000..337ea821 --- /dev/null +++ b/internal/database/migrations/20260106113936_update_daily_checklist_activity_task_fk.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE daily_checklist_activity_tasks + DROP CONSTRAINT IF EXISTS fk_dcat_checklist, + ADD CONSTRAINT fk_dcat_daily_checklist + FOREIGN KEY (checklist_id) REFERENCES daily_checklists(id) ON DELETE CASCADE; diff --git a/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.down.sql b/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.down.sql new file mode 100644 index 00000000..921645e0 --- /dev/null +++ b/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE daily_checklist_activity_task_assignments + DROP CONSTRAINT IF EXISTS daily_checklist_activity_task_assignments_task_employee_key; diff --git a/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.up.sql b/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.up.sql new file mode 100644 index 00000000..b4fd9e18 --- /dev/null +++ b/internal/database/migrations/20260106150814_add_unique_activity_task_assignments.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE daily_checklist_activity_task_assignments + ADD CONSTRAINT daily_checklist_activity_task_assignments_task_employee_key + UNIQUE (task_id, employee_id); diff --git a/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.down.sql b/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.down.sql new file mode 100644 index 00000000..fb17404d --- /dev/null +++ b/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE phase_activities + DROP COLUMN IF EXISTS deleted_at; + +ALTER TABLE phases + DROP COLUMN IF EXISTS deleted_at; + +ALTER TABLE employees + DROP COLUMN IF EXISTS deleted_at; diff --git a/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.up.sql b/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.up.sql new file mode 100644 index 00000000..0fdf6531 --- /dev/null +++ b/internal/database/migrations/20260106164640_add_deleted_at_to_master_tables.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE employees + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +ALTER TABLE phases + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +ALTER TABLE phase_activities + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; diff --git a/internal/database/seed/seeder.backup b/internal/database/seed/seeder.backup new file mode 100644 index 00000000..c0e3628c --- /dev/null +++ b/internal/database/seed/seeder.backup @@ -0,0 +1,1047 @@ +// package seed + +// import ( +// "errors" +// "fmt" +// "strings" +// "time" + +// entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +// "gitlab.com/mbugroup/lti-api.git/internal/utils" + +// "gorm.io/gorm" +// ) + +// func Run(db *gorm.DB) error { +// return db.Transaction(func(tx *gorm.DB) error { +// users, err := seedUsers(tx) +// if err != nil { +// return err +// } +// adminID := users["admin"] + +// uoms, err := seedUoms(tx, adminID) +// if err != nil { +// return err +// } + +// areas, err := seedAreas(tx, adminID) +// if err != nil { +// return err +// } + +// locations, err := seedLocations(tx, adminID, areas) +// if err != nil { +// return err +// } + +// productCategories, err := seedProductCategories(tx, adminID) +// if err != nil { +// return err +// } + +// if _, err := seedFlocks(tx, adminID); err != nil { +// return err +// } + +// if _, err := seedFcr(tx, adminID); err != nil { +// return err +// } + +// kandangs, err := seedKandangs(tx, adminID, locations, users) +// if err != nil { +// return err +// } + +// if err := seedWarehouses(tx, adminID, areas, locations, kandangs); err != nil { +// return err +// } + +// suppliers, err := seedSuppliers(tx, adminID) +// if err != nil { +// return err +// } + +// if err := seedCustomers(tx, adminID, users); err != nil { +// return err +// } + +// if err := seedProducts(tx, adminID, uoms, productCategories, suppliers); err != nil { +// return err +// } + +// if err := seedNonstocks(tx, adminID, uoms, suppliers); err != nil { +// return err +// } + +// if err := seedBanks(tx, adminID); err != nil { +// return err +// } + +// if err := seedProductWarehouse(tx, adminID); err != nil { +// return err +// } + +// if err := seedTransferStock(tx); err != nil { +// return err +// } +// fmt.Println("✅ Master data seeding completed") +// return nil +// }) +// } + +// func seedUsers(tx *gorm.DB) (map[string]uint, error) { +// seeds := []struct { +// Key string +// Data entity.User +// }{ +// { +// Key: "admin", +// Data: entity.User{Email: "admin@mbugroup.id", IdUser: 1, Name: "Super Admin"}, +// }, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// var user entity.User +// err := tx.Where("email = ?", seed.Data.Email).First(&user).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// user = seed.Data +// if err := tx.Create(&user).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Key] = user.Id +// } + +// return result, nil +// } + +// func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor"} +// result := make(map[string]uint, len(names)) + +// for _, name := range names { +// var uom entity.Uom +// err := tx.Where("name = ?", name).First(&uom).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// uom = entity.Uom{Name: name, CreatedBy: createdBy} +// if err := tx.Create(&uom).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[name] = uom.Id +// } + +// return result, nil +// } + +// func seedAreas(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// names := []string{"Priangan", "Banten"} +// result := make(map[string]uint, len(names)) + +// for _, name := range names { +// var area entity.Area +// err := tx.Where("name = ?", name).First(&area).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// area = entity.Area{Name: name, CreatedBy: createdBy} +// if err := tx.Create(&area).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[name] = area.Id +// } + +// return result, nil +// } + +// func seedLocations(tx *gorm.DB, createdBy uint, areas map[string]uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Address string +// Area string +// }{ +// {"Singaparna", "Tasik", "Priangan"}, +// {"Cikaum", "Cikaum", "Banten"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// areaID, ok := areas[seed.Area] +// if !ok { +// return nil, fmt.Errorf("area %s not seeded", seed.Area) +// } + +// var loc entity.Location +// err := tx.Where("name = ?", seed.Name).First(&loc).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// loc = entity.Location{ +// Name: seed.Name, +// Address: seed.Address, +// AreaId: areaID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&loc).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Name] = loc.Id +// } + +// return result, nil +// } + +// func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// names := []string{"Flock Priangan", "Flock Banten"} +// result := make(map[string]uint, len(names)) + +// for _, name := range names { +// var flock entity.Flock +// err := tx.Where("name = ?", name).First(&flock).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// flock = entity.Flock{ +// Name: name, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&flock).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } else { +// if err := tx.Model(&entity.Flock{}).Where("id = ?", flock.Id).Updates(map[string]any{ +// "created_by": createdBy, +// }).Error; err != nil { +// return nil, err +// } +// } +// result[name] = flock.Id +// } + +// return result, nil +// } + +// func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Status utils.KandangStatus +// Capacity float64 +// Location string +// PicKey string +// }{ +// {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, +// {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, +// {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, +// {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// locID, ok := locations[seed.Location] +// if !ok { +// return nil, fmt.Errorf("location %s not seeded", seed.Location) +// } +// picID, ok := users[seed.PicKey] +// if !ok { +// return nil, fmt.Errorf("user %s not seeded", seed.PicKey) +// } + +// var kandang entity.Kandang +// err := tx.Where("name = ?", seed.Name).First(&kandang).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// kandang = entity.Kandang{ +// Name: seed.Name, +// Status: string(seed.Status), +// LocationId: locID, +// PicId: picID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&kandang).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } else { +// updates := map[string]any{ +// "location_id": locID, +// "pic_id": picID, +// "status": string(seed.Status), +// } +// if err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { +// return nil, err +// } +// } +// result[seed.Name] = kandang.Id +// } + +// return result, nil +// } + +// func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { +// seeds := []struct { +// Name string +// Type string +// Area string +// Location *string +// Kandang *string +// }{ +// {Name: "Gudang Priangan", Type: string(utils.WarehouseTypeArea), Area: "Priangan"}, +// {Name: "Gudang Singaparna", Type: string(utils.WarehouseTypeLokasi), Area: "Priangan", Location: strPtr("Singaparna")}, +// {Name: "Gudang Singaparna 1", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 1")}, +// {Name: "Gudang Singaparna 2", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 2")}, +// {Name: "Gudang Banten", Type: string(utils.WarehouseTypeArea), Area: "Banten"}, +// {Name: "Gudang Cikaum", Type: string(utils.WarehouseTypeLokasi), Area: "Banten", Location: strPtr("Cikaum")}, +// {Name: "Gudang Cikaum 1", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 1")}, +// {Name: "Gudang Cikaum 2", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 2")}, +// } + +// for _, seed := range seeds { +// areaID, ok := areas[seed.Area] +// if !ok { +// return fmt.Errorf("area %s not seeded", seed.Area) +// } + +// var warehouse entity.Warehouse +// err := tx.Where("name = ?", seed.Name).First(&warehouse).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// warehouse = entity.Warehouse{ +// Name: seed.Name, +// Type: seed.Type, +// AreaId: areaID, +// CreatedBy: createdBy, +// } +// } else if err != nil { +// return err +// } + +// if seed.Location != nil { +// locID, ok := locations[*seed.Location] +// if !ok { +// return fmt.Errorf("location %s not seeded", *seed.Location) +// } +// warehouse.LocationId = uintPtr(locID) +// } +// if seed.Kandang != nil { +// kandangID, ok := kandangs[*seed.Kandang] +// if !ok { +// return fmt.Errorf("kandang %s not seeded", *seed.Kandang) +// } +// warehouse.KandangId = uintPtr(kandangID) +// } + +// if warehouse.Id == 0 { +// if err := tx.Create(&warehouse).Error; err != nil { +// return err +// } +// } else { +// if err := tx.Model(&entity.Warehouse{}).Where("id = ?", warehouse.Id).Updates(map[string]any{ +// "type": warehouse.Type, +// "area_id": warehouse.AreaId, +// "location_id": warehouse.LocationId, +// "kandang_id": warehouse.KandangId, +// }).Error; err != nil { +// return err +// } +// } +// } + +// return nil +// } + +// func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Code string +// }{ +// {"Pullet", "PLT"}, +// {"Bahan Baku", "RAW"}, +// {"Day Old Chick", "DOC"}, +// {"Telur", "EGG"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// var category entity.ProductCategory +// err := tx.Where("name = ?", seed.Name).First(&category).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// category = entity.ProductCategory{Name: seed.Name, Code: seed.Code, CreatedBy: createdBy} +// if err := tx.Create(&category).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } else { +// if err := tx.Model(&entity.ProductCategory{}).Where("id = ?", category.Id).Updates(map[string]any{ +// "code": seed.Code, +// }).Error; err != nil { +// return nil, err +// } +// } +// result[seed.Name] = category.Id +// } + +// return result, nil +// } + +// func seedSuppliers(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Alias string +// Category string +// Email string +// Phone string +// Address string +// }{ +// {"PT CHAROEN POKPHAND INDONESIA Tbk", "CPI", string(utils.SupplierCategorySapronak), "cpi@gmail.com", "081200000001", "Jl. Pakan 1, Bekasi"}, +// {"BOP Vendor", "BOP", string(utils.SupplierCategoryBOP), "bop@gmail.com", "081200000002", "Jl. Veteriner 3, Bogor"}, +// {"Ekspedisi", "EKS", string(utils.SupplierCategoryBOP), "bop@gmail.com", "081200000002", "Jl. Veteriner 3, Bogor"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for idx, seed := range seeds { +// var supplier entity.Supplier +// err := tx.Where("name = ?", seed.Name).First(&supplier).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// supplier = entity.Supplier{ +// Name: seed.Name, +// Alias: seed.Alias, +// Pic: "John Doe", +// Type: string(utils.CustomerSupplierTypeBisnis), +// Category: seed.Category, +// Phone: seed.Phone, +// Email: seed.Email, +// Address: seed.Address, +// DueDate: 30, +// CreatedBy: createdBy, +// AccountNumber: strPtr(fmt.Sprintf("%03d", idx+1)), +// } +// if err := tx.Create(&supplier).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Name] = supplier.Id +// } + +// return result, nil +// } + +// func seedCustomers(tx *gorm.DB, createdBy uint, users map[string]uint) error { +// seeds := []struct { +// Name string +// PicKey string +// Address string +// Phone string +// Email string +// }{ +// {"Abdul Azis", "admin", "Jl. Raya Utama 1, Bekasi", "082100000001", "abdul.azis@gmail.com"}, +// } + +// for idx, seed := range seeds { +// picID, ok := users[seed.PicKey] +// if !ok { +// return fmt.Errorf("user %s not seeded", seed.PicKey) +// } + +// var customer entity.Customer +// err := tx.Where("name = ?", seed.Name).First(&customer).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// customer = entity.Customer{ +// Name: seed.Name, +// PicId: picID, +// Type: string(utils.CustomerSupplierTypeBisnis), +// Address: seed.Address, +// Phone: seed.Phone, +// Email: seed.Email, +// AccountNumber: *strPtr(fmt.Sprintf("%03d", idx+1)), +// CreatedBy: createdBy, +// } +// if err := tx.Create(&customer).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } +// } + +// return nil +// } + +// func seedFcr(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Standards []struct { +// Weight float64 +// FcrNumber float64 +// Mortality float64 +// } +// }{ +// { +// Name: "FCR Layer", +// Standards: []struct { +// Weight float64 +// FcrNumber float64 +// Mortality float64 +// }{ +// {Weight: 0.8, FcrNumber: 1.60, Mortality: 2.0}, +// {Weight: 1.5, FcrNumber: 1.75, Mortality: 3.5}, +// }, +// }, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// var fcr entity.Fcr +// err := tx.Where("name = ?", seed.Name).First(&fcr).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// fcr = entity.Fcr{Name: seed.Name, CreatedBy: createdBy} +// if err := tx.Create(&fcr).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Name] = fcr.Id + +// for _, std := range seed.Standards { +// var standard entity.FcrStandard +// err := tx.Where("fcr_id = ? AND weight = ?", fcr.Id, std.Weight).First(&standard).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// standard = entity.FcrStandard{ +// FcrID: fcr.Id, +// Weight: std.Weight, +// FcrNumber: std.FcrNumber, +// Mortality: std.Mortality, +// } +// if err := tx.Create(&standard).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } else { +// if err := tx.Model(&entity.FcrStandard{}).Where("id = ?", standard.Id).Updates(map[string]any{ +// "fcr_number": std.FcrNumber, +// "mortality": std.Mortality, +// }).Error; err != nil { +// return nil, err +// } +// } +// } +// } + +// return result, nil +// } + +// func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories map[string]uint, suppliers map[string]uint) error { +// seeds := []struct { +// Name string +// Brand string +// Sku string +// Uom string +// Category string +// Price float64 +// Selling *float64 +// Tax *float64 +// Expiry *int +// Suppliers []string +// Flags []utils.FlagType +// }{ +// { +// Name: "DOC Broiler", +// Brand: "MBU Broiler", +// Sku: "BRO0001", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 7500, +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagDOC}, +// }, +// { +// Name: "Ayam Pullet", +// Brand: "MBU Pullet", +// Sku: "PLT0001", +// Uom: "Ekor", +// Category: "Pullet", +// Price: 15000, +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagPullet}, +// }, +// { +// Name: "Ayam Afkir", +// Brand: "-", +// Sku: "1", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagAyamAfkir}, +// }, +// { +// Name: "Ayam Mati", +// Brand: "-", +// Sku: "2", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagAyamMati}, +// }, +// { +// Name: "Ayam Culling", +// Brand: "-", +// Sku: "3", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagAyamCulling}, +// }, +// { +// Name: "Telur Konsumsi Baik", +// Brand: "-", +// Sku: "4", +// Uom: "Unit", +// Category: "Telur", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagTelurUtuh}, +// }, +// { +// Name: "Telur Pecah", +// Brand: "-", +// Sku: "5", +// Uom: "Unit", +// Category: "Telur", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagTelurPecah}, +// }, +// { +// Name: "281 SPECIAL STARTER", +// Brand: "281 STARTER", +// Sku: "281", +// Uom: "Kilogram", +// Category: "Bahan Baku", +// Price: 7850, +// Expiry: intPtr(60), +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter}, +// }, +// { +// Name: "Ayam Layer", +// Brand: "-", +// Sku: "LYR0001", +// Uom: "Ekor", +// Category: "Pullet", +// Price: 20000, +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagLayer}, +// }, +// } + +// for _, seed := range seeds { +// uomID, ok := uoms[seed.Uom] +// if !ok { +// return fmt.Errorf("uom %s not seeded", seed.Uom) +// } +// categoryID, ok := categories[seed.Category] +// if !ok { +// return fmt.Errorf("product category %s not seeded", seed.Category) +// } + +// var product entity.Product +// err := tx.Where("name = ?", seed.Name).First(&product).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// selling := seed.Selling +// tax := seed.Tax +// product = entity.Product{ +// Name: seed.Name, +// Brand: seed.Brand, +// Sku: &seed.Sku, +// UomId: uomID, +// ProductCategoryId: categoryID, +// ProductPrice: seed.Price, +// SellingPrice: selling, +// Tax: tax, +// ExpiryPeriod: seed.Expiry, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&product).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// updates := map[string]any{ +// "brand": seed.Brand, +// "uom_id": uomID, +// "product_category_id": categoryID, +// "product_price": seed.Price, +// "selling_price": seed.Selling, +// "tax": seed.Tax, +// "expiry_period": seed.Expiry, +// } +// if seed.Sku != "" { +// updates["sku"] = seed.Sku +// } +// if err := tx.Model(&entity.Product{}).Where("id = ?", product.Id).Updates(updates).Error; err != nil { +// return err +// } +// } + +// for _, supplierName := range seed.Suppliers { +// supplierID, ok := suppliers[supplierName] +// if !ok { +// return fmt.Errorf("supplier %s not seeded", supplierName) +// } +// var existing entity.ProductSupplier +// err := tx.Where("product_id = ? AND supplier_id = ?", product.Id, supplierID).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// link := entity.ProductSupplier{ProductId: product.Id, SupplierId: supplierID} +// if err := tx.Create(&link).Error; err != nil { +// return err +// } +// } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { +// return err +// } +// } + +// if err := seedFlags(tx, product.Id, entity.FlagableTypeProduct, seed.Flags); err != nil { +// return err +// } +// } + +// return nil +// } + +// func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers map[string]uint) error { +// seeds := []struct { +// Name string +// Uom string +// Suppliers []string +// Flags []utils.FlagType +// }{ +// { +// Name: "Expedisi DOC", +// Uom: "Ekor", +// Suppliers: []string{"Ekspedisi"}, +// Flags: []utils.FlagType{utils.FlagEkspedisi}, +// }, +// { +// Name: "Solar", +// Uom: "Liter", +// Suppliers: []string{"BOP Vendor"}, +// Flags: []utils.FlagType{}, +// }, +// } + +// for _, seed := range seeds { +// uomID, ok := uoms[seed.Uom] +// if !ok { +// return fmt.Errorf("uom %s not seeded", seed.Uom) +// } + +// var nonstock entity.Nonstock +// err := tx.Where("name = ?", seed.Name).First(&nonstock).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// nonstock = entity.Nonstock{ +// Name: seed.Name, +// UomId: uomID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&nonstock).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// if err := tx.Model(&entity.Nonstock{}).Where("id = ?", nonstock.Id).Updates(map[string]any{ +// "uom_id": uomID, +// }).Error; err != nil { +// return err +// } +// } + +// for _, supplierName := range seed.Suppliers { +// supplierID, ok := suppliers[supplierName] +// if !ok { +// return fmt.Errorf("supplier %s not seeded", supplierName) +// } +// var existing entity.NonstockSupplier +// err := tx.Where("nonstock_id = ? AND supplier_id = ?", nonstock.Id, supplierID).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// link := entity.NonstockSupplier{NonstockId: nonstock.Id, SupplierId: supplierID} +// if err := tx.Create(&link).Error; err != nil { +// return err +// } +// } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { +// return err +// } +// } + +// if err := seedFlags(tx, nonstock.Id, entity.FlagableTypeNonstock, seed.Flags); err != nil { +// return err +// } +// } + +// return nil +// } + +// // nanti saya isi + +// func seedFlags(tx *gorm.DB, flagableID uint, flagableType string, flags []utils.FlagType) error { +// if len(flags) == 0 { +// return nil +// } +// for _, flag := range flags { +// name := strings.ToUpper(string(flag)) +// var existing entity.Flag +// err := tx.Where("name = ? AND flagable_id = ? AND flagable_type = ?", name, flagableID, flagableType).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// record := entity.Flag{ +// Name: name, +// FlagableID: flagableID, +// FlagableType: flagableType, +// } +// if err := tx.Create(&record).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } +// } +// return nil +// } + +// func seedBanks(tx *gorm.DB, createdBy uint) error { +// seeds := []struct { +// Name string +// Alias string +// Owner *string +// AccountNumber string +// }{ +// { +// Name: "Bank Central Asia", +// Alias: "BCA", +// AccountNumber: "1234567890", +// Owner: ptr("PT MBU Group"), +// }, +// { +// Name: "Bank Rakyat Indonesia", +// Alias: "BRI", +// AccountNumber: "9876543210", +// Owner: ptr("PT MBU Group"), +// }, +// { +// Name: "Bank Mandiri", +// Alias: "MAND", +// AccountNumber: "1122334455", +// Owner: ptr("PT MBU Group"), +// }, +// } + +// for _, seed := range seeds { +// var bank entity.Bank +// err := tx.Where("name = ?", seed.Name).First(&bank).Error + +// if errors.Is(err, gorm.ErrRecordNotFound) { +// bank = entity.Bank{ +// Name: seed.Name, +// Alias: seed.Alias, +// Owner: seed.Owner, +// AccountNumber: seed.AccountNumber, +// CreatedBy: createdBy, +// CreatedAt: time.Now(), +// UpdatedAt: time.Now(), +// } +// if err := tx.Create(&bank).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// // update data jika sudah ada +// if err := tx.Model(&entity.Bank{}).Where("id = ?", bank.Id).Updates(map[string]any{ +// "alias": seed.Alias, +// "owner": seed.Owner, +// "account_number": seed.AccountNumber, +// "updated_at": time.Now(), +// }).Error; err != nil { +// return err +// } +// } +// } + +// return nil +// } + +// func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { +// seeds := []struct { +// ProductName string +// WarehouseName string +// Quantity float64 +// }{ +// {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 0}, +// {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 0}, +// {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 0}, +// {ProductName: "DOC Broiler", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, +// {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, +// {ProductName: "Telur Pecah", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, +// {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Cikaum 1", Quantity: 0}, +// {ProductName: "Telur Pecah", WarehouseName: "Gudang Cikaum 1", Quantity: 0}, +// } + +// for _, seed := range seeds { +// var product entity.Product +// if err := tx.Where("name = ?", seed.ProductName).First(&product).Error; err != nil { +// if errors.Is(err, gorm.ErrRecordNotFound) { +// return fmt.Errorf("product %q not found for product warehouse seeding", seed.ProductName) +// } +// return err +// } + +// var warehouse entity.Warehouse +// if err := tx.Where("name = ?", seed.WarehouseName).First(&warehouse).Error; err != nil { +// if errors.Is(err, gorm.ErrRecordNotFound) { +// return fmt.Errorf("warehouse %q not found for product warehouse seeding", seed.WarehouseName) +// } +// return err +// } + +// var productWarehouse entity.ProductWarehouse +// err := tx.Where("product_id = ? AND warehouse_id = ?", product.Id, warehouse.Id).First(&productWarehouse).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// productWarehouse = entity.ProductWarehouse{ +// ProductId: product.Id, +// WarehouseId: warehouse.Id, +// Quantity: seed.Quantity, +// // CreatedBy: createdBy, +// } +// if err := tx.Create(&productWarehouse).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// if err := tx.Model(&productWarehouse).Updates(map[string]any{ +// "quantity": seed.Quantity, +// }).Error; err != nil { +// return err +// } +// } +// } + +// return nil +// } + +// func seedTransferStock(tx *gorm.DB) error { + +// transfer := entity.StockTransfer{ +// FromWarehouseId: 1, +// ToWarehouseId: 2, +// Reason: "Seed transfer stock", +// TransferDate: time.Now(), +// MovementNumber: "SEED-TRF-00001", +// CreatedBy: 1, +// } +// if err := tx.Create(&transfer).Error; err != nil { +// return err +// } + +// details := []entity.StockTransferDetail{ +// { +// StockTransferId: transfer.Id, +// ProductId: 1, + +// SourceProductWarehouseID: func() *uint64 { id := uint64(1); return &id }(), +// DestProductWarehouseID: func() *uint64 { id := uint64(2); return &id }(), +// UsageQty: 10, +// PendingQty: 0, +// TotalQty: 10, +// TotalUsed: 0, +// }, +// { +// StockTransferId: transfer.Id, +// ProductId: 2, + +// SourceProductWarehouseID: func() *uint64 { id := uint64(1); return &id }(), +// DestProductWarehouseID: func() *uint64 { id := uint64(2); return &id }(), +// UsageQty: 5, +// PendingQty: 0, +// TotalQty: 5, +// TotalUsed: 0, +// }, +// } +// for i := range details { +// if err := tx.Create(&details[i]).Error; err != nil { +// return err +// } +// } + +// deliveries := []entity.StockTransferDelivery{ +// { +// StockTransferId: transfer.Id, +// SupplierId: 1, +// VehiclePlate: "B 1234 XYZ", +// DriverName: "Driver Seed", +// DocumentPath: "seed.pdf", +// ShippingCostItem: 1000, +// ShippingCostTotal: 2000, +// }, +// } +// for i := range deliveries { +// if err := tx.Create(&deliveries[i]).Error; err != nil { +// return err +// } +// } + +// detailMap := make(map[uint64]uint64) +// for _, d := range details { +// detailMap[d.ProductId] = d.Id +// } + +// deliveryItems := []entity.StockTransferDeliveryItem{ +// { +// StockTransferDeliveryId: deliveries[0].Id, +// StockTransferDetailId: detailMap[1], +// Quantity: 50, +// }, +// { +// StockTransferDeliveryId: deliveries[0].Id, +// StockTransferDetailId: detailMap[2], +// Quantity: 30, +// }, +// } +// for i := range deliveryItems { +// if err := tx.Create(&deliveryItems[i]).Error; err != nil { +// return err +// } +// } + +// return nil +// } +// func ptr[T any](v T) *T { +// return &v +// } + +// func strPtr(s string) *string { +// return &s +// } + +// func intPtr(v int) *int { +// return &v +// } + +// func uintPtr(v uint) *uint { +// return &v +// } diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index d848711e..b4f6886e 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "strings" - "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -25,66 +24,20 @@ func Run(db *gorm.DB) error { return err } - areas, err := seedAreas(tx, adminID) - if err != nil { - return err - } - - locations, err := seedLocations(tx, adminID, areas) - if err != nil { - return err - } - productCategories, err := seedProductCategories(tx, adminID) if err != nil { return err } - if _, err := seedFlocks(tx, adminID); err != nil { - return err - } - - if _, err := seedFcr(tx, adminID); err != nil { - return err - } - - kandangs, err := seedKandangs(tx, adminID, locations, users) - if err != nil { - return err - } - - if err := seedWarehouses(tx, adminID, areas, locations, kandangs); err != nil { - return err - } - suppliers, err := seedSuppliers(tx, adminID) if err != nil { return err } - if err := seedCustomers(tx, adminID, users); err != nil { - return err - } - if err := seedProducts(tx, adminID, uoms, productCategories, suppliers); err != nil { return err } - if err := seedNonstocks(tx, adminID, uoms, suppliers); err != nil { - return err - } - - if err := seedBanks(tx, adminID); err != nil { - return err - } - - if err := seedProductWarehouse(tx, adminID); err != nil { - return err - } - - if err := seedTransferStock(tx); err != nil { - return err - } fmt.Println("✅ Master data seeding completed") return nil }) @@ -141,224 +94,6 @@ func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedAreas(tx *gorm.DB, createdBy uint) (map[string]uint, error) { - names := []string{"Priangan", "Banten"} - result := make(map[string]uint, len(names)) - - for _, name := range names { - var area entity.Area - err := tx.Where("name = ?", name).First(&area).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - area = entity.Area{Name: name, CreatedBy: createdBy} - if err := tx.Create(&area).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - result[name] = area.Id - } - - return result, nil -} - -func seedLocations(tx *gorm.DB, createdBy uint, areas map[string]uint) (map[string]uint, error) { - seeds := []struct { - Name string - Address string - Area string - }{ - {"Singaparna", "Tasik", "Priangan"}, - {"Cikaum", "Cikaum", "Banten"}, - } - - result := make(map[string]uint, len(seeds)) - - for _, seed := range seeds { - areaID, ok := areas[seed.Area] - if !ok { - return nil, fmt.Errorf("area %s not seeded", seed.Area) - } - - var loc entity.Location - err := tx.Where("name = ?", seed.Name).First(&loc).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - loc = entity.Location{ - Name: seed.Name, - Address: seed.Address, - AreaId: areaID, - CreatedBy: createdBy, - } - if err := tx.Create(&loc).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - result[seed.Name] = loc.Id - } - - return result, nil -} - -func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { - names := []string{"Flock Priangan", "Flock Banten"} - result := make(map[string]uint, len(names)) - - for _, name := range names { - var flock entity.Flock - err := tx.Where("name = ?", name).First(&flock).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - flock = entity.Flock{ - Name: name, - CreatedBy: createdBy, - } - if err := tx.Create(&flock).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } else { - if err := tx.Model(&entity.Flock{}).Where("id = ?", flock.Id).Updates(map[string]any{ - "created_by": createdBy, - }).Error; err != nil { - return nil, err - } - } - result[name] = flock.Id - } - - return result, nil -} - -func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { - seeds := []struct { - Name string - Status utils.KandangStatus - Capacity float64 - Location string - PicKey string - }{ - {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, - {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, - {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, - {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, - } - - result := make(map[string]uint, len(seeds)) - - for _, seed := range seeds { - locID, ok := locations[seed.Location] - if !ok { - return nil, fmt.Errorf("location %s not seeded", seed.Location) - } - picID, ok := users[seed.PicKey] - if !ok { - return nil, fmt.Errorf("user %s not seeded", seed.PicKey) - } - - var kandang entity.Kandang - err := tx.Where("name = ?", seed.Name).First(&kandang).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - kandang = entity.Kandang{ - Name: seed.Name, - Status: string(seed.Status), - LocationId: locID, - PicId: picID, - CreatedBy: createdBy, - } - if err := tx.Create(&kandang).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } else { - updates := map[string]any{ - "location_id": locID, - "pic_id": picID, - "status": string(seed.Status), - } - if err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { - return nil, err - } - } - result[seed.Name] = kandang.Id - } - - return result, nil -} - -func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { - seeds := []struct { - Name string - Type string - Area string - Location *string - Kandang *string - }{ - {Name: "Gudang Priangan", Type: string(utils.WarehouseTypeArea), Area: "Priangan"}, - {Name: "Gudang Singaparna", Type: string(utils.WarehouseTypeLokasi), Area: "Priangan", Location: strPtr("Singaparna")}, - {Name: "Gudang Singaparna 1", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 1")}, - {Name: "Gudang Singaparna 2", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 2")}, - {Name: "Gudang Banten", Type: string(utils.WarehouseTypeArea), Area: "Banten"}, - {Name: "Gudang Cikaum", Type: string(utils.WarehouseTypeLokasi), Area: "Banten", Location: strPtr("Cikaum")}, - {Name: "Gudang Cikaum 1", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 1")}, - {Name: "Gudang Cikaum 2", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 2")}, - } - - for _, seed := range seeds { - areaID, ok := areas[seed.Area] - if !ok { - return fmt.Errorf("area %s not seeded", seed.Area) - } - - var warehouse entity.Warehouse - err := tx.Where("name = ?", seed.Name).First(&warehouse).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - warehouse = entity.Warehouse{ - Name: seed.Name, - Type: seed.Type, - AreaId: areaID, - CreatedBy: createdBy, - } - } else if err != nil { - return err - } - - if seed.Location != nil { - locID, ok := locations[*seed.Location] - if !ok { - return fmt.Errorf("location %s not seeded", *seed.Location) - } - warehouse.LocationId = uintPtr(locID) - } - if seed.Kandang != nil { - kandangID, ok := kandangs[*seed.Kandang] - if !ok { - return fmt.Errorf("kandang %s not seeded", *seed.Kandang) - } - warehouse.KandangId = uintPtr(kandangID) - } - - if warehouse.Id == 0 { - if err := tx.Create(&warehouse).Error; err != nil { - return err - } - } else { - if err := tx.Model(&entity.Warehouse{}).Where("id = ?", warehouse.Id).Updates(map[string]any{ - "type": warehouse.Type, - "area_id": warehouse.AreaId, - "location_id": warehouse.LocationId, - "kandang_id": warehouse.KandangId, - }).Error; err != nil { - return err - } - } - } - - return nil -} - func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error) { seeds := []struct { Name string @@ -440,113 +175,6 @@ func seedSuppliers(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedCustomers(tx *gorm.DB, createdBy uint, users map[string]uint) error { - seeds := []struct { - Name string - PicKey string - Address string - Phone string - Email string - }{ - {"Abdul Azis", "admin", "Jl. Raya Utama 1, Bekasi", "082100000001", "abdul.azis@gmail.com"}, - } - - for idx, seed := range seeds { - picID, ok := users[seed.PicKey] - if !ok { - return fmt.Errorf("user %s not seeded", seed.PicKey) - } - - var customer entity.Customer - err := tx.Where("name = ?", seed.Name).First(&customer).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - customer = entity.Customer{ - Name: seed.Name, - PicId: picID, - Type: string(utils.CustomerSupplierTypeBisnis), - Address: seed.Address, - Phone: seed.Phone, - Email: seed.Email, - AccountNumber: *strPtr(fmt.Sprintf("%03d", idx+1)), - CreatedBy: createdBy, - } - if err := tx.Create(&customer).Error; err != nil { - return err - } - } else if err != nil { - return err - } - } - - return nil -} - -func seedFcr(tx *gorm.DB, createdBy uint) (map[string]uint, error) { - seeds := []struct { - Name string - Standards []struct { - Weight float64 - FcrNumber float64 - Mortality float64 - } - }{ - { - Name: "FCR Layer", - Standards: []struct { - Weight float64 - FcrNumber float64 - Mortality float64 - }{ - {Weight: 0.8, FcrNumber: 1.60, Mortality: 2.0}, - {Weight: 1.5, FcrNumber: 1.75, Mortality: 3.5}, - }, - }, - } - - result := make(map[string]uint, len(seeds)) - - for _, seed := range seeds { - var fcr entity.Fcr - err := tx.Where("name = ?", seed.Name).First(&fcr).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - fcr = entity.Fcr{Name: seed.Name, CreatedBy: createdBy} - if err := tx.Create(&fcr).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - result[seed.Name] = fcr.Id - - for _, std := range seed.Standards { - var standard entity.FcrStandard - err := tx.Where("fcr_id = ? AND weight = ?", fcr.Id, std.Weight).First(&standard).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - standard = entity.FcrStandard{ - FcrID: fcr.Id, - Weight: std.Weight, - FcrNumber: std.FcrNumber, - Mortality: std.Mortality, - } - if err := tx.Create(&standard).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } else { - if err := tx.Model(&entity.FcrStandard{}).Where("id = ?", standard.Id).Updates(map[string]any{ - "fcr_number": std.FcrNumber, - "mortality": std.Mortality, - }).Error; err != nil { - return nil, err - } - } - } - } - - return result, nil -} - func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories map[string]uint, suppliers map[string]uint) error { seeds := []struct { Name string @@ -560,77 +188,88 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories Expiry *int Suppliers []string Flags []utils.FlagType + IsVisible bool }{ { - Name: "DOC Broiler", - Brand: "MBU Broiler", - Sku: "BRO0001", + Name: "ISA Brown", + Brand: "ISA Brown", + Sku: "ISA0001", Uom: "Ekor", Category: "Day Old Chick", Price: 7500, Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagDOC}, + Flags: []utils.FlagType{utils.FlagDOC, utils.FlagPullet, utils.FlagLayer}, + IsVisible: true, }, { - Name: "Ayam Pullet", - Brand: "MBU Pullet", - Sku: "PLT0001", + Name: "Ayam Afkir", + Brand: "-", + Sku: "1", Uom: "Ekor", - Category: "Pullet", - Price: 15000, - Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagPullet}, + Category: "Day Old Chick", + Price: 1, + Flags: []utils.FlagType{utils.FlagAyamAfkir}, + IsVisible: false, }, { - Name: "Ayam Afkir", - Brand: "-", - Sku: "1", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, + Name: "Ayam Mati", + Brand: "-", + Sku: "2", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + Flags: []utils.FlagType{utils.FlagAyamMati}, + IsVisible: false, }, { - Name: "Ayam Mati", - Brand: "-", - Sku: "2", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, + Name: "Ayam Culling", + Brand: "-", + Sku: "3", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + Flags: []utils.FlagType{utils.FlagAyamCulling}, + IsVisible: false, }, { - Name: "Ayam Culling", - Brand: "-", - Sku: "3", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, + Name: "Telur Utuh", + Brand: "-", + Sku: "4", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurUtuh}, + IsVisible: false, }, { - Name: "Telur Konsumsi Baik", - Brand: "-", - Sku: "4", - Uom: "Unit", - Category: "Telur", - Price: 1, + Name: "Telur Pecah", + Brand: "-", + Sku: "5", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurPecah}, + IsVisible: false, }, { - Name: "Telur Pecah", - Brand: "-", - Sku: "5", - Uom: "Unit", - Category: "Telur", - Price: 1, + Name: "Telur Putih", + Brand: "-", + Sku: "6", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurPutih}, + IsVisible: false, }, { - Name: "281 SPECIAL STARTER", - Brand: "281 STARTER", - Sku: "281", - Uom: "Kilogram", - Category: "Bahan Baku", - Price: 7850, - Expiry: intPtr(60), - Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter}, + Name: "Telur Retak", + Brand: "-", + Sku: "7", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurRetak}, + IsVisible: false, }, } @@ -709,78 +348,78 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories return nil } -func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers map[string]uint) error { - seeds := []struct { - Name string - Uom string - Suppliers []string - Flags []utils.FlagType - }{ - { - Name: "Expedisi DOC", - Uom: "Ekor", - Suppliers: []string{"Ekspedisi"}, - Flags: []utils.FlagType{utils.FlagEkspedisi}, - }, - { - Name: "Solar", - Uom: "Liter", - Suppliers: []string{"BOP Vendor"}, - Flags: []utils.FlagType{}, - }, - } +// func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers map[string]uint) error { +// seeds := []struct { +// Name string +// Uom string +// Suppliers []string +// Flags []utils.FlagType +// }{ +// { +// Name: "LAJ", +// Uom: "Unit", +// Suppliers: []string{"Ekspedisi"}, +// Flags: []utils.FlagType{utils.FlagEkspedisi}, +// }, +// { +// Name: "Solar", +// Uom: "Liter", +// Suppliers: []string{"BOP Vendor"}, +// Flags: []utils.FlagType{}, +// }, +// } - for _, seed := range seeds { - uomID, ok := uoms[seed.Uom] - if !ok { - return fmt.Errorf("uom %s not seeded", seed.Uom) - } +// for _, seed := range seeds { +// uomID, ok := uoms[seed.Uom] +// if !ok { +// return fmt.Errorf("uom %s not seeded", seed.Uom) +// } - var nonstock entity.Nonstock - err := tx.Where("name = ?", seed.Name).First(&nonstock).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - nonstock = entity.Nonstock{ - Name: seed.Name, - UomId: uomID, - CreatedBy: createdBy, - } - if err := tx.Create(&nonstock).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - if err := tx.Model(&entity.Nonstock{}).Where("id = ?", nonstock.Id).Updates(map[string]any{ - "uom_id": uomID, - }).Error; err != nil { - return err - } - } +// var nonstock entity.Nonstock +// err := tx.Where("name = ?", seed.Name).First(&nonstock).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// nonstock = entity.Nonstock{ +// Name: seed.Name, +// UomId: uomID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&nonstock).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// if err := tx.Model(&entity.Nonstock{}).Where("id = ?", nonstock.Id).Updates(map[string]any{ +// "uom_id": uomID, +// }).Error; err != nil { +// return err +// } +// } - for _, supplierName := range seed.Suppliers { - supplierID, ok := suppliers[supplierName] - if !ok { - return fmt.Errorf("supplier %s not seeded", supplierName) - } - var existing entity.NonstockSupplier - err := tx.Where("nonstock_id = ? AND supplier_id = ?", nonstock.Id, supplierID).First(&existing).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - link := entity.NonstockSupplier{NonstockId: nonstock.Id, SupplierId: supplierID} - if err := tx.Create(&link).Error; err != nil { - return err - } - } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - } +// for _, supplierName := range seed.Suppliers { +// supplierID, ok := suppliers[supplierName] +// if !ok { +// return fmt.Errorf("supplier %s not seeded", supplierName) +// } +// var existing entity.NonstockSupplier +// err := tx.Where("nonstock_id = ? AND supplier_id = ?", nonstock.Id, supplierID).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// link := entity.NonstockSupplier{NonstockId: nonstock.Id, SupplierId: supplierID} +// if err := tx.Create(&link).Error; err != nil { +// return err +// } +// } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { +// return err +// } +// } - if err := seedFlags(tx, nonstock.Id, entity.FlagableTypeNonstock, seed.Flags); err != nil { - return err - } - } +// if err := seedFlags(tx, nonstock.Id, entity.FlagableTypeNonstock, seed.Flags); err != nil { +// return err +// } +// } - return nil -} +// return nil +// } // nanti saya isi @@ -808,213 +447,6 @@ func seedFlags(tx *gorm.DB, flagableID uint, flagableType string, flags []utils. return nil } -func seedBanks(tx *gorm.DB, createdBy uint) error { - seeds := []struct { - Name string - Alias string - Owner *string - AccountNumber string - }{ - { - Name: "Bank Central Asia", - Alias: "BCA", - AccountNumber: "1234567890", - Owner: ptr("PT MBU Group"), - }, - { - Name: "Bank Rakyat Indonesia", - Alias: "BRI", - AccountNumber: "9876543210", - Owner: ptr("PT MBU Group"), - }, - { - Name: "Bank Mandiri", - Alias: "MAND", - AccountNumber: "1122334455", - Owner: ptr("PT MBU Group"), - }, - } - - for _, seed := range seeds { - var bank entity.Bank - err := tx.Where("name = ?", seed.Name).First(&bank).Error - - if errors.Is(err, gorm.ErrRecordNotFound) { - bank = entity.Bank{ - Name: seed.Name, - Alias: seed.Alias, - Owner: seed.Owner, - AccountNumber: seed.AccountNumber, - CreatedBy: createdBy, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - if err := tx.Create(&bank).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - // update data jika sudah ada - if err := tx.Model(&entity.Bank{}).Where("id = ?", bank.Id).Updates(map[string]any{ - "alias": seed.Alias, - "owner": seed.Owner, - "account_number": seed.AccountNumber, - "updated_at": time.Now(), - }).Error; err != nil { - return err - } - } - } - - return nil -} - -func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { - seeds := []struct { - ProductName string - WarehouseName string - Quantity float64 - }{ - {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 100}, - {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 200}, - {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 300}, - {ProductName: "DOC Broiler", WarehouseName: "Gudang Singaparna 1", Quantity: 5000}, - {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Singaparna 1", Quantity: 600}, - {ProductName: "Telur Pecah", WarehouseName: "Gudang Singaparna 1", Quantity: 80}, - {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Cikaum 1", Quantity: 450}, - {ProductName: "Telur Pecah", WarehouseName: "Gudang Cikaum 1", Quantity: 60}, - } - - for _, seed := range seeds { - var product entity.Product - if err := tx.Where("name = ?", seed.ProductName).First(&product).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("product %q not found for product warehouse seeding", seed.ProductName) - } - return err - } - - var warehouse entity.Warehouse - if err := tx.Where("name = ?", seed.WarehouseName).First(&warehouse).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("warehouse %q not found for product warehouse seeding", seed.WarehouseName) - } - return err - } - - var productWarehouse entity.ProductWarehouse - err := tx.Where("product_id = ? AND warehouse_id = ?", product.Id, warehouse.Id).First(&productWarehouse).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - productWarehouse = entity.ProductWarehouse{ - ProductId: product.Id, - WarehouseId: warehouse.Id, - Quantity: seed.Quantity, - CreatedBy: createdBy, - } - if err := tx.Create(&productWarehouse).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - if err := tx.Model(&productWarehouse).Updates(map[string]any{ - "quantity": seed.Quantity, - }).Error; err != nil { - return err - } - } - } - - return nil -} - -func seedTransferStock(tx *gorm.DB) error { - - transfer := entity.StockTransfer{ - FromWarehouseId: 1, - ToWarehouseId: 2, - Reason: "Seed transfer stock", - TransferDate: time.Now(), - MovementNumber: "SEED-TRF-00001", - CreatedBy: 1, - } - if err := tx.Create(&transfer).Error; err != nil { - return err - } - - details := []entity.StockTransferDetail{ - { - StockTransferId: transfer.Id, - ProductId: 1, - Quantity: 10, - }, - { - StockTransferId: transfer.Id, - ProductId: 2, - Quantity: 5, - }, - } - for i := range details { - if err := tx.Create(&details[i]).Error; err != nil { - return err - } - } - - deliveries := []entity.StockTransferDelivery{ - { - StockTransferId: transfer.Id, - SupplierId: 1, - VehiclePlate: "B 1234 XYZ", - DriverName: "Driver Seed", - DocumentPath: "seed.pdf", - ShippingCostItem: 1000, - ShippingCostTotal: 2000, - }, - } - for i := range deliveries { - if err := tx.Create(&deliveries[i]).Error; err != nil { - return err - } - } - - detailMap := make(map[uint64]uint64) - for _, d := range details { - detailMap[d.ProductId] = d.Id - } - - deliveryItems := []entity.StockTransferDeliveryItem{ - { - StockTransferDeliveryId: deliveries[0].Id, - StockTransferDetailId: detailMap[1], - Quantity: 50, - }, - { - StockTransferDeliveryId: deliveries[0].Id, - StockTransferDetailId: detailMap[2], - Quantity: 30, - }, - } - for i := range deliveryItems { - if err := tx.Create(&deliveryItems[i]).Error; err != nil { - return err - } - } - - return nil -} -func ptr[T any](v T) *T { - return &v -} - func strPtr(s string) *string { return &s } - -func intPtr(v int) *int { - return &v -} - -func uintPtr(v uint) *uint { - return &v -} diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go new file mode 100644 index 00000000..bbc93167 --- /dev/null +++ b/internal/entities/adjustment_stock.go @@ -0,0 +1,29 @@ +package entities + +import "time" + +// AdjustmentStock tracks FIFO allocation for stock adjustments +// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse +// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse +type AdjustmentStock struct { + Id uint `gorm:"primaryKey"` + StockLogId uint `gorm:"column:stock_log_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + + // === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) === + // Tracks stock added to warehouse via adjustment INCREASE + TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available + TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot + + // === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) === + // Tracks stock consumed from warehouse via adjustment DECREASE + UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed + PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock) + + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` + + // Relations + StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/entities/area.go b/internal/entities/area.go index 0af4d1f0..cda0a8ed 100644 --- a/internal/entities/area.go +++ b/internal/entities/area.go @@ -8,7 +8,7 @@ import ( type Area struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:areas_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:areas_name_unique,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/bank.go b/internal/entities/bank.go index 3c2a93da..0275a517 100644 --- a/internal/entities/bank.go +++ b/internal/entities/bank.go @@ -8,9 +8,9 @@ import ( type Bank struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:banks_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:banks_name_unique,where:deleted_at IS NULL"` Alias string `gorm:"not null;size:5"` - Owner *string `gorm:""` + Owner *string `gorm:"type:varchar(50)"` AccountNumber string `gorm:"not null;size:50"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/customer.go b/internal/entities/customer.go index 98d0c861..f171f0ff 100644 --- a/internal/entities/customer.go +++ b/internal/entities/customer.go @@ -8,12 +8,12 @@ import ( type Customer struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:customers_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:customers_name_unique,where:deleted_at IS NULL"` PicId uint `gorm:"not null"` Type string `gorm:"not null;size:50"` Address string `gorm:"not null"` Phone string `gorm:"not null;size:20"` - Email string `gorm:"not null"` + Email string `gorm:"type:varchar(50);not null"` AccountNumber string `gorm:"not null;size:50"` Balance float64 `gorm:"default:0"` CreatedBy uint `gorm:"not null"` diff --git a/internal/entities/daily-checklist.go b/internal/entities/daily-checklist.go new file mode 100644 index 00000000..8b62b1a3 --- /dev/null +++ b/internal/entities/daily-checklist.go @@ -0,0 +1,81 @@ +package entities + +import "time" + +type DailyChecklist struct { + Id uint `gorm:"primaryKey"` + KandangId uint `gorm:"not null"` + ChecklistId *uint + Date time.Time `gorm:"type:date;not null"` + Name *string `gorm:"type:varchar(255)"` + Status *string `gorm:"type:varchar(255)"` + Category string `gorm:"type:category_code;not null"` + TotalScore *int + DocumentPath *string + RejectReason *string + CreatedBy *uint + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` + Checklist *Checklist `gorm:"foreignKey:ChecklistId;references:Id"` + Creator *User `gorm:"foreignKey:CreatedBy;references:Id"` + Tasks []DailyChecklistTask `gorm:"foreignKey:DailyChecklistId;references:Id"` +} + +type DailyChecklistPhase struct { + Id uint `gorm:"primaryKey"` + ChecklistId uint `gorm:"not null"` + PhaseId uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + + Checklist Checklist `gorm:"foreignKey:ChecklistId;references:Id"` + Phase Phases `gorm:"foreignKey:PhaseId;references:Id"` +} + +type DailyChecklistActivityTask struct { + Id uint `gorm:"primaryKey"` + ChecklistId uint `gorm:"not null"` + PhaseId uint `gorm:"not null"` + PhaseActivityId uint `gorm:"not null"` + TimeType *string `gorm:"type:text"` + Notes *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Checklist DailyChecklist `gorm:"foreignKey:ChecklistId;references:Id"` + Phase Phases `gorm:"foreignKey:PhaseId;references:Id"` + PhaseActivity PhaseActivity `gorm:"foreignKey:PhaseActivityId;references:Id"` + Assignments []DailyChecklistActivityTaskAssignment `gorm:"foreignKey:TaskId;references:Id"` +} + +type DailyChecklistActivityTaskAssignment struct { + Id uint `gorm:"primaryKey"` + TaskId uint `gorm:"not null"` + EmployeeId uint `gorm:"not null"` + Checked bool `gorm:"not null;default:false"` + Note *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Task DailyChecklistActivityTask `gorm:"foreignKey:TaskId;references:Id"` + Employee Employee `gorm:"foreignKey:EmployeeId;references:Id"` +} + +type DailyChecklistTask struct { + Id uint `gorm:"primaryKey"` + DailyChecklistId uint `gorm:"not null"` + ChecklistId uint `gorm:"not null"` + ChecklistItemId *uint + IsCompleted bool `gorm:"not null;default:false"` + ScoreValue *int + Notes *string `gorm:"type:text"` + PhotoProof *string + Status *string + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + DailyChecklist *DailyChecklist `gorm:"foreignKey:DailyChecklistId;references:Id"` + Checklist Checklist `gorm:"foreignKey:ChecklistId;references:Id"` + ChecklistItem *PhaseActivity `gorm:"foreignKey:ChecklistItemId;references:Id"` +} diff --git a/internal/entities/document.go b/internal/entities/document.go new file mode 100644 index 00000000..54974a02 --- /dev/null +++ b/internal/entities/document.go @@ -0,0 +1,18 @@ +package entities + +import "time" + +type Document struct { + Id uint `gorm:"primaryKey"` + DocumentableType string `gorm:"size:50;not null;index:documents_documentable_polymorphic,priority:1"` + DocumentableId uint64 `gorm:"not null;index:documents_documentable_polymorphic,priority:2"` + Type string `gorm:"size:50;not null"` + Path string `gorm:"size:50;not null"` + Name string `gorm:"size:50;not null"` + Ext string `gorm:"size:50;not null"` + Size float64 `gorm:"type:numeric(15,3);not null"` + CreatedBy *uint `gorm:"index"` + CreatedAt time.Time `gorm:"autoCreateTime"` + + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/employee.go b/internal/entities/employee.go new file mode 100644 index 00000000..a93cbb46 --- /dev/null +++ b/internal/entities/employee.go @@ -0,0 +1,31 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Employee struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + IsActive bool `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + EmployeeKandangs []EmployeeKandang `gorm:"foreignKey:EmployeeId;references:Id"` +} + +type Employees = Employee + +type EmployeeKandang struct { + Id uint `gorm:"primaryKey"` + EmployeeId uint `gorm:"not null"` + KandangId uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + Employee Employee `gorm:"foreignKey:EmployeeId;references:Id"` + Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` +} diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 74998e6a..7bea3076 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -1,7 +1,6 @@ package entities import ( - "database/sql" "time" "gorm.io/gorm" @@ -13,20 +12,21 @@ type Expense struct { SupplierId uint64 `gorm:""` Category string `gorm:"type:varchar(50);not null"` PoNumber string `gorm:"type:varchar(50)"` - DocumentPath sql.NullString `gorm:"type:json"` // Dokumen pengajuan - RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` // Dokumen realisasi - RealizationDate time.Time `gorm:"type:date;column:realization_date"` // Tanggal realisasi - ExpenseDate time.Time `gorm:"type:date;not null"` - GrandTotal float64 `gorm:"type:numeric(15,3);default:0"` - Note string `gorm:"type:text"` + LocationId uint64 `gorm:"not null"` + ProjectFlockId *string `gorm:"type:json"` + RealizationDate time.Time `gorm:"type:date;column:realization_date"` + TransactionDate time.Time `gorm:"type:date;not null"` + Notes string `gorm:"type:text;column:notes"` CreatedBy uint64 `gorm:""` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - // Relations - Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` - LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` + Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` + Location *Location `gorm:"foreignKey:LocationId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` + Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"` + RealizationDocuments []Document `gorm:"foreignKey:DocumentableId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` } diff --git a/internal/entities/expense_nonstock.go b/internal/entities/expense_nonstock.go index 7be2053a..ccd4194c 100644 --- a/internal/entities/expense_nonstock.go +++ b/internal/entities/expense_nonstock.go @@ -1,20 +1,23 @@ package entities -type ExpenseNonstock struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - ExpenseId *uint64 `gorm:""` - ProjectFlockKandangId *uint64 `gorm:""` - KandangId *uint64 `gorm:""` - NonstockId *uint64 `gorm:""` - Qty float64 `gorm:"type:numeric(15,3);not null"` - UnitPrice float64 `gorm:"type:numeric(15,3);not null"` - TotalPrice float64 `gorm:"type:numeric(15,3);not null"` - Note string `gorm:"type:text"` +import ( + "time" +) + +type ExpenseNonstock struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + ExpenseId *uint64 `gorm:""` + ProjectFlockKandangId *uint64 `gorm:""` + KandangId *uint64 `gorm:""` + NonstockId *uint64 `gorm:""` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Price float64 `gorm:"type:numeric(15,3);not null;column:price"` + Notes string `gorm:"type:text;column:notes"` + CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"` - // Relations Expense *Expense `gorm:"foreignKey:ExpenseId;references:Id"` ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` Kandang *Kandang `gorm:"foreignKey:KandangId;references:Id"` Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"` - Realization *ExpenseRealization `gorm:"foreignKey:ExpenseNonstockId;references:Id"` + Realization *ExpenseRealization `gorm:"foreignKey:Id;references:ExpenseNonstockId"` } diff --git a/internal/entities/expense_realization.go b/internal/entities/expense_realization.go index 629fdfb7..3c4b1f07 100644 --- a/internal/entities/expense_realization.go +++ b/internal/entities/expense_realization.go @@ -5,16 +5,12 @@ import ( ) type ExpenseRealization struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - ExpenseNonstockId *uint64 `gorm:""` - RealizationQty float64 `gorm:"type:numeric(15,3);not null"` - RealizationUnitPrice float64 `gorm:"type:numeric(15,3);not null"` - RealizationTotalPrice float64 `gorm:"type:numeric(15,3);not null"` - RealizationDate time.Time `gorm:"type:date;not null"` - Note *string `gorm:"type:text"` - CreatedBy *uint64 `gorm:""` + Id uint64 `gorm:"primaryKey;autoIncrement"` + ExpenseNonstockId *uint64 `gorm:""` + Qty float64 `gorm:"type:numeric(15,3);not null;"` + Price float64 `gorm:"type:numeric(15,3);not null;"` + Notes string `gorm:"type:text;"` + CreatedAt time.Time `gorm:"type:timestamptz;default:CURRENT_TIMESTAMP"` - // Relations ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` } diff --git a/internal/entities/fcr.go b/internal/entities/fcr.go index 4bf96eaf..776c314e 100644 --- a/internal/entities/fcr.go +++ b/internal/entities/fcr.go @@ -8,7 +8,7 @@ import ( type Fcr struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:idx_suppliers_name,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:idx_suppliers_name,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/flag.go b/internal/entities/flag.go index aba2c8d5..e86f81ee 100644 --- a/internal/entities/flag.go +++ b/internal/entities/flag.go @@ -9,7 +9,7 @@ const ( type Flag struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:flags_unique_flagable"` + Name string `gorm:"type:varchar(50);size:50;not null;uniqueIndex:flags_unique_flagable"` FlagableID uint `gorm:"not null;uniqueIndex:flags_unique_flagable;index:flags_flagable_lookup,priority:2"` FlagableType string `gorm:"size:50;not null;uniqueIndex:flags_unique_flagable;index:flags_flagable_lookup,priority:1"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/initial.go b/internal/entities/initial.go new file mode 100644 index 00000000..c562d748 --- /dev/null +++ b/internal/entities/initial.go @@ -0,0 +1,30 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Initial struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ReferenceNumber string `gorm:"type:varchar(100);not null"` + TransactionType string `gorm:"type:varchar(50);not null"` + InitialBalanceType string `gorm:"type:varchar(20);not null"` + PartyType string `gorm:"type:varchar(50);not null;index:initials_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:initials_party_polymorphic,priority:2"` + BankId *uint `gorm:"index"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedBy uint `gorm:"index" json:"-"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Bank Bank `gorm:"foreignKey:BankId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Customer *Customer `gorm:"foreignKey:PartyId;references:Id"` + Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 882184b3..e4db5655 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -8,7 +8,7 @@ import ( type Kandang struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` Status string `gorm:"type:varchar(50);not null"` LocationId uint `gorm:"not null"` Capacity float64 `gorm:"not null"` @@ -20,5 +20,6 @@ type Kandang struct { CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"` Pic User `gorm:"foreignKey:PicId;references:Id"` + Warehouses []Warehouse `gorm:"foreignKey:KandangId;references:Id"` ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"` } diff --git a/internal/entities/location.go b/internal/entities/location.go index 1dba8f82..58b90c6e 100644 --- a/internal/entities/location.go +++ b/internal/entities/location.go @@ -8,7 +8,7 @@ import ( type Location struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:locations_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:locations_name_unique,where:deleted_at IS NULL"` Address string `gorm:"not null"` AreaId uint `gorm:"not null"` CreatedBy uint `gorm:"not null"` diff --git a/internal/entities/marketing_delivery_product.go b/internal/entities/marketing_delivery_product.go index 47f6e89d..7ac3d967 100644 --- a/internal/entities/marketing_delivery_product.go +++ b/internal/entities/marketing_delivery_product.go @@ -5,15 +5,20 @@ import ( ) type MarketingDeliveryProduct struct { - Id uint `gorm:"primaryKey;autoIncrement"` - MarketingProductId uint `gorm:"uniqueIndex;not null"` - Qty float64 `gorm:"type:numeric(15,3)"` - UnitPrice float64 `gorm:"type:numeric(15,3)"` - TotalWeight float64 `gorm:"type:numeric(15,3)"` - AvgWeight float64 `gorm:"type:numeric(15,3)"` - TotalPrice float64 `gorm:"type:numeric(15,3)"` - DeliveryDate *time.Time `gorm:"type:timestamptz"` - VehicleNumber string `gorm:"type:varchar(50)"` + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingProductId uint `gorm:"uniqueIndex;not null"` + ProductWarehouseId uint `gorm:"not null"` + UnitPrice float64 `gorm:"type:numeric(15,3)"` + TotalWeight float64 `gorm:"type:numeric(15,3)"` + AvgWeight float64 `gorm:"type:numeric(15,3)"` + TotalPrice float64 `gorm:"type:numeric(15,3)"` + DeliveryDate *time.Time `gorm:"type:timestamptz"` + VehicleNumber string `gorm:"type:varchar(50)"` + + // FIFO Fields + UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + CreatedAt *time.Time `gorm:"type:timestamptz;not null"` MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` } diff --git a/internal/entities/nonstock.go b/internal/entities/nonstock.go index 0e78ca8b..ca6f57b7 100644 --- a/internal/entities/nonstock.go +++ b/internal/entities/nonstock.go @@ -8,7 +8,7 @@ import ( type Nonstock struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:nonstocks_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:nonstocks_name_unique,where:deleted_at IS NULL"` UomId uint `gorm:"not null"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/payment.go b/internal/entities/payment.go new file mode 100644 index 00000000..e48800fb --- /dev/null +++ b/internal/entities/payment.go @@ -0,0 +1,32 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Payment struct { + Id uint `gorm:"primaryKey;autoIncrement"` + PaymentCode string `gorm:"type:varchar(50);not null"` + ReferenceNumber *string `gorm:"type:varchar(100)"` + TransactionType string `gorm:"type:varchar(50)"` + PartyType string `gorm:"type:varchar(50);not null;index:payments_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:payments_party_polymorphic,priority:2"` + PaymentDate time.Time `gorm:"not null"` + PaymentMethod string `gorm:"type:varchar(20);not null"` + BankId *uint `gorm:"not null;index:idx_payments_bank_id"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedBy uint `gorm:"index" json:"-"` + + BankWarehouse Bank `gorm:"foreignKey:BankId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Customer *Customer `gorm:"foreignKey:PartyId;references:Id"` + Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/phase.go b/internal/entities/phase.go new file mode 100644 index 00000000..178ed695 --- /dev/null +++ b/internal/entities/phase.go @@ -0,0 +1,43 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Phases struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + IsActive bool `gorm:"not null;default:true"` + Category string `gorm:"type:category_code;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Activities []PhaseActivity `gorm:"foreignKey:PhaseId;references:Id"` +} + +type PhaseActivity struct { + Id uint `gorm:"primaryKey"` + PhaseId uint `gorm:"not null"` + Name string `gorm:"not null"` + Description *string `gorm:"type:text"` + TimeType *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Phase Phases `gorm:"foreignKey:PhaseId;references:Id"` +} + +type Checklist struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + Description *string `gorm:"type:text"` + PhaseId *uint + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Phase *Phases `gorm:"foreignKey:PhaseId;references:Id"` +} diff --git a/internal/entities/product-category.go b/internal/entities/product-category.go index 45acc299..c59c9c6f 100644 --- a/internal/entities/product-category.go +++ b/internal/entities/product-category.go @@ -8,7 +8,7 @@ import ( type ProductCategory struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:product_categories_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:product_categories_name_unique,where:deleted_at IS NULL"` Code string `gorm:"not null;size:10;uniqueIndex:product_categories_code_unique,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/product.go b/internal/entities/product.go index 52b04627..d8ce59fc 100644 --- a/internal/entities/product.go +++ b/internal/entities/product.go @@ -8,8 +8,8 @@ import ( type Product struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:products_name_unique,where:deleted_at IS NULL"` - Brand string `gorm:"not null"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:products_name_unique,where:deleted_at IS NULL"` + Brand string `gorm:"type:varchar(50);not null"` Sku *string `gorm:"size:100;uniqueIndex:products_sku_unique,where:deleted_at IS NULL"` UomId uint `gorm:"not null"` ProductCategoryId uint `gorm:"not null"` @@ -21,10 +21,12 @@ type Product struct { CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + IsVisible bool `gorm:"column:is_visible;default:true"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Uom Uom `gorm:"foreignKey:UomId;references:Id"` - ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` - ProductSuppliers []ProductSupplier `gorm:"foreignKey:ProductId;references:Id"` - Flags []Flag `gorm:"polymorphic:Flagable;polymorphicValue:products"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Uom Uom `gorm:"foreignKey:UomId;references:Id"` + ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` + ProductSuppliers []ProductSupplier `gorm:"foreignKey:ProductId;references:Id"` + Flags []Flag `gorm:"polymorphic:Flagable;polymorphicValue:products"` + ProductWarehouses []ProductWarehouse `gorm:"foreignKey:ProductId;references:Id"` } diff --git a/internal/entities/product_warehouse.go b/internal/entities/product_warehouse.go index 745dd298..8e1ece25 100644 --- a/internal/entities/product_warehouse.go +++ b/internal/entities/product_warehouse.go @@ -1,23 +1,15 @@ package entities -import ( - "time" - - "gorm.io/gorm" -) - type ProductWarehouse struct { - Id uint `gorm:"primaryKey;autoIncrement"` - ProductId uint `gorm:"not null"` - WarehouseId uint `gorm:"not null"` - Quantity float64 `gorm:"default:0"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - CreatedBy uint `gorm:"not null"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + Id uint `gorm:"primaryKey;column:id"` + ProductId uint `gorm:"column:product_id;not null"` + WarehouseId uint `gorm:"column:warehouse_id;not null"` + ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"` + Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"` // Relations - Product Product `gorm:"foreignKey:ProductId;references:Id"` - Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Product Product `gorm:"foreignKey:ProductId;references:Id"` + Warehouse Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` + ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + StockLogs []StockLog `gorm:"foreignKey:ProductWarehouseId;references:Id"` } diff --git a/internal/entities/production_standard.go b/internal/entities/production_standard.go new file mode 100644 index 00000000..1214f6cf --- /dev/null +++ b/internal/entities/production_standard.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type ProductionStandard struct { + Id uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(100);uniqueIndex;not null"` + ProjectCategory string `gorm:"type:varchar(20);not null"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + DeletedAt *time.Time `gorm:"type:timestamptz"` + CreatedBy uint `gorm:"not null"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + ProductionStandardDetails []ProductionStandardDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` + StandardGrowthDetails []StandardGrowthDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/production_standard_detail.go b/internal/entities/production_standard_detail.go new file mode 100644 index 00000000..1a18c8b8 --- /dev/null +++ b/internal/entities/production_standard_detail.go @@ -0,0 +1,20 @@ +package entities + +import ( + "time" +) + +type ProductionStandardDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + Week int `gorm:"not null"` + TargetHenDayProduction *float64 `gorm:"type:numeric(15,3)"` + TargetHenHouseProduction *float64 `gorm:"type:numeric(15,3)"` + TargetEggWeight *float64 `gorm:"type:numeric(15,3)"` + TargetEggMass *float64 `gorm:"type:numeric(15,3)"` + StandardFCR *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/project_budget.go b/internal/entities/project_budget.go new file mode 100644 index 00000000..c74455b6 --- /dev/null +++ b/internal/entities/project_budget.go @@ -0,0 +1,17 @@ +package entities + +import ( + "time" +) + +type ProjectBudget struct { + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null"` + NonstockId uint `gorm:"not null"` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Price float64 `gorm:"type:numeric(15,3);not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + + Nonstock *Nonstock `gorm:"foreignKey:NonstockId;references:Id"` + ProjectFlock *ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` +} diff --git a/internal/entities/project_flock_kandang_uniformity.go b/internal/entities/project_flock_kandang_uniformity.go new file mode 100644 index 00000000..bf320c72 --- /dev/null +++ b/internal/entities/project_flock_kandang_uniformity.go @@ -0,0 +1,26 @@ +package entities + +import "time" + +type ProjectFlockKandangUniformity struct { + Id uint `gorm:"primaryKey"` + Uniformity float64 `gorm:"type:numeric(15,3)"` + Week int `gorm:"not null"` + Cv float64 `gorm:"type:numeric(15,3)"` + ChickQtyOfWeight float64 `gorm:"type:numeric(15,3)"` + MeanUp float64 `gorm:"type:numeric(15,3)"` + MeanDown float64 `gorm:"type:numeric(15,3)"` + ProjectFlockKandangId uint `gorm:"not null"` + UniformQty float64 `gorm:"type:numeric(15,3)"` + NotUniformQty float64 `gorm:"type:numeric(15,3)"` + UniformDate *time.Time `gorm:"type:timestamptz"` + CreatedBy uint `gorm:"not null"` + + ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} + +func (ProjectFlockKandangUniformity) TableName() string { + return "project_flock_kandang_uniformity" +} diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go index e8745455..7243c9c4 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -12,6 +12,7 @@ type ProjectFlock struct { AreaId uint `gorm:"not null"` Category string `gorm:"type:varchar(20);not null"` FcrId uint `gorm:"not null"` + ProductionStandardId uint `gorm:"column:production_standard_id"` LocationId uint `gorm:"not null"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` @@ -20,10 +21,12 @@ type ProjectFlock struct { Area Area `gorm:"foreignKey:AreaId;references:Id"` Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` Kandangs []Kandang `gorm:"many2many:project_flock_kandangs;joinTableForeignKey:project_flock_id;joinTableReferences:kandang_id" json:"kandangs,omitempty"` KandangHistory []ProjectFlockKandang `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"` + Budgets []ProjectBudget `gorm:"foreignKey:ProjectFlockId;references:Id" json:"-"` LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go index d4bd7452..0ce4fc25 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -3,11 +3,12 @@ package entities import "time" type ProjectFlockKandang struct { - Id uint `gorm:"primaryKey"` - ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` - KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` - Period int `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` + KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` + Period int `gorm:"not null"` + ClosedAt *time.Time `gorm:"index"` + CreatedAt time.Time `gorm:"autoCreateTime"` ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` diff --git a/internal/entities/purchase.go b/internal/entities/purchase.go index 36b698b2..66b88c63 100644 --- a/internal/entities/purchase.go +++ b/internal/entities/purchase.go @@ -5,19 +5,18 @@ import ( ) type Purchase struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` + Id uint `gorm:"primaryKey;autoIncrement"` PrNumber string `gorm:"not null"` PoNumber *string PoDate *time.Time - SupplierId uint64 `gorm:"not null"` - CreditTerm *int + SupplierId uint `gorm:"not null"` + CreditTerm int `gorm:"column:credit_term;not null;default:0"` DueDate *time.Time - GrandTotal float64 `gorm:"type:numeric(15,3);default:0"` Notes *string CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt *time.Time `gorm:"index"` - CreatedBy uint64 `gorm:"not null"` + CreatedBy uint `gorm:"not null"` // Relations Supplier Supplier `gorm:"foreignKey:SupplierId;references:Id"` diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go index 59f1a030..724c6376 100644 --- a/internal/entities/purchase_item.go +++ b/internal/entities/purchase_item.go @@ -5,22 +5,25 @@ import ( ) type PurchaseItem struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - PurchaseId uint64 `gorm:"not null"` - ProductId uint64 `gorm:"not null"` - WarehouseId uint64 `gorm:"not null"` - ProductWarehouseId *uint64 - ReceivedDate *time.Time - TravelNumber *string - TravelNumberDocs *string - VehicleNumber *string - SubQty float64 `gorm:"type:numeric(15,3);not null"` - TotalQty float64 `gorm:"type:numeric(15,3);default:0"` - TotalUsed float64 `gorm:"type:numeric(15,3);default:0"` - Price float64 `gorm:"type:numeric(15,3);default:0"` - TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` + Id uint `gorm:"primaryKey;autoIncrement"` + PurchaseId uint `gorm:"not null"` + ProductId uint `gorm:"not null"` + WarehouseId uint `gorm:"not null"` + ProductWarehouseId *uint + ProjectFlockKandangId *uint + ReceivedDate *time.Time + TravelNumber *string + TravelNumberDocs *string + VehicleNumber *string + SubQty float64 `gorm:"type:numeric(15,3);not null"` + TotalQty float64 `gorm:"type:numeric(15,3);default:0"` + TotalUsed float64 `gorm:"type:numeric(15,3);default:0"` + Price float64 `gorm:"type:numeric(15,3);default:0"` + TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` + ExpenseNonstockId *uint64 // Relations + ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"` Product *Product `gorm:"foreignKey:ProductId;references:Id"` Warehouse *Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` diff --git a/internal/entities/recording.go b/internal/entities/recording.go index 42535365..7f952a62 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -13,11 +13,14 @@ type Recording struct { Day *int `gorm:"column:day"` TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"` CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` - DailyGain *float64 `gorm:"column:daily_gain"` - AvgDailyGain *float64 `gorm:"column:avg_daily_gain"` CumIntake *int `gorm:"column:cum_intake"` FcrValue *float64 `gorm:"column:fcr_value"` TotalChickQty *float64 `gorm:"column:total_chick_qty"` + HandDay *float64 `gorm:"column:hand_day"` + HandHouse *float64 `gorm:"column:hand_house"` + FeedIntake *float64 `gorm:"column:feed_intake"` + EggMesh *float64 `gorm:"column:egg_mesh"` + EggWeight *float64 `gorm:"column:egg_weight"` CreatedBy uint `gorm:"column:created_by"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` @@ -25,10 +28,17 @@ type Recording struct { ProjectFlockKandang *ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - BodyWeights []RecordingBW `gorm:"foreignKey:RecordingId;references:Id"` Depletions []RecordingDepletion `gorm:"foreignKey:RecordingId;references:Id"` Stocks []RecordingStock `gorm:"foreignKey:RecordingId;references:Id"` Eggs []RecordingEgg `gorm:"foreignKey:RecordingId;references:Id"` LatestApproval *Approval `gorm:"-" json:"-"` + + StandardHandDay *float64 `gorm:"-"` + StandardHandHouse *float64 `gorm:"-"` + StandardFeedIntake *float64 `gorm:"-"` + StandardMaxDepletion *float64 `gorm:"-"` + StandardEggMesh *float64 `gorm:"-"` + StandardEggWeight *float64 `gorm:"-"` + StandardFcr *float64 `gorm:"-"` } diff --git a/internal/entities/recording_bw.go b/internal/entities/recording_bw.go deleted file mode 100644 index 041df0f6..00000000 --- a/internal/entities/recording_bw.go +++ /dev/null @@ -1,15 +0,0 @@ -package entities - -import "time" - -type RecordingBW struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null;index"` - AvgWeight float64 `gorm:"column:avg_weight;not null"` - Qty float64 `gorm:"column:qty;not null"` - TotalWeight float64 `gorm:"column:total_weight;not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - - Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` -} diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index 28eafeb7..90546448 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -7,24 +7,12 @@ type RecordingEgg struct { RecordingId uint `gorm:"column:recording_id;not null;index"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` Qty int `gorm:"column:qty;not null"` + Weight *float64 `gorm:"column:weight"` CreatedBy uint `gorm:"column:created_by"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` - GradingEggs []GradingEgg `gorm:"foreignKey:RecordingEggId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + ProductFlagName *string `gorm:"column:product_flag_name" json:"-"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` } - -type GradingEgg struct { - Id uint `gorm:"primaryKey"` - RecordingEggId uint `gorm:"column:recording_egg_id;not null;index"` - Qty float64 `gorm:"column:qty;not null"` - Grade string `gorm:"column:grade;type:varchar(50)"` - CreatedBy uint `gorm:"column:created_by"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - - RecordingEgg RecordingEgg `gorm:"foreignKey:RecordingEggId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` -} diff --git a/internal/entities/standard_growth_detail.go b/internal/entities/standard_growth_detail.go new file mode 100644 index 00000000..a3d19fb8 --- /dev/null +++ b/internal/entities/standard_growth_detail.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type StandardGrowthDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + TargetMeanBw *float64 `gorm:"type:numeric(15,3)"` + MaxDepletion *float64 `gorm:"type:numeric(15,3)"` + MinUniformity float64 `gorm:"type:numeric(15,3);not null"` + Week int `gorm:"not null"` + FeedIntake *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + CreatedBy uint `gorm:"not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/stock-transfer.go b/internal/entities/stock-transfer.go index e003d601..7da7a9f5 100644 --- a/internal/entities/stock-transfer.go +++ b/internal/entities/stock-transfer.go @@ -20,4 +20,5 @@ type StockTransfer struct { Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` CreatedUser *User `gorm:"foreignKey:CreatedBy"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } diff --git a/internal/entities/stock_allocation.go b/internal/entities/stock_allocation.go new file mode 100644 index 00000000..614762a1 --- /dev/null +++ b/internal/entities/stock_allocation.go @@ -0,0 +1,33 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + StockAllocationStatusPending = "PENDING" + StockAllocationStatusActive = "ACTIVE" + StockAllocationStatusReleased = "RELEASED" +) + +// StockAllocation links a usable record (consumption) with an incoming stock record. +// The combination lets us trace FIFO deductions while keeping each module focused on its own fields. +type StockAllocation struct { + Id uint `gorm:"primaryKey"` + ProductWarehouseId uint `gorm:"not null;index"` + StockableType string `gorm:"size:100;not null;index:stock_allocations_lookup,priority:1"` + StockableId uint `gorm:"not null;index:stock_allocations_lookup,priority:2"` + UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"` + UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"` + Qty float64 `gorm:"type:numeric(15,3);not null"` + Status string `gorm:"size:20;not null;default:ACTIVE"` + Note *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + ReleasedAt *time.Time `gorm:"index"` + DeletedAt gorm.DeletedAt `gorm:"index"` + + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 6546e790..d6acafb8 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -1,35 +1,20 @@ package entities -import ( - "time" - - "gorm.io/gorm" -) - -const ( - LogTypeAdjustment = "ADJUSTMENT" - LogTypeTransfer = "TRANSFER" -) - -const ( - TransactionTypeIncrease = "INCREASE" - TransactionTypeDecrease = "DECREASE" -) +import "time" type StockLog struct { - Id uint `gorm:"primaryKey;column:id"` - TransactionType string `gorm:"type:varchar(20);not null"` - Quantity float64 `gorm:"type:numeric(15,3);not null"` - BeforeQuantity float64 `gorm:"type:numeric(15,3);not null"` - AfterQuantity float64 `gorm:"type:numeric(15,3);not null"` - LogType string `gorm:"type:varchar(50);not null;index:stock_logs_flaggable_lookup,priority:1"` - LogId uint `gorm:"not null;index:stock_logs_flaggable_lookup,priority:2"` - Note string `gorm:"type:text"` - ProductWarehouseId uint `gorm:"not null;index"` - CreatedBy uint `gorm:"index"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + Id uint `gorm:"primaryKey;column:id"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"` + CreatedBy uint `gorm:"column:created_by;not null;index"` + + Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"` + Decrease float64 `gorm:"column:decrease;type:numeric(15,3);default:0"` + + LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"` + LoggableId uint `gorm:"column:loggable_id;not null"` + + Notes string `gorm:"column:notes;type:text"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` ProductWarehouse *ProductWarehouse `json:"product_warehouse,omitempty" gorm:"foreignKey:ProductWarehouseId;references:Id"` CreatedUser *User `json:"created_user,omitempty" gorm:"foreignKey:CreatedBy;references:Id"` diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 3a7562ea..0eeccc04 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -4,20 +4,21 @@ import "time" // DETAIL EKSPEDISI type StockTransferDelivery struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - StockTransferId uint64 - SupplierId uint64 - VehiclePlate string - DriverName string - DocumentNumber string - DocumentPath string - ShippingCostItem float64 - ShippingCostTotal float64 - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` - // Relations - StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` - Supplier *Supplier `gorm:"foreignKey:SupplierId"` - Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` -} \ No newline at end of file + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + SupplierId uint64 + VehiclePlate string + DriverName string + DocumentNumber string + DocumentPath string + ShippingCostItem float64 + ShippingCostTotal float64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Supplier *Supplier `gorm:"foreignKey:SupplierId"` + Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go index 253a3bf8..9ab27824 100644 --- a/internal/entities/stock_transfer_detail.go +++ b/internal/entities/stock_transfer_detail.go @@ -7,12 +7,28 @@ type StockTransferDetail struct { Id uint64 `gorm:"primaryKey;autoIncrement"` StockTransferId uint64 ProductId uint64 - Quantity float64 - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` - // Relations - StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` - Product *Product `gorm:"foreignKey:ProductId"` - DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` + + // === FIFO FIELDS - SOURCE WAREHOUSE (Usable) === + // Tracking stock yang DIAMBIL dari source warehouse + SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"` + UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil + PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock) + + // === FIFO FIELDS - DESTINATION WAREHOUSE (Stockable) === + // Tracking stock yang DITAMBAHKAN ke destination warehouse + DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"` + TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia + TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini + + // === METADATA === + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + + // === RELATIONS === + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Product *Product `gorm:"foreignKey:ProductId"` + SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"` + DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"` + DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` } diff --git a/internal/entities/supplier.go b/internal/entities/supplier.go index 7d801896..bdbb4dfe 100644 --- a/internal/entities/supplier.go +++ b/internal/entities/supplier.go @@ -8,14 +8,14 @@ import ( type Supplier struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:suppliers_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:suppliers_name_unique,where:deleted_at IS NULL"` Alias string `gorm:"not null;size:5"` - Pic string `gorm:"not null"` + Pic string `gorm:"type:varchar(50);not null"` Type string `gorm:"not null;size:50"` Category string `gorm:"not null;size:20"` - Hatchery *string `gorm:"size:255"` + Hatchery *string `gorm:"type:varchar(50)"` Phone string `gorm:"not null;size:20"` - Email string `gorm:"not null"` + Email string `gorm:"type:varchar(50);not null"` Address string `gorm:"not null"` Npwp *string `gorm:"size:50"` AccountNumber *string `gorm:"size:50"` diff --git a/internal/entities/transaction.go b/internal/entities/transaction.go new file mode 100644 index 00000000..b099bd08 --- /dev/null +++ b/internal/entities/transaction.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Transaction struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/uniformity.go b/internal/entities/uniformity.go new file mode 100644 index 00000000..8402ad3b --- /dev/null +++ b/internal/entities/uniformity.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Uniformity struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/uom.go b/internal/entities/uom.go index a3335428..8f3e3f91 100644 --- a/internal/entities/uom.go +++ b/internal/entities/uom.go @@ -8,7 +8,7 @@ import ( type Uom struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:uoms_name_unique,where:deleted_at IS NULL"` + Name string `gorm:"type:varchar(50);not null;uniqueIndex:uoms_name_unique,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/user.go b/internal/entities/user.go index dcef91d0..d8f4d9c8 100644 --- a/internal/entities/user.go +++ b/internal/entities/user.go @@ -9,8 +9,8 @@ import ( type User struct { Id uint `gorm:"primaryKey"` IdUser int64 `gorm:"uniqueIndex"` - Email string `gorm:"uniqueIndex"` - Name string `gorm:"not null"` + Email string `gorm:"type:varchar(50);uniqueIndex"` + Name string `gorm:"type:varchar(50);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 index 31a0476e..fe2d96aa 100644 --- a/internal/entities/warehouse.go +++ b/internal/entities/warehouse.go @@ -8,7 +8,7 @@ import ( type Warehouse struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` + Name string `gorm:"type:varchar(50);not null"` Type string `gorm:"not null"` AreaId uint `gorm:"not null"` LocationId *uint diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 10f9a3f8..a831c25b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -3,14 +3,13 @@ package middleware import ( "strings" + "github.com/gofiber/fiber/v2" "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" - - "github.com/gofiber/fiber/v2" ) const ( @@ -91,7 +90,6 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl c.Locals(authContextLocalsKey, ctx) c.Locals(authUserLocalsKey, user) - return c.Next() } } @@ -105,6 +103,14 @@ func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) { return nil, false } +func ActorIDFromContext(c *fiber.Ctx) (uint, error) { + user, ok := AuthenticatedUser(c) + if !ok || user == nil || user.Id == 0 { + return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + return user.Id, nil +} + // AuthDetails returns the full authentication context (token, claims, user). func AuthDetails(c *fiber.Ctx) (*AuthContext, bool) { value := c.Locals(authContextLocalsKey) @@ -191,3 +197,71 @@ func hasAllScopes(have, required []string) bool { } return true } + +// RequirePermissions ensures the authenticated user possesses all specified permissions. +func RequirePermissions(perms ...string) fiber.Handler { + required := canonicalPermissions(perms) + return func(c *fiber.Ctx) error { + if len(required) == 0 { + return c.Next() + } + + ctx, ok := AuthDetails(c) + if !ok || ctx == nil { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + + userPerms := ctx.permissionSet() + if len(userPerms) == 0 { + return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") + } + + for _, perm := range required { + if _, has := userPerms[perm]; !has { + return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") + } + } + + return c.Next() + } +} + +// HasPermission reports whether the current request context includes the given permission. +func HasPermission(c *fiber.Ctx, perm string) bool { + ctx, ok := AuthDetails(c) + if !ok || ctx == nil { + return false + } + perm = canonicalPermission(perm) + if perm == "" { + return false + } + _, has := ctx.permissionSet()[perm] + return has +} + +func (a *AuthContext) permissionSet() map[string]struct{} { + if a == nil || a.Permissions == nil { + return nil + } + return a.Permissions +} + +func canonicalPermissions(perms []string) []string { + out := make([]string, 0, len(perms)) + seen := make(map[string]struct{}, len(perms)) + for _, perm := range perms { + if canonical := canonicalPermission(perm); canonical != "" { + if _, ok := seen[canonical]; ok { + continue + } + seen[canonical] = struct{}{} + out = append(out, canonical) + } + } + return out +} + +func canonicalPermission(perm string) string { + return strings.ToLower(strings.TrimSpace(perm)) +} diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 3ebe6866..e9148927 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -1,75 +1,234 @@ package middleware -import ( - "strings" +// project-flock +const ( + P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" + P_ProjectFlockKandangsCheckClosing = "lti.production.project_flock_kandangs.closing.detail" + P_ProjectFlockKandangsGetAll = "lti.production.project_flock_kandangs.list" + P_ProjectFlockKandangsGetOne = "lti.production.project_flock_kandangs.detail" - "github.com/gofiber/fiber/v2" + P_ProjectFlockGetAll = "lti.production.project_flocks.list" + P_ProjectFlockCreate = "lti.production.project_flocks.create" + P_ProjectFlockGetOne = "lti.production.project_flocks.detail" + P_ProjectFlockUpdate = "lti.production.project_flocks.update" + P_ProjectFlockDelete = "lti.production.project_flocks.delete" + P_ProjectFlockApprove = "lti.production.project_flocks.approve" + P_ProjectFlockLookup = "lti.production.project_flocks.lookup" + P_ProjectFlockNextPeriod = "lti.production.project_flocks.next_period" + P_ProjectFlockResubmit = "lti.production.project_flocks.resubmit" ) -// RequirePermissions ensures the authenticated user possesses all specified permissions. -func RequirePermissions(perms ...string) fiber.Handler { - required := canonicalPermissions(perms) - return func(c *fiber.Ctx) error { - if len(required) == 0 { - return c.Next() - } +const ( + P_ExpenseGetAll = "lti.expense.list" + P_ExpenseCreateOne = "lti.expense.create" + P_ExpenseUpdateOne = "lti.expense.update" + P_ExpenseGetOne = "lti.expense.detail" + P_ExpenseDeleteOne = "lti.expense.delete" + P_ExpenseApprovalManager = "lti.expense.approve.manager" + P_ExpenseApprovalFinance = "lti.expense.approve.finance" + P_ExpenseCreateRealizations = "lti.expense.create.realization" + P_ExpenseUpdateRealizations = "lti.expense.update.realization" + P_ExpenseCompleteExpense = "lti.expense.complete.expense" + P_ExpenseDocument = "lti.expense.document" + P_ExpenseDocumentRealizations = "lti.expense.document.realization" +) +const ( + P_AdjustmentGetAll = "lti.inventory.list" + P_AdjustmentCreate = "lti.inventory.create" + P_AdjustmentGetOne = "lti.inventory.detail" +) +const ( + P_ApprovalGetAll = "lti.approval.list" +) +const ( + P_ReportExpenseGetAll = "lti.repport.expense.list" + P_ReportDeliveryGetAll = "lti.repport.delivery.list" + P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list" + P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" +) - ctx, ok := AuthDetails(c) - if !ok || ctx == nil { - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } +const ( + P_ProductStockGetAll = "lti.inventory.product_stock.list" + P_ProductStockGetOne = "lti.inventory.product_stock.detail" + P_ProductWarehousekGetAll = "lti.inventory.product_warehouses.list" + P_ProductWarehouseGetOne = "lti.inventory.product_warehouses.detail" +) +const ( + P_ClosingGetAll = "lti.closing.list" + P_ClosingDetail = "lti.closing.detail" +) - userPerms := ctx.permissionSet() - if len(userPerms) == 0 { - return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") - } +const ( + P_TransferGetAll = "lti.inventory.transfer.list" + P_TransferGetOne = "lti.inventory.transfer.detail" + P_TransferCreateOne = "lti.inventory.transfer.create" +) - for _, perm := range required { - if _, has := userPerms[perm]; !has { - return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") - } - } +const ( + P_TransferToLaying_GetAll = "lti.production.transfer_to_laying.list" + P_TransferToLaying_GetOne = "lti.production.transfer_to_laying.detail" + P_TransferToLaying_CreateOne = "lti.production.transfer_to_laying.create" + P_TransferToLaying_UpdateOne = "lti.production.transfer_to_laying.update" + P_TransferToLaying_DeleteOne = "lti.production.transfer_to_laying.delete" + P_TransferToLaying_Approval = "lti.production.transfer_to_laying.approve" + P_TransferToLaying_GetAvailableQty = "lti.production.transfer_to_laying.getavailableqty" +) - return c.Next() - } -} +const ( + P_DeliveryGetAll = "lti.marketing.delivery_order.list" + P_DeliveryGetOne = "lti.marketing.delivery_order.detail" + P_DeliveryUpdateOne = "lti.marketing.delivery_order.update" + P_DeliveryCreateOne = "lti.marketing.delivery_order.Create" + P_SalesOrderDelete = "lti.marketing.sales_order.delete" + P_SalesOrderApproval = "lti.marketing.sales_order.approve" + P_SalesOrderCreateOne = "lti.marketing.sales_order.create" + P_SalesOrderUpdateOne = "lti.marketing.sales_order.update" +) -// HasPermission reports whether the current request context includes the given permission. -func HasPermission(c *fiber.Ctx, perm string) bool { - ctx, ok := AuthDetails(c) - if !ok || ctx == nil { - return false - } - perm = canonicalPermission(perm) - if perm == "" { - return false - } - _, has := ctx.permissionSet()[perm] - return has -} +const ( + P_AreaGetAll = "lti.master.area.list" + P_AreaGetOne = "lti.master.area.detail" + P_AreaCreateOne = "lti.master.area.create" + P_AreaUpdateOne = "lti.master.area.update" + P_AreaDeleteOne = "lti.master.area.delete" -func (a *AuthContext) permissionSet() map[string]struct{} { - if a == nil || a.Permissions == nil { - return nil - } - return a.Permissions -} + P_BanksGetAll = "lti.master.banks.list" + P_BanksGetOne = "lti.master.banks.detail" + P_BanksCreateOne = "lti.master.banks.create" + P_BanksUpdateOne = "lti.master.banks.update" + P_BanksDeleteOne = "lti.master.banks.delete" -func canonicalPermissions(perms []string) []string { - out := make([]string, 0, len(perms)) - seen := make(map[string]struct{}, len(perms)) - for _, perm := range perms { - if canonical := canonicalPermission(perm); canonical != "" { - if _, ok := seen[canonical]; ok { - continue - } - seen[canonical] = struct{}{} - out = append(out, canonical) - } - } - return out -} + P_CustomerGetAll = "lti.master.customer.list" + P_CustomerGetOne = "lti.master.customer.detail" + P_CustomerCreateOne = "lti.master.customer.create" + P_CustomerUpdateOne = "lti.master.customer.update" + P_CustomerDeleteOne = "lti.master.customer.delete" -func canonicalPermission(perm string) string { - return strings.ToLower(strings.TrimSpace(perm)) -} + P_FcrGetAll = "lti.master.fcr.list" + P_FcrGetOne = "lti.master.fcr.detail" + P_FcrCreateOne = "lti.master.fcr.create" + P_FcrUpdateOne = "lti.master.fcr.update" + P_FcrDeleteOne = "lti.master.fcr.delete" + + P_FlocksGetAll = "lti.master.flocks.list" + P_FlocksGetOne = "lti.master.flocks.detail" + P_FlocksCreateOne = "lti.master.flocks.create" + P_FlocksUpdateOne = "lti.master.flocks.update" + P_FlocksDeleteOne = "lti.master.flocks.delete" + + P_KandangsGetAll = "lti.master.kandangs.list" + P_KandangsGetOne = "lti.master.kandangs.detail" + P_KandangsCreateOne = "lti.master.kandangs.create" + P_KandangsUpdateOne = "lti.master.kandangs.update" + P_KandangsDeleteOne = "lti.master.kandangs.delete" + + P_LocationsGetAll = "lti.master.locations.list" + P_LocationsGetOne = "lti.master.locations.detail" + P_LocationsCreateOne = "lti.master.locations.create" + P_LocationsUpdateOne = "lti.master.locations.update" + P_LocationsDeleteOne = "lti.master.locations.delete" + + P_NonstocksGetAll = "lti.master.nonstocks.list" + P_NonstocksGetOne = "lti.master.nonstocks.detail" + P_NonstocksCreateOne = "lti.master.nonstocks.create" + P_NonstocksUpdateOne = "lti.master.nonstocks.update" + P_NonstocksDeleteOne = "lti.master.nonstocks.delete" + + P_ProductCategoriesGetAll = "lti.master.Product_categories.list" + P_ProductCategoriesGetOne = "lti.master.Product_categories.detail" + P_ProductCategoriesCreateOne = "lti.master.Product_categories.create" + P_ProductCategoriesUpdateOne = "lti.master.Product_categories.update" + P_ProductCategoriesDeleteOne = "lti.master.Product_categories.delete" + + P_ProductsGetAll = "lti.master.Products.list" + P_ProductsGetOne = "lti.master.Products.detail" + P_ProductsCreateOne = "lti.master.Products.create" + P_ProductsUpdateOne = "lti.master.Products.update" + P_ProductsDeleteOne = "lti.master.Products.delete" + + P_SuppliersGetAll = "lti.master.suppliers.list" + P_SuppliersGetOne = "lti.master.suppliers.detail" + P_SuppliersCreateOne = "lti.master.suppliers.create" + P_SuppliersUpdateOne = "lti.master.suppliers.update" + P_SuppliersDeleteOne = "lti.master.suppliers.delete" + + P_UomsGetAll = "lti.master.uoms.list" + P_UomsGetOne = "lti.master.uoms.detail" + P_UomsCreateOne = "lti.master.uoms.create" + P_UomsUpdateOne = "lti.master.uoms.update" + P_UomsDeleteOne = "lti.master.uoms.delete" + + P_WarehousesGetAll = "lti.master.warehouses.list" + P_WarehousesGetOne = "lti.master.warehouses.detail" + P_WarehousesCreateOne = "lti.master.warehouses.create" + P_WarehousesUpdateOne = "lti.master.warehouses.update" + P_WarehousesDeleteOne = "lti.master.warehouses.delete" + + P_Production_Standart_GetAll = "lti.master.production_standards.list" + P_Production_Standart_CreateOne = "lti.master.production_standards.create" + P_Production_Standart_GetOne = "lti.master.production_standards.detail" + P_Production_Standart_UpdateOne = "lti.master.production_standards.update" + P_Production_Standart_DeleteOne = "lti.master.production_standards.delete" +) + +// finance +const ( + P_Finances_Initial_Balances_CreateOne = "lti.finance.initial_balances.create" + P_Finances_Initial_Balances_GetOne = "lti.finance.initial_balances.detail" + P_Finances_Initial_Balances_UpdateOne = "lti.finance.initial_balances.update" + + P_Finances_Injections_CreateOne = "lti.finance.injections.create" + P_Finances_Injections_GetOne = "lti.finance.injections.detail" + P_Finances_Injections_UpdateOne = "lti.finance.injections.update" + + P_Finances_Payments_CreateOne = "lti.finance.payments.create" + P_Finances_Payments_UpdateOne = "lti.finance.payments.update" + P_Finances_Payments_GetOne = "lti.finance.payments.detail" + + P_Finances_Transaction_GetAll = "lti.finance.transactions.list" + P_Finances_Transaction_GetOne = "lti.finance.transactions.detail" + P_Finances_Transaction_DeleteOne = "lti.finance.transactions.delete" +) +const ( + P_ChickinsCreateOne = "lti.production.chickins.create" + P_ChickinsGetOne = "lti.production.chickins.detail" + P_ChickinsApproval = "lti.production.chickins.approve" +) + +// recording +const ( + P_RecordingGetAll = "lti.production.recording.list" + P_RecordingGetOne = "lti.production.recording.detail" + P_RecordingCreateOne = "lti.production.recording.create" + P_RecordingUpdateOne = "lti.production.recording.update" + P_RecordingDeleteOne = "lti.production.recording.delete" + P_RecordingNextDay = "lti.production.recording.next_day" + P_RecordingApproval = "lti.production.recording.approve" +) + +const ( + P_PurchaseGetAll = "lti.Purchase.list" + P_PurchaseGetOne = "lti.Purchase.detail" + P_PurchaseCreateOne = "lti.Purchase.create" + P_PurchaseUpdateOne = "lti.Purchase.update" + P_PurchaseDeleteOne = "lti.Purchase.delete" + P_PurchaseItemDeleteOne = "lti.Purchase.delete.item" + P_PurchaseReceive = "lti.Purchase.receive" + P_PurchaseApprovalStaff = "lti.Purchase.approve.staff" + P_PurchaseApprovalManager = "lti.Purchase.approve.manager" +) + +const ( + P_Uniformities_GetAll = "lti.production.uniformity.list" + P_Uniformities_GetOne = "lti.production.uniformity.detail" + P_Uniformities_Verify = "lti.production.uniformity.verify" + P_Uniformities_CreateOne = "lti.production.uniformity.create" + P_Uniformities_UpdateOne = "lti.production.uniformity.update" + P_Uniformities_DeleteOne = "lti.production.uniformity.delete" + P_Uniformities_Approval = "lti.production.uniformity.approve" +) + +const ( + P_UserGetAll = "lti.users.list" + P_UserGetOne = "lti.users.detail" +) diff --git a/internal/modules/approvals/route.go b/internal/modules/approvals/route.go index b7d66abd..cd479c03 100644 --- a/internal/modules/approvals/route.go +++ b/internal/modules/approvals/route.go @@ -3,7 +3,7 @@ package approvals import ( // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" "github.com/gofiber/fiber/v2" - + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/controllers" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -12,8 +12,8 @@ import ( func ApprovalRoutes(v1 fiber.Router, u user.UserService, s common.ApprovalService) { _ = u ctrl := controller.NewApprovalController(s) - route := v1.Group("/approvals") + route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) + route.Get("/", ctrl.GetAll,m.RequirePermissions(m.P_ApprovalGetAll)) } diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go new file mode 100644 index 00000000..c4580efb --- /dev/null +++ b/internal/modules/closings/controllers/closing.controller.go @@ -0,0 +1,353 @@ +package controller + +import ( + "math" + "strconv" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ClosingController struct { + ClosingService service.ClosingService + SapronakService service.SapronakService +} + +func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService) *ClosingController { + return &ClosingController{ + ClosingService: closingService, + SapronakService: sapronakService, + } +} + +func (u *ClosingController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ClosingService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ClosingListItemDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved closing projects list successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + }) +} + +func (u *ClosingController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid id") + } + + result, err := u.ClosingService.GetProjectFlockByID(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved closing information successfully", + Data: result, + }) +} + +func (u *ClosingController) GetClosingSummary(c *fiber.Ctx) error { + param := c.Params("projectFlockId") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") + } + + result, err := u.ClosingService.GetClosingSummary(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved project information successfully", + Data: result, + }) +} + +func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { + param := c.Params("project_flock_id") + + projectFlockID, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") + } + + projectFlock, err := u.ClosingService.GetProjectFlockByID(c, uint(projectFlockID)) + if err != nil { + return err + } + + result, err := u.ClosingService.GetPenjualan(c, uint(projectFlockID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get closing penjualan successfully", + Data: dto.ToPenjualanRealisasiResponseDTO(projectFlock.Category, uint(projectFlockID), result), + }) +} + +func (u *ClosingController) GetOverhead(c *fiber.Ctx) error { + param := c.Params("project_flock_id") + + projectFlockID, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") + } + + result, err := u.ClosingService.GetOverhead(c, uint(projectFlockID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get overhead successfully", + Data: result, + }) +} + +func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { + param := c.Params("projectFlockId") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") + } + + query := &validation.ClosingSapronakQuery{ + Type: strings.ToLower(c.Query("type")), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { + return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + result, totalResults, err := u.ClosingService.GetClosingSapronak(c, uint(id), query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ClosingSapronakItemDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved closing report (sapronak) successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + }) +} + +func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { + param := c.Params("project_flock_id") + flag := c.Query("flag", "") + + projectID, err := strconv.Atoi(param) + if err != nil || projectID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + + result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag) + if err != nil { + return err + } + + payload := dto.ToSapronakProjectAggregatedFromReports(result, flag) + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get perhitungan sapronak per project successfully", + Data: payload, + }) +} + +func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error { + projectParam := c.Params("project_flock_id") + kandangParam := c.Params("project_flock_kandang_id") + flag := c.Query("flag", "") + + projectID, err := strconv.Atoi(projectParam) + if err != nil || projectID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + pfkID, err := strconv.Atoi(kandangParam) + if err != nil || pfkID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + + result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag) + if err != nil { + return err + } + + payload := dto.ToSapronakProjectAggregatedFromReport(result, flag) + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get perhitungan sapronak per kandang successfully", + Data: payload, + }) +} + +func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error { + param := c.Params("project_flock_id") + + projectFlockID, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") + } + + result, err := u.ClosingService.GetClosingKeuangan(c, uint(projectFlockID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get closing keuangan successfully", + Data: result, + }) +} + +func (u *ClosingController) GetExpeditionHPP(c *fiber.Ctx) error { + param := c.Params("project_flock_id") + + projectFlockID, err := strconv.Atoi(param) + if err != nil || projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + + var projectFlockKandangID *uint + if raw := c.Query("project_flock_kandang_id"); raw != "" { + idInt, convErr := strconv.Atoi(raw) + if convErr != nil || idInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + idUint := uint(idInt) + projectFlockKandangID = &idUint + } + + result, err := u.ClosingService.GetExpeditionHPP(c, uint(projectFlockID), projectFlockKandangID) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get expedition HPP successfully", + Data: result, + }) +} + +func (u *ClosingController) GetExpeditionHPPByKandang(c *fiber.Ctx) error { + projectParam := c.Params("project_flock_id") + kandangParam := c.Params("project_flock_kandang_id") + + projectFlockID, err := strconv.Atoi(projectParam) + if err != nil || projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + + pfkID, err := strconv.Atoi(kandangParam) + if err != nil || pfkID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + + kandangID := uint(pfkID) + + result, err := u.ClosingService.GetExpeditionHPP(c, uint(projectFlockID), &kandangID) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get expedition HPP successfully", + Data: result, + }) +} + +func (u *ClosingController) GetClosingDataProduksi(c *fiber.Ctx) error { + param := c.Params("projectFlockId") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") + } + + result, err := u.ClosingService.GetClosingDataProduksi(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved production data successfully", + Data: result, + }) +} diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go new file mode 100644 index 00000000..ac172c83 --- /dev/null +++ b/internal/modules/closings/dto/closing.dto.go @@ -0,0 +1,225 @@ +package dto + +import ( + "fmt" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ClosingRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ClosingListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ClosingDetailDTO struct { + ClosingListDTO +} + +type ClosingListItemDTO struct { + Id uint `json:"id"` + ProjectName string `json:"project_name"` + LocationID uint `json:"location_id"` + LocationName string `json:"location_name"` + ProjectCategory string `json:"project_category"` + Period int `json:"period"` + ClosingDate string `json:"closing_date"` + ShedLabel string `json:"shed_label"` + ShedCount int `json:"shed_count"` + // SalesPaidAmount int64 `json:"sales_paid_amount"` + // SalesRemainingAmount int64 `json:"sales_remaining_amount"` + // SalesPaymentStatus string `json:"sales_payment_status"` + ProjectStatus string `json:"project_status"` +} + +type ClosingSummaryDTO struct { + FlockID uint `json:"flock_id"` + Period int `json:"period"` + // JenisProduk string `json:"jenis_produk"` + // LabelPopulasi string `json:"label_populasi"` + Population int `json:"population"` + PopulationFormatted string `json:"population_formatted"` + ProjectType string `json:"project_type"` + ActiveHouseCount int `json:"active_house_count"` + ActiveHouseLabel string `json:"active_house_label"` + SalesPaymentStatus string `json:"sales_payment_status"` + // StatusPembayaranMitra string `json:"status_pembayaran_mitra"` + StatusProject string `json:"project_status"` + StatusClosing string `json:"closing_status"` +} + +type ClosingPurchaseDTO struct { + InitialPopulation int `json:"initial_population"` + ClaimCulling int `json:"claim_culling"` + FinalPopulation int `json:"final_population"` + FeedIn float64 `json:"feed_in"` + FeedUsed float64 `json:"feed_used"` + FeedUsedPerHead float64 `json:"feed_used_per_head"` +} + +type ClosingSalesDTO struct { + SalesPopulation int `json:"sales_population"` + SalesWeight float64 `json:"sales_weight"` + AverageWeight float64 `json:"average_weight"` + AverageSellingPrice float64 `json:"chicken_average_selling_price"` +} + +type ClosingEggSalesDTO struct { + EggPieces int `json:"egg_pieces"` + EggMassKg float64 `json:"egg_mass_kg"` + AverageEggWeightKg float64 `json:"average_egg_weight_kg"` + AverageSellingPrice float64 `json:"egg_average_selling_price"` +} + +type ClosingPerformanceDTO struct { + Depletion float64 `json:"depletion"` + Age float64 `json:"age_day"` + MortalityStd float64 `json:"mortality_std"` + MortalityAct float64 `json:"mortality_act"` + DeffMortality float64 `json:"deff_mortality"` + FcrStd float64 `json:"fcr_std"` + FcrAct float64 `json:"fcr_act"` + DeffFcr float64 `json:"deff_fcr"` + Awg float64 `json:"awg"` +} + +type ClosingSalesGroupDTO struct { + Chicken ClosingSalesDTO `json:"chicken"` + Egg *ClosingEggSalesDTO `json:"egg,omitempty"` +} + +type ClosingProductionReportDTO struct { + Purchase ClosingPurchaseDTO `json:"purchase"` + Sales ClosingSalesGroupDTO `json:"sales"` + Performance ClosingPerformanceDTO `json:"performance"` +} + +func ToClosingSummaryDTO(project entity.ProjectFlock, statusProject, statusClosing string) ClosingSummaryDTO { + history := project.KandangHistory + + period := maxPeriod(history) + kandangCount := len(history) + population := sumPopulation(history) + populationInt := int(population) + + return ClosingSummaryDTO{ + FlockID: project.Id, + Period: period, + // JenisProduk: project.Category, + // LabelPopulasi: "", + Population: populationInt, + PopulationFormatted: fmt.Sprintf("%d Ekor", populationInt), + ProjectType: project.Category, + ActiveHouseCount: kandangCount, + ActiveHouseLabel: fmt.Sprintf("%d Kandang", kandangCount), + SalesPaymentStatus: "Tempo", + // StatusPembayaranMitra: "", + StatusProject: statusProject, + StatusClosing: statusClosing, + } +} + +func ToClosingListItemDTO(project entity.ProjectFlock, projectStatus string) ClosingListItemDTO { + shedCount := len(project.KandangHistory) + + return ClosingListItemDTO{ + Id: project.Id, + ProjectName: project.FlockName, + LocationID: project.LocationId, + LocationName: project.Location.Name, + ProjectCategory: project.Category, + Period: maxPeriod(project.KandangHistory), + ClosingDate: "17-Nov-2025", + ShedLabel: fmt.Sprintf("%d Kandang", shedCount), + ShedCount: shedCount, + // SalesPaidAmount: 21993726, + // SalesRemainingAmount: 11075919, + // SalesPaymentStatus: "Lunas", + ProjectStatus: projectStatus, + } +} + +func maxPeriod(history []entity.ProjectFlockKandang) int { + max := 0 + for _, h := range history { + if h.Period > max { + max = h.Period + } + } + return max +} + +func sumPopulation(history []entity.ProjectFlockKandang) float64 { + var total float64 + for _, h := range history { + for _, chickin := range h.Chickins { + total += chickin.UsageQty + chickin.PendingUsageQty + } + } + return total +} + +// === Mapper Functions === + +func ToClosingRelationDTO(e entity.ProjectFlock) ClosingRelationDTO { + return ClosingRelationDTO{ + Id: e.Id, + } +} + +func ToClosingListDTO(e entity.ProjectFlock) ClosingListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return ClosingListDTO{ + Id: e.Id, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToClosingListDTOs(e []entity.ProjectFlock) []ClosingListDTO { + result := make([]ClosingListDTO, len(e)) + for i, r := range e { + result[i] = ToClosingListDTO(r) + } + return result +} + +func ToClosingDetailDTO(e entity.ProjectFlock) ClosingDetailDTO { + return ClosingDetailDTO{ + ClosingListDTO: ToClosingListDTO(e), + } +} + +func CalculateAgeFromChickinDataProduksi(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int { + if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { + return 0 + } + + earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate + for _, chickin := range projectFlockKandang.Chickins { + if chickin.ChickInDate.Before(earliestChickinDate) { + earliestChickinDate = chickin.ChickInDate + } + } + + ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24) + ageInWeeks := ageInDays / 7 + return ageInWeeks +} diff --git a/internal/modules/closings/dto/closingExpedition.dto.go b/internal/modules/closings/dto/closingExpedition.dto.go new file mode 100644 index 00000000..5f8a09d4 --- /dev/null +++ b/internal/modules/closings/dto/closingExpedition.dto.go @@ -0,0 +1,14 @@ +package dto + +// ExpeditionCostItemDTO merepresentasikan biaya ekspedisi per vendor. +type ExpeditionCostItemDTO struct { + Id uint64 `json:"id"` + ExpeditionVendorName string `json:"expedition_vendor_name"` + HPPAmount float64 `json:"hpp_amount"` +} + +// ExpeditionHPPDTO adalah struktur response utama untuk HPP Ekspedisi. +type ExpeditionHPPDTO struct { + ExpeditionCosts []ExpeditionCostItemDTO `json:"expedition_costs"` + TotalHPPAmount float64 `json:"total_hpp_amount"` +} diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go new file mode 100644 index 00000000..08bfb5fc --- /dev/null +++ b/internal/modules/closings/dto/closingKeuangan.dto.go @@ -0,0 +1,589 @@ +package dto + +import ( + "slices" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === CONSTANTS === +const ( + HPPGroupPengeluaran = "HPP dan Pengeluaran" + HPPGroupBahanBaku = "HPP dan Bahan Baku" + HPPLabelOverhead = "Pengeluaran Overhead" + HPPLabelEkspedisi = "Beban Ekspedisi" + HPPSummaryLabel = "HPP" + + PLSalesTypeChicken = "Penjualan Ayam Besar" + PLSalesTypeEgg = "Penjualan Telur" + + PLItemTypeSapronak = "Pembelian Sapronak" + PLItemTypeOverhead = "Pengeluaran Overhead" + PLItemTypeEkspedisi = "Beban Ekspedisi" + + PLSummaryLabelGrossProfit = "LABA RUGI BRUTTO" + PLSummaryLabelSubTotal = "SUB TOTAL" + PLSummaryLabelNetProfit = "LABA RUGI NETTO" + + PurchaseLabelPrefix = "Pembelian " +) + +// === CONTEXT STRUCTS === + +type CalculationContext struct { + TotalPopulation float64 + TotalWeightProduced float64 + TotalEggWeightKg float64 + TotalDepletion float64 + TotalWeightSold float64 + ActualPopulation float64 +} + +type ClosingKeuanganInput struct { + ProjectFlockCategory string + PurchaseItems []entities.PurchaseItem + Budgets []entities.ProjectBudget + Realizations []entities.ExpenseRealization + DeliveryProducts []entities.MarketingDeliveryProduct + Chickins []entities.ProjectChickin + TotalWeightProduced float64 + TotalEggWeightKg float64 + TotalDepletion float64 +} + +// === BASE METRICS === + +type FinancialMetrics struct { + RpPerBird float64 `json:"rp_per_bird"` + RpPerKg float64 `json:"rp_per_kg"` + Amount float64 `json:"amount"` +} + +type Comparison struct { + Budgeting FinancialMetrics `json:"budgeting"` + Realization FinancialMetrics `json:"realization"` +} + +// === HPP PURCHASES PACKAGE === + +type HppItem struct { + Type string `json:"type"` + Comparison +} + +type HppGroup struct { + GroupName string `json:"group_name"` + Data []HppItem `json:"data"` +} + +type SummaryHpp struct { + Label string `json:"label"` + Comparison `json:"-"` + EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` + EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` +} + +type HppPurchasesSection struct { + Hpp []HppGroup `json:"hpp"` + SummaryHpp SummaryHpp `json:"summary_hpp"` +} + +// === PROFIT LOSS PACKAGE === + +type PLItem struct { + Type string `json:"type"` + FinancialMetrics +} + +type PLSummaryItem struct { + Label string `json:"label"` + FinancialMetrics +} + +type PLSummaryGroup struct { + GrossProfit PLSummaryItem `json:"gross_profit"` + SubTotal PLSummaryItem `json:"sub_total"` + NetProfit PLSummaryItem `json:"net_profit"` +} + +type ProfitLossData struct { + Penjualan []PLItem `json:"penjualan"` + Pembelian []PLItem `json:"pembelian"` + Overhead PLItem `json:"overhead"` + Ekspedisi PLItem `json:"ekspedisi"` + Summary PLSummaryGroup `json:"summary"` +} + +type ProfitLossSection struct { + Data ProfitLossData `json:"data"` +} + +// === RESPONSE DTO (ROOT) === + +type ReportResponse struct { + HppPurchases HppPurchasesSection `json:"hpp_purchases"` + ProfitLoss ProfitLossSection `json:"profit_loss"` +} + +// === MAPPER FUNCTIONS === + +func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { + return FinancialMetrics{ + RpPerBird: rpPerBird, + RpPerKg: rpPerKg, + Amount: amount, + } +} + +func ToComparison(budgeting, realization FinancialMetrics) Comparison { + return Comparison{ + Budgeting: budgeting, + Realization: realization, + } +} + +// === HPP PENGELUARAN (from Purchase Items) === + +func getFlagLabel(flagType utils.FlagType) string { + return PurchaseLabelPrefix + string(flagType) +} + +func buildHppItemsByPurchaseFlags(purchaseItems []entities.PurchaseItem, ctx CalculationContext) []HppItem { + flags := []utils.FlagType{ + utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, utils.FlagPakan, + utils.FlagPreStarter, utils.FlagStarter, utils.FlagFinisher, + utils.FlagOVK, utils.FlagObat, utils.FlagVitamin, utils.FlagKimia, + } + + items := []HppItem{} + seenFlags := make(map[utils.FlagType]bool) + + for _, item := range purchaseItems { + if item.Product == nil || len(item.Product.Flags) == 0 { + continue + } + + for _, flag := range item.Product.Flags { + flagType := utils.FlagType(flag.Name) + + if slices.Contains(flags, flagType) && !seenFlags[flagType] { + amount := sumPurchasesByFlag(purchaseItems, flagType) + rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.TotalPopulation, ctx.TotalWeightProduced) + + items = append(items, HppItem{ + Type: getFlagLabel(flagType), + Comparison: ToComparison( + ToFinancialMetrics(rpPerBird, rpPerKg, amount), + ToFinancialMetrics(rpPerBird, rpPerKg, amount), + ), + }) + seenFlags[flagType] = true + } + } + } + + return items +} + +// === HPP BAHAN BAKU (from ProjectBudget + ExpenseRealization) === + +func createHppOverheadItem(budgetAmount, realizationAmount float64, ctx CalculationContext) HppItem { + budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(budgetAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) + realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(realizationAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) + + return HppItem{ + Type: HPPLabelOverhead, + Comparison: ToComparison( + ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount), + ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount), + ), + } +} + +func createHppEkspedisiItem(ekspedisiAmount float64, ctx CalculationContext) HppItem { + ekspedisiRpPerBird, ekspedisiRpPerKg := calculatePerUnitMetrics(ekspedisiAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) + + return HppItem{ + Type: HPPLabelEkspedisi, + Comparison: ToComparison( + ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount), + ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount), + ), + } +} + +func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppGroup { + items := []HppItem{} + + budgetAmount := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) + realizationAmount := getOperationalExpenses(realizations) + + if budgetAmount > 0 || realizationAmount > 0 { + items = append(items, createHppOverheadItem(budgetAmount, realizationAmount, ctx)) + } + + ekspedisiAmount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi)) + items = append(items, createHppEkspedisiItem(ekspedisiAmount, ctx)) + + return HppGroup{ + GroupName: HPPGroupBahanBaku, + Data: items, + } +} + +// === HPP SUMMARY === + +func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp { + purchaseTotal := sumPurchaseTotal(purchaseItems) + budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) + totalBudget := purchaseTotal + budgetTotal + + totalRealization := sumRealizationsByFilter(realizations, func(*entities.ExpenseRealization) bool { return true }) + + budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced) + realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) + + summary := SummaryHpp{ + Label: label, + Comparison: ToComparison( + ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), + ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), + ), + } + + if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 { + budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg) + realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg) + + summary.EggBudgeting = &FinancialMetrics{ + RpPerBird: 0, + RpPerKg: budgetEggRpPerKg, + Amount: totalBudget, + } + summary.EggRealization = &FinancialMetrics{ + RpPerBird: 0, + RpPerKg: realizationEggRpPerKg, + Amount: totalRealization, + } + } + + return summary +} + +func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection { + hppGroups := []HppGroup{ + { + GroupName: HPPGroupPengeluaran, + Data: buildHppItemsByPurchaseFlags(purchaseItems, ctx), + }, + ToHppBahanBakuGroup(budgets, realizations, ctx), + } + + summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx) + + return HppPurchasesSection{ + Hpp: hppGroups, + SummaryHpp: summaryHpp, + } +} + +// === PROFIT & LOSS === + +func ToPLItem(itemType string, metrics FinancialMetrics) PLItem { + return PLItem{ + Type: itemType, + FinancialMetrics: metrics, + } +} + +func ToPLSummaryItem(label string, metrics FinancialMetrics) PLSummaryItem { + return PLSummaryItem{ + Label: label, + FinancialMetrics: metrics, + } +} + +func createPLItemWithMetrics(itemType string, amount float64, ctx CalculationContext) PLItem { + rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightProduced) + return ToPLItem(itemType, ToFinancialMetrics(rpPerBird, rpPerKg, amount)) +} + +func sumPLItems(items []PLItem) (totalAmount, totalPerBird float64) { + for _, item := range items { + totalAmount += item.Amount + totalPerBird += item.RpPerBird + } + return +} + +func createPenjualanItem(salesType string, amount float64, ctx CalculationContext) PLItem { + rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightSold) + return ToPLItem(salesType, ToFinancialMetrics(rpPerBird, rpPerKg, amount)) +} + +func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.MarketingDeliveryProduct, ctx CalculationContext) []PLItem { + items := []PLItem{} + + categorized := categorizeDeliveriesBySalesType(deliveryProducts) + + if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) { + ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken]) + telurAmount := sumDeliveriesByCategory(categorized[PLSalesTypeEgg]) + + items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx)) + items = append(items, createPenjualanItem(PLSalesTypeEgg, telurAmount, ctx)) + } else { + ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken]) + items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx)) + } + + return items +} + +func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { + purchaseAmount := sumPurchaseTotal(purchases) + + return []PLItem{ + createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx), + } +} + +func ToOverheadItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { + realizationAmount := getOperationalExpenses(realizations) + return []PLItem{ + createPLItemWithMetrics(PLItemTypeOverhead, realizationAmount, ctx), + } +} + +func ToEkspedisiItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { + amount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi)) + return []PLItem{ + createPLItemWithMetrics(PLItemTypeEkspedisi, amount, ctx), + } +} + +func ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) PLSummaryGroup { + totalPenjualan, totalPenjualanPerBird := sumPLItems(penjualanItems) + totalPembelian, totalPembelianPerBird := sumPLItems(pembelianItems) + totalOverhead, totalOverheadPerBird := sumPLItems(overheadItems) + totalEkspedisi, totalEkspedisiPerBird := sumPLItems(ekspedisiItems) + + grossProfit := totalPenjualan - totalPembelian + grossProfitPerBird := totalPenjualanPerBird - totalPembelianPerBird + + totalOtherExpenses := totalOverhead + totalEkspedisi + totalOtherExpensesPerBird := totalOverheadPerBird + totalEkspedisiPerBird + + netProfit := grossProfit - totalOtherExpenses + netProfitPerBird := grossProfitPerBird - totalOtherExpensesPerBird + + return PLSummaryGroup{ + GrossProfit: ToPLSummaryItem(PLSummaryLabelGrossProfit, ToFinancialMetrics(grossProfitPerBird, 0, grossProfit)), + SubTotal: ToPLSummaryItem(PLSummaryLabelSubTotal, ToFinancialMetrics(totalOtherExpensesPerBird, 0, totalOtherExpenses)), + NetProfit: ToPLSummaryItem(PLSummaryLabelNetProfit, ToFinancialMetrics(netProfitPerBird, 0, netProfit)), + } +} + +func ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossData { + summary := ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems) + + totalOverhead := aggregatePLItems(overheadItems, PLItemTypeOverhead) + totalEkspedisi := aggregatePLItems(ekspedisiItems, PLItemTypeEkspedisi) + + return ProfitLossData{ + Penjualan: penjualanItems, + Pembelian: pembelianItems, + Overhead: totalOverhead, + Ekspedisi: totalEkspedisi, + Summary: summary, + } +} + +func ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossSection { + return ProfitLossSection{ + Data: ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems), + } +} + +func aggregatePLItems(items []PLItem, label string) PLItem { + totalAmount, totalPerBird := sumPLItems(items) + return ToPLItem(label, ToFinancialMetrics(totalPerBird, 0, totalAmount)) +} + +func ToReportResponse(hppPurchases HppPurchasesSection, profitLoss ProfitLossSection) ReportResponse { + return ReportResponse{ + HppPurchases: hppPurchases, + ProfitLoss: profitLoss, + } +} + +func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse { + var totalPopulation float64 + var totalWeightSold float64 + + for _, chickin := range input.Chickins { + totalPopulation += chickin.UsageQty + } + + for _, delivery := range input.DeliveryProducts { + totalWeightSold += delivery.TotalWeight + } + + ctx := CalculationContext{ + TotalPopulation: totalPopulation, + TotalWeightProduced: input.TotalWeightProduced, + TotalEggWeightKg: input.TotalEggWeightKg, + TotalDepletion: input.TotalDepletion, + TotalWeightSold: totalWeightSold, + ActualPopulation: totalPopulation - input.TotalDepletion, + } + + hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx) + penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx) + pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx) + overheadItems := ToOverheadItems(input.Realizations, ctx) + ekspedisiItems := ToEkspedisiItems(input.Realizations, ctx) + plSection := ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems) + + return ToReportResponse(hppSection, plSection) +} + +// === HELPER FUNCTIONS === + +func calculatePerUnitMetrics(amount, totalPopulation, totalWeightSold float64) (rpPerBird, rpPerKg float64) { + if totalPopulation > 0 { + rpPerBird = amount / totalPopulation + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } + return rpPerBird, rpPerKg +} + +func hasProductFlag(flags []entities.Flag, flagType utils.FlagType) bool { + for _, flag := range flags { + if strings.ToUpper(flag.Name) == string(flagType) { + return true + } + } + return false +} + +func filterByPurchaseFlag(flagType utils.FlagType) func(*entities.PurchaseItem) bool { + return func(item *entities.PurchaseItem) bool { + if item.Product == nil || len(item.Product.Flags) == 0 { + return false + } + return hasProductFlag(item.Product.Flags, flagType) + } +} + +func filterRealizationByNonstockFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool { + return func(realization *entities.ExpenseRealization) bool { + if realization.ExpenseNonstock == nil || realization.ExpenseNonstock.Nonstock == nil { + return false + } + return hasProductFlag(realization.ExpenseNonstock.Nonstock.Flags, flagType) + } +} + +func filterRealizationExceptFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool { + hasFlag := filterRealizationByNonstockFlag(flagType) + return func(realization *entities.ExpenseRealization) bool { + return !hasFlag(realization) + } +} + +func sumByFilter[T any](items []T, extractor func(*T) float64, filter func(*T) bool) float64 { + amount := 0.0 + for i := range items { + if filter(&items[i]) { + amount += extractor(&items[i]) + } + } + return amount +} + +func sumPurchasesByFilter(purchases []entities.PurchaseItem, filter func(*entities.PurchaseItem) bool) float64 { + return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, filter) +} + +func sumPurchasesByFlag(purchases []entities.PurchaseItem, flagType utils.FlagType) float64 { + return sumPurchasesByFilter(purchases, filterByPurchaseFlag(flagType)) +} + +func sumPurchaseTotal(purchases []entities.PurchaseItem) float64 { + return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, func(*entities.PurchaseItem) bool { return true }) +} + +func sumBudgetsByFilter(budgets []entities.ProjectBudget, filter func(*entities.ProjectBudget) bool) float64 { + return sumByFilter(budgets, func(b *entities.ProjectBudget) float64 { return b.Price * b.Qty }, filter) +} + +func sumRealizationsByFilter(realizations []entities.ExpenseRealization, filter func(*entities.ExpenseRealization) bool) float64 { + return sumByFilter(realizations, func(r *entities.ExpenseRealization) float64 { return r.Price * r.Qty }, filter) +} + +func getOperationalExpenses(realizations []entities.ExpenseRealization) float64 { + return sumRealizationsByFilter(realizations, filterRealizationExceptFlag(utils.FlagEkspedisi)) +} + +func isChickenProductFlag(flagType utils.FlagType) bool { + switch flagType { + case utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, + utils.FlagAyamAfkir, utils.FlagAyamCulling, utils.FlagAyamMati: + return true + } + return false +} + +func isEggProductFlag(flagType utils.FlagType) bool { + switch flagType { + case utils.FlagTelur, utils.FlagTelurUtuh, utils.FlagTelurPecah, + utils.FlagTelurPutih, utils.FlagTelurRetak: + return true + } + return false +} + +func getSalesTypeFromProductFlags(product *entities.Product) string { + if product == nil || len(product.Flags) == 0 { + return PLSalesTypeChicken + } + + for _, flag := range product.Flags { + flagType := utils.FlagType(strings.ToUpper(flag.Name)) + + if isEggProductFlag(flagType) { + return PLSalesTypeEgg + } + if isChickenProductFlag(flagType) { + return PLSalesTypeChicken + } + } + + return PLSalesTypeChicken +} + +func categorizeDeliveriesBySalesType(deliveries []entities.MarketingDeliveryProduct) map[string][]entities.MarketingDeliveryProduct { + categorized := make(map[string][]entities.MarketingDeliveryProduct) + + for _, delivery := range deliveries { + product := delivery.MarketingProduct.ProductWarehouse.Product + salesType := getSalesTypeFromProductFlags(&product) + + categorized[salesType] = append(categorized[salesType], delivery) + } + + return categorized +} + +func sumDeliveriesByCategory(deliveries []entities.MarketingDeliveryProduct) float64 { + amount := 0.0 + for _, delivery := range deliveries { + amount += delivery.TotalPrice + } + return amount +} diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go new file mode 100644 index 00000000..4c7b4d35 --- /dev/null +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -0,0 +1,119 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + deliveryOrdersDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" +) + +// === Response DTO === +type SalesDTO struct { + Id uint `json:"id"` + RealizationDate time.Time `json:"realization_date"` + Age int `json:"age"` + DoNumber string `json:"do_number"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AvgWeight float64 `json:"avg_weight"` + Price float64 `json:"price"` + TotalPrice float64 `json:"total_price"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + PaymentStatus string `json:"payment_status"` +} + +type PenjualanRealisasiResponseDTO struct { + Sales []SalesDTO `json:"sales"` +} + +// === Mapper Functions === + +func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { + + age := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate) + + var product *productDTO.ProductRelationDTO + if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { + mapped := productDTO.ToProductRelationDTO(e.MarketingProduct.ProductWarehouse.Product) + product = &mapped + } + + var customer *customerDTO.CustomerRelationDTO + if e.MarketingProduct.Marketing.Customer.Id != 0 { + mapped := customerDTO.ToCustomerRelationDTO(e.MarketingProduct.Marketing.Customer) + customer = &mapped + } + + var kandang *kandangDTO.KandangRelationDTO + if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil && e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang) + kandang = &mapped + } + + doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) + + return SalesDTO{ + Id: e.Id, + RealizationDate: *e.DeliveryDate, + Age: age, + DoNumber: doNumber, + Product: product, + Customer: customer, + Qty: e.UsageQty, // Show allocated quantity from FIFO + Weight: e.TotalWeight, + AvgWeight: e.AvgWeight, + Price: e.UnitPrice, + TotalPrice: e.TotalPrice, + Kandang: kandang, + PaymentStatus: "Paid", + } +} + +func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO { + result := make([]SalesDTO, len(e)) + for i, r := range e { + result[i] = ToSalesDTO(r) + } + return result +} + +func ToPenjualanRealisasiResponseDTO(projectType string, projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { + + return PenjualanRealisasiResponseDTO{ + + Sales: ToSalesDTOs(e), + } +} + +func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int { + if len(realisasi) > 0 { + for _, item := range realisasi { + if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { + return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period + } + } + } + return 0 +} + +func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int { + if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { + return 0 + } + + earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate + for _, chickin := range projectFlockKandang.Chickins { + if chickin.ChickInDate.Before(earliestChickinDate) { + earliestChickinDate = chickin.ChickInDate + } + } + + ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24) + ageInWeeks := ageInDays / 7 + return ageInWeeks +} diff --git a/internal/modules/closings/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go new file mode 100644 index 00000000..71975da1 --- /dev/null +++ b/internal/modules/closings/dto/closingOverhead.dto.go @@ -0,0 +1,176 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +// === DTO Structs === + +type OverheadDTO struct { + ItemName string `json:"item_name"` + UOMName string `json:"uom_name"` + BudgetQuantity float64 `json:"budget_quantity"` + BudgetUnitPrice float64 `json:"budget_unit_price"` + BudgetTotalAmount float64 `json:"budget_total_amount"` + ActualDate string `json:"actual_date"` + ActualQuantity float64 `json:"actual_quantity"` + ActualUnitPrice float64 `json:"actual_unit_price"` + ActualTotalAmount float64 `json:"actual_total_amount"` + CostPerBird float64 `json:"cost_per_bird"` +} + +type TotalDTO struct { + BudgetQuantity float64 `json:"budget_quantity"` + BudgetTotalAmount float64 `json:"budget_total_amount"` + ActualQuantity float64 `json:"actual_quantity"` + ActualTotalAmount float64 `json:"actual_total_amount"` + CostPerBird float64 `json:"cost_per_bird"` +} + +type OverheadListDTO struct { + Total TotalDTO `json:"total"` + Overheads []OverheadDTO `json:"overheads"` +} + +// === Mapper Functions === + +func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseRealization) OverheadDTO { + if budget == nil && realization == nil { + return OverheadDTO{} + } + + var itemName, itemUOM string + if budget != nil { + itemName, itemUOM = getItemInfo(budget.Nonstock) + } + + if itemName == "" && realization != nil && realization.ExpenseNonstock != nil { + itemName, itemUOM = getItemInfo(realization.ExpenseNonstock.Nonstock) + } + + dto := OverheadDTO{ + ItemName: itemName, + UOMName: itemUOM, + } + + if budget != nil { + dto.BudgetQuantity = budget.Qty + dto.BudgetUnitPrice = budget.Price + dto.BudgetTotalAmount = calculateTotal(budget.Qty, budget.Price) + } + + if realization != nil { + dto.ActualQuantity = realization.Qty + dto.ActualUnitPrice = realization.Price + dto.ActualTotalAmount = calculateTotal(realization.Qty, realization.Price) + dto.ActualDate = formatRealizationDate(realization) + } + + return dto +} + +func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64) OverheadListDTO { + overheadsByNonstockID := make(map[uint]*OverheadDTO) + latestDateByNonstockID := make(map[uint]string) + + for i := range budgets { + nonstockID := budgets[i].NonstockId + if overheadsByNonstockID[nonstockID] == nil { + overheadsByNonstockID[nonstockID] = &OverheadDTO{} + } + + itemName, itemUOM := getItemInfo(budgets[i].Nonstock) + overheadsByNonstockID[nonstockID].ItemName = itemName + overheadsByNonstockID[nonstockID].UOMName = itemUOM + overheadsByNonstockID[nonstockID].BudgetQuantity = budgets[i].Qty + overheadsByNonstockID[nonstockID].BudgetUnitPrice = budgets[i].Price + overheadsByNonstockID[nonstockID].BudgetTotalAmount = calculateTotal(budgets[i].Qty, budgets[i].Price) + } + + for i := range realizations { + if realizations[i].ExpenseNonstock == nil || realizations[i].ExpenseNonstock.NonstockId == nil { + continue + } + + nonstockID := uint(*realizations[i].ExpenseNonstock.NonstockId) + if overheadsByNonstockID[nonstockID] == nil { + overheadsByNonstockID[nonstockID] = &OverheadDTO{} + } + + overheadsByNonstockID[nonstockID].ActualQuantity += realizations[i].Qty + overheadsByNonstockID[nonstockID].ActualTotalAmount += calculateTotal(realizations[i].Qty, realizations[i].Price) + + if overheadsByNonstockID[nonstockID].ItemName == "" { + itemName, itemUOM := getItemInfo(realizations[i].ExpenseNonstock.Nonstock) + overheadsByNonstockID[nonstockID].ItemName = itemName + overheadsByNonstockID[nonstockID].UOMName = itemUOM + } + + realizationDateStr := formatRealizationDate(&realizations[i]) + if realizationDateStr != "" { + if latestDateByNonstockID[nonstockID] == "" || realizationDateStr > latestDateByNonstockID[nonstockID] { + latestDateByNonstockID[nonstockID] = realizationDateStr + } + } + } + + var totalBudgetQuantity, totalBudgetAmount, totalActualQuantity, totalActualAmount float64 + overheadItems := make([]OverheadDTO, 0, len(overheadsByNonstockID)) + + for nonstockID, overhead := range overheadsByNonstockID { + overhead.ActualDate = latestDateByNonstockID[nonstockID] + + overhead.CostPerBird = calculateCostPerBird(overhead.ActualTotalAmount, totalActualPopulation) + + if overhead.ActualQuantity > 0 { + overhead.ActualUnitPrice = overhead.ActualTotalAmount / overhead.ActualQuantity + } + + totalBudgetQuantity += overhead.BudgetQuantity + totalBudgetAmount += overhead.BudgetTotalAmount + totalActualQuantity += overhead.ActualQuantity + totalActualAmount += overhead.ActualTotalAmount + + overheadItems = append(overheadItems, *overhead) + } + + return OverheadListDTO{ + Total: TotalDTO{ + BudgetQuantity: totalBudgetQuantity, + BudgetTotalAmount: totalBudgetAmount, + ActualQuantity: totalActualQuantity, + ActualTotalAmount: totalActualAmount, + CostPerBird: calculateCostPerBird(totalActualAmount, totalActualPopulation), + }, + Overheads: overheadItems, + } +} + +// === Helper Functions === + +func getItemInfo(nonstock *entity.Nonstock) (string, string) { + if nonstock != nil && nonstock.Id != 0 { + return nonstock.Name, nonstock.Uom.Name + } + return "", "" +} + +func calculateTotal(qty, price float64) float64 { + return qty * price +} + +func calculateCostPerBird(totalPrice, totalActualPopulation float64) float64 { + if totalActualPopulation > 0 { + return totalPrice / totalActualPopulation + } + return 0 +} + +func formatRealizationDate(realization *entity.ExpenseRealization) string { + if realization != nil && realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Expense != nil { + if !realization.ExpenseNonstock.Expense.RealizationDate.IsZero() { + return realization.ExpenseNonstock.Expense.RealizationDate.Format("2006-01-02T15:04:05Z07:00") + } + } + return "" +} diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go new file mode 100644 index 00000000..768c727e --- /dev/null +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -0,0 +1,253 @@ +package dto + +import ( + "strings" + "time" +) + +type SapronakDetailDTO struct { + ProductID uint `json:"product_id"` + ProductName string `json:"product_name"` + Flag string `json:"flag"` + Tanggal *time.Time `json:"tanggal,omitempty"` + NoReferensi string `json:"no_referensi,omitempty"` + JenisTransaksi string `json:"jenis_transaksi,omitempty"` + QtyMasuk float64 `json:"qty_masuk"` + QtyKeluar float64 `json:"qty_keluar"` + Harga float64 `json:"harga"` + Nilai float64 `json:"nilai"` +} + +type SapronakGroupDTO struct { + Flag string `json:"flag"` + Items []SapronakDetailDTO `json:"items"` + TotalMasuk float64 `json:"total_masuk"` + TotalKeluar float64 `json:"total_keluar"` + SaldoAkhir float64 `json:"saldo_akhir"` + TotalNilai float64 `json:"total_nilai"` +} + +type SapronakItemDTO struct { + ProductID uint `json:"product_id"` + ProductName string `json:"product_name"` + Flag string `json:"flag"` + IncomingQty float64 `json:"incoming_qty"` + IncomingValue float64 `json:"incoming_value"` + UsageQty float64 `json:"usage_qty"` + UsageValue float64 `json:"usage_value"` + RemainingQty float64 `json:"remaining_qty"` + AveragePrice float64 `json:"average_price"` +} + +type SapronakReportDTO struct { + ProjectFlockKandangID uint `json:"project_flock_kandang_id"` + ProjectFlockID uint `json:"project_flock_id"` + ProjectName string `json:"project_name"` + KandangID uint `json:"kandang_id"` + KandangName string `json:"kandang_name"` + Period int `json:"period"` + Status string `json:"status"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` + TotalIncomingValue float64 `json:"total_incoming_value"` + TotalUsageValue float64 `json:"total_usage_value"` + Items []SapronakItemDTO `json:"items"` + Groups []SapronakGroupDTO `json:"groups,omitempty"` +} + +// Simplified view for project-level sapronak response +type SapronakCategoryRowDTO struct { + ID int `json:"id"` + Date string `json:"date"` + ReferenceNumber string `json:"reference_number"` + QtyIn float64 `json:"qty_in"` + QtyOut float64 `json:"qty_out"` + QtyUsed float64 `json:"qty_used"` + Description string `json:"description"` + ProductCategory string `json:"product_category"` + UnitPrice float64 `json:"unit_price"` + TotalAmount float64 `json:"total_amount"` + Notes string `json:"notes"` +} + +type SapronakCategoryTotalDTO struct { + Label string `json:"label"` + QtyIn float64 `json:"qty_in"` + QtyOut float64 `json:"qty_out"` + QtyUsed float64 `json:"qty_used"` + AvgUnitPrice float64 `json:"avg_unit_price"` + TotalAmount float64 `json:"total_amount"` +} + +type SapronakCategoryDTO struct { + Rows []SapronakCategoryRowDTO `json:"rows"` + Total SapronakCategoryTotalDTO `json:"total"` +} + +type SapronakProjectAggregatedDTO struct { + Doc *SapronakCategoryDTO `json:"doc,omitempty"` + Ovk *SapronakCategoryDTO `json:"ovk,omitempty"` + Pakan *SapronakCategoryDTO `json:"pakan,omitempty"` + Pullet *SapronakCategoryDTO `json:"pullet,omitempty"` +} + +type ClosingSapronakItemDTO struct { + Id uint64 `json:"id"` + Date string `json:"date"` + ReferenceNumber string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + ProductName string `json:"product_name"` + ProductCategory string `json:"product_category"` + ProductSubCategory string `json:"product_sub_category"` + SourceWarehouse string `json:"source_warehouse"` + DestinationWarehouse string `json:"destination_warehouse,omitempty"` + // Destination string `json:"destination,omitempty"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + FormattedQuantity string `json:"formatted_quantity"` + Notes string `json:"notes"` + SortDate time.Time `json:"-"` +} + +type ClosingSapronakDTO struct { + IncomingSapronak []ClosingSapronakItemDTO `json:"incoming_sapronak"` + OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"` +} + +// === Mapper Functions for Aggregated Sapronak Response === + +func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { + result := SapronakProjectAggregatedDTO{} + + if len(reports) == 0 { + return result + } + + rep := reports[0] + return ToSapronakProjectAggregatedFromReport(&rep, flag) +} + +func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { + result := SapronakProjectAggregatedDTO{} + + if report == nil { + report = &SapronakReportDTO{} + } + + normalizeFlag := func(raw string) string { + normalized := strings.ToUpper(strings.TrimSpace(raw)) + if normalized == "PULLET" { + return "DOC" + } + return normalized + } + filter := normalizeFlag(flag) + + byFlag := map[string]**SapronakCategoryDTO{} + if filter == "" || filter == "DOC" { + result.Doc = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} + byFlag["DOC"] = &result.Doc + } + if filter == "" || filter == "OVK" { + result.Ovk = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} + byFlag["OVK"] = &result.Ovk + } + if filter == "" || filter == "PAKAN" { + result.Pakan = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} + byFlag["PAKAN"] = &result.Pakan + } + + formatDate := func(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("02-Jan-2006") + } + + for _, group := range report.Groups { + flagKey := normalizeFlag(group.Flag) + ptr := byFlag[flagKey] + if ptr == nil || *ptr == nil { + continue + } + target := *ptr + + rowIndexByProduct := make(map[string]int) + + getOrCreateRow := func(productKey string, base SapronakCategoryRowDTO) *SapronakCategoryRowDTO { + if idx, ok := rowIndexByProduct[productKey]; ok { + return &target.Rows[idx] + } + target.Rows = append(target.Rows, base) + idx := len(target.Rows) - 1 + rowIndexByProduct[productKey] = idx + return &target.Rows[idx] + } + + for idx, item := range group.Items { + productKey := strings.ToUpper(flagKey + "|" + item.ProductName) + baseRow := SapronakCategoryRowDTO{ + ID: idx + 1, + Date: formatDate(item.Tanggal), + ReferenceNumber: item.NoReferensi, + Description: item.ProductName, + ProductCategory: item.ProductName, + UnitPrice: item.Harga, + Notes: "-", + } + + row := getOrCreateRow(productKey, baseRow) + + switch strings.ToLower(item.JenisTransaksi) { + case "pembelian", "adjustment masuk", "mutasi masuk": + row.QtyIn += item.QtyMasuk + row.TotalAmount += item.Nilai + case "pemakaian", "adjustment keluar": + row.QtyUsed += item.QtyKeluar + case "mutasi keluar": + row.QtyOut += item.QtyKeluar + default: + row.QtyIn += item.QtyMasuk + row.TotalAmount += item.Nilai + } + + if row.QtyIn > 0 { + row.UnitPrice = row.TotalAmount / row.QtyIn + } + } + + for i := range target.Rows { + target.Rows[i].ID = i + 1 + } + } + + buildTotals := func(cat *SapronakCategoryDTO, label string) { + if cat == nil { + return + } + var qtyIn, qtyOut, qtyUsed, total float64 + for _, r := range cat.Rows { + qtyIn += r.QtyIn + qtyOut += r.QtyOut + qtyUsed += r.QtyUsed + total += r.TotalAmount + } + avg := 0.0 + if qtyIn > 0 { + avg = total / qtyIn + } + cat.Total = SapronakCategoryTotalDTO{ + Label: label, + QtyIn: qtyIn, + QtyOut: qtyOut, + QtyUsed: qtyUsed, + AvgUnitPrice: avg, + TotalAmount: total, + } + } + + buildTotals(result.Doc, "TOTAL DOC") + buildTotals(result.Ovk, "TOTAL OVK") + buildTotals(result.Pakan, "TOTAL PAKAN") + return result +} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go new file mode 100644 index 00000000..c89e6125 --- /dev/null +++ b/internal/modules/closings/module.go @@ -0,0 +1,45 @@ +package closings + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" + sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" + rExpenseRealization "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ClosingModule struct{} + +func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + closingRepo := rClosing.NewClosingRepository(db) + userRepo := rUser.NewUserRepository(db) + projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) + projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) + projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db) + marketingRepo := rMarketings.NewMarketingRepository(db) + marketingDeliveryProductRepo := rMarketings.NewMarketingDeliveryProductRepository(db) + expenseRealizationRepo := rExpenseRealization.NewExpenseRealizationRepository(db) + chickinRepo := rChickin.NewChickinRepository(db) + recordingRepo := rRecording.NewRecordingRepository(db) + purchaseRepo := rPurchase.NewPurchaseRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + + closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, validate) + sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ClosingRoutes(router, userService, closingService, sapronakService) +} diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go new file mode 100644 index 00000000..4948ae5e --- /dev/null +++ b/internal/modules/closings/repositories/closing.repository.go @@ -0,0 +1,1031 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +type ClosingRepository interface { + repository.BaseRepository[entity.ProjectFlock] + GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) + SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) + SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) + SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error) + SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) + SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) + GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error) + GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) + FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) + FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) + FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) + FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) + GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) +} + +type ClosingRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectFlock] +} + +func NewClosingRepository(db *gorm.DB) ClosingRepository { + return &ClosingRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlock](db), + } +} + +type SapronakRow struct { + Id uint64 `gorm:"column:id"` + SortDate time.Time `gorm:"column:sort_date"` + DateText string `gorm:"column:date_text"` + ReferenceNumber string `gorm:"column:reference_number"` + TransactionType string `gorm:"column:transaction_type"` + ProductName string `gorm:"column:product_name"` + ProductCategory string `gorm:"column:product_category"` + ProductSubCategory string `gorm:"column:product_sub_category"` + SourceWarehouse string `gorm:"column:source_warehouse"` + DestinationWarehouse string `gorm:"column:destination_warehouse"` + Destination string `gorm:"column:destination"` + Quantity float64 `gorm:"column:quantity"` + Unit string `gorm:"column:unit"` + Notes string `gorm:"column:notes"` +} + +type ExpeditionHPPRow struct { + SupplierName string `gorm:"column:supplier_name"` + TotalAmount float64 `gorm:"column:total_amount"` +} + +type SapronakQueryParams struct { + Type string + WarehouseIDs []uint + ProjectFlockKandangIDs []uint + Limit int + Offset int +} + +func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { + db := r.DB().WithContext(ctx) + + var ( + unionParts []string + args []any + ) + + switch params.Type { + case validation.SapronakTypeIncoming: + if len(params.WarehouseIDs) == 0 { + return []SapronakRow{}, 0, nil + } + unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) + case validation.SapronakTypeOutgoing: + if len(params.WarehouseIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingTransfersSQL) + args = append(args, params.WarehouseIDs) + } + if len(params.ProjectFlockKandangIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) + args = append(args, params.ProjectFlockKandangIDs) + } + if len(unionParts) == 0 { + return []SapronakRow{}, 0, nil + } + default: + return nil, 0, fmt.Errorf("invalid sapronak type: %s", params.Type) + } + + unionSQL := strings.Join(unionParts, " UNION ALL ") + + var totalResults int64 + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined", unionSQL) + if err := db.Raw(countSQL, args...).Scan(&totalResults).Error; err != nil { + return nil, 0, err + } + + dataArgs := append(append([]any{}, args...), params.Limit, params.Offset) + dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL) + + var rows []SapronakRow + if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { + return nil, 0, err + } + + return rows, totalResults, nil +} + +func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) { + if len(projectFlockKandangIDs) == 0 { + return 0, 0, nil + } + + var purchaseAgg struct { + TotalIn float64 `gorm:"column:total_in"` + } + + err := r.DB().WithContext(ctx). + Table("purchase_items pi"). + Joins("JOIN flags f ON f.flagable_id = pi.product_id AND f.flagable_type = 'products'"). + Where("f.name = ?", "PAKAN"). + Where("pi.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Select("COALESCE(SUM(pi.total_qty), 0) AS total_in"). + Scan(&purchaseAgg).Error + if err != nil { + return 0, 0, err + } + + var usageAgg struct { + TotalUsed float64 `gorm:"column:total_used"` + } + + err = r.DB().WithContext(ctx). + Table("recording_stocks rs"). + Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products prod ON prod.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). + Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("f.name = ?", "PAKAN"). + Select("COALESCE(SUM(COALESCE(rs.usage_qty, 0) + COALESCE(rs.pending_qty, 0)), 0) AS total_used"). + Scan(&usageAgg).Error + if err != nil { + return 0, 0, err + } + + return purchaseAgg.TotalIn, usageAgg.TotalUsed, nil +} + +func (r *ClosingRepositoryImpl) SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { + if len(projectFlockKandangIDs) == 0 { + return 0, nil + } + + var agg struct { + Total float64 `gorm:"column:total_culling"` + } + + err := r.DB().WithContext(ctx). + Table("recording_depletions rd"). + Joins("JOIN product_warehouses pw ON pw.id = rd.product_warehouse_id"). + Joins("JOIN products prod ON prod.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). + Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("f.name = ?", utils.FlagAyamCulling). + Select("COALESCE(SUM(rd.qty), 0) AS total_culling"). + Scan(&agg).Error + if err != nil { + return 0, err + } + + return agg.Total, nil +} + +func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error) { + if len(projectFlockKandangIDs) == 0 { + return 0, 0, 0, nil + } + + var agg struct { + TotalWeight float64 `gorm:"column:total_weight"` + TotalQty float64 `gorm:"column:total_qty"` + TotalPrice float64 `gorm:"column:total_price"` + } + + err := r.DB().WithContext(ctx). + Table("marketing_products mp"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Select("COALESCE(SUM(mp.total_weight), 0) AS total_weight, COALESCE(SUM(mp.qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price"). + Scan(&agg).Error + if err != nil { + return 0, 0, 0, err + } + + return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil +} + +func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return 0, 0, 0, nil + } + + var agg struct { + TotalWeight float64 `gorm:"column:total_weight"` + TotalQty float64 `gorm:"column:total_qty"` + TotalPrice float64 `gorm:"column:total_price"` + } + + err := r.DB().WithContext(ctx). + Table("marketing_products mp"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN products prod ON prod.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). + Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("f.name IN ?", flagNames). + Select("COALESCE(SUM(mp.total_weight), 0) AS total_weight, COALESCE(SUM(mp.qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price"). + Scan(&agg).Error + if err != nil { + return 0, 0, 0, err + } + + return agg.TotalWeight, agg.TotalQty, agg.TotalPrice, nil +} + +func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) { + if len(projectFlockKandangIDs) == 0 || len(flagNames) == 0 { + return 0, nil + } + + var agg struct { + TotalQty float64 `gorm:"column:total_qty"` + } + + err := r.DB().WithContext(ctx). + Table("recording_eggs re"). + Joins("JOIN product_warehouses pw ON pw.id = re.product_warehouse_id"). + Joins("JOIN products prod ON prod.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). + Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). + Where("f.name IN ?", flagNames). + Select("COALESCE(SUM(re.qty), 0) AS total_qty"). + Scan(&agg).Error + if err != nil { + return 0, err + } + + return agg.TotalQty, nil +} + +func (r *ClosingRepositoryImpl) GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error) { + if fcrID == 0 { + return []entity.FcrStandard{}, nil + } + + var standards []entity.FcrStandard + if err := r.DB().WithContext(ctx). + Where("fcr_id = ?", fcrID). + Order("weight ASC"). + Find(&standards).Error; err != nil { + return nil, err + } + + return standards, nil +} + +func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) { + db := r.DB().WithContext(ctx) + + if projectFlockID == 0 { + return nil, fmt.Errorf("invalid project flock id") + } + + query := db. + Table("expense_realizations AS er"). + Joins("JOIN expense_nonstocks ens ON ens.id = er.expense_nonstock_id"). + Joins("JOIN expenses e ON e.id = ens.expense_id"). + Joins("JOIN project_flock_kandangs pfk ON pfk.id = ens.project_flock_kandang_id"). + Joins("JOIN nonstocks n ON n.id = ens.nonstock_id"). + Joins("JOIN flags f ON f.flagable_id = n.id AND f.flagable_type = ?", entity.FlagableTypeNonstock). + Joins("JOIN suppliers s ON s.id = e.supplier_id"). + Where("pfk.project_flock_id = ?", projectFlockID). + Where("e.category = ?", "BOP"). + Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))) + + if projectFlockKandangID != nil && *projectFlockKandangID != 0 { + query = query.Where("pfk.id = ?", *projectFlockKandangID) + } + + var rows []ExpeditionHPPRow + err := query. + Select( + "e.supplier_id AS supplier_id, " + + "s.name AS supplier_name, " + + "SUM(er.qty * er.price) AS total_amount", + ). + Group("e.supplier_id, s.name"). + Scan(&rows).Error + if err != nil { + return nil, err + } + + return rows, nil +} + +const ( + sapronakIncomingPurchasesSQL = ` +SELECT + CAST(pi.id AS BIGINT) AS id, + COALESCE(pi.received_date, '1970-01-01') AS sort_date, + COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text, + COALESCE(p.po_number, '') AS reference_number, + 'Purchase' AS transaction_type, + prod.name AS product_name, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + '-' AS source_warehouse, + w.name AS destination_warehouse, + '' AS destination, + pi.total_qty AS quantity, + u.name AS unit, + COALESCE(p.notes, '') AS notes +FROM purchase_items pi +JOIN purchases p ON p.id = pi.purchase_id +JOIN products prod ON prod.id = pi.product_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pi.warehouse_id +WHERE pi.warehouse_id IN ? +` + + sapronakIncomingTransfersSQL = ` +SELECT + CAST(st.id AS BIGINT) AS id, + st.transfer_date AS sort_date, + TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, + st.movement_number AS reference_number, + 'Internal Transfer In' AS transaction_type, + prod.name AS product_name, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + COALESCE(fw.name, '') AS source_warehouse, + COALESCE(tw.name, '') AS destination_warehouse, + '' AS destination, + std.usage_qty AS quantity, + u.name AS unit, + 'Stock Refill' AS notes +FROM stock_transfer_details std +JOIN stock_transfers st ON st.id = std.stock_transfer_id +LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id +LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id +JOIN products prod ON prod.id = std.product_id +JOIN uoms u ON u.id = prod.uom_id +WHERE st.to_warehouse_id IN ? +` + + sapronakOutgoingTransfersSQL = ` +SELECT + CAST(st.id AS BIGINT) AS id, + st.transfer_date AS sort_date, + TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, + st.movement_number AS reference_number, + 'Internal Transfer Out' AS transaction_type, + prod.name AS product_name, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + COALESCE(fw.name, '') AS source_warehouse, + '' AS destination_warehouse, + COALESCE(tw.name, '') AS destination, + std.usage_qty AS quantity, + u.name AS unit, + 'Transfer to other unit' AS notes +FROM stock_transfer_details std +JOIN stock_transfers st ON st.id = std.stock_transfer_id +LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id +LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id +JOIN products prod ON prod.id = std.product_id +JOIN uoms u ON u.id = prod.uom_id +WHERE st.from_warehouse_id IN ? +` + + sapronakOutgoingMarketingsSQL = ` +SELECT + CAST(mp.id AS BIGINT) AS id, + m.so_date AS sort_date, + TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text, + m.so_number AS reference_number, + 'Trading Sales' AS transaction_type, + prod.name AS product_name, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + w.name AS source_warehouse, + '' AS destination_warehouse, + 'RETAIL CUSTOMER' AS destination, + mp.qty AS quantity, + u.name AS unit, + m.notes AS notes +FROM marketing_products mp +JOIN marketings m ON m.id = mp.marketing_id +JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id +JOIN products prod ON prod.id = pw.product_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pw.warehouse_id +WHERE pw.project_flock_kandang_id IN ? +` +) + +type SapronakIncomingRow struct { + ProductID uint + ProductName string + Flag string + Qty float64 + Value float64 + DefaultPrice float64 +} + +type SapronakUsageRow struct { + ProductID uint + ProductName string + Flag string + Qty float64 + DefaultPrice float64 +} + +type SapronakDetailRow struct { + ProductID uint + ProductName string + Flag string + Date *time.Time + Reference string + QtyIn float64 + QtyOut float64 + Price float64 +} + +func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) } + +func applyJoins(db *gorm.DB, joins ...string) *gorm.DB { + for _, j := range joins { + if strings.TrimSpace(j) != "" { + db = db.Joins(j) + } + } + return db +} + +func sapronakFlags(flags ...utils.FlagType) []string { + out := make([]string, len(flags)) + for i, f := range flags { + out[i] = string(f) + } + return out +} + +var ( + sapronakFlagsAll = sapronakFlags(utils.FlagDOC, utils.FlagPakan, utils.FlagOVK, utils.FlagPullet) + sapronakFlagsUsage = sapronakFlags(utils.FlagPakan, utils.FlagOVK) + sapronakFlagsChickin = sapronakFlags(utils.FlagDOC, utils.FlagPullet) +) + +func groupSapronakDetails(rows []SapronakDetailRow) map[uint][]SapronakDetailRow { + m := make(map[uint][]SapronakDetailRow) + for _, row := range rows { + m[row.ProductID] = append(m[row.ProductID], row) + } + return m +} + +func scanAndGroupDetails(db *gorm.DB) (map[uint][]SapronakDetailRow, error) { + rows := make([]SapronakDetailRow, 0) + if err := db.Scan(&rows).Error; err != nil { + return nil, err + } + return groupSapronakDetails(rows), nil +} + +// ========================= +// Usage (summary + details) +// ========================= + +func (r *ClosingRepositoryImpl) usageQuery( + ctx context.Context, + table string, + pwJoinCond string, + joins []string, + where string, + args ...any, +) *gorm.DB { + db := r.withCtx(ctx).Table(table).Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(SUM(usage_qty), 0) AS qty, + COALESCE(p.product_price, 0) AS default_price + `) + db = applyJoins(db, joins...) + return db. + Joins("JOIN product_warehouses pw ON "+pwJoinCond). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where(where, args...) +} + +func (r *ClosingRepositoryImpl) fetchSapronakUsage( + ctx context.Context, + table string, + pwJoinCond string, + joins []string, + where string, + args ...any, +) ([]SapronakUsageRow, error) { + rows := make([]SapronakUsageRow, 0) + db := r.usageQuery(ctx, table, pwJoinCond, joins, where, args...) + if err := db.Group("pw.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func (r *ClosingRepositoryImpl) detailQuery( + ctx context.Context, + table string, + pwJoinCond string, + joins []string, + selectSQL string, + where string, + args ...any, +) *gorm.DB { + db := r.withCtx(ctx). + Table(table). + Joins("JOIN product_warehouses pw ON "+pwJoinCond). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct) + + db = applyJoins(db, joins...) + return db.Select(selectSQL).Where(where, args...) +} + +func (r *ClosingRepositoryImpl) fetchSapronakDetails( + ctx context.Context, + table string, + pwJoinCond string, + joins []string, + selectSQL string, + where string, + args ...any, +) (map[uint][]SapronakDetailRow, error) { + return scanAndGroupDetails(r.detailQuery(ctx, table, pwJoinCond, joins, selectSQL, where, args...)) +} + +func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { + if pfkID == 0 { + return nil, nil + } + return r.fetchSapronakUsage( + ctx, + "recording_stocks rs", + "pw.id = rs.product_warehouse_id", + []string{"JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"}, + "r.project_flock_kandangs_id = ? AND f.name IN ?", + pfkID, + sapronakFlagsUsage, + ) +} + +func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { + if pfkID == 0 { + return []SapronakUsageRow{}, nil + } + return r.fetchSapronakUsage( + ctx, + "project_chickins pc", + "pw.id = pc.product_warehouse_id", + nil, + "pc.project_flock_kandang_id = ? AND pc.usage_qty > 0 AND f.name IN ?", + pfkID, + sapronakFlagsChickin, + ) +} + +func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { + return r.fetchSapronakDetails( + ctx, + "recording_stocks rs", + "pw.id = rs.product_warehouse_id", + []string{"JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"}, // penting: supaya alias r valid + ` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + r.record_datetime AS date, + CAST(r.id AS TEXT) AS reference, + 0 AS qty_in, + COALESCE(rs.usage_qty,0) AS qty_out, + COALESCE(p.product_price,0) AS price + `, + "r.project_flock_kandangs_id = ? AND f.name IN ?", + pfkID, + sapronakFlagsUsage, + ) +} + +func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { + return r.fetchSapronakDetails( + ctx, + "project_chickins pc", + "pw.id = pc.product_warehouse_id", + nil, + ` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + pc.chick_in_date AS date, + CAST(pc.id AS TEXT) AS reference, + 0 AS qty_in, + COALESCE(pc.usage_qty,0) AS qty_out, + COALESCE(p.product_price,0) AS price + `, + "pc.project_flock_kandang_id = ? AND pc.usage_qty > 0 AND f.name IN ?", + pfkID, + sapronakFlagsChickin, + ) +} + +func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { + return r.withCtx(ctx). + Table("purchase_items AS pi"). + Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). + Joins("JOIN products p ON p.id = pi.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Where("pi.received_date IS NOT NULL") +} + +func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) { + rows := make([]SapronakIncomingRow, 0) + db := r.incomingPurchaseBase(ctx, kandangID).Select(` + pi.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(SUM(pi.total_qty), 0) AS qty, + COALESCE(SUM(pi.total_qty * pi.price), 0) AS value, + COALESCE(p.product_price, 0) AS default_price + `) + if err := db.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) { + return scanAndGroupDetails( + r.incomingPurchaseBase(ctx, kandangID).Select(` + pi.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + pi.received_date AS date, + COALESCE(po.po_number, '') AS reference, + COALESCE(pi.total_qty,0) AS qty_in, + 0 AS qty_out, + COALESCE(pi.price,0) AS price + `), + ) +} + +type stockLogSapronakRow struct { + ID uint `gorm:"column:id"` + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + Flag string `gorm:"column:flag"` + CreatedAt *time.Time `gorm:"column:created_at"` + Increase float64 `gorm:"column:increase"` + Decrease float64 `gorm:"column:decrease"` + Price float64 `gorm:"column:price"` + MovementNumber string `gorm:"column:movement_number"` +} + +func (r *ClosingRepositoryImpl) fetchStockLogs(ctx context.Context, kandangID uint, logType any, withMovement bool) ([]stockLogSapronakRow, error) { + rows := make([]stockLogSapronakRow, 0) + + movementSelect := "'' AS movement_number" + joins := []string{} + if withMovement { + movementSelect = "COALESCE(st.movement_number,'') AS movement_number" + joins = append(joins, "JOIN stock_transfers st ON st.id = sl.loggable_id") + } + + db := r.withCtx(ctx). + Table("stock_logs sl"). + Select(` + sl.id AS id, + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + sl.created_at AS created_at, + COALESCE(sl.increase,0) AS increase, + COALESCE(sl.decrease,0) AS decrease, + COALESCE(p.product_price,0) AS price, + `+movementSelect+` + `). + Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id") + + db = applyJoins(db, joins...) + + if err := db. + Where("sl.loggable_type = ?", logType). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) string) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow) { + in := make(map[uint][]SapronakDetailRow) + out := make(map[uint][]SapronakDetailRow) + + for _, row := range rows { + base := SapronakDetailRow{ + ProductID: row.ProductID, + ProductName: row.ProductName, + Flag: row.Flag, + Date: row.CreatedAt, + Reference: refFn(row), + Price: row.Price, + } + + if row.Increase > 0 { + d := base + d.QtyIn = row.Increase + in[row.ProductID] = append(in[row.ProductID], d) + } + if row.Decrease > 0 { + d := base + d.QtyOut = row.Decrease + out[row.ProductID] = append(out[row.ProductID], d) + } + } + + return in, out +} + +func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false) + if err != nil { + return nil, nil, err + } + in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { return fmt.Sprintf("ADJ-%d", row.ID) }) + return in, out, nil +} + +func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeTransfer), true) + if err != nil { + return nil, nil, err + } + in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { + if ref := strings.TrimSpace(row.MovementNumber); ref != "" { + return ref + } + return fmt.Sprintf("TRF-%d", row.ID) + }) + return in, out, nil +} + +type ActualUsageCostRow struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + FlagName string `gorm:"column:flag_name"` + TotalQty float64 `gorm:"column:total_qty"` + TotalPrice float64 `gorm:"column:total_price"` + AveragePrice float64 `gorm:"column:average_price"` +} + +func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) { + if projectFlockID == 0 { + return []ActualUsageCostRow{}, nil + } + + db := r.DB().WithContext(ctx) + + // Get all project flock kandang IDs for this project flock + var pfkIDs []uint + err := db.Table("project_flock_kandangs"). + Where("project_flock_id = ?", projectFlockID). + Pluck("id", &pfkIDs).Error + if err != nil { + return nil, err + } + + if len(pfkIDs) == 0 { + return []ActualUsageCostRow{}, nil + } + + var rows []ActualUsageCostRow + + // Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll) + purchaseStockableKey := "PURCHASE_ITEMS" + transferStockableKey := "STOCK_TRANSFER_DETAILS" + + recordingQuery := db. + Table("recordings AS r"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + COALESCE(f.name, tf.name) AS flag_name, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) + ELSE 0 + END + ), 0) AS total_qty, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END + ), 0) AS total_price, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) + ELSE 0 + END + ), 0) AS qty_divisor, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END + ), 0) / NULLIF(COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) + ELSE 0 + END + ), 0), 0) AS average_price`, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey). + Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", + "recording_stocks", entity.StockAllocationStatusActive). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). + Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey). + Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct). + Where("r.project_flock_kandangs_id IN ?", pfkIDs). + Where("r.deleted_at IS NULL"). + Group("pw.product_id, p.name, COALESCE(f.name, tf.name)") + + if err := recordingQuery.Scan(&rows).Error; err != nil { + return nil, err + } + + // Part 2: Get usage from project_chickins (DOC, Pullet) + chickinQuery := db. + Table("project_chickins AS pc"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag_name, + COALESCE(SUM(pc.usage_qty), 0) AS total_qty, + COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS total_price, + COALESCE(AVG(COALESCE(pi.price, 0)), 0) AS average_price + `). + Joins("JOIN product_warehouses AS pw ON pw.id = pc.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("pc.project_flock_kandang_id IN ?", pfkIDs). + Where("pc.usage_qty > 0"). + Group("pw.product_id, p.name, f.name") + + var chickinRows []ActualUsageCostRow + if err := chickinQuery.Scan(&chickinRows).Error; err != nil { + return nil, err + } + + // Merge results + rows = append(rows, chickinRows...) + + return rows, nil +} + +func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { + if len(productIDs) == 0 { + return []entity.Product{}, nil + } + + var products []entity.Product + err := r.DB().WithContext(ctx). + Preload("Flags"). + Where("id IN ?", productIDs). + Find(&products).Error + + if err != nil { + return nil, err + } + + return products, nil +} diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go new file mode 100644 index 00000000..79c83c22 --- /dev/null +++ b/internal/modules/closings/route.go @@ -0,0 +1,35 @@ +package closings + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/controllers" + closing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService) { + ctrl := controller.NewClosingController(s, sapronakSvc) + + route := v1.Group("/closings") + route.Use(m.Auth(u)) + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", m.RequirePermissions(m.P_ClosingGetAll), ctrl.GetAll) + route.Get("/:project_flock_id/penjualan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetPenjualan) + route.Get("/:projectFlockId", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSummary) + route.Get("/:project_flock_id/overhead", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetOverhead) + route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang) + route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByProject) + route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak) + route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPP) + route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang) + route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) + route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan) +} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go new file mode 100644 index 00000000..ddf52b49 --- /dev/null +++ b/internal/modules/closings/services/closing.service.go @@ -0,0 +1,842 @@ +package service + +import ( + "context" + "errors" + "math" + "strconv" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" + expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + marketingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + purchaseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ClosingService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) + GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) + GetPenjualan(ctx *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) + GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) + GetOverhead(ctx *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) + GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) + GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) + GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) + GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) +} + +type closingService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ClosingRepository + ProjectFlockRepo projectflockRepository.ProjectflockRepository + MarketingRepo marketingRepository.MarketingRepository + MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository + ApprovalSvc commonSvc.ApprovalService + ExpenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository + ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository + ChickinRepo chickinRepository.ProjectChickinRepository + PurchaseRepo purchaseRepository.PurchaseRepository + RecordingRepo recordingRepository.RecordingRepository +} + +func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, purchaseRepo purchaseRepository.PurchaseRepository, recordingRepo recordingRepository.RecordingRepository, validate *validator.Validate) ClosingService { + return &closingService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ProjectFlockRepo: projectFlockRepo, + MarketingRepo: marketingRepo, + MarketingDeliveryProductRepo: marketingDeliveryProductRepo, + ApprovalSvc: approvalSvc, + ExpenseRealizationRepo: expenseRealizationRepo, + ProjectBudgetRepo: projectBudgetRepo, + ChickinRepo: chickinRepo, + PurchaseRepo: purchaseRepo, + RecordingRepo: recordingRepo, + } +} + +func (s closingService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s closingService) withClosingRelations(db *gorm.DB) *gorm.DB { + return s.withRelations(db). + Preload("Location"). + Preload("KandangHistory"). + Preload("KandangHistory.Chickins") +} + +func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withClosingRelations(db) + if params.Search != "" { + return db.Where("flock_name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get closings: %+v", err) + return nil, 0, err + } + + result := make([]dto.ClosingListItemDTO, 0, len(closings)) + for _, closing := range closings { + statusProject, _, err := s.getApprovalStatuses(c.Context(), closing.Id) + if err != nil { + s.Log.Errorf("Failed to retrieve approval statuses for project flock %d: %+v", closing.Id, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval status") + } + + result = append(result, dto.ToClosingListItemDTO(closing, statusProject)) + } + + return result, total, nil +} + +func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") + } + if err != nil { + return nil, err + } + return projectFlock, nil +} + +func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) { + + realisasi, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { + return db. + Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product"). + Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). + Preload("MarketingProduct.ProductWarehouse.Product.Uom"). + Preload("MarketingProduct.ProductWarehouse.Product.Flags"). + Preload("MarketingProduct.ProductWarehouse.Warehouse"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). + Preload("MarketingProduct.Marketing"). + Preload("MarketingProduct.Marketing.Customer"). + Order("marketing_delivery_products.delivery_date DESC") + }) + if err != nil { + return nil, err + } + if len(realisasi) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "Penjualan realisasi not found") + } + return realisasi, nil +} + +func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) { + if projectFlockID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withClosingRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found") + } + if err != nil { + s.Log.Errorf("Failed get project flock %d for closing summary: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + statusProject, statusClosing, err := s.getApprovalStatuses(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to retrieve approval statuses for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval status") + } + + summary := dto.ToClosingSummaryDTO(*project, statusProject, statusClosing) + + return &summary, nil +} + +func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) { + if projectFlockID == 0 { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + if params == nil { + params = &validation.ClosingSapronakQuery{} + } + + if params.Page == 0 { + params.Page = 1 + } + if params.Limit == 0 { + params.Limit = 10 + } + + if err := s.Validate.Struct(params); err != nil { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if params.Type != validation.SapronakTypeIncoming && params.Type != validation.SapronakTypeOutgoing { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + s.Log.Errorf("Failed get project flock %d for sapronak closing: %+v", projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") + } + + var projectFlockKandangIDs []uint + if params.Type == validation.SapronakTypeOutgoing { + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } + } + + offset := (params.Page - 1) * params.Limit + rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{ + Type: params.Type, + WarehouseIDs: warehouseIDs, + ProjectFlockKandangIDs: projectFlockKandangIDs, + Limit: params.Limit, + Offset: offset, + }) + if err != nil { + s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sapronak data") + } + + items := make([]dto.ClosingSapronakItemDTO, 0, len(rows)) + for _, row := range rows { + dateStr := row.DateText + if dateStr == "" && !row.SortDate.IsZero() { + dateStr = row.SortDate.Format("02-Jan-2006") + } + items = append(items, dto.ClosingSapronakItemDTO{ + Id: row.Id, + Date: dateStr, + ReferenceNumber: row.ReferenceNumber, + TransactionType: row.TransactionType, + ProductName: row.ProductName, + ProductCategory: row.ProductCategory, + ProductSubCategory: row.ProductSubCategory, + SourceWarehouse: row.SourceWarehouse, + DestinationWarehouse: row.DestinationWarehouse, + // Destination: row.Destination, + Quantity: row.Quantity, + Unit: row.Unit, + FormattedQuantity: formatQuantity(row.Quantity, row.Unit), + Notes: row.Notes, + SortDate: row.SortDate, + }) + } + + return items, totalResults, nil +} + +func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { + var kandangIDs []uint + db := s.Repository.DB().WithContext(ctx) + + if err := db.Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ?", projectFlockID). + Pluck("kandang_id", &kandangIDs).Error; err != nil { + return nil, err + } + + if len(kandangIDs) == 0 { + return []uint{}, nil + } + + var warehouses []entity.Warehouse + if err := db.Where("kandang_id IN ?", kandangIDs).Find(&warehouses).Error; err != nil { + return nil, err + } + + unique := make(map[uint]struct{}) + for _, warehouse := range warehouses { + unique[warehouse.Id] = struct{}{} + } + + ids := make([]uint, 0, len(unique)) + for id := range unique { + ids = append(ids, id) + } + + return ids, nil +} + +func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint) ([]uint, error) { + var ids []uint + err := s.Repository.DB().WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ?", projectFlockID). + Pluck("id", &ids).Error + if err != nil { + return nil, err + } + + return ids, nil +} + +func formatQuantity(qty float64, uom string) string { + qtyStr := strconv.FormatFloat(qty, 'f', -1, 64) + if uom == "" { + return qtyStr + } + return qtyStr + " " + uom +} + +func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID uint) (string, string, error) { + if s.ApprovalSvc == nil { + return "", "Belum Selesai", nil + } + + records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "") + if err != nil { + return "", "", err + } + + var ( + minStep uint16 + statusProject string + completed int + latestActionAt time.Time + ) + + for _, rec := range records { + if minStep == 0 || rec.StepNumber < minStep { + minStep = rec.StepNumber + } + + if latestActionAt.IsZero() || rec.ActionAt.After(latestActionAt) { + latestActionAt = rec.ActionAt + statusProject = rec.StepName + } + } + + if statusProject == "" && minStep > 0 { + if label, ok := approvalutils.ApprovalStepName(utils.ApprovalWorkflowProjectFlock, approvalutils.ApprovalStep(minStep)); ok { + statusProject = label + } + } + + statusClosing := "Belum Selesai" + switch { + case len(records) == 0 || completed == 0: + statusClosing = "Belum Selesai" + case completed < len(records): + statusClosing = "Sebagian" + default: + statusClosing = "Selesai" + } + + return statusProject, statusClosing, nil +} + +func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) { + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + + realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + + chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + + var totalChickinQty float64 + for _, chickin := range chickins { + totalChickinQty += chickin.UsageQty + } + + totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) + } + + totalActualPopulation := totalChickinQty - totalDepletion + + result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation) + + return &result, nil +} + +func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) { + if projectFlockID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: func(ctx context.Context, id uint) (bool, error) { + _, err := s.ProjectFlockRepo.GetByID(ctx, id, nil) + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return err == nil, err + }}, + ); err != nil { + return nil, err + } + + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") + } + + // Get actual usage cost instead of purchase items + actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost") + } + + // Convert actual usage rows to pseudo purchase items + purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows) + + realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") + } + + deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { + return db.Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product") + }) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products") + } + + chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") + } + + totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) + } + + totalEggWeightKg, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalEggProductionWeightByProjectFlockID error: %v", err) + } + + totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) + } + + input := dto.ClosingKeuanganInput{ + ProjectFlockCategory: projectFlock.Category, + PurchaseItems: purchaseItems, + Budgets: budgets, + Realizations: realizations, + DeliveryProducts: deliveryProducts, + Chickins: chickins, + TotalWeightProduced: totalWeightProduced, + TotalEggWeightKg: totalEggWeightKg, + TotalDepletion: totalDepletion, + } + + report := dto.ToClosingKeuanganReport(input) + + return &report, nil +} + +func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { + if projectFlockID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + rows, err := s.Repository.GetExpeditionHPP(c.Context(), projectFlockID, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to get expedition HPP for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch expedition HPP") + } + + expeditionCosts := make([]dto.ExpeditionCostItemDTO, 0, len(rows)) + var totalHPP float64 + + for idx, row := range rows { + expeditionCosts = append(expeditionCosts, dto.ExpeditionCostItemDTO{ + Id: uint64(idx + 1), + ExpeditionVendorName: row.SupplierName, + HPPAmount: row.TotalAmount, + }) + + totalHPP += row.TotalAmount + } + + result := &dto.ExpeditionHPPDTO{ + ExpeditionCosts: expeditionCosts, + TotalHPPAmount: totalHPP, + } + + return result, nil +} + +func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) { + if projectFlockID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withClosingRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found") + } + if err != nil { + s.Log.Errorf("Failed get project flock %d for closing data produksi: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + var population float64 + for _, history := range project.KandangHistory { + for _, chickin := range history.Chickins { + population += chickin.UsageQty + } + } + + isGrowing := strings.EqualFold(project.Category, string(utils.ProjectFlockCategoryGrowing)) + + projectFlockKandangIDs, err := s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs") + } + + feedIn, feedUsed, err := s.Repository.SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c.Context(), projectFlockKandangIDs) + if err != nil { + s.Log.Errorf("Failed to sum feed purchase/used qty for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch feed purchase data") + } + + claimCulling, err := s.Repository.SumClaimCullingByProjectFlockKandangIDs(c.Context(), projectFlockKandangIDs) + if err != nil { + s.Log.Errorf("Failed to sum claim culling for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch claim culling data") + } + + finalPopulation := population - claimCulling + + var standards []entity.FcrStandard + if project.FcrId > 0 { + standards, err = s.Repository.GetFcrStandardsByFcrID(c.Context(), project.FcrId) + if err != nil { + s.Log.Errorf("Failed to fetch FCR standards for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data") + } + } + age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data") + } + + feedUsedPerHead := 0.0 + if population > 0 { + feedUsedPerHead = feedUsed / population + } + + purchase := dto.ClosingPurchaseDTO{ + InitialPopulation: int(population), + ClaimCulling: int(claimCulling), + FinalPopulation: int(finalPopulation), + FeedIn: feedIn, + FeedUsed: feedUsed, + FeedUsedPerHead: feedUsedPerHead, + } + + chickenFlagNames := []string{string(utils.FlagPullet)} + chickenSalesWeight, chickenSalesQty, chickenSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, chickenFlagNames) + if err != nil { + s.Log.Errorf("Failed to fetch chicken sales data for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chicken sales data") + } + + var chickenAverageWeight float64 + if chickenSalesQty > 0 { + chickenAverageWeight = chickenSalesWeight / chickenSalesQty + } + + var chickenAverageSellingPrice float64 + if chickenSalesWeight > 0 { + chickenAverageSellingPrice = chickenSalesPrice / chickenSalesWeight + } + + chickenSales := dto.ClosingSalesDTO{ + SalesPopulation: int(chickenSalesQty), + SalesWeight: chickenSalesWeight, + AverageWeight: chickenAverageWeight, + AverageSellingPrice: chickenAverageSellingPrice, + } + + chickenDepletion := population - chickenSalesQty + if chickenDepletion < 0 { + chickenDepletion = 0 + } + + chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age, standards) + + var eggSales *dto.ClosingEggSalesDTO + var eggPerformance *dto.ClosingPerformanceDTO + if !isGrowing { + eggFlagNames := []string{ + string(utils.FlagTelur), + string(utils.FlagTelurUtuh), + string(utils.FlagTelurPecah), + string(utils.FlagTelurPutih), + string(utils.FlagTelurRetak), + } + + eggSalesWeight, eggSalesQty, eggSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, eggFlagNames) + if err != nil { + s.Log.Errorf("Failed to fetch egg sales data for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch egg sales data") + } + + var averageEggWeight float64 + if eggSalesQty > 0 { + averageEggWeight = eggSalesWeight / eggSalesQty + } + + var averageEggSellingPrice float64 + if eggSalesWeight > 0 { + averageEggSellingPrice = eggSalesPrice / eggSalesWeight + } + + eggSales = &dto.ClosingEggSalesDTO{ + EggPieces: int(eggSalesQty), + EggMassKg: eggSalesWeight, + AverageEggWeightKg: averageEggWeight, + AverageSellingPrice: averageEggSellingPrice, + } + + harvestEggQty, err := s.Repository.SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, eggFlagNames) + if err != nil { + s.Log.Errorf("Failed to fetch recording egg qty for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch egg harvest data") + } + + eggDepletion := harvestEggQty - eggSalesQty + if eggDepletion < 0 { + eggDepletion = 0 + } + + eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age, standards) + eggPerformance = &eggPerf + } + + sales := dto.ClosingSalesGroupDTO{ + Chicken: chickenSales, + Egg: eggSales, + } + + performance := dto.ClosingPerformanceDTO{ + Depletion: chickenPerformance.Depletion, + Age: age, + MortalityStd: chickenPerformance.MortalityStd, + MortalityAct: chickenPerformance.MortalityAct, + DeffMortality: chickenPerformance.DeffMortality, + } + if eggPerformance != nil { + performance.FcrStd = eggPerformance.FcrStd + performance.FcrAct = eggPerformance.FcrAct + performance.DeffFcr = eggPerformance.DeffFcr + performance.Awg = eggPerformance.Awg + } else { + performance.FcrStd = chickenPerformance.FcrStd + performance.FcrAct = chickenPerformance.FcrAct + performance.DeffFcr = chickenPerformance.DeffFcr + performance.Awg = chickenPerformance.Awg + } + + result := dto.ClosingProductionReportDTO{ + Purchase: purchase, + Sales: sales, + Performance: performance, + } + + return &result, nil +} + +func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint) (float64, error) { + deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(ctx, projectFlockID, func(db *gorm.DB) *gorm.DB { + return db. + Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins") + }) + if err != nil { + return 0, err + } + + var ( + totalQty float64 + totalAgeWeeks float64 + ) + + for _, product := range deliveryProducts { + if product.UsageQty == 0 { + continue + } + projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang + ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate) + totalAgeWeeks += float64(ageWeeks) * product.UsageQty + totalQty += product.UsageQty + } + + if totalQty == 0 { + return 0, nil + } + + return totalAgeWeeks / totalQty, nil +} + +func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO { + mortalityStd, fcrStd := closestFcrValues(standards, averageWeight) + + fcrAct := 0.0 + if totalWeight > 0 { + fcrAct = feedUsed / totalWeight + } + + mortalityAct := 0.0 + if basePopulation > 0 { + mortalityAct = (depletion / basePopulation) * 100 + } + + deffMortality := mortalityAct - mortalityStd + deffFcr := fcrAct - fcrStd + + awg := 0.0 + if age > 0 { + awg = averageWeight / age + } + + return dto.ClosingPerformanceDTO{ + Depletion: depletion, + Age: age, + MortalityStd: mortalityStd, + MortalityAct: mortalityAct, + DeffMortality: deffMortality, + FcrStd: fcrStd, + FcrAct: fcrAct, + DeffFcr: deffFcr, + Awg: awg, + } +} + +func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (float64, float64) { + if len(standards) == 0 || averageWeight <= 0 { + return 0, 0 + } + + closest := standards[0] + minDiff := math.Abs(closest.Weight - averageWeight) + for _, std := range standards[1:] { + diff := math.Abs(std.Weight - averageWeight) + if diff < minDiff { + minDiff = diff + closest = std + } + } + + return closest.Mortality, closest.FcrNumber +} + +func (s closingService) convertActualUsageToPurchaseItems(ctx context.Context, actualUsageRows []repository.ActualUsageCostRow) []entity.PurchaseItem { + if len(actualUsageRows) == 0 { + return []entity.PurchaseItem{} + } + + // Collect all product IDs + productIDs := make([]uint, len(actualUsageRows)) + for i, row := range actualUsageRows { + productIDs[i] = row.ProductID + } + + // Fetch products with flags from repository + products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, productIDs) + if err != nil { + s.Log.Warnf("Failed to fetch products for actual usage: %v", err) + products = []entity.Product{} + } + + // Create product map + productMap := make(map[uint]*entity.Product) + for i := range products { + productMap[products[i].Id] = &products[i] + } + + // Convert to pseudo purchase items + purchaseItems := make([]entity.PurchaseItem, 0, len(actualUsageRows)) + for _, row := range actualUsageRows { + product := productMap[row.ProductID] + + // Skip if product not found + if product == nil { + s.Log.Warnf("Product ID %d not found for actual usage", row.ProductID) + continue + } + + purchaseItem := entity.PurchaseItem{ + Id: 0, // Pseudo item, no ID + ProductId: row.ProductID, + TotalQty: row.TotalQty, + TotalPrice: row.TotalPrice, + Price: row.AveragePrice, + Product: product, + } + + purchaseItems = append(purchaseItems, purchaseItem) + } + + return purchaseItems +} diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go new file mode 100644 index 00000000..b923db5d --- /dev/null +++ b/internal/modules/closings/services/sapronak.service.go @@ -0,0 +1,685 @@ +package service + +import ( + "context" + "strings" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" + projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type SapronakService interface { + GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) + GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) +} + +type sapronakService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ClosingRepository + ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository +} + +func NewSapronakService( + repo repository.ClosingRepository, + pfkRepo projectflockRepository.ProjectFlockKandangRepository, + validate *validator.Validate, +) SapronakService { + return &sapronakService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ProjectFlockKandangRepo: pfkRepo, + } +} + +func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) { + if projectFlockID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") + } + reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ + ProjectFlockID: projectFlockID, + Status: "all", + Flag: flag, + }) + if err != nil { + return nil, err + } + if len(reports) <= 1 { + return reports, nil + } + + combined := s.combineSapronakReports(reports, projectFlockID) + return []dto.SapronakReportDTO{combined}, nil +} + +func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) { + if projectFlockID == 0 || pfkID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") + } + + results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ + ProjectFlockID: projectFlockID, + ProjectFlockKandangID: pfkID, + Status: "all", + Flag: flag, + }) + if err != nil { + return nil, err + } + + for _, res := range results { + if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID { + return &res, nil + } + } + + return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") +} + +func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { + pfks, err := s.loadProjectFlockKandangs(ctx, params) + if err != nil { + return nil, err + } + if len(pfks) == 0 { + return []dto.SapronakReportDTO{}, nil + } + + filterStatus := strings.ToLower(strings.TrimSpace(params.Status)) + if filterStatus == "" { + filterStatus = "all" + } + + results := make([]dto.SapronakReportDTO, 0, len(pfks)) + for _, pfk := range pfks { + status := "closing" + if pfk.ClosedAt == nil { + status = "active" + } + + if (filterStatus == "active" && status != "active") || (filterStatus == "closing" && status != "closing") { + continue + } + + // We no longer filter by date for closing sapronak report; pass nil pointers. + items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, nil, nil, params.Flag) + if err != nil { + s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report") + } + + results = append(results, dto.SapronakReportDTO{ + ProjectFlockKandangID: pfk.Id, + ProjectFlockID: pfk.ProjectFlockId, + ProjectName: pfk.ProjectFlock.FlockName, + KandangID: pfk.KandangId, + KandangName: pfk.Kandang.Name, + Period: pfk.Period, + Status: status, + StartDate: nil, + EndDate: nil, + TotalIncomingValue: totalIncoming, + TotalUsageValue: totalUsage, + Items: items, + Groups: groups, + }) + } + + return results, nil +} + +func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { + db := s.ProjectFlockKandangRepo.DB().WithContext(ctx). + Preload("ProjectFlock"). + Preload("Kandang"). + Preload("Chickins") + + if params != nil { + if params.ProjectFlockID > 0 { + db = db.Where("project_flock_kandangs.project_flock_id = ?", params.ProjectFlockID) + } + if params.KandangID > 0 { + db = db.Where("project_flock_kandangs.kandang_id = ?", params.KandangID) + } + if params.ProjectFlockKandangID > 0 { + db = db.Where("project_flock_kandangs.id = ?", params.ProjectFlockKandangID) + } + } + + var pfks []entity.ProjectFlockKandang + if err := db.Find(&pfks).Error; err != nil { + s.Log.Errorf("Failed to load project flock kandangs for sapronak report: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load project flock kandangs") + } + return pfks, nil +} + +func (s sapronakService) combineSapronakReports(reports []dto.SapronakReportDTO, projectID uint) dto.SapronakReportDTO { + if len(reports) == 0 { + return dto.SapronakReportDTO{} + } + + var ( + totalIncoming float64 + totalUsage float64 + projectName = reports[0].ProjectName + ) + + itemMap := make(map[uint]dto.SapronakItemDTO) + groupMap := make(map[string]*dto.SapronakGroupDTO) + + ensureGroup := func(flag string) *dto.SapronakGroupDTO { + if g, ok := groupMap[flag]; ok { + return g + } + groupMap[flag] = &dto.SapronakGroupDTO{Flag: flag} + return groupMap[flag] + } + + for _, r := range reports { + totalIncoming += r.TotalIncomingValue + totalUsage += r.TotalUsageValue + + for _, it := range r.Items { + cur := itemMap[it.ProductID] + if cur.ProductID == 0 { + cur.ProductID = it.ProductID + cur.ProductName = it.ProductName + cur.Flag = it.Flag + } + cur.IncomingQty += it.IncomingQty + cur.IncomingValue += it.IncomingValue + cur.UsageQty += it.UsageQty + cur.UsageValue += it.UsageValue + if cur.IncomingQty >= cur.UsageQty { + cur.RemainingQty = cur.IncomingQty - cur.UsageQty + } else { + cur.RemainingQty = 0 + } + if cur.IncomingQty > 0 { + cur.AveragePrice = cur.IncomingValue / cur.IncomingQty + } else { + cur.AveragePrice = it.AveragePrice + } + itemMap[it.ProductID] = cur + } + + for _, g := range r.Groups { + agg := ensureGroup(g.Flag) + agg.TotalMasuk += g.TotalMasuk + agg.TotalKeluar += g.TotalKeluar + agg.SaldoAkhir += g.SaldoAkhir + agg.TotalNilai += g.TotalNilai + agg.Items = append(agg.Items, g.Items...) + } + } + + items := make([]dto.SapronakItemDTO, 0, len(itemMap)) + for _, it := range itemMap { + items = append(items, it) + } + + groups := make([]dto.SapronakGroupDTO, 0, len(groupMap)) + for _, g := range groupMap { + groups = append(groups, *g) + } + + return dto.SapronakReportDTO{ + ProjectFlockID: projectID, + ProjectName: projectName, + Status: "combined", + StartDate: nil, + TotalIncomingValue: totalIncoming, + TotalUsageValue: totalUsage, + Items: items, + Groups: groups, + } +} + +func mapIncomingUsage(incomingRows []repository.SapronakIncomingRow, usageRows []repository.SapronakUsageRow) (map[uint]repository.SapronakIncomingRow, map[uint]repository.SapronakUsageRow) { + incoming := make(map[uint]repository.SapronakIncomingRow, len(incomingRows)) + for _, row := range incomingRows { + incoming[row.ProductID] = row + } + usage := make(map[uint]repository.SapronakUsageRow, len(usageRows)) + for _, row := range usageRows { + usage[row.ProductID] = row + } + return incoming, usage +} + +type sapronakDetailMaps struct { + Incoming map[uint][]dto.SapronakDetailDTO + Usage map[uint][]dto.SapronakDetailDTO + AdjIncoming map[uint][]dto.SapronakDetailDTO + AdjOutgoing map[uint][]dto.SapronakDetailDTO + TransferIn map[uint][]dto.SapronakDetailDTO + TransferOut map[uint][]dto.SapronakDetailDTO +} + +func buildSapronakDetails( + incomingRows map[uint][]repository.SapronakDetailRow, + usageRows map[uint][]repository.SapronakDetailRow, + adjIncomingRows map[uint][]repository.SapronakDetailRow, + adjOutgoingRows map[uint][]repository.SapronakDetailRow, + transferInRows map[uint][]repository.SapronakDetailRow, + transferOutRows map[uint][]repository.SapronakDetailRow, +) sapronakDetailMaps { + result := sapronakDetailMaps{ + Incoming: make(map[uint][]dto.SapronakDetailDTO), + Usage: make(map[uint][]dto.SapronakDetailDTO), + AdjIncoming: make(map[uint][]dto.SapronakDetailDTO), + AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO), + TransferIn: make(map[uint][]dto.SapronakDetailDTO), + TransferOut: make(map[uint][]dto.SapronakDetailDTO), + } + + addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) { + for pid, rows := range src { + for _, r := range rows { + d := dto.SapronakDetailDTO{ + ProductID: r.ProductID, + ProductName: r.ProductName, + Flag: r.Flag, + Tanggal: r.Date, + NoReferensi: r.Reference, + JenisTransaksi: jenis, + Harga: r.Price, + } + if masuk { + d.QtyMasuk = r.QtyIn + d.Nilai = r.QtyIn * r.Price + } else { + d.QtyKeluar = r.QtyOut + d.Nilai = r.QtyOut * r.Price + } + target[pid] = append(target[pid], d) + } + } + } + + addRows(result.Incoming, incomingRows, "Pembelian", true) + addRows(result.Usage, usageRows, "Pemakaian", false) + addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true) + addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false) + addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true) + addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false) + + return result +} + +func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { + // For sapronak closing report we intentionally ignore date range + // and aggregate all historical transactions for the kandang/project. + incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) + if err != nil { + return nil, nil, 0, 0, err + } + incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId) + if err != nil { + return nil, nil, 0, 0, err + } + usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id) + if err != nil { + return nil, nil, 0, 0, err + } + chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id) + if err != nil { + return nil, nil, 0, 0, err + } + usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id) + if err != nil { + return nil, nil, 0, 0, err + } + chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id) + if err != nil { + return nil, nil, 0, 0, err + } + adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId) + if err != nil { + return nil, nil, 0, 0, err + } + transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId) + if err != nil { + return nil, nil, 0, 0, err + } + + filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter)) + matchesFlag := func(f string) bool { + if filterFlag == "" { + return true + } + candidate := strings.ToUpper(f) + if filterFlag == "DOC" || filterFlag == "PULLET" { + return candidate == "DOC" || candidate == "PULLET" + } + return candidate == filterFlag + } + + // For project flocks with category GROWING, pullet usage from chickin + // should not be counted yet. Only when category is LAYING we allow + // pullet usage to contribute to qty_used. + isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying)) + + if !isLaying { + filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows)) + for _, row := range chickinUsageRows { + if strings.ToUpper(row.Flag) == "DOC" { + filteredUsage = append(filteredUsage, row) + } + } + chickinUsageRows = filteredUsage + + filteredDetail := make(map[uint][]repository.SapronakDetailRow, len(chickinUsageDetailsRows)) + for pid, rows := range chickinUsageDetailsRows { + for _, d := range rows { + if strings.ToUpper(d.Flag) == "DOC" { + filteredDetail[pid] = append(filteredDetail[pid], d) + } + } + } + chickinUsageDetailsRows = filteredDetail + } + + allUsageRows := append(usageRows, chickinUsageRows...) + incoming, usage := mapIncomingUsage(incomingRows, allUsageRows) + itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage)) + groupMap := make(map[string]*dto.SapronakGroupDTO) + + for pid, rows := range chickinUsageDetailsRows { + if len(rows) == 0 { + continue + } + usageDetailsRows[pid] = append(usageDetailsRows[pid], rows...) + } + + detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows) + incomingDetails := detailMaps.Incoming + usageDetails := detailMaps.Usage + adjIncoming := detailMaps.AdjIncoming + adjOutgoing := detailMaps.AdjOutgoing + transIncoming := detailMaps.TransferIn + transOutgoing := detailMaps.TransferOut + + ensureGroup := func(flag string) *dto.SapronakGroupDTO { + if g, ok := groupMap[flag]; ok { + return g + } + groupMap[flag] = &dto.SapronakGroupDTO{Flag: flag} + return groupMap[flag] + } + + for _, row := range incoming { + if !matchesFlag(row.Flag) { + continue + } + avgPrice := row.DefaultPrice + if row.Qty > 0 && row.Value > 0 { + avgPrice = row.Value / row.Qty + } + + itemMap[row.ProductID] = dto.SapronakItemDTO{ + ProductID: row.ProductID, + ProductName: row.ProductName, + Flag: row.Flag, + IncomingQty: row.Qty, + IncomingValue: row.Value, + RemainingQty: row.Qty, + AveragePrice: avgPrice, + } + } + + for _, row := range usage { + if !matchesFlag(row.Flag) { + continue + } + existing := itemMap[row.ProductID] + price := existing.AveragePrice + if price == 0 { + price = row.DefaultPrice + } + + usageValue := row.Qty * price + + existing.ProductID = row.ProductID + if existing.ProductName == "" { + existing.ProductName = row.ProductName + } + if existing.Flag == "" { + existing.Flag = row.Flag + } + existing.AveragePrice = price + existing.UsageQty += row.Qty + existing.UsageValue += usageValue + if existing.IncomingQty >= existing.UsageQty { + existing.RemainingQty = existing.IncomingQty - existing.UsageQty + } else { + existing.RemainingQty = 0 + } + + itemMap[row.ProductID] = existing + } + + for productID, details := range adjIncoming { + for _, d := range details { + if !matchesFlag(d.Flag) { + continue + } + existing := itemMap[productID] + if existing.Flag == "" { + existing.Flag = d.Flag + } + if existing.ProductName == "" { + existing.ProductName = d.ProductName + } + existing.IncomingQty += d.QtyMasuk + existing.IncomingValue += d.Nilai + if existing.IncomingQty > 0 { + existing.AveragePrice = existing.IncomingValue / existing.IncomingQty + } + if existing.IncomingQty >= existing.UsageQty { + existing.RemainingQty = existing.IncomingQty - existing.UsageQty + } else { + existing.RemainingQty = 0 + } + itemMap[productID] = existing + } + } + + for productID, details := range adjOutgoing { + for _, d := range details { + if !matchesFlag(d.Flag) { + continue + } + existing := itemMap[productID] + if existing.Flag == "" { + existing.Flag = d.Flag + } + if existing.ProductName == "" { + existing.ProductName = d.ProductName + } + existing.UsageQty += d.QtyKeluar + existing.UsageValue += d.Nilai + if existing.IncomingQty >= existing.UsageQty { + existing.RemainingQty = existing.IncomingQty - existing.UsageQty + } else { + existing.RemainingQty = 0 + } + itemMap[productID] = existing + } + } + + for productID, details := range transIncoming { + for _, d := range details { + if !matchesFlag(d.Flag) { + continue + } + existing := itemMap[productID] + if existing.Flag == "" { + existing.Flag = d.Flag + } + if existing.ProductName == "" { + existing.ProductName = d.ProductName + } + existing.IncomingQty += d.QtyMasuk + existing.IncomingValue += d.Nilai + if existing.IncomingQty > 0 { + existing.AveragePrice = existing.IncomingValue / existing.IncomingQty + } + if existing.IncomingQty >= existing.UsageQty { + existing.RemainingQty = existing.IncomingQty - existing.UsageQty + } else { + existing.RemainingQty = 0 + } + itemMap[productID] = existing + } + } + + items := make([]dto.SapronakItemDTO, 0, len(itemMap)) + var totalIncoming, totalUsage float64 + for _, item := range itemMap { + totalIncoming += item.IncomingValue + totalUsage += item.UsageValue + items = append(items, item) + } + + for productID, details := range incomingDetails { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + if !matchesFlag(flag) { + continue + } + group := ensureGroup(flag) + for _, d := range details { + d.Flag = flag + d.ProductName = name + group.Items = append(group.Items, d) + group.TotalMasuk += d.QtyMasuk + group.TotalNilai += d.Nilai + group.SaldoAkhir += d.QtyMasuk + } + } + + for productID, details := range adjIncoming { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + if !matchesFlag(flag) { + continue + } + group := ensureGroup(flag) + for _, d := range details { + d.Flag = flag + d.ProductName = name + group.Items = append(group.Items, d) + group.TotalMasuk += d.QtyMasuk + group.TotalNilai += d.Nilai + group.SaldoAkhir += d.QtyMasuk + } + } + + for productID, details := range usageDetails { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + if !matchesFlag(flag) { + continue + } + group := ensureGroup(flag) + for _, d := range details { + d.Flag = flag + d.ProductName = name + group.Items = append(group.Items, d) + group.TotalKeluar += d.QtyKeluar + group.SaldoAkhir -= d.QtyKeluar + } + } + + for productID, details := range adjOutgoing { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + if !matchesFlag(flag) { + continue + } + group := ensureGroup(flag) + for _, d := range details { + d.Flag = flag + d.ProductName = name + group.Items = append(group.Items, d) + group.TotalKeluar += d.QtyKeluar + group.SaldoAkhir -= d.QtyKeluar + } + } + + for productID, details := range transIncoming { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + if !matchesFlag(flag) { + continue + } + group := ensureGroup(flag) + for _, d := range details { + d.Flag = flag + d.ProductName = name + group.Items = append(group.Items, d) + group.TotalMasuk += d.QtyMasuk + group.TotalNilai += d.Nilai + group.SaldoAkhir += d.QtyMasuk + } + } + + for productID, details := range transOutgoing { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + if !matchesFlag(flag) { + continue + } + group := ensureGroup(flag) + for _, d := range details { + d.Flag = flag + d.ProductName = name + group.Items = append(group.Items, d) + group.TotalKeluar += d.QtyKeluar + group.SaldoAkhir -= d.QtyKeluar + } + } + + groups := make([]dto.SapronakGroupDTO, 0, len(groupMap)) + for _, g := range groupMap { + groups = append(groups, *g) + } + + return items, groups, totalIncoming, totalUsage, nil +} diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go new file mode 100644 index 00000000..610e89b8 --- /dev/null +++ b/internal/modules/closings/validations/closing.validation.go @@ -0,0 +1,26 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} + +const ( + SapronakTypeIncoming = "incoming" + SapronakTypeOutgoing = "outgoing" +) + +type ClosingSapronakQuery struct { + Type string `query:"type" validate:"required,oneof=incoming outgoing"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` +} diff --git a/internal/modules/closings/validations/sapronak.validation.go b/internal/modules/closings/validations/sapronak.validation.go new file mode 100644 index 00000000..78f64d08 --- /dev/null +++ b/internal/modules/closings/validations/sapronak.validation.go @@ -0,0 +1,9 @@ +package validation + +type CountSapronakQuery struct { + ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"` + KandangID uint `query:"kandang_id" validate:"omitempty,gt=0"` + ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` + Status string `query:"status" validate:"omitempty,oneof=active closing all"` + Flag string `query:"flag" validate:"omitempty,oneof=DOC OVK PAKAN PULLET doc ovk pakan pullet"` +} diff --git a/internal/modules/constants/route.go b/internal/modules/constants/route.go index 1da14371..46def610 100644 --- a/internal/modules/constants/route.go +++ b/internal/modules/constants/route.go @@ -12,6 +12,5 @@ func ConstantRoutes(v1 fiber.Router, s constant.ConstantService) { ctrl := controller.NewConstantController(s) route := v1.Group("/constants") - route.Get("/", ctrl.GetAll) } diff --git a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go new file mode 100644 index 00000000..b5a9b7b5 --- /dev/null +++ b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go @@ -0,0 +1,243 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type DailyChecklistController struct { + DailyChecklistService service.DailyChecklistService +} + +func NewDailyChecklistController(dailyChecklistService service.DailyChecklistService) *DailyChecklistController { + return &DailyChecklistController{ + DailyChecklistService: dailyChecklistService, + } +} + +func (u *DailyChecklistController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.DailyChecklistService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.DailyChecklistListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all dailyChecklists successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToDailyChecklistListDTOs(result), + }) +} + +func (u *DailyChecklistController) 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.DailyChecklistService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get dailyChecklist successfully", + Data: dto.ToDailyChecklistListDTO(*result), + }) +} + +func (u *DailyChecklistController) 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.DailyChecklistService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create dailyChecklist successfully", + Data: dto.ToDailyChecklistListDTO(*result), + }) +} + +func (u *DailyChecklistController) 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.DailyChecklistService.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 dailyChecklist successfully", + Data: dto.ToDailyChecklistListDTO(*result), + }) +} + +func (u *DailyChecklistController) 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.DailyChecklistService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete dailyChecklist successfully", + }) +} + +func (u *DailyChecklistController) CreateDailyChecklistPhase(c *fiber.Ctx) error { + param := c.Params("idDailyChecklist") + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id") + } + + req := new(validation.AssignPhases) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if err := u.DailyChecklistService.AssignPhases(c, uint(id), req); err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Daily checklist phases saved successfully", + }) +} + +func (u *DailyChecklistController) CreateAssignment(c *fiber.Ctx) error { + param := c.Params("idDailyChecklist") + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id") + } + + req := new(validation.AssignTask) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if err := u.DailyChecklistService.AssignTasks(c, uint(id), req); err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Daily checklist assignments saved successfully", + }) +} + +func (u *DailyChecklistController) RemoveAssignment(c *fiber.Ctx) error { + dailyChecklistParam := c.Params("idDailyChecklist") + employeeParam := c.Params("idEmployee") + + dailyChecklistID, err := strconv.Atoi(dailyChecklistParam) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid daily checklist id") + } + + employeeID, err := strconv.Atoi(employeeParam) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id") + } + + if err := u.DailyChecklistService.RemoveAssignment(c, uint(dailyChecklistID), uint(employeeID)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Assignment removed successfully", + }) +} + +func (u *DailyChecklistController) GetAllTasks(c *fiber.Ctx) error { + checklistParam := c.Query("checklist_id", "") + if checklistParam == "" { + return fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") + } + + checklistID, err := strconv.Atoi(checklistParam) + if err != nil || checklistID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid checklist_id") + } + + result, err := u.DailyChecklistService.GetTasks(c, uint(checklistID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get daily checklist tasks successfully", + Data: result, + }) +} diff --git a/internal/modules/daily-checklists/dto/daily-checklist.dto.go b/internal/modules/daily-checklists/dto/daily-checklist.dto.go new file mode 100644 index 00000000..31953def --- /dev/null +++ b/internal/modules/daily-checklists/dto/daily-checklist.dto.go @@ -0,0 +1,76 @@ +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 DailyChecklistRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type DailyChecklistListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DailyChecklistDetailDTO struct { + DailyChecklistListDTO +} + +// === Mapper Functions === + +func ToDailyChecklistRelationDTO(e entity.DailyChecklist) DailyChecklistRelationDTO { + var name string + if e.Name != nil { + name = *e.Name + } + + return DailyChecklistRelationDTO{ + Id: e.Id, + Name: name, + } +} + +func ToDailyChecklistListDTO(e entity.DailyChecklist) DailyChecklistListDTO { + var createdUser *userDTO.UserRelationDTO + // if e.CreatedUser.Id != 0 { + // mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + // createdUser = &mapped + // } + + var name string + if e.Name != nil { + name = *e.Name + } + + return DailyChecklistListDTO{ + Id: e.Id, + Name: name, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToDailyChecklistListDTOs(e []entity.DailyChecklist) []DailyChecklistListDTO { + result := make([]DailyChecklistListDTO, len(e)) + for i, r := range e { + result[i] = ToDailyChecklistListDTO(r) + } + return result +} + +func ToDailyChecklistDetailDTO(e entity.DailyChecklist) DailyChecklistDetailDTO { + return DailyChecklistDetailDTO{ + DailyChecklistListDTO: ToDailyChecklistListDTO(e), + } +} diff --git a/internal/modules/daily-checklists/module.go b/internal/modules/daily-checklists/module.go new file mode 100644 index 00000000..bc82d5f6 --- /dev/null +++ b/internal/modules/daily-checklists/module.go @@ -0,0 +1,27 @@ +package dailyChecklists + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rDailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" + sDailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" + rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type DailyChecklistModule struct{} + +func (DailyChecklistModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + dailyChecklistRepo := rDailyChecklist.NewDailyChecklistRepository(db) + phasesRepo := rPhases.NewPhasesRepository(db) + userRepo := rUser.NewUserRepository(db) + + dailyChecklistService := sDailyChecklist.NewDailyChecklistService(dailyChecklistRepo, phasesRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + DailyChecklistRoutes(router, userService, dailyChecklistService) +} diff --git a/internal/modules/daily-checklists/repositories/daily-checklist.repository.go b/internal/modules/daily-checklists/repositories/daily-checklist.repository.go new file mode 100644 index 00000000..e653ba3b --- /dev/null +++ b/internal/modules/daily-checklists/repositories/daily-checklist.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type DailyChecklistRepository interface { + repository.BaseRepository[entity.DailyChecklist] +} + +type DailyChecklistRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.DailyChecklist] +} + +func NewDailyChecklistRepository(db *gorm.DB) DailyChecklistRepository { + return &DailyChecklistRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.DailyChecklist](db), + } +} diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go new file mode 100644 index 00000000..c8542671 --- /dev/null +++ b/internal/modules/daily-checklists/route.go @@ -0,0 +1,35 @@ +package dailyChecklists + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/controllers" + dailyChecklist "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist.DailyChecklistService) { + ctrl := controller.NewDailyChecklistController(s) + + route := v1.Group("/daily-checklists") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + + // create task + route.Post("/phase/:idDailyChecklist", ctrl.CreateDailyChecklistPhase) + + // create assigment + route.Post("/assignment/:idDailyChecklist", ctrl.CreateAssignment) + // remove assignment + route.Delete("/:idDailyChecklist/assignments/:idEmployee", ctrl.RemoveAssignment) + + //get all tasks + route.Get("/tasks", ctrl.GetAllTasks) + + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go new file mode 100644 index 00000000..bf5320e6 --- /dev/null +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -0,0 +1,410 @@ +package service + +import ( + "errors" + "strconv" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" + phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + "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" + "gorm.io/gorm/clause" +) + +type DailyChecklistService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.DailyChecklist, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error + AssignTasks(ctx *fiber.Ctx, id uint, req *validation.AssignTask) error + RemoveAssignment(ctx *fiber.Ctx, id uint, employeeID uint) error + GetTasks(ctx *fiber.Ctx, checklistID uint) ([]entity.DailyChecklistActivityTask, error) +} + +type dailyChecklistService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.DailyChecklistRepository + PhaseRepo phaseRepo.PhasesRepository +} + +func NewDailyChecklistService(repo repository.DailyChecklistRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate) DailyChecklistService { + return &dailyChecklistService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + PhaseRepo: phaseRepo, + } +} + +func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB { + return db +} + +func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.DailyChecklist, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + dailyChecklists, 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 dailyChecklists: %+v", err) + return nil, 0, err + } + return dailyChecklists, total, nil +} + +func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) { + dailyChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + if err != nil { + s.Log.Errorf("Failed get dailyChecklist by id: %+v", err) + return nil, err + } + return dailyChecklist, nil +} + +func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + date, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") + } + + status := req.Status + category := req.Category + + createBody := &entity.DailyChecklist{ + KandangId: req.KandangId, + Date: date, + Category: category, + Status: &status, + } + + err = s.Repository.DB().WithContext(c.Context()).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "date"}, {Name: "kandang_id"}, {Name: "category"}}, + DoUpdates: clause.Assignments(map[string]any{"status": status, "updated_at": time.Now()}), + }).Create(createBody).Error + if err != nil { + s.Log.Errorf("Failed to upsert dailyChecklist: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + 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, "DailyChecklist not found") + } + s.Log.Errorf("Failed to update dailyChecklist: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s dailyChecklistService) 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, "DailyChecklist not found") + } + s.Log.Errorf("Failed to delete dailyChecklist: %+v", err) + return err + } + return nil +} + +func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validation.AssignPhases) error { + if err := s.Validate.Struct(req); err != nil { + return err + } + + if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return err + } + + phaseIDs, err := parsePhaseIDs(req.PhaseIDs) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if len(phaseIDs) > 0 { + phases, err := s.PhaseRepo.GetByIDs(c.Context(), phaseIDs, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, "Phase not found") + } + return err + } + if len(phases) != len(phaseIDs) { + return fiber.NewError(fiber.StatusBadRequest, "Phase not found") + } + } + + db := s.Repository.DB() + if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistPhase{}).Error; err != nil { + return err + } + + if len(phaseIDs) == 0 { + return nil + } + + records := make([]entity.DailyChecklistPhase, 0, len(phaseIDs)) + for _, pid := range phaseIDs { + records = append(records, entity.DailyChecklistPhase{ + ChecklistId: id, + PhaseId: pid, + }) + } + + if err := tx.Create(&records).Error; err != nil { + return err + } + + if err := tx.Where("checklist_id = ?", id).Delete(&entity.DailyChecklistActivityTask{}).Error; err != nil { + return err + } + + var activities []entity.PhaseActivity + if err := tx.Where("phase_id IN ?", phaseIDs).Find(&activities).Error; err != nil { + return err + } + + activityRecords := make([]entity.DailyChecklistActivityTask, 0, len(activities)) + for _, activity := range activities { + activityRecords = append(activityRecords, entity.DailyChecklistActivityTask{ + ChecklistId: id, + PhaseId: activity.PhaseId, + PhaseActivityId: activity.Id, + TimeType: activity.TimeType, + }) + } + + if len(activityRecords) == 0 { + return nil + } + + return tx.Create(&activityRecords).Error + }); err != nil { + s.Log.Errorf("Failed to assign phases to daily checklist: %+v", err) + return err + } + + return nil +} + +func (s dailyChecklistService) RemoveAssignment(c *fiber.Ctx, id uint, employeeID uint) error { + if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return err + } + + if employeeID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid employee id") + } + + db := s.Repository.DB() + if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + var tasks []entity.DailyChecklistActivityTask + if err := tx.Where("checklist_id = ?", id).Find(&tasks).Error; err != nil { + return err + } + + if len(tasks) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "No activity tasks found for this checklist") + } + + taskIDs := collectTaskIDs(tasks) + return tx.Where("task_id IN ? AND employee_id = ?", taskIDs, employeeID). + Delete(&entity.DailyChecklistActivityTaskAssignment{}).Error + }); err != nil { + s.Log.Errorf("Failed to remove assignment: %+v", err) + return err + } + + return nil +} + +func (s dailyChecklistService) GetTasks(c *fiber.Ctx, checklistID uint) ([]entity.DailyChecklistActivityTask, error) { + if checklistID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") + } + + if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return nil, err + } + + var tasks []entity.DailyChecklistActivityTask + if err := s.Repository.DB().WithContext(c.Context()). + Where("checklist_id = ?", checklistID). + Order("created_at ASC"). + Find(&tasks).Error; err != nil { + s.Log.Errorf("Failed to get daily checklist tasks: %+v", err) + return nil, err + } + + return tasks, nil +} + +func parsePhaseIDs(raw string) ([]uint, error) { + parts := strings.Split(raw, ",") + result := make([]uint, 0, len(parts)) + seen := make(map[uint]struct{}) + + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + + num, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return nil, errors.New("invalid phase id: " + value) + } + u := uint(num) + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + result = append(result, u) + } + + return result, nil +} + +func parseIDs(raw string) ([]uint, error) { + parts := strings.Split(raw, ",") + result := make([]uint, 0, len(parts)) + seen := make(map[uint]struct{}) + + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + + num, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return nil, errors.New("invalid employee id: " + value) + } + u := uint(num) + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + result = append(result, u) + } + + return result, nil +} + +func collectTaskIDs(tasks []entity.DailyChecklistActivityTask) []uint { + result := make([]uint, len(tasks)) + for i, task := range tasks { + result[i] = task.Id + } + return result +} +func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validation.AssignTask) error { + if err := s.Validate.Struct(req); err != nil { + return err + } + + employeeIDs, err := parseIDs(req.EmployeeIDs) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if len(employeeIDs) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "employee_ids cannot be empty") + } + + if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") + } + return err + } + + db := s.Repository.DB() + if err := db.WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + var tasks []entity.DailyChecklistActivityTask + if err := tx.Where("checklist_id = ?", id).Find(&tasks).Error; err != nil { + return err + } + + if len(tasks) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "No activity tasks found for this checklist") + } + + assignments := make([]entity.DailyChecklistActivityTaskAssignment, 0, len(tasks)*len(employeeIDs)) + for _, task := range tasks { + for _, empID := range employeeIDs { + assignments = append(assignments, entity.DailyChecklistActivityTaskAssignment{ + TaskId: task.Id, + EmployeeId: empID, + }) + } + } + + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "task_id"}, {Name: "employee_id"}}, + DoUpdates: clause.Assignments(map[string]any{"updated_at": time.Now()}), + }).Create(&assignments).Error + }); err != nil { + s.Log.Errorf("Failed to assign tasks to daily checklist: %+v", err) + return err + } + + return nil +} diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go new file mode 100644 index 00000000..ba81fd0d --- /dev/null +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -0,0 +1,26 @@ +package validation + +type Create struct { + Date string `json:"date" validate:"required"` + KandangId uint `json:"kandang_id" validate:"required"` + Category string `json:"category" validate:"required"` + Status string `json:"status" validate:"required"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} + +type AssignPhases struct { + PhaseIDs string `json:"phase_ids" validate:"required"` +} + +type AssignTask struct { + EmployeeIDs string `json:"employee_ids" validate:"required"` +} diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 08256b24..666642ca 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -90,36 +90,32 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { } req.SupplierID = supplierID + locationID, err := strconv.ParseUint(c.FormValue("location_id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format") + } + req.LocationID = locationID + form, err := c.MultipartForm() if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") } req.Documents = form.File["documents"] - costPerKandangJSON := c.FormValue("cost_per_kandangs") - if costPerKandangJSON != "" { + expenseNonstocksJSON := c.FormValue("expense_nonstocks") + if expenseNonstocksJSON != "" { - if err := json.Unmarshal([]byte(costPerKandangJSON), &req.CostPerKandangs); err != nil { + if err := json.Unmarshal([]byte(expenseNonstocksJSON), &req.ExpenseNonstocks); err != nil { - var singleCostPerKandang validation.CostPerKandang - if err := json.Unmarshal([]byte(costPerKandangJSON), &singleCostPerKandang); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid cost_per_kandangs JSON: %v", err)) + var singleExpenseNonstock validation.ExpenseNonstock + if err := json.Unmarshal([]byte(expenseNonstocksJSON), &singleExpenseNonstock); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) } - if singleCostPerKandang.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required") - } - - req.CostPerKandangs = []validation.CostPerKandang{singleCostPerKandang} - } else { - for i, costPerKandang := range req.CostPerKandangs { - if costPerKandang.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for cost_per_kandangs[%d]", i)) - } - } + req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock} } } else { - return fiber.NewError(fiber.StatusBadRequest, "Field cost_per_kandangs is required") + return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required") } result, err := u.ExpenseService.CreateOne(c, req) @@ -151,24 +147,43 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { } req.Documents = form.File["documents"] - if transactionDate := c.FormValue("transaction_date"); transactionDate != "" { + + transactionDate := c.FormValue("transaction_date") + if transactionDate != "" { req.TransactionDate = &transactionDate } - costPerKandangJSON := c.FormValue("cost_per_kandang") - if costPerKandangJSON != "" { - var costPerKandang []validation.CostPerKandang - if err := json.Unmarshal([]byte(costPerKandangJSON), &costPerKandang); err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid cost_per_kandang JSON: %v", err)) + categoryVal := c.FormValue("category") + if categoryVal != "" { + req.Category = &categoryVal + } + + supplierIDVal := c.FormValue("supplier_id") + if supplierIDVal != "" { + supplierID, err := strconv.ParseUint(supplierIDVal, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid supplier_id format") + } + req.SupplierID = &supplierID + } + + locationIDVal := c.FormValue("location_id") + if locationIDVal != "" { + locationID, err := strconv.ParseUint(locationIDVal, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format") + } + req.LocationID = &locationID + } + + expenseNonstocksJSON := c.FormValue("expense_nonstocks") + if expenseNonstocksJSON != "" { + var expenseNonstocks []validation.ExpenseNonstock + if err := json.Unmarshal([]byte(expenseNonstocksJSON), &expenseNonstocks); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) } - for i, costPerKandang := range costPerKandang { - if costPerKandang.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for cost_per_kandang[%d]", i)) - } - } - - req.CostPerKandang = &costPerKandang + req.ExpenseNonstocks = &expenseNonstocks } result, err := u.ExpenseService.UpdateOne(c, req, uint(id)) @@ -188,12 +203,12 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { func (u *ExpenseController) DeleteOne(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.Atoi(param) + id64, err := strconv.ParseUint(param, 10, 64) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } - if err := u.ExpenseService.DeleteOne(c, uint(id)); err != nil { + if err := u.ExpenseService.DeleteOne(c, id64); err != nil { return err } @@ -300,13 +315,18 @@ func (u *ExpenseController) UpdateRealization(c *fiber.Ctx) error { req.Documents = form.File["documents"] - req.RealizationDate = c.FormValue("realization_date") + realizationDate := c.FormValue("realization_date") + if realizationDate != "" { + req.RealizationDate = &realizationDate + } realizationsJSON := c.FormValue("realizations") if realizationsJSON != "" { - if err := json.Unmarshal([]byte(realizationsJSON), &req.Realizations); err != nil { + var realizations []validation.RealizationItem + if err := json.Unmarshal([]byte(realizationsJSON), &realizations); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid realizations JSON: %v", err)) } + req.Realizations = &realizations } expense, err := u.ExpenseService.UpdateRealization(c, uint(id), &req) diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index bee50c6d..129c2e96 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -1,7 +1,6 @@ package dto import ( - "encoding/json" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -15,10 +14,9 @@ import ( // === DTO Structs === type ExpenseRelationDTO struct { - Id uint64 `json:"id"` - PoNumber string `json:"po_number"` - ExpenseDate time.Time `json:"expense_date"` - GrandTotal float64 `json:"grand_total"` + Id uint64 `json:"id"` + PoNumber string `json:"po_number"` + TransactionDate time.Time `json:"transaction_date"` } type ExpenseBaseDTO struct { @@ -28,8 +26,7 @@ type ExpenseBaseDTO struct { Category string `json:"category"` Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"` RealizationDate *time.Time `json:"realization_date,omitempty"` - ExpenseDate time.Time `json:"expense_date"` - GrandTotal float64 `json:"grand_total"` + TransactionDate time.Time `json:"transaction_date"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` } @@ -43,8 +40,8 @@ type ExpenseListDTO struct { type ExpenseDetailDTO struct { ExpenseBaseDTO - Documents []DocumentDTO `json:"documents,omitempty"` - RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` + Documents []DocumentDTO `json:"documents"` + RealizationDocs []DocumentDTO `json:"realization_docs"` Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` TotalPengajuan float64 `json:"total_pengajuan"` TotalRealisasi float64 `json:"total_realisasi"` @@ -55,26 +52,30 @@ type ExpenseDetailDTO struct { } type ExpenseNonstockDTO struct { - Id uint64 `json:"id"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - TotalPrice float64 `json:"total_price"` - Note *string `json:"note,omitempty"` - Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + Id uint64 `json:"id"` + ExpenseId *uint64 `json:"expense_id,omitempty"` + ProjectFlockKandangId *uint64 `json:"project_flock_kandang_id,omitempty"` + KandangId *uint64 `json:"kandang_id,omitempty"` + NonstockId *uint64 `json:"nonstock_id,omitempty"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Notes string `json:"notes"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + CreatedAt time.Time `json:"created_at"` } type ExpenseRealizationDTO struct { - Id uint64 `json:"id"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - TotalPrice float64 `json:"total_price"` - Note *string `json:"note,omitempty"` - Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + Id uint64 `json:"id"` + ExpenseNonstockId *uint64 `json:"expense_nonstock_id,omitempty"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Notes string `json:"notes"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + CreatedAt time.Time `json:"created_at"` } type KandangGroupDTO struct { Id uint64 `json:"id"` - KandangId uint64 `json:"kandang_id"` Name string `json:"name,omitempty"` Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"` Realisasi []ExpenseRealizationDTO `json:"realisasi,omitempty"` @@ -89,10 +90,9 @@ type DocumentDTO struct { func ToExpenseRelationDTO(e entity.Expense) ExpenseRelationDTO { return ExpenseRelationDTO{ - Id: e.Id, - PoNumber: e.PoNumber, - ExpenseDate: e.ExpenseDate, - GrandTotal: e.GrandTotal, + Id: e.Id, + PoNumber: e.PoNumber, + TransactionDate: e.TransactionDate, } } @@ -105,11 +105,9 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { realizationDate = &e.RealizationDate } - if len(e.Nonstocks) > 0 && e.Nonstocks[0].Kandang != nil { - if e.Nonstocks[0].Kandang.Location.Id != 0 { - mapped := locationDTO.ToLocationRelationDTO(e.Nonstocks[0].Kandang.Location) - location = &mapped - } + if e.Location != nil && e.Location.Id != 0 { + mapped := locationDTO.ToLocationRelationDTO(*e.Location) + location = &mapped } if e.Supplier != nil && e.Supplier.Id != 0 { @@ -124,8 +122,7 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { Category: e.Category, Supplier: supplier, RealizationDate: realizationDate, - ExpenseDate: e.ExpenseDate, - GrandTotal: e.GrandTotal, + TransactionDate: e.TransactionDate, Location: location, } } @@ -178,12 +175,18 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var pengajuans []ExpenseNonstockDTO var realisasi []ExpenseRealizationDTO - if e.DocumentPath.Valid && e.DocumentPath.String != "" { - json.Unmarshal([]byte(e.DocumentPath.String), &documents) + for _, doc := range e.Documents { + documents = append(documents, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } - if e.RealizationDocumentPath.Valid && e.RealizationDocumentPath.String != "" { - json.Unmarshal([]byte(e.RealizationDocumentPath.String), &realizationDocs) + for _, doc := range e.RealizationDocuments { + realizationDocs = append(realizationDocs, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } if len(e.Nonstocks) > 0 { @@ -192,10 +195,9 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { for _, ns := range e.Nonstocks { pengajuanDTO := ToExpenseNonstockDTO(ns) - pengajuans = append(pengajuans, pengajuanDTO) - if ns.Realization != nil && ns.Realization.Id != 0 { + if ns.Realization != nil { var nonstock *nonstockDTO.NonstockRelationDTO if ns.Nonstock != nil && ns.Nonstock.Id != 0 { mapped := nonstockDTO.ToNonstockRelationDTO(*ns.Nonstock) @@ -203,12 +205,13 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { } realisasiDTO := ExpenseRealizationDTO{ - Id: ns.Realization.Id, - Qty: ns.Realization.RealizationQty, - UnitPrice: ns.Realization.RealizationUnitPrice, - TotalPrice: ns.Realization.RealizationTotalPrice, - Note: ns.Realization.Note, - Nonstock: nonstock, + Id: ns.Realization.Id, + ExpenseNonstockId: ns.Realization.ExpenseNonstockId, + Qty: ns.Realization.Qty, + Price: ns.Realization.Price, + Notes: ns.Realization.Notes, + Nonstock: nonstock, + CreatedAt: ns.Realization.CreatedAt, } realisasi = append(realisasi, realisasiDTO) } @@ -217,12 +220,12 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var totalPengajuan float64 for _, p := range pengajuans { - totalPengajuan += p.TotalPrice + totalPengajuan += p.Qty * p.Price } var totalRealisasi float64 for _, r := range realisasi { - totalRealisasi += r.TotalPrice + totalRealisasi += r.Qty * r.Price } kandangs := ToKandangGroupDTO(pengajuans, realisasi, e.Nonstocks) @@ -248,41 +251,52 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO { } return ExpenseNonstockDTO{ - Id: ns.Id, - Qty: ns.Qty, - UnitPrice: ns.UnitPrice, - TotalPrice: ns.TotalPrice, - Note: &ns.Note, - Nonstock: nonstock, + Id: ns.Id, + ExpenseId: ns.ExpenseId, + ProjectFlockKandangId: ns.ProjectFlockKandangId, + KandangId: ns.KandangId, + NonstockId: ns.NonstockId, + Qty: ns.Qty, + Price: ns.Price, + Notes: ns.Notes, + Nonstock: nonstock, + CreatedAt: ns.CreatedAt, } } func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO { kandangMap := make(map[uint64]*KandangGroupDTO) + var directPengajuans []ExpenseNonstockDTO + var directRealisasi []ExpenseRealizationDTO for _, p := range pengajuans { var kandangId uint64 var kandangName string - for _, ns := range nonstocks { - if ns.Id == p.Id && ns.Kandang != nil { - kandangId = uint64(ns.Kandang.Id) - kandangName = ns.Kandang.Name - break + if p.KandangId != nil { + kandangId = *p.KandangId + for _, ns := range nonstocks { + if ns.Id == p.Id && ns.Kandang != nil { + kandangName = ns.Kandang.Name + break + } } } if kandangId > 0 { + if kandangMap[kandangId] == nil { kandangMap[kandangId] = &KandangGroupDTO{ Id: kandangId, - KandangId: kandangId, Name: kandangName, Pengajuans: []ExpenseNonstockDTO{}, Realisasi: []ExpenseRealizationDTO{}, } } kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p) + } else { + + directPengajuans = append(directPengajuans, p) } } @@ -302,13 +316,24 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali if kandangMap[kandangId] == nil { kandangMap[kandangId] = &KandangGroupDTO{ Id: kandangId, - KandangId: kandangId, Name: kandangName, Pengajuans: []ExpenseNonstockDTO{}, Realisasi: []ExpenseRealizationDTO{}, } } kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) + } else { + } + } + + // If there are direct expenses (without kandang), add them as a special entry with id=0 + if len(directPengajuans) > 0 || len(directRealisasi) > 0 { + kandangMap[0] = &KandangGroupDTO{ + Id: 0, + + Name: "", + Pengajuans: directPengajuans, + Realisasi: directRealisasi, } } diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go index 2f71a349..b495b5b9 100644 --- a/internal/modules/expenses/module.go +++ b/internal/modules/expenses/module.go @@ -1,6 +1,7 @@ package expenses import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -11,7 +12,6 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" sExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -32,15 +32,20 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalRepo := commonRepo.NewApprovalRepository(db) realizationRepo := rExpense.NewExpenseRealizationRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } // Register workflow steps for EXPENSES approval if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) } - expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, validate) + expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate) userService := sUser.NewUserService(userRepo, validate) ExpenseRoutes(router, userService, expenseService) diff --git a/internal/modules/expenses/repositories/expense.repository.go b/internal/modules/expenses/repositories/expense.repository.go index 588583da..8796c761 100644 --- a/internal/modules/expenses/repositories/expense.repository.go +++ b/internal/modules/expenses/repositories/expense.repository.go @@ -2,17 +2,24 @@ package repository import ( "context" + "errors" + "fmt" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) type ExpenseRepository interface { repository.BaseRepository[entity.Expense] - IdExists(ctx context.Context, id uint64) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) GetNextSequence(ctx context.Context) (int, error) GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error) + WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB + CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) + DeleteOne(ctx context.Context, id uint) error } type ExpenseRepositoryImpl struct { @@ -25,8 +32,8 @@ func NewExpenseRepository(db *gorm.DB) ExpenseRepository { } } -func (r *ExpenseRepositoryImpl) IdExists(ctx context.Context, id uint64) (bool, error) { - return repository.Exists[entity.Expense](ctx, r.DB(), uint(id)) +func (r *ExpenseRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Expense](ctx, r.DB(), id) } func (r *ExpenseRepositoryImpl) GetNextSequence(ctx context.Context) (int, error) { @@ -49,3 +56,77 @@ func (r *ExpenseRepositoryImpl) GetWithSupplier(ctx context.Context, id uint64) } return &expense, nil } + +func (r *ExpenseRepositoryImpl) WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + if pfkID == 0 && kandangID == 0 { + return db + } + q := db.Joins("JOIN expense_nonstocks ON expense_nonstocks.expense_id = expenses.id") + if pfkID > 0 && kandangID > 0 { + return q.Where("expense_nonstocks.project_flock_kandang_id = ? OR expense_nonstocks.kandang_id = ?", pfkID, kandangID) + } + if pfkID > 0 { + return q.Where("expense_nonstocks.project_flock_kandang_id = ?", pfkID) + } + return q.Where("expense_nonstocks.kandang_id = ?", kandangID) + } +} + +func (r *ExpenseRepositoryImpl) CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) { + if pfkID == 0 && kandangID == 0 { + return 0, nil + } + + var ids []uint64 + if err := r.DB().WithContext(ctx). + Table("expenses"). + Scopes(r.WithProjectFlockKandangFilter(pfkID, kandangID)). + Group("expenses.id").Where("expenses.deleted_at IS NULL"). + Pluck("expenses.id", &ids).Error; err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + + var unfinished int64 + for _, id := range ids { + var latest entity.Approval + err := r.DB().WithContext(ctx). + Table("approvals"). + Where("approvable_type = ? AND approvable_id = ?", utils.ApprovalWorkflowExpense.String(), id). + Order("action_at DESC"). + Limit(1). + First(&latest).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, err + } + if isFinished != nil { + if !isFinished(&latest) { + unfinished++ + } + } + } + return unfinished, nil +} + +func (r *ExpenseRepositoryImpl) DeleteOne(ctx context.Context, id uint) error { + // Cast to uint64 to match entity.Id type + id64 := uint64(id) + deletedAt := time.Now() + + // Use raw SQL with interpolated integer to avoid type issues + // Interpolate id directly as integer literal (safe because it's uint64) + result := r.DB().WithContext(ctx). + Exec(`UPDATE "expenses" SET "deleted_at" = $1 WHERE "id" = `+fmt.Sprintf("%d", id64)+` AND "deleted_at" IS NULL`, + deletedAt) + + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 77f075f7..474b2962 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -5,6 +5,8 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -12,6 +14,8 @@ type ExpenseRealizationRepository interface { repository.BaseRepository[entity.ExpenseRealization] IdExists(ctx context.Context, id uint64) (bool, error) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) + GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) } type ExpenseRealizationRepositoryImpl struct { @@ -30,11 +34,104 @@ func (r *ExpenseRealizationRepositoryImpl) IdExists(ctx context.Context, id uint func (r *ExpenseRealizationRepositoryImpl) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) { var realization entity.ExpenseRealization - err := r.DB().WithContext(ctx). - Where("expense_nonstock_id = ?", expenseNonstockID). - First(&realization).Error - if err != nil { - return nil, err - } - return &realization, nil + err := r.DB().WithContext(ctx).Where("expense_nonstock_id = ?", expenseNonstockID).First(&realization).Error + return &realization, err +} + +func (r *ExpenseRealizationRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) { + var realizations []entity.ExpenseRealization + err := r.DB().WithContext(ctx). + Preload("ExpenseNonstock"). + Preload("ExpenseNonstock.Nonstock"). + Preload("ExpenseNonstock.Nonstock.Uom"). + Preload("ExpenseNonstock.Nonstock.Flags"). + Preload("ExpenseNonstock.Expense"). + Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). + Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). + Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). + Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). + Where("project_flock_kandangs.project_flock_id = ? OR kandangs.id IN (SELECT kandang_id FROM project_flock_kandangs WHERE project_flock_id = ?)", projectFlockID, projectFlockID). + Find(&realizations).Error + return realizations, err +} + +func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) { + var realizations []entity.ExpenseRealization + var total int64 + + db := r.DB().WithContext(ctx). + Model(&entity.ExpenseRealization{}). + Preload("ExpenseNonstock", func(db *gorm.DB) *gorm.DB { + return db. + Preload("Expense"). + Preload("Expense.Supplier"). + Preload("Kandang"). + Preload("Kandang.Location"). + Preload("Nonstock"). + Preload("Nonstock.Flags") + }). + Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). + Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). + Joins("LEFT JOIN suppliers ON suppliers.id = expenses.supplier_id") + + if filters.Search != "" { + db = db.Where("expenses.category LIKE ? OR expenses.reference_number LIKE ? OR expenses.po_number LIKE ? OR expenses.notes LIKE ? OR suppliers.name LIKE ?", + "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%") + } + + if filters.Category != "" { + db = db.Where("expenses.category = ?", filters.Category) + } + + if filters.SupplierId > 0 { + db = db.Where("expenses.supplier_id = ?", filters.SupplierId) + } + + if filters.KandangId > 0 { + db = db.Where("expense_nonstocks.kandang_id = ?", filters.KandangId) + } + + if filters.ProjectFlockKandangId > 0 { + db = db.Where("expense_nonstocks.project_flock_kandang_id = ?", filters.ProjectFlockKandangId) + } + + if filters.NonstockId > 0 { + db = db.Where("expense_nonstocks.nonstock_id = ?", filters.NonstockId) + } + + locationID := filters.LocationId + areaID := filters.AreaId + + if locationID > 0 || areaID > 0 { + db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id") + + if locationID > 0 { + db = db.Where("kandangs.location_id = ?", uint(locationID)) + } + + if areaID > 0 { + db = db.Joins("JOIN locations ON locations.id = kandangs.location_id"). + Where("locations.area_id = ?", uint(areaID)) + } + } + + if filters.RealizationDate != "" { + if realizationDate, err := utils.ParseDateString(filters.RealizationDate); err == nil { + db = db.Where("DATE(expenses.realization_date) = ?", realizationDate) + } + } + + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := db. + Offset(offset). + Limit(limit). + Order("expense_realizations.created_at DESC"). + Find(&realizations).Error; err != nil { + return nil, 0, err + } + + return realizations, total, nil } diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 805cb886..9c22bde3 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -1,7 +1,7 @@ package expenses import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/controllers" expense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,23 +13,25 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService ctrl := controller.NewExpenseController(s) route := v1.Group("/expenses") + route.Use(m.Auth(u)) + // route.Use(m.Auth(u)) // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Get("/:id", m.Auth(u), ctrl.GetOne) // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) - route.Post("/approvals/manager", ctrl.Approval) - route.Post("/approvals/finance", ctrl.Approval) - route.Post("/:id/realizations", ctrl.CreateRealization) - route.Patch("/:id/realizations", ctrl.UpdateRealization) - route.Post("/:id/complete", ctrl.CompleteExpense) - route.Delete("/:id/documents/:documentId", ctrl.DeleteDocument) - route.Delete("/:id/realization-documents/:documentId", ctrl.DeleteRealizationDocument) + route.Get("/", m.RequirePermissions(m.P_ExpenseGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_ExpenseCreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_ExpenseGetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_ExpenseUpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_ExpenseDeleteOne), ctrl.DeleteOne) + route.Post("/approvals/manager", m.RequirePermissions(m.P_ExpenseApprovalManager), ctrl.Approval) + route.Post("/approvals/finance", m.RequirePermissions(m.P_ExpenseApprovalFinance), ctrl.Approval) + route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) + route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) + route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense) + route.Delete("/:id/documents/:documentId", m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument) + route.Delete("/:id/realization-documents/:documentId", m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument) } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 0d0779f0..50646ed6 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,16 +2,14 @@ package service import ( "context" - "database/sql" "encoding/json" "errors" "fmt" - "mime/multipart" - "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" @@ -32,7 +30,7 @@ type ExpenseService interface { GetOne(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error) - DeleteOne(ctx *fiber.Ctx, id uint) error + DeleteOne(ctx *fiber.Ctx, id uint64) error CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) @@ -49,9 +47,10 @@ type expenseService struct { ApprovalSvc commonSvc.ApprovalService RealizationRepository repository.ExpenseRealizationRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService } -func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService { +func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService { return &expenseService{ Log: utils.Log, Validate: validate, @@ -61,6 +60,7 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR ApprovalSvc: approvalSvc, RealizationRepository: realizationRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, } } @@ -68,11 +68,18 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("Supplier"). + Preload("Location"). Preload("Nonstocks.Nonstock"). Preload("Nonstocks.Realization"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.Kandang"). - Preload("Nonstocks.Kandang.Location") + Preload("Nonstocks.Kandang.Location"). + Preload("Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense)) + }). + Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization)) + }) } func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { @@ -139,17 +146,14 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen supplierID := uint(req.SupplierID) - supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) { - return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id) - } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc}, + commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists}, ); err != nil { return nil, err } - for _, costPerKandang := range req.CostPerKandangs { - for _, costItem := range costPerKandang.CostItems { + for _, expenseNonstock := range req.ExpenseNonstocks { + for _, costItem := range expenseNonstock.CostItems { nonstockId := uint(costItem.NonstockID) if err := commonSvc.EnsureRelations(c.Context(), @@ -184,26 +188,56 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction) - referenceNumber, err := s.generateReferenceNumber(dbTransaction) + referenceNumber, err := GenerateExpenseReferenceNumber(c.Context(), expenseRepoTx) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number") } - var grandTotal float64 - for _, costPerKandang := range req.CostPerKandangs { - for _, costItem := range costPerKandang.CostItems { - grandTotal += costItem.TotalCost + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } + createdBy := uint64(actorID) + + hasKandang := false + for _, ens := range req.ExpenseNonstocks { + if ens.KandangID != nil { + hasKandang = true + break + } + } + + var projectFlockIdJSON *string + if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) { + projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction) + activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location") + } + + if len(activeProjectFlocks) > 0 { + projectFlockIDs := make([]uint64, len(activeProjectFlocks)) + for i, pf := range activeProjectFlocks { + projectFlockIDs[i] = uint64(pf.Id) + } + + projectFlockIdsJSON, err := json.Marshal(projectFlockIDs) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids") + } + jsonStr := string(projectFlockIdsJSON) + projectFlockIdJSON = &jsonStr } } - createdBy := uint64(1) //todo get from auth expense = &entity.Expense{ ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, Category: req.Category, SupplierId: req.SupplierID, - ExpenseDate: expenseDate, - GrandTotal: grandTotal, + LocationId: req.LocationID, + ProjectFlockId: projectFlockIdJSON, + TransactionDate: expenseDate, CreatedBy: createdBy, } @@ -211,49 +245,50 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense") } - if len(req.CostPerKandangs) > 0 { + if len(req.ExpenseNonstocks) > 0 { - for _, costPerKandang := range req.CostPerKandangs { + for _, expenseNonstock := range req.ExpenseNonstocks { + + isAttachingToKandang := (expenseNonstock.KandangID != nil) var projectFlockKandangId *uint64 + var kandangId *uint64 - if req.Category == "BOP" { + if isAttachingToKandang { + kandangId = expenseNonstock.KandangID - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(costPerKandang.KandangID)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + if req.Category == string(utils.ExpenseCategoryBOP) { + + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + id := uint64(projectFlockKandang.Id) + projectFlockKandangId = &id } - id := uint64(projectFlockKandang.Id) - projectFlockKandangId = &id + + } else { + kandangId = nil + projectFlockKandangId = nil } - for _, costItem := range costPerKandang.CostItems { + for _, costItem := range expenseNonstock.CostItems { nonstockId := costItem.NonstockID - var kandangId *uint64 - if req.Category == "NON-BOP" { - id := uint64(costPerKandang.KandangID) - kandangId = &id - } else if req.Category == "BOP" { - if projectFlockKandangId != nil { - kandangId = &costPerKandang.KandangID - } - } - - expenseNonstock := &entity.ExpenseNonstock{ + newExpenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expense.Id, ProjectFlockKandangId: projectFlockKandangId, KandangId: kandangId, NonstockId: &nonstockId, Qty: costItem.Quantity, - TotalPrice: costItem.TotalCost, - Note: costItem.Notes, + Price: costItem.Price, + Notes: costItem.Notes, } - if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } @@ -273,9 +308,23 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: expense.Id, + CreatedBy: &createdByUint, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -302,9 +351,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } @@ -328,10 +375,32 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format") } - updateBody["expense_date"] = expenseDate + updateBody["transaction_date"] = expenseDate } - if len(updateBody) == 0 && req.CostPerKandang == nil && len(req.Documents) == 0 { + if req.Category != nil { + updateBody["category"] = *req.Category + } + + if req.SupplierID != nil { + supplierID := uint(*req.SupplierID) + supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) { + return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id) + } + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc}, + ); err != nil { + return nil, err + } + updateBody["supplier_id"] = *req.SupplierID + } + + if req.LocationID != nil { + locationID := uint(*req.LocationID) + updateBody["location_id"] = locationID + } + + if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 { responseDTO, err := s.GetOne(c, id) if err != nil { @@ -346,6 +415,24 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx) + currentExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil { + return err + } + categoryChanged := false + var newCategory string + if req.Category != nil && *req.Category != currentExpense.Category { + categoryChanged = true + newCategory = *req.Category + } + if len(updateBody) > 0 { if err := expenseRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -355,52 +442,101 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - if req.CostPerKandang != nil { + if categoryChanged { + if currentExpense.Category == string(utils.ExpenseCategoryBOP) && newCategory == string(utils.ExpenseCategoryNonBOP) { - if err := tx.Where("expense_id = ?", id).Delete(&entity.ExpenseNonstock{}).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense items") - } - - var grandTotal float64 - for _, cpk := range *req.CostPerKandang { - for _, costItem := range cpk.CostItems { - grandTotal += costItem.TotalCost + var existingExpenseNonstocks []entity.ExpenseNonstock + if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks") } - } - if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{ - "grand_total": grandTotal, - }, nil); err != nil { - - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense grand total") - } - - for _, cpk := range *req.CostPerKandang { - var projectFlockKandangId *uint64 - - expense, err := expenseRepoTx.GetByID(c.Context(), id, nil) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Expense not found") + for _, ens := range existingExpenseNonstocks { + updateData := map[string]interface{}{ + "project_flock_kandang_id": nil, } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + if err := expenseNonstockRepoTx.PatchOne(c.Context(), uint(ens.Id), updateData, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id to null") + } + } + } else if currentExpense.Category == string(utils.ExpenseCategoryNonBOP) && newCategory == string(utils.ExpenseCategoryBOP) { + + var existingExpenseNonstocks []entity.ExpenseNonstock + if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks") } - if expense.Category == "BOP" { - - projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(cpk.KandangID)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) + for _, ens := range existingExpenseNonstocks { + if ens.KandangId != nil { + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*ens.KandangId)) + if err != nil { + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil { + return err + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + } + projectFlockKandangId := uint64(projectFlockKandang.Id) + + updateData := map[string]interface{}{ + "project_flock_kandang_id": projectFlockKandangId, + } + if err := expenseNonstockRepoTx.PatchOne(c.Context(), uint(ens.Id), updateData, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") } - id := uint64(projectFlockKandang.Id) - projectFlockKandangId = &id + } + } + } + + if req.ExpenseNonstocks != nil { + + var existingExpenseNonstocks []entity.ExpenseNonstock + if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks for deletion") + } + + for _, ens := range existingExpenseNonstocks { + if err := expenseNonstockRepoTx.DeleteOne(c.Context(), uint(ens.Id)); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete expense nonstock") + } + } + + updatedExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get updated expense") + } + + for _, expenseNonstock := range *req.ExpenseNonstocks { + var projectFlockKandangId *uint64 + var kandangId *uint64 + + // Check if attaching to kandang + if expenseNonstock.KandangID != nil { + kandangId = expenseNonstock.KandangID + + if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { + // BOP with kandang: Get active project flock kandang + projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + } + id := uint64(projectFlockKandang.Id) + projectFlockKandangId = &id + } + // NON-BOP: projectFlockKandangId stays nil } - for _, costItem := range cpk.CostItems { + for _, costItem := range expenseNonstock.CostItems { nonstockId := uint(costItem.NonstockID) if err := commonSvc.EnsureRelations(c.Context(), @@ -409,36 +545,28 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return err } - var kandangId *uint64 - if expense.Category == "NON-BOP" { - id := uint64(cpk.KandangID) - kandangId = &id - } else if expense.Category == "BOP" { - - if projectFlockKandangId != nil { - kandangId = &cpk.KandangID - } - } - expenseId := uint64(id) - expenseNonstock := &entity.ExpenseNonstock{ + newExpenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expenseId, ProjectFlockKandangId: projectFlockKandangId, KandangId: kandangId, NonstockId: &costItem.NonstockID, Qty: costItem.Quantity, - TotalPrice: costItem.TotalCost, - Note: costItem.Notes, + Price: costItem.Price, + Notes: costItem.Notes, } - if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } } } - actorID := uint(1) // TODO: replace with authenticated user id + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } if *latestApproval.Action != entity.ApprovalActionUpdated { approvalAction := entity.ApprovalActionUpdated @@ -455,9 +583,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: uint64(id), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -478,17 +620,30 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return responseDTO, nil } -func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error { +func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error { + idUint := uint(id) if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &idUint, Exists: s.Repository.IdExists}, ); err != nil { return err } + expense, err := s.Repository.GetByID(c.Context(), idUint, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Expense not found for ID %d: %+v", id, err) + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + s.Log.Errorf("Failed to get expense for ID %d: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } - if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return err + } + if err := s.Repository.DeleteOne(c.Context(), idUint); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Expense not found for ID %d: %+v", id, err) return fiber.NewError(fiber.StatusNotFound, "Expense not found") @@ -506,9 +661,7 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } @@ -518,7 +671,19 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format") } - createdBy := uint64(1) // TODO: replace with authenticated user id + expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return nil, err + } if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { @@ -543,13 +708,14 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va } realization := &entity.ExpenseRealization{ - ExpenseNonstockId: &expenseNonstockID, - RealizationQty: realizationItem.Qty, - RealizationUnitPrice: realizationItem.UnitPrice, - RealizationTotalPrice: realizationItem.TotalPrice, - RealizationDate: realizationDate, - Note: realizationItem.Notes, - CreatedBy: &createdBy, + ExpenseNonstockId: &expenseNonstockID, + Qty: realizationItem.Qty, + Price: realizationItem.Price, + Notes: "", + } + + if realizationItem.Notes != nil { + realization.Notes = *realizationItem.Notes } if err := realizationRepoTx.CreateOne(c.Context(), realization, nil); err != nil { @@ -563,9 +729,24 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -576,7 +757,7 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va expenseID, utils.ExpenseStepRealisasi, &approvalAction, - uint(createdBy), + uint(1), // TODO: replace with authenticated user id nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval") @@ -597,14 +778,15 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) { if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } - actorID := uint(1) // TODO: replace with authenticated user id + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -652,94 +834,134 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { - s.Log.Errorf("Validation failed for UpdateRealization: %+v", err) + return nil, err } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists}, ); err != nil { return nil, err } + expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return nil, err + } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") } - if latestApproval != nil && latestApproval.StepNumber != uint16(utils.ExpenseStepRealisasi) { + if latestApproval != nil && (latestApproval.StepNumber < uint16(utils.ExpenseStepRealisasi)) { currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)] return nil, fiber.NewError(fiber.StatusBadRequest, - fmt.Sprintf("Cannot update realization at %s step. Must be at Realisasi step", currentStepName)) + fmt.Sprintf("tidak bisa update realisasi pada step %s. Harus pada step Realisasi atau selesai", currentStepName)) } - var realizationDate *time.Time - if req.RealizationDate != "" { - parsedDate, err := utils.ParseDateString(req.RealizationDate) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format") - } - realizationDate = &parsedDate - } - - if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) realizationRepoTx := repository.NewExpenseRealizationRepository(tx) expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx) expenseRepoTx := repository.NewExpenseRepository(tx) - for _, realizationItem := range req.Realizations { + // Check if only updating documents + updateDataOnly := req.Realizations == nil && len(req.Documents) > 0 - expenseNonstockID := realizationItem.ExpenseNonstockID + if req.Realizations != nil { + for _, realizationItem := range *req.Realizations { - if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil { - return err - } + expenseNonstockID := realizationItem.ExpenseNonstockID - existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - - return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock") + if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil { + return err } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing realization") + existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + + return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock") + } + + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing realization") + } + + updateData := map[string]interface{}{ + "qty": realizationItem.Qty, + "price": realizationItem.Price, + } + + if realizationItem.Notes != nil { + updateData["notes"] = *realizationItem.Notes + } + + if err := realizationRepoTx.PatchOne(c.Context(), uint(existingRealization.Id), updateData, nil); err != nil { + s.Log.Errorf("Failed to update realization: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization") + } } - updateData := map[string]interface{}{ - "realization_qty": realizationItem.Qty, - "realization_unit_price": realizationItem.UnitPrice, - "realization_total_price": realizationItem.TotalPrice, - "realization_date": *realizationDate, - } + } - if realizationItem.Notes != nil { - updateData["note"] = *realizationItem.Notes - } - - if err := realizationRepoTx.PatchOne(c.Context(), uint(existingRealization.Id), updateData, nil); err != nil { - s.Log.Errorf("Failed to update realization: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization") + if req.RealizationDate != nil { + if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{"realization_date": *req.RealizationDate}, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") } } - if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{ - "realization_date": *realizationDate, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") + } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if !updateDataOnly && *latestApproval.Action == entity.ApprovalActionUpdated { + actorID := uint(1) // TODO: replace with authenticated user id + approvalAction := entity.ApprovalActionUpdated + if _, err := approvalSvcTx.CreateApproval( + c.Context(), + utils.ApprovalWorkflowExpense, + expenseID, + utils.ExpenseStepRealisasi, + &approvalAction, + actorID, + nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval") } } return nil - }); err != nil { - return nil, err + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "gagal update realisasi expense") } responseDTO, err := s.GetOne(c, expenseID) @@ -749,145 +971,48 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va return responseDTO, nil } -func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx repository.ExpenseRepository, expenseID uint, documents []*multipart.FileHeader, isRealization bool) error { - - if len(documents) == 0 { - return nil - } - - var existingDocuments []expenseDto.DocumentDTO - var fieldName string - - if isRealization { - fieldName = "realization_document_path" - } else { - fieldName = "document_path" - } - - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - - if !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document processing") - } - } else { - - var documentField sql.NullString - if isRealization { - documentField = expense.RealizationDocumentPath - } else { - documentField = expense.DocumentPath - } - - if documentField.Valid && documentField.String != "" { - if err := json.Unmarshal([]byte(documentField.String), &existingDocuments); err != nil { - existingDocuments = []expenseDto.DocumentDTO{} - } - } - } - - var startID uint64 = 1 - if len(existingDocuments) > 0 { - - maxID := uint64(0) - for _, doc := range existingDocuments { - if doc.ID > maxID { - maxID = doc.ID - } - } - startID = maxID + 1 - } - - for i, doc := range documents { - documentPath := doc.Filename - - document := expenseDto.DocumentDTO{ - ID: startID + uint64(i), - Path: documentPath, - } - existingDocuments = append(existingDocuments, document) - } - - documentJSON, err := json.Marshal(existingDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil -} - func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error { if err := commonSvc.EnsureRelations(ctx.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists}, ); err != nil { return err } - if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { - expenseRepoTx := repository.NewExpenseRepository(tx) + if s.DocumentSvc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document deletion") + // Verify document exists and belongs to the expense + var documentableType string + if isRealization { + documentableType = string(utils.DocumentableTypeExpenseRealization) + } else { + documentableType = string(utils.DocumentableTypeExpense) + } + + documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID)) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents") + } + + documentFound := false + var documentIDsToDelete []uint + for _, doc := range documents { + if uint64(doc.Id) == documentID { + documentFound = true + documentIDsToDelete = append(documentIDsToDelete, doc.Id) + break } + } - var existingDocuments []expenseDto.DocumentDTO - var fieldName string + if !documentFound { + return fiber.NewError(fiber.StatusNotFound, "Document not found") + } - if isRealization { - fieldName = "realization_document_path" - if expense.RealizationDocumentPath.Valid && expense.RealizationDocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.RealizationDocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing realization documents") - } - } - } else { - fieldName = "document_path" - if expense.DocumentPath.Valid && expense.DocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.DocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing documents") - } - } - } - - var updatedDocuments []expenseDto.DocumentDTO - documentFound := false - - for _, doc := range existingDocuments { - if doc.ID == documentID { - documentFound = true - continue - } - updatedDocuments = append(updatedDocuments, doc) - } - - if !documentFound { - return fiber.NewError(fiber.StatusNotFound, "Document not found") - } - - documentJSON, err := json.Marshal(updatedDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil - }); err != nil { - return err + // Delete document from database and storage + if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document") } return nil @@ -898,20 +1023,21 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, return nil, fiber.NewError(fiber.StatusBadRequest, "No expense IDs provided") } - actorID := uint(1) // TODO: replace with authenticated user id + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } var results []expenseDto.ExpenseDetailDTO - err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) expenseRepoTx := repository.NewExpenseRepository(tx) for _, id := range req.ApprovableIds { if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: func(ctx context.Context, id uint) (bool, error) { - return s.Repository.IdExists(ctx, uint64(id)) - }}, + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, ); err != nil { return err } @@ -950,6 +1076,21 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } else { return fiber.NewError(fiber.StatusBadRequest, "Invalid approval action") } + if approvalAction == entity.ApprovalActionApproved { + expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense") + } + + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return err + } + } if _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -996,17 +1137,6 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, return results, nil } -func (s *expenseService) generateReferenceNumber(ctx *gorm.DB) (string, error) { - - sequence, err := s.Repository.GetNextSequence(ctx.Statement.Context) - if err != nil { - return "", err - } - refNum := fmt.Sprintf("BOP-LTI-%05d", sequence) - - return refNum, nil -} - func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) { expenseRepoTx := repository.NewExpenseRepository(ctx) @@ -1030,13 +1160,45 @@ func (s *expenseService) validateExpenseNonstockRelation(ctx *fiber.Ctx, expense return nil } -// func actorIDFromContext(c *fiber.Ctx) (uint, error) { -// user, ok := authmiddleware.AuthenticatedUser(c) -// if !ok || user == nil || user.Id == 0 { -// return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } -// return user.Id, nil -// } +func (s *expenseService) ensureProjectFlockNotClosedForExpense( + ctx context.Context, + expense *entity.Expense, +) error { + // Kalau repo belum di-wire atau expense kosong → gak usah ngecek apa-apa + if s.ProjectFlockKandangRepo == nil || expense == nil { + return nil + } -// return user.Id, nil -// } + seen := make(map[uint]struct{}) + + for _, ens := range expense.Nonstocks { + // Field ini pointer, bisa nil + if ens.ProjectFlockKandangId == nil || *ens.ProjectFlockKandangId == 0 { + continue + } + + pfkID := uint(*ens.ProjectFlockKandangId) + if _, ok := seen[pfkID]; ok { + continue + } + seen[pfkID] = struct{}{} + + pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, pfkID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Project flock %d tidak ditemukan", pfkID), + ) + } + s.Log.Errorf("Failed to validate project flock %d for expense %d: %+v", pfkID, expense.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + } + // ❗ RULE: kalau ClosedAt tidak nil → project sudah closing + if pfk.ClosedAt != nil { + return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing") + } + } + + return nil +} diff --git a/internal/modules/expenses/services/number_helper.go b/internal/modules/expenses/services/number_helper.go new file mode 100644 index 00000000..2d1be912 --- /dev/null +++ b/internal/modules/expenses/services/number_helper.go @@ -0,0 +1,17 @@ +package service + +import ( + "context" + "fmt" + + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" +) + +// GenerateExpenseReferenceNumber builds a new reference number using the expense sequence. +func GenerateExpenseReferenceNumber(ctx context.Context, repo expenseRepo.ExpenseRepository) (string, error) { + sequence, err := repo.GetNextSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("BOP-LTI-%05d", sequence), nil +} diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 4e909b66..4501b87d 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -5,30 +5,34 @@ import ( ) type Create struct { - PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"` - TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` - Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` - SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` - Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` - CostPerKandangs []CostPerKandang `form:"cost_per_kandangs" json:"cost_per_kandangs" validate:"required,min=1,dive"` + PoNumber string `form:"po_number" json:"po_number" validate:"omitempty,max=50"` + TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` + Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` + SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` + LocationID uint64 `form:"location_id" json:"location_id" validate:"required,gt=0"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"` } -type CostPerKandang struct { - KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"` +type ExpenseNonstock struct { + KandangID *uint64 `form:"kandang_id" json:"kandang_id" validate:"omitempty"` CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"` } type CostItem struct { NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"` Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"` - TotalCost float64 `form:"total_cost" json:"total_cost" validate:"required,gt=0"` - Notes string `form:"notes" json:"notes" validate:"required,max=500"` + Price float64 `form:"price" json:"price" validate:"required,gt=0"` + Notes string `form:"notes" json:"notes" validate:"omitempty,max=500"` } type Update struct { - TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` - CostPerKandang *[]CostPerKandang `form:"cost_per_kandang" json:"cost_per_kandang" validate:"omitempty,min=1,dive"` - Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` + Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` + SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` + LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"` + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` + ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` } type Query struct { @@ -44,16 +48,15 @@ type CreateRealization struct { } type UpdateRealization struct { - RealizationDate string `form:"realization_date" json:"realization_date" validate:"omitempty,datetime=2006-01-02"` + RealizationDate *string `form:"realization_date" json:"realization_date" validate:"omitempty,datetime=2006-01-02"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` - Realizations []RealizationItem `form:"realizations" json:"realizations" validate:"required,min=1,dive"` + Realizations *[]RealizationItem `form:"realizations" json:"realizations" validate:"omitempty,min=1,dive"` } type RealizationItem struct { ExpenseNonstockID uint64 `form:"expense_nonstock_id" json:"expense_nonstock_id" validate:"required,gt=0"` Qty float64 `form:"qty" json:"qty" validate:"required,gt=0"` - UnitPrice float64 `form:"unit_price" json:"unit_price" validate:"required,gt=0"` - TotalPrice float64 `form:"total_price" json:"total_price" validate:"required,gt=0"` + Price float64 `form:"price" json:"price" validate:"required,gt=0"` Notes *string `form:"notes" json:"notes" validate:"omitempty,max=500"` } diff --git a/internal/modules/finance/initials/controllers/initial.controller.go b/internal/modules/finance/initials/controllers/initial.controller.go new file mode 100644 index 00000000..4aef677a --- /dev/null +++ b/internal/modules/finance/initials/controllers/initial.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type InitialController struct { + InitialService service.InitialService +} + +func NewInitialController(initialService service.InitialService) *InitialController { + return &InitialController{ + InitialService: initialService, + } +} + +func (u *InitialController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.InitialService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} + +func (u *InitialController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InitialService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} + +func (u *InitialController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InitialService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} diff --git a/internal/modules/finance/initials/dto/initial.dto.go b/internal/modules/finance/initials/dto/initial.dto.go new file mode 100644 index 00000000..5eb76e9c --- /dev/null +++ b/internal/modules/finance/initials/dto/initial.dto.go @@ -0,0 +1,163 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type InitialRelationDTO struct { + Id uint `json:"id"` + ReferenceNumber string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + InitialBalanceType string `json:"initial_balance_type"` + InitialBalanceTypeLabel string `json:"initial_balance_type_label"` + Party Party `json:"party"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + Direction string `json:"direction"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` +} + +type InitialListDTO struct { + InitialRelationDTO + CreatedBy uint `json:"created_by"` + CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type InitialDetailDTO struct { + InitialListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToInitialRelationDTO(e entity.Payment) InitialRelationDTO { + reference := "" + if e.ReferenceNumber != nil { + reference = *e.ReferenceNumber + } + + initialBalanceType := initialBalanceTypeFromPayment(e) + return InitialRelationDTO{ + Id: e.Id, + ReferenceNumber: reference, + TransactionType: transactionTypeLabel(e.TransactionType), + InitialBalanceType: initialBalanceType, + InitialBalanceTypeLabel: initialBalanceLabel(initialBalanceType), + Party: partyFromInitial(e), + Bank: bankFromInitial(e), + Direction: e.Direction, + Nominal: e.Nominal, + Notes: e.Notes, + } +} + +func ToInitialListDTO(e entity.Payment) InitialListDTO { + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return InitialListDTO{ + InitialRelationDTO: ToInitialRelationDTO(e), + CreatedBy: e.CreatedBy, + CreatedByUser: userFromInitial(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToInitialListDTOs(e []entity.Payment) []InitialListDTO { + result := make([]InitialListDTO, len(e)) + for i, r := range e { + result[i] = ToInitialListDTO(r) + } + return result +} + +func ToInitialDetailDTO(e entity.Payment) InitialDetailDTO { + return InitialDetailDTO{ + InitialListDTO: ToInitialListDTO(e), + } +} + +func partyFromInitial(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromInitial(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func transactionTypeLabel(transactionType string) string { + if strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) { + return "Saldo Awal" + } + return transactionType +} + +func initialBalanceLabel(balanceType string) string { + switch strings.ToUpper(strings.TrimSpace(balanceType)) { + case "NEGATIVE": + return "Saldo Awal Negatif" + case "POSITIVE": + return "Saldo Awal Positif" + default: + return balanceType + } +} + +func initialBalanceTypeFromPayment(e entity.Payment) string { + if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 { + return "NEGATIVE" + } + return "POSITIVE" +} diff --git a/internal/modules/finance/initials/module.go b/internal/modules/finance/initials/module.go new file mode 100644 index 00000000..051c8d3f --- /dev/null +++ b/internal/modules/finance/initials/module.go @@ -0,0 +1,36 @@ +package initials + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories" + sInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type InitialModule struct{} + +func (InitialModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + initialRepo := rInitial.NewInitialRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register initial approval workflow: %v", err)) + } + + initialService := sInitial.NewInitialService(initialRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + InitialRoutes(router, userService, initialService) +} diff --git a/internal/modules/finance/initials/repositories/initial.repository.go b/internal/modules/finance/initials/repositories/initial.repository.go new file mode 100644 index 00000000..9c285c5c --- /dev/null +++ b/internal/modules/finance/initials/repositories/initial.repository.go @@ -0,0 +1,51 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type InitialRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + CustomerExists(ctx context.Context, customerId uint) (bool, error) + SupplierExists(ctx context.Context, supplierId uint) (bool, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type InitialRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewInitialRepository(db *gorm.DB) InitialRepository { + return &InitialRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *InitialRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *InitialRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, customerId) +} + +func (r *InitialRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, supplierId) +} + +func (r *InitialRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/initials/route.go b/internal/modules/finance/initials/route.go new file mode 100644 index 00000000..b216fd23 --- /dev/null +++ b/internal/modules/finance/initials/route.go @@ -0,0 +1,21 @@ +package initials + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/controllers" + initial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func InitialRoutes(v1 fiber.Router, u user.UserService, s initial.InitialService) { + ctrl := controller.NewInitialController(s) + + route := v1.Group("/initial-balances") + route.Use(m.Auth(u)) + + route.Post("/",m.RequirePermissions(m.P_Finances_Initial_Balances_CreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_Finances_Initial_Balances_GetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_Finances_Initial_Balances_UpdateOne), ctrl.UpdateOne) +} diff --git a/internal/modules/finance/initials/services/initial.service.go b/internal/modules/finance/initials/services/initial.service.go new file mode 100644 index 00000000..2eb15d3b --- /dev/null +++ b/internal/modules/finance/initials/services/initial.service.go @@ -0,0 +1,336 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type InitialService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type initialService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.InitialRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewInitialService( + repo repository.InitialRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) InitialService { + return &initialService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowInitial, + } +} + +func (s initialService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + initial, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if err != nil { + s.Log.Errorf("Failed get initial by id: %+v", err) + return nil, err + } + if !isInitialTransaction(initial.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for initial %d: %+v", id, err) + } else { + initial.LatestApproval = approval + } + } + return initial, nil +} + +func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + party, err := normalizePartyType(req.PartyType) + if err != nil { + return nil, err + } + + if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil { + return nil, err + } + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + balanceType, err := normalizeInitialBalanceType(req.InitialBalanceType) + if err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generateInitialCode(c.Context()) + if err != nil { + return nil, err + } + + reference := req.ReferenceNumber + createBody := &entity.Payment{ + PaymentCode: code, + ReferenceNumber: &reference, + TransactionType: string(utils.TransactionTypeSaldoAwal), + PartyType: party, + PartyId: req.PartyId, + PaymentDate: time.Now(), + PaymentMethod: string(utils.PaymentMethodSaldo), + BankId: req.BankId, + Direction: directionForInitialType(balanceType), + Nominal: signedNominal(balanceType, req.Nominal), + Notes: req.Note, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + initialRepoTx := repository.NewInitialRepository(dbTransaction) + if err := initialRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.InitialStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create initial: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.ReferenceNumber != nil { + updateBody["reference_number"] = *req.ReferenceNumber + } + if req.Note != nil { + updateBody["notes"] = *req.Note + } + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + + requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil + requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil + var existing *entity.Payment + if requiresVerification { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if err != nil { + s.Log.Errorf("Failed get initial by id: %+v", err) + return nil, err + } + if !isInitialTransaction(current.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + existing = current + } + + if req.PartyType != nil || req.PartyId != nil { + partyType := existing.PartyType + partyId := existing.PartyId + + if req.PartyType != nil { + normalized, err := normalizePartyType(*req.PartyType) + if err != nil { + return nil, err + } + partyType = normalized + updateBody["party_type"] = partyType + } + if req.PartyId != nil { + partyId = *req.PartyId + updateBody["party_id"] = partyId + } + + if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { + return nil, err + } + } + + if req.InitialBalanceType != nil || req.Nominal != nil { + balanceType := balanceTypeFromPayment(existing) + if req.InitialBalanceType != nil { + normalized, err := normalizeInitialBalanceType(*req.InitialBalanceType) + if err != nil { + return nil, err + } + balanceType = normalized + } + + nominal := math.Abs(existing.Nominal) + if req.Nominal != nil { + nominal = *req.Nominal + } + + updateBody["direction"] = directionForInitialType(balanceType) + updateBody["nominal"] = signedNominal(balanceType, nominal) + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + s.Log.Errorf("Failed to update initial: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func isInitialTransaction(transactionType string) bool { + return strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) +} + +func balanceTypeFromPayment(payment *entity.Payment) string { + if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 { + return "NEGATIVE" + } + return "POSITIVE" +} + +func normalizePartyType(partyType string) (string, error) { + party := strings.ToUpper(strings.TrimSpace(partyType)) + if !utils.IsValidPaymentParty(party) { + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } + return party, nil +} + +func normalizeInitialBalanceType(balanceType string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(balanceType)) + switch normalized { + case "NEGATIVE", "POSITIVE": + return normalized, nil + default: + return "", utils.BadRequest("`initial_balance_type` must be `NEGATIVE` or `POSITIVE`") + } +} + +func directionForInitialType(balanceType string) string { + if strings.EqualFold(balanceType, "NEGATIVE") { + return "OUT" + } + return "IN" +} + +func signedNominal(balanceType string, nominal float64) float64 { + normalized := math.Abs(nominal) + if strings.EqualFold(balanceType, "NEGATIVE") { + return -normalized + } + return normalized +} + +func (s initialService) generateInitialCode(ctx context.Context) (string, error) { + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("INIT-%05d", sequence), nil +} + +func (s initialService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s initialService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists}, + ) + case utils.PaymentPartySupplier: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists}, + ) + default: + return utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} diff --git a/internal/modules/finance/initials/validations/initial.validation.go b/internal/modules/finance/initials/validations/initial.validation.go new file mode 100644 index 00000000..27df2eea --- /dev/null +++ b/internal/modules/finance/initials/validations/initial.validation.go @@ -0,0 +1,27 @@ +package validation + +type Create struct { + PartyType string `json:"party_type" validate:"required_strict,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"` + InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Note string `json:"note" validate:"required_strict,max=500"` +} + +type Update struct { + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty" validate:"omitempty,max=100"` + InitialBalanceType *string `json:"initial_balance_type,omitempty" validate:"omitempty,oneof=NEGATIVE POSITIVE"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Note *string `json:"note,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/injections/controllers/injection.controller.go b/internal/modules/finance/injections/controllers/injection.controller.go new file mode 100644 index 00000000..8f6c6b6d --- /dev/null +++ b/internal/modules/finance/injections/controllers/injection.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type InjectionController struct { + InjectionService service.InjectionService +} + +func NewInjectionController(injectionService service.InjectionService) *InjectionController { + return &InjectionController{ + InjectionService: injectionService, + } +} + +func (u *InjectionController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.InjectionService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get injection successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} + +func (u *InjectionController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InjectionService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Balance injection created successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} + +func (u *InjectionController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.InjectionService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update injection successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} diff --git a/internal/modules/finance/injections/dto/injection.dto.go b/internal/modules/finance/injections/dto/injection.dto.go new file mode 100644 index 00000000..d0be7f3f --- /dev/null +++ b/internal/modules/finance/injections/dto/injection.dto.go @@ -0,0 +1,102 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type InjectionRelationDTO struct { + Id uint `json:"id"` + TransactionType string `json:"transaction_type"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + AdjustmentDate string `json:"adjustment_date"` + Direction string `json:"direction"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` +} + +type InjectionListDTO struct { + InjectionRelationDTO + CreatedBy uint `json:"created_by"` + CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type InjectionDetailDTO struct { + InjectionListDTO +} + +// === Mapper Functions === + +func ToInjectionRelationDTO(e entity.Payment) InjectionRelationDTO { + return InjectionRelationDTO{ + Id: e.Id, + TransactionType: transactionTypeLabel(e.TransactionType), + Bank: bankFromInjection(e), + AdjustmentDate: utils.FormatDate(e.PaymentDate), + Direction: e.Direction, + Nominal: e.Nominal, + Notes: e.Notes, + } +} + +func ToInjectionListDTO(e entity.Payment) InjectionListDTO { + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return InjectionListDTO{ + InjectionRelationDTO: ToInjectionRelationDTO(e), + CreatedBy: e.CreatedBy, + CreatedByUser: userFromInjection(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToInjectionListDTOs(e []entity.Payment) []InjectionListDTO { + result := make([]InjectionListDTO, len(e)) + for i, r := range e { + result[i] = ToInjectionListDTO(r) + } + return result +} + +func ToInjectionDetailDTO(e entity.Payment) InjectionDetailDTO { + return InjectionDetailDTO{ + InjectionListDTO: ToInjectionListDTO(e), + } +} + +func bankFromInjection(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromInjection(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func transactionTypeLabel(transactionType string) string { + if strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) { + return "Injection" + } + return transactionType +} diff --git a/internal/modules/finance/injections/module.go b/internal/modules/finance/injections/module.go new file mode 100644 index 00000000..0c4517e6 --- /dev/null +++ b/internal/modules/finance/injections/module.go @@ -0,0 +1,36 @@ +package injections + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories" + sInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type InjectionModule struct{} + +func (InjectionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + injectionRepo := rInjection.NewInjectionRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) + } + + injectionService := sInjection.NewInjectionService(injectionRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + InjectionRoutes(router, userService, injectionService) +} diff --git a/internal/modules/finance/injections/repositories/injection.repository.go b/internal/modules/finance/injections/repositories/injection.repository.go new file mode 100644 index 00000000..2e6869b7 --- /dev/null +++ b/internal/modules/finance/injections/repositories/injection.repository.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type InjectionRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type InjectionRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewInjectionRepository(db *gorm.DB) InjectionRepository { + return &InjectionRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *InjectionRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *InjectionRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/injections/route.go b/internal/modules/finance/injections/route.go new file mode 100644 index 00000000..25d1047f --- /dev/null +++ b/internal/modules/finance/injections/route.go @@ -0,0 +1,21 @@ +package injections + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/controllers" + injection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func InjectionRoutes(v1 fiber.Router, u user.UserService, s injection.InjectionService) { + ctrl := controller.NewInjectionController(s) + + route := v1.Group("/injections") + route.Use(m.Auth(u)) + + route.Post("/", m.RequirePermissions(m.P_Finances_Injections_CreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_Finances_Injections_GetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_Finances_Injections_UpdateOne), ctrl.UpdateOne) +} diff --git a/internal/modules/finance/injections/services/injection.service.go b/internal/modules/finance/injections/services/injection.service.go new file mode 100644 index 00000000..1b1062b4 --- /dev/null +++ b/internal/modules/finance/injections/services/injection.service.go @@ -0,0 +1,230 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type InjectionService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type injectionService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.InjectionRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewInjectionService( + repo repository.InjectionRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) InjectionService { + return &injectionService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowInjection, + } +} + +func (s injectionService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse") +} + +func (s injectionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + injection, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if err != nil { + s.Log.Errorf("Failed get injection by id: %+v", err) + return nil, err + } + if !isInjectionTransaction(injection.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for injection %d: %+v", id, err) + } else { + injection.LatestApproval = approval + } + } + return injection, nil +} + +func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + adjustmentDate, err := utils.ParseDateString(req.AdjustmentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generateInjectionCode(c.Context()) + if err != nil { + return nil, err + } + + createBody := &entity.Payment{ + PaymentCode: code, + TransactionType: string(utils.TransactionTypeInjection), + PartyType: string(utils.PaymentPartyCustomer), + PartyId: 0, + PaymentDate: adjustmentDate, + PaymentMethod: string(utils.PaymentMethodSaldo), + BankId: req.BankId, + Direction: "IN", + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + injectionRepoTx := repository.NewInjectionRepository(dbTransaction) + if err := injectionRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.InjectionStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create injection: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s injectionService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + requiresVerification := req.BankId != nil || req.AdjustmentDate != nil || req.Nominal != nil || req.Notes != nil + if requiresVerification { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if err != nil { + s.Log.Errorf("Failed get injection by id: %+v", err) + return nil, err + } + if !isInjectionTransaction(current.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + } + + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + if req.AdjustmentDate != nil { + parsedDate, err := utils.ParseDateString(*req.AdjustmentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + updateBody["payment_date"] = parsedDate + } + if req.Nominal != nil { + updateBody["nominal"] = *req.Nominal + } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + s.Log.Errorf("Failed to update injection: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func isInjectionTransaction(transactionType string) bool { + return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) +} + +func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) { + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("INJ-%05d", sequence), nil +} + +func (s injectionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s injectionService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} diff --git a/internal/modules/finance/injections/validations/injection.validation.go b/internal/modules/finance/injections/validations/injection.validation.go new file mode 100644 index 00000000..eb324525 --- /dev/null +++ b/internal/modules/finance/injections/validations/injection.validation.go @@ -0,0 +1,21 @@ +package validation + +type Create struct { + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + AdjustmentDate string `json:"adjustment_date" validate:"required_strict"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` +} + +type Update struct { + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/module.go b/internal/modules/finance/module.go new file mode 100644 index 00000000..ded5fbae --- /dev/null +++ b/internal/modules/finance/module.go @@ -0,0 +1,13 @@ +package finance + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type FinanceModule struct{} + +func (FinanceModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + RegisterRoutes(router, db, validate) +} diff --git a/internal/modules/finance/payments/controllers/payment.controller.go b/internal/modules/finance/payments/controllers/payment.controller.go new file mode 100644 index 00000000..5bccecf4 --- /dev/null +++ b/internal/modules/finance/payments/controllers/payment.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PaymentController struct { + PaymentService service.PaymentService +} + +func NewPaymentController(paymentService service.PaymentService) *PaymentController { + return &PaymentController{ + PaymentService: paymentService, + } +} + +func (u *PaymentController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.PaymentService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} + +func (u *PaymentController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PaymentService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} + +func (u *PaymentController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.PaymentService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} diff --git a/internal/modules/finance/payments/dto/payment.dto.go b/internal/modules/finance/payments/dto/payment.dto.go new file mode 100644 index 00000000..23005e2d --- /dev/null +++ b/internal/modules/finance/payments/dto/payment.dto.go @@ -0,0 +1,189 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type PaymentRelationDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number,omitempty"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"` +} + +type PaymentListDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type PaymentDetailDTO struct { + PaymentListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToPaymentRelationDTO(e entity.Payment) PaymentRelationDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + + return PaymentRelationDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + } +} + +func ToPaymentListDTO(e entity.Payment) PaymentListDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return PaymentListDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToPaymentListDTOs(e []entity.Payment) []PaymentListDTO { + result := make([]PaymentListDTO, len(e)) + for i, r := range e { + result[i] = ToPaymentListDTO(r) + } + return result +} + +func ToPaymentDetailDTO(e entity.Payment) PaymentDetailDTO { + return PaymentDetailDTO{ + PaymentListDTO: ToPaymentListDTO(e), + } +} + +func partyFromPayment(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func paymentCodeFromPayment(e entity.Payment) string { + if e.PaymentCode != "" { + return e.PaymentCode + } + if e.ReferenceNumber != nil { + return *e.ReferenceNumber + } + return "" +} + +func transactionTypeFromPayment(e entity.Payment) string { + if e.TransactionType != "" { + return e.TransactionType + } + return e.Direction +} + +func paymentAmounts(direction string, nominal float64) (float64, float64) { + switch strings.ToUpper(direction) { + case "IN": + return 0, nominal + case "OUT": + return nominal, 0 + default: + return 0, 0 + } +} diff --git a/internal/modules/finance/payments/module.go b/internal/modules/finance/payments/module.go new file mode 100644 index 00000000..fdc0ce47 --- /dev/null +++ b/internal/modules/finance/payments/module.go @@ -0,0 +1,36 @@ +package payments + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gorm.io/gorm" + + rPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories" + sPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type PaymentModule struct{} + +func (PaymentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + paymentRepo := rPayment.NewPaymentRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) + } + + paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + PaymentRoutes(router, userService, paymentService) +} diff --git a/internal/modules/finance/payments/repositories/payment.repository.go b/internal/modules/finance/payments/repositories/payment.repository.go new file mode 100644 index 00000000..b16f8881 --- /dev/null +++ b/internal/modules/finance/payments/repositories/payment.repository.go @@ -0,0 +1,62 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type PaymentRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + CustomerExists(ctx context.Context, customerId uint) (bool, error) + SupplierExists(ctx context.Context, supplierId uint) (bool, error) + SupplierCategory(ctx context.Context, supplierId uint) (string, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type PaymentRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewPaymentRepository(db *gorm.DB) PaymentRepository { + return &PaymentRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *PaymentRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *PaymentRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, customerId) +} + +func (r *PaymentRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, supplierId) +} + +func (r *PaymentRepositoryImpl) SupplierCategory(ctx context.Context, supplierId uint) (string, error) { + var supplier entity.Supplier + if err := r.db.WithContext(ctx). + Select("id", "category"). + First(&supplier, supplierId).Error; err != nil { + return "", err + } + return supplier.Category, nil +} + +func (r *PaymentRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/payments/route.go b/internal/modules/finance/payments/route.go new file mode 100644 index 00000000..c5147fc0 --- /dev/null +++ b/internal/modules/finance/payments/route.go @@ -0,0 +1,21 @@ +package payments + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/controllers" + payment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func PaymentRoutes(v1 fiber.Router, u user.UserService, s payment.PaymentService) { + ctrl := controller.NewPaymentController(s) + + route := v1.Group("/payments") + route.Use(m.Auth(u)) + + route.Post("/",m.RequirePermissions(m.P_Finances_Payments_CreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_Finances_Payments_GetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_Finances_Payments_UpdateOne), ctrl.UpdateOne) +} diff --git a/internal/modules/finance/payments/services/payment.service.go b/internal/modules/finance/payments/services/payment.service.go new file mode 100644 index 00000000..356288f1 --- /dev/null +++ b/internal/modules/finance/payments/services/payment.service.go @@ -0,0 +1,362 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type PaymentService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type paymentService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.PaymentRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewPaymentService( + repo repository.PaymentRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) PaymentService { + return &paymentService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowPayment, + } +} + +func (s paymentService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s paymentService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + payment, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment by id: %+v", err) + return nil, err + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for payment %d: %+v", id, err) + } else { + payment.LatestApproval = approval + } + } + return payment, nil +} + +func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + //! CHECK PARTY TYPE + party, err := normalizePartyType(req.PartyType) + if err != nil { + return nil, err + } + + //! CHECK EXISTS + if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil { + return nil, err + } + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + //? NORMALIZE + paymentDate, err := utils.ParseDateString(req.PaymentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + method, err := normalizePaymentMethod(req.PaymentMethod) + if err != nil { + return nil, err + } + transactionType, err := s.resolveTransactionType(c.Context(), party, req.PartyId) + if err != nil { + return nil, err + } + + //? GET CREATED BY + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generatePaymentCode(c.Context(), party) + if err != nil { + return nil, err + } + + createBody := &entity.Payment{ + PaymentCode: code, + ReferenceNumber: req.ReferenceNumber, + TransactionType: transactionType, + PartyType: party, + PartyId: req.PartyId, + PaymentDate: paymentDate, + PaymentMethod: method, + BankId: req.BankId, + Direction: directionForParty(party), + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + paymentRepoTx := repository.NewPaymentRepository(dbTransaction) + if err := paymentRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.PaymentStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create payment: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s paymentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.PaymentDate != nil { + parsedDate, err := utils.ParseDateString(*req.PaymentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + updateBody["payment_date"] = parsedDate + } + if req.Nominal != nil { + updateBody["nominal"] = *req.Nominal + } + if req.ReferenceNumber != nil { + updateBody["reference_number"] = *req.ReferenceNumber + } + if req.PaymentMethod != nil { + method, err := normalizePaymentMethod(*req.PaymentMethod) + if err != nil { + return nil, err + } + updateBody["payment_method"] = method + } + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + + if req.PartyType != nil || req.PartyId != nil { + existing, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment by id: %+v", err) + return nil, err + } + + partyType := existing.PartyType + partyId := existing.PartyId + + if req.PartyType != nil { + normalized, err := normalizePartyType(*req.PartyType) + if err != nil { + return nil, err + } + partyType = normalized + updateBody["party_type"] = partyType + updateBody["direction"] = directionForParty(partyType) + } + if req.PartyId != nil { + partyId = *req.PartyId + updateBody["party_id"] = partyId + } + + if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { + return nil, err + } + + transactionType, err := s.resolveTransactionType(c.Context(), partyType, partyId) + if err != nil { + return nil, err + } + updateBody["transaction_type"] = transactionType + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + s.Log.Errorf("Failed to update payment: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func normalizePartyType(partyType string) (string, error) { + party := strings.ToUpper(strings.TrimSpace(partyType)) + if !utils.IsValidPaymentParty(party) { + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } + return party, nil +} + +func normalizePaymentMethod(method string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(method)) + if !utils.IsValidPaymentMethod(normalized) { + return "", utils.BadRequest("Invalid payment_method") + } + return normalized, nil +} + +func directionForParty(partyType string) string { + if utils.PaymentParty(partyType) == utils.PaymentPartyCustomer { + return "IN" + } + return "OUT" +} + +func (s paymentService) resolveTransactionType(ctx context.Context, partyType string, partyId uint) (string, error) { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return string(utils.TransactionTypePenjualan), nil + case utils.PaymentPartySupplier: + category, err := s.getSupplierCategory(ctx, partyId) + if err != nil { + return "", err + } + if isSupplierCategoryBiaya(category) { + return string(utils.TransactionTypeBiaya), nil + } + return string(utils.TransactionTypePembelian), nil + default: + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s paymentService) generatePaymentCode(ctx context.Context, partyType string) (string, error) { + prefix := "PAY" + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + prefix = "PAY-IN" + case utils.PaymentPartySupplier: + prefix = "PAY-OUT" + } + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("%s-%05d", prefix, sequence), nil +} + +func (s paymentService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s paymentService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists}, + ) + case utils.PaymentPartySupplier: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists}, + ) + default: + return utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s paymentService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} + +func (s paymentService) getSupplierCategory(ctx context.Context, supplierId uint) (string, error) { + category, err := s.Repository.SupplierCategory(ctx, supplierId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", utils.NotFound("Supplier not found") + } + return "", err + } + return strings.ToUpper(strings.TrimSpace(category)), nil +} + +func isSupplierCategoryBiaya(category string) bool { + switch strings.ToUpper(strings.TrimSpace(category)) { + case string(utils.SupplierCategoryBOP), "BIAYA": + return true + default: + return false + } +} diff --git a/internal/modules/finance/payments/validations/payment.validation.go b/internal/modules/finance/payments/validations/payment.validation.go new file mode 100644 index 00000000..14c8f151 --- /dev/null +++ b/internal/modules/finance/payments/validations/payment.validation.go @@ -0,0 +1,29 @@ +package validation + +type Create struct { + PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"` + Nominal float64 `json:"nominal" validate:"required_strict"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"` + BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` +} + +type Update struct { + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/route.go b/internal/modules/finance/route.go new file mode 100644 index 00000000..bc99bf7e --- /dev/null +++ b/internal/modules/finance/route.go @@ -0,0 +1,31 @@ +package finance + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/modules" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + payments "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments" + initials "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials" + injections "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections" + transactions "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions" + // MODULE IMPORTS +) + +func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + group := router.Group("/finance") + + allModules := []modules.Module{ + payments.PaymentModule{}, + initials.InitialModule{}, + injections.InjectionModule{}, + transactions.TransactionModule{}, + // MODULE REGISTRY + } + + for _, m := range allModules { + m.RegisterRoutes(group, db, validate) + } +} diff --git a/internal/modules/finance/transactions/controllers/transaction.controller.go b/internal/modules/finance/transactions/controllers/transaction.controller.go new file mode 100644 index 00000000..fa3e1369 --- /dev/null +++ b/internal/modules/finance/transactions/controllers/transaction.controller.go @@ -0,0 +1,96 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type TransactionController struct { + TransactionService service.TransactionService +} + +func NewTransactionController(transactionService service.TransactionService) *TransactionController { + return &TransactionController{ + TransactionService: transactionService, + } +} + +func (u *TransactionController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.TransactionService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.TransactionListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all transactions successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToTransactionListDTOs(result), + }) +} + +func (u *TransactionController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.TransactionService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get transaction successfully", + Data: dto.ToTransactionListDTO(*result), + }) +} + +func (u *TransactionController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.TransactionService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete transaction successfully", + }) +} diff --git a/internal/modules/finance/transactions/dto/transaction.dto.go b/internal/modules/finance/transactions/dto/transaction.dto.go new file mode 100644 index 00000000..25740344 --- /dev/null +++ b/internal/modules/finance/transactions/dto/transaction.dto.go @@ -0,0 +1,189 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type TransactionRelationDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number,omitempty"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"` +} + +type TransactionListDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type TransactionDetailDTO struct { + TransactionListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToTransactionRelationDTO(e entity.Payment) TransactionRelationDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + + return TransactionRelationDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + } +} + +func ToTransactionListDTO(e entity.Payment) TransactionListDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return TransactionListDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToTransactionListDTOs(e []entity.Payment) []TransactionListDTO { + result := make([]TransactionListDTO, len(e)) + for i, r := range e { + result[i] = ToTransactionListDTO(r) + } + return result +} + +func ToTransactionDetailDTO(e entity.Payment) TransactionDetailDTO { + return TransactionDetailDTO{ + TransactionListDTO: ToTransactionListDTO(e), + } +} + +func partyFromPayment(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func paymentCodeFromPayment(e entity.Payment) string { + if e.PaymentCode != "" { + return e.PaymentCode + } + if e.ReferenceNumber != nil { + return *e.ReferenceNumber + } + return "" +} + +func transactionTypeFromPayment(e entity.Payment) string { + if e.TransactionType != "" { + return e.TransactionType + } + return e.Direction +} + +func paymentAmounts(direction string, nominal float64) (float64, float64) { + switch strings.ToUpper(direction) { + case "IN": + return 0, nominal + case "OUT": + return nominal, 0 + default: + return 0, 0 + } +} diff --git a/internal/modules/finance/transactions/module.go b/internal/modules/finance/transactions/module.go new file mode 100644 index 00000000..c98931a3 --- /dev/null +++ b/internal/modules/finance/transactions/module.go @@ -0,0 +1,42 @@ +package transactions + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories" + sTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type TransactionModule struct{} + +func (TransactionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + transactionRepo := rTransaction.NewTransactionRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) + } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register initial approval workflow: %v", err)) + } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) + } + + transactionService := sTransaction.NewTransactionService(transactionRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + TransactionRoutes(router, userService, transactionService) +} diff --git a/internal/modules/finance/transactions/repositories/transaction.repository.go b/internal/modules/finance/transactions/repositories/transaction.repository.go new file mode 100644 index 00000000..d1629e8b --- /dev/null +++ b/internal/modules/finance/transactions/repositories/transaction.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type TransactionRepository interface { + repository.BaseRepository[entity.Payment] +} + +type TransactionRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] +} + +func NewTransactionRepository(db *gorm.DB) TransactionRepository { + return &TransactionRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + } +} diff --git a/internal/modules/finance/transactions/route.go b/internal/modules/finance/transactions/route.go new file mode 100644 index 00000000..17baa9e8 --- /dev/null +++ b/internal/modules/finance/transactions/route.go @@ -0,0 +1,21 @@ +package transactions + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/controllers" + transaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func TransactionRoutes(v1 fiber.Router, u user.UserService, s transaction.TransactionService) { + ctrl := controller.NewTransactionController(s) + + route := v1.Group("/transactions") + route.Use(m.Auth(u)) + + route.Get("/",m.RequirePermissions(m.P_Finances_Transaction_GetAll), ctrl.GetAll) + route.Get("/:id",m.RequirePermissions(m.P_Finances_Transaction_GetOne), ctrl.GetOne) + route.Delete("/:id",m.RequirePermissions(m.P_Finances_Transaction_DeleteOne), ctrl.DeleteOne) +} diff --git a/internal/modules/finance/transactions/services/transaction.service.go b/internal/modules/finance/transactions/services/transaction.service.go new file mode 100644 index 00000000..f7398d43 --- /dev/null +++ b/internal/modules/finance/transactions/services/transaction.service.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "errors" + "strings" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type TransactionService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type transactionService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.TransactionRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflows map[string]approvalutils.ApprovalWorkflowKey +} + +func NewTransactionService( + repo repository.TransactionRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) TransactionService { + return &transactionService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflows: map[string]approvalutils.ApprovalWorkflowKey{ + string(utils.TransactionTypeSaldoAwal): utils.ApprovalWorkflowInitial, + string(utils.TransactionTypeInjection): utils.ApprovalWorkflowInjection, + }, + } +} + +func (s transactionService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" + return db.Where( + `LOWER(payment_code) LIKE ? OR + LOWER(COALESCE(reference_number, '')) LIKE ? OR + LOWER(COALESCE(transaction_type, '')) LIKE ? OR + LOWER(COALESCE(notes, '')) LIKE ?`, + like, like, like, like, + ) + } + return db.Order("payment_date DESC").Order("created_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get transactions: %+v", err) + return nil, 0, err + } + s.attachApprovals(c.Context(), transactions) + return transactions, total, nil +} + +func (s transactionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + transaction, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Transaction not found") + } + if err != nil { + s.Log.Errorf("Failed get transaction by id: %+v", err) + return nil, err + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget( + c.Context(), + s.workflowForTransaction(transaction), + id, + s.approvalQueryModifier(), + ) + if err != nil { + s.Log.Warnf("Unable to load latest approval for transaction %d: %+v", id, err) + } else { + transaction.LatestApproval = approval + } + } + return transaction, nil +} + +func (s transactionService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Transaction not found") + } + s.Log.Errorf("Failed to delete transaction: %+v", err) + return err + } + return nil +} + +func (s transactionService) attachApprovals(ctx context.Context, transactions []entity.Payment) { + if s.ApprovalSvc == nil || len(transactions) == 0 { + return + } + + workflowIDs := map[approvalutils.ApprovalWorkflowKey][]uint{} + for _, transaction := range transactions { + workflow := s.workflowForTransaction(&transaction) + workflowIDs[workflow] = append(workflowIDs[workflow], transaction.Id) + } + + approvalByWorkflow := make(map[approvalutils.ApprovalWorkflowKey]map[uint]*entity.Approval, len(workflowIDs)) + for workflow, ids := range workflowIDs { + if len(ids) == 0 { + continue + } + approvals, err := s.ApprovalSvc.LatestByTargets(ctx, workflow, ids, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for transactions: %+v", err) + continue + } + approvalByWorkflow[workflow] = approvals + } + + for i := range transactions { + workflow := s.workflowForTransaction(&transactions[i]) + if approvals, ok := approvalByWorkflow[workflow]; ok { + transactions[i].LatestApproval = approvals[transactions[i].Id] + } + } +} + +func (s transactionService) workflowForTransaction(transaction *entity.Payment) approvalutils.ApprovalWorkflowKey { + if transaction == nil { + return utils.ApprovalWorkflowPayment + } + transactionType := strings.TrimSpace(strings.ToUpper(transaction.TransactionType)) + if transactionType == "" { + return utils.ApprovalWorkflowPayment + } + if workflow, ok := s.approvalWorkflows[transactionType]; ok { + return workflow + } + return utils.ApprovalWorkflowPayment +} + +func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} diff --git a/internal/modules/finance/transactions/validations/transaction.validation.go b/internal/modules/finance/transactions/validations/transaction.validation.go new file mode 100644 index 00000000..7d16d3ee --- /dev/null +++ b/internal/modules/finance/transactions/validations/transaction.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index f91e6eda..008f9966 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -33,10 +33,8 @@ type ProductWarehouseDTO struct { type AdjustmentRelationDTO struct { Id uint `json:"id"` - TransactionType string `json:"transaction_type"` - Quantity float64 `json:"quantity"` - BeforeQuantity float64 `json:"before_quantity"` - AfterQuantity float64 `json:"after_quantity"` + Increase float64 `json:"increase"` + Decrease float64 `json:"decrease"` Note string `json:"note,omitempty"` ProductWarehouseId uint `json:"product_warehouse_id"` ProductWarehouse *ProductWarehouseDTO `json:"product_warehouse,omitempty"` @@ -105,11 +103,9 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO { func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO { return AdjustmentRelationDTO{ Id: e.Id, - TransactionType: e.TransactionType, - Quantity: e.Quantity, - BeforeQuantity: e.BeforeQuantity, - AfterQuantity: e.AfterQuantity, - Note: e.Note, + Note: e.Notes, + Increase: e.Increase, + Decrease: e.Decrease, ProductWarehouseId: e.ProductWarehouseId, ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), } @@ -136,6 +132,6 @@ func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO { func ToAdjustmentDetailDTO(e *entity.StockLog) AdjustmentDetailDTO { return AdjustmentDetailDTO{ AdjustmentListDTO: ToAdjustmentListDTO(e), - UpdatedAt: e.UpdatedAt, + // UpdatedAt: e.UpdatedAt, } } diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index b3e12676..08e556ea 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -5,26 +5,78 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type AdjustmentModule struct{} func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + // Repositories stockLogsRepo := rStockLogs.NewStockLogRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) + adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate) + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("ADJUSTMENT_IN"), + Table: "adjustment_stocks", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic("Failed to register ADJUSTMENT_IN as Stockable: " + err.Error()) + } + + err = fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("ADJUSTMENT_OUT"), + Table: "adjustment_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic("Failed to register ADJUSTMENT_OUT as Usable: " + err.Error()) + } + + adjustmentService := sAdjustment.NewAdjustmentService( + productRepo, + stockLogsRepo, + warehouseRepo, + productWarehouseRepo, + adjustmentStockRepo, + fifoService, + validate, + projectFlockKandangRepo, + ) userService := sUser.NewUserService(userRepo, validate) AdjustmentRoutes(router, userService, adjustmentService) diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go new file mode 100644 index 00000000..8d62b05c --- /dev/null +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -0,0 +1,50 @@ +package repositories + +import ( + "context" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type AdjustmentStockRepository interface { + CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error + GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) + WithTx(tx *gorm.DB) AdjustmentStockRepository + DB() *gorm.DB +} + +type adjustmentStockRepositoryImpl struct { + db *gorm.DB +} + +func NewAdjustmentStockRepository(db *gorm.DB) AdjustmentStockRepository { + return &adjustmentStockRepositoryImpl{db: db} +} + +func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error { + q := r.db.WithContext(ctx) + if modifier != nil { + q = modifier(q) + } + return q.Create(data).Error +} + +func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { + var record entity.AdjustmentStock + err := r.db.WithContext(ctx). + Where("stock_log_id = ?", stockLogID). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepository { + return &adjustmentStockRepositoryImpl{db: tx} +} + +func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB { + return r.db +} diff --git a/internal/modules/inventory/adjustments/route.go b/internal/modules/inventory/adjustments/route.go index 8f58bb4d..f99fe01e 100644 --- a/internal/modules/inventory/adjustments/route.go +++ b/internal/modules/inventory/adjustments/route.go @@ -1,7 +1,7 @@ package adjustments import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/controllers" adjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,10 +13,10 @@ func AdjustmentRoutes(v1 fiber.Router, u user.UserService, s adjustment.Adjustme ctrl := controller.NewAdjustmentController(s) route := v1.Group("/adjustments") - + route.Use(m.Auth(u)) // Standard CRUD routes following master data pattern - route.Get("/", ctrl.AdjustmentHistory) // Get all with pagination and filters - route.Post("/", ctrl.Adjustment) // Create adjustment - route.Get("/:id", ctrl.GetOne) + route.Get("/",m.RequirePermissions(m.P_AdjustmentGetAll), ctrl.AdjustmentHistory) // Get all with pagination and filters + route.Post("/",m.RequirePermissions(m.P_AdjustmentCreate), ctrl.Adjustment) // Create adjustment + route.Get("/:id",m.RequirePermissions(m.P_AdjustmentGetOne), ctrl.GetOne) } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index e1c4166d..47d41648 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -1,23 +1,26 @@ package service import ( + "context" "errors" + "fmt" "strings" - common "gitlab.com/mbugroup/lti-api.git/internal/common/service" - - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" - ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" - warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" - stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gorm.io/gorm" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" + ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" ) type AdjustmentService interface { @@ -27,22 +30,37 @@ type AdjustmentService interface { } type adjustmentService struct { - Log *logrus.Logger - Validate *validator.Validate - StockLogsRepository stockLogsRepo.StockLogRepository - WarehouseRepo warehouseRepo.WarehouseRepository - ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository - ProductRepo productRepo.ProductRepository + Log *logrus.Logger + Validate *validator.Validate + StockLogsRepository stockLogsRepo.StockLogRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository + ProductRepo productRepo.ProductRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository + FifoSvc common.FifoService } -func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate) AdjustmentService { +func NewAdjustmentService( + productRepo productRepo.ProductRepository, + stockLogsRepo stockLogsRepo.StockLogRepository, + warehouseRepo warehouseRepo.WarehouseRepository, + productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, + adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository, + fifoSvc common.FifoService, + validate *validator.Validate, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, +) AdjustmentService { return &adjustmentService{ - Log: utils.Log, - Validate: validate, - StockLogsRepository: stockLogsRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - ProductRepo: productRepo, + Log: utils.Log, + Validate: validate, + StockLogsRepository: stockLogsRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + AdjustmentStockRepository: adjustmentStockRepo, + FifoSvc: fifoSvc, } } @@ -66,7 +84,7 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LogType != entity.LogTypeAdjustment { + if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") } @@ -78,7 +96,10 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, err } ctx := c.Context() - + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists}, common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists}, @@ -90,33 +111,51 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } transactionType := strings.ToUpper(req.TransactionType) - if transactionType != entity.TransactionTypeIncrease && transactionType != entity.TransactionTypeDecrease { + if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") } var createdLogId uint - isProductWarehouseExist, err := s.ProductWarehouseRepo.ProductWarehouseExistByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) - if err != nil { - s.Log.Errorf("Failed to check product warehouse existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + var projectFlockKandangID *uint + pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) + if err == nil && pfkID > 0 { + projectFlockKandangID = &pfkID } - if !isProductWarehouseExist { + + pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk( + ctx, + uint(req.ProductID), + uint(req.WarehouseID), + projectFlockKandangID, + ) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to find product warehouse: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") + } newPW := &entity.ProductWarehouse{ - ProductId: uint(req.ProductID), - WarehouseId: uint(req.WarehouseID), - Quantity: 0, - CreatedBy: 1, // TODO: should Get from auth middleware + ProductId: uint(req.ProductID), + WarehouseId: uint(req.WarehouseID), + Quantity: 0, + ProjectFlockKandangId: projectFlockKandangID, } if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil { s.Log.Errorf("Failed to create product warehouse: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create product warehouse") } - s.Log.Infof("Product warehouse created: %+v", newPW.Id) + pw = newPW } + if err := common.EnsureProjectFlockNotClosedForProductWarehouses( + ctx, + s.StockLogsRepository.DB(), + []uint{pw.Id}, + ); err != nil { + return nil, err + } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) if err != nil { @@ -124,26 +163,25 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } + // Create StockLog for history tracking afterQuantity := productWarehouse.Quantity - if transactionType == entity.TransactionTypeIncrease { + newLog := &entity.StockLog{ + LoggableType: string(utils.StockLogTypeAdjustment), + LoggableId: 0, + Notes: req.Note, + ProductWarehouseId: productWarehouse.Id, + CreatedBy: actorID, + } + + if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity + newLog.Increase = afterQuantity } else { if productWarehouse.Quantity < req.Quantity { return fiber.NewError(fiber.StatusBadRequest, "Insufficient stock for adjustment") } afterQuantity -= req.Quantity - } - - newLog := &entity.StockLog{ - TransactionType: transactionType, - Quantity: req.Quantity, - BeforeQuantity: productWarehouse.Quantity, - AfterQuantity: afterQuantity, - LogType: entity.LogTypeAdjustment, - LogId: 0, - Note: req.Note, - ProductWarehouseId: productWarehouse.Id, - CreatedBy: 1, // TODO: should Get from auth middleware + newLog.Decrease = afterQuantity } if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { @@ -151,6 +189,47 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return err } + // Create AdjustmentStock record for FIFO tracking + adjustmentStock := &entity.AdjustmentStock{ + StockLogId: newLog.Id, + ProductWarehouseId: productWarehouse.Id, + } + if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { + s.Log.Errorf("Failed to create adjustment stock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") + } + + if transactionType == string(utils.StockLogTransactionTypeIncrease) { + // Adjustment INCREASE → Replenish stock (Stockable) + note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) + _, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ + StockableKey: "ADJUSTMENT_IN", + StockableID: adjustmentStock.Id, + ProductWarehouseID: uint(productWarehouse.Id), + Quantity: req.Quantity, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err)) + } + + } else { + // Adjustment DECREASE → Consume stock (Usable) + _, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ + UsableKey: "ADJUSTMENT_OUT", + UsableID: adjustmentStock.Id, + ProductWarehouseID: uint(productWarehouse.Id), + Quantity: req.Quantity, + AllowPending: false, // Don't allow pending for adjustment + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err)) + } + } + + // Update ProductWarehouse quantity (for backward compatibility/reporting) productWarehouse.Quantity = afterQuantity if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) @@ -169,6 +248,32 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return s.GetOne(c, createdLogId) } +func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { + warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) + } + s.Log.Errorf("Failed to get warehouse %d: %+v", warehouseID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") + } + + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) + } + + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) + } + s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") + } + + return uint(projectFlockKandang.Id), nil +} + func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err @@ -177,7 +282,6 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) if err != nil { - s.Log.Errorf("Failed to check warehouse existence: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") } if query.WarehouseID > 0 && !isWarehousesExist { @@ -197,7 +301,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu db = s.withRelations(db) - db = db.Where("log_type = ?", entity.LogTypeAdjustment) + db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) if query.TransactionType != "" { db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) diff --git a/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go b/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go deleted file mode 100644 index 512a5786..00000000 --- a/internal/modules/inventory/marketing-delivery-products/repositories/marketing-delivery-products.repository.go +++ /dev/null @@ -1,51 +0,0 @@ -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 MarketingDeliveryProductRepository interface { - repository.BaseRepository[entity.MarketingDeliveryProduct] - GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) - GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) -} - -type MarketingDeliveryProductRepositoryImpl struct { - *repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct] -} - -func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository { - return &MarketingDeliveryProductRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), - } -} - -func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) { - var deliveryProduct entity.MarketingDeliveryProduct - if err := r.DB().WithContext(ctx).Where("marketing_product_id = ?", marketingProductID).First(&deliveryProduct).Error; err != nil { - return nil, err - } - return &deliveryProduct, nil -} - -func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { - var deliveryProducts []entity.MarketingDeliveryProduct - - // Raw query untuk mengambil delivery products berdasarkan marketing ID dengan preload MarketingProduct - // Filter: hanya ambil yang sudah memiliki delivery_date (delivery date tidak null) - if err := r.DB().WithContext(ctx). - Preload("MarketingProduct"). - Joins("INNER JOIN marketing_products mp ON marketing_delivery_products.marketing_product_id = mp.id"). - Where("mp.marketing_id = ?", marketingId). - Where("marketing_delivery_products.delivery_date IS NOT NULL"). - Order("marketing_delivery_products.id ASC"). - Find(&deliveryProducts).Error; err != nil { - return nil, err - } - - return deliveryProducts, nil -} diff --git a/internal/modules/inventory/product-stocks/controllers/product-stock.controller.go b/internal/modules/inventory/product-stocks/controllers/product-stock.controller.go new file mode 100644 index 00000000..430941ae --- /dev/null +++ b/internal/modules/inventory/product-stocks/controllers/product-stock.controller.go @@ -0,0 +1,77 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" + // entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type ProductStockController struct { + ProductStockService service.ProductStockService +} + +func NewProductStockController(productStockService service.ProductStockService) *ProductStockController { + return &ProductStockController{ + ProductStockService: productStockService, + } +} + +func (u *ProductStockController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ProductStockService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductStockListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all productStocks successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProductStockListDTOs(result), + }) +} + +func (u *ProductStockController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + res, err := u.ProductStockService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved product successfully", + Data: dto.ToProductStockDetailDTO(*res), + }) +} diff --git a/internal/modules/inventory/product-stocks/dto/product-stock.dto.go b/internal/modules/inventory/product-stocks/dto/product-stock.dto.go new file mode 100644 index 00000000..e571d2b6 --- /dev/null +++ b/internal/modules/inventory/product-stocks/dto/product-stock.dto.go @@ -0,0 +1,224 @@ +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" + productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" + uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ProductStockRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type ProductStockListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Brand string `json:"brand"` + Sku *string `json:"sku,omitempty"` + ProductPrice float64 `json:"product_price"` + SellingPrice *float64 `json:"selling_price,omitempty"` + Tax *float64 `json:"tax,omitempty"` + ExpiryPeriod *int `json:"expiry_period,omitempty"` + Flags []string `json:"flags"` + Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` + ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Suppliers []SupplierDTO `json:"suppliers,omitempty"` + ProductWarehouses []ProductWarehouseDTO `json:"product_warehouses,omitempty"` + TotalStock float64 `json:"total_stock"` +} + +type ProductStockDetailDTO struct { + ProductStockListDTO +} + +type SupplierDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` +} + +type ProductWarehouseDTO struct { + Id uint `json:"id"` + ProductId uint `json:"product_id"` + WarehouseId uint `json:"warehouse_id"` + WarehouseName string `json:"warehouse_name"` + Location *locationDTO.LocationRelationDTO `json:"location"` + CurrentStock float64 `json:"current_stock"` + StockLogs []StockLogDetailDTO `json:"stock_logs"` +} + +type StockLogDetailDTO struct { + Id uint `json:"id"` + Increase float64 `json:"increase"` + Decrease float64 `json:"decrease"` + LoggableType string `json:"loggable_type"` + LoggableId uint `json:"loggable_id"` + Notes *string `json:"notes"` + ProductWarehouseId uint `json:"product_warehouse_id"` + CreatedBy uint `json:"created_by"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// === Mapper Functions === +func ToProductStockListDTO(e entity.Product) ProductStockListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + var categoryRef *productCategoryDTO.ProductCategoryRelationDTO + if e.ProductCategory.Id != 0 { + mapped := productCategoryDTO.ToProductCategoryRelationDTO(e.ProductCategory) + categoryRef = &mapped + } + + flags := make([]string, len(e.Flags)) + for i, f := range e.Flags { + flags[i] = f.Name + } + + var uomRef *uomDTO.UomRelationDTO + if e.Uom.Id != 0 { + mapped := uomDTO.ToUomRelationDTO(e.Uom) + uomRef = &mapped + } + + return ProductStockListDTO{ + Id: e.Id, + Name: e.Name, + Flags: flags, + Uom: uomRef, + Brand: e.Brand, + Sku: e.Sku, + ProductPrice: e.ProductPrice, + SellingPrice: e.SellingPrice, + Tax: e.Tax, + ExpiryPeriod: e.ExpiryPeriod, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + ProductCategory: categoryRef, + Suppliers: mapSupplierDTOs(e.ProductSuppliers), + TotalStock: calculateTotalStock(e.ProductWarehouses), + } +} + +func ToProductStockListDTOs(e []entity.Product) []ProductStockListDTO { + result := make([]ProductStockListDTO, len(e)) + for i, r := range e { + result[i] = ToProductStockListDTO(r) + } + return result +} + +func ToProductStockDetailDTO(e entity.Product) ProductStockDetailDTO { + base := ToProductStockListDTO(e) + base.ProductWarehouses = mapProductWarehouseDTOs(e.ProductWarehouses) + + return ProductStockDetailDTO{ + ProductStockListDTO: base, + } +} + +// --- helpers --- + +func mapSupplierDTOs(src []entity.ProductSupplier) []SupplierDTO { + if len(src) == 0 { + return nil + } + result := make([]SupplierDTO, 0, len(src)) + for _, ps := range src { + if ps.Supplier.Id == 0 { + continue + } + result = append(result, SupplierDTO{ + Id: ps.Supplier.Id, + Name: ps.Supplier.Name, + Alias: ps.Supplier.Alias, + Category: ps.Supplier.Category, + }) + } + return result +} + +func mapProductWarehouseDTOs(src []entity.ProductWarehouse) []ProductWarehouseDTO { + if len(src) == 0 { + return []ProductWarehouseDTO{} + } + result := make([]ProductWarehouseDTO, 0, len(src)) + for _, pw := range src { + dto := ProductWarehouseDTO{ + Id: pw.Id, + ProductId: pw.ProductId, + WarehouseId: pw.WarehouseId, + CurrentStock: pw.Quantity, + StockLogs: mapStockLogs(pw.StockLogs), + } + if pw.Warehouse.Id != 0 { + dto.WarehouseName = pw.Warehouse.Name + if pw.Warehouse.Location != nil { + mapped := locationDTO.ToLocationRelationDTO(*pw.Warehouse.Location) + dto.Location = &mapped + } + } + result = append(result, dto) + } + return result +} + +func mapStockLogs(src []entity.StockLog) []StockLogDetailDTO { + if len(src) == 0 { + return []StockLogDetailDTO{} + } + result := make([]StockLogDetailDTO, 0, len(src)) + for _, log := range src { + var notes *string + if log.Notes != "" { + n := log.Notes + notes = &n + } + + result = append(result, StockLogDetailDTO{ + Id: log.Id, + Increase: log.Increase, + Decrease: log.Decrease, + LoggableType: log.LoggableType, + LoggableId: log.LoggableId, + Notes: notes, + ProductWarehouseId: log.ProductWarehouseId, + CreatedBy: log.CreatedBy, + CreatedUser: mapCreatedUser(log.CreatedUser), + CreatedAt: log.CreatedAt, + }) + } + return result +} + +func mapCreatedUser(user *entity.User) *userDTO.UserRelationDTO { + if user == nil || user.Id == 0 { + return nil + } + mapped := userDTO.ToUserRelationDTO(*user) + return &mapped +} + +func calculateTotalStock(productWarehouses []entity.ProductWarehouse) float64 { + var total float64 + for _, pw := range productWarehouses { + total += pw.Quantity + } + return total +} diff --git a/internal/modules/inventory/product-stocks/module.go b/internal/modules/inventory/product-stocks/module.go new file mode 100644 index 00000000..43bcd1be --- /dev/null +++ b/internal/modules/inventory/product-stocks/module.go @@ -0,0 +1,25 @@ +package productStocks + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + sProductStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/services" + + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProductStockModule struct{} + +func (ProductStockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + productRepo := rProduct.NewProductRepository(db) + userRepo := rUser.NewUserRepository(db) + + productStockService := sProductStock.NewProductStockService(productRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ProductStockRoutes(router, userService, productStockService) +} diff --git a/internal/modules/inventory/product-stocks/repositories/product-stock.repository.go b/internal/modules/inventory/product-stocks/repositories/product-stock.repository.go new file mode 100644 index 00000000..d6e5368d --- /dev/null +++ b/internal/modules/inventory/product-stocks/repositories/product-stock.repository.go @@ -0,0 +1,21 @@ +package repository + +// import ( +// entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +// "gitlab.com/mbugroup/lti-api.git/internal/common/repository" +// "gorm.io/gorm" +// ) + +// type ProductStockRepository interface { +// repository.BaseRepository[entity.ProductStock] +// } + +// type ProductStockRepositoryImpl struct { +// *repository.BaseRepositoryImpl[entity.ProductStock] +// } + +// func NewProductStockRepository(db *gorm.DB) ProductStockRepository { +// return &ProductStockRepositoryImpl{ +// BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductStock](db), +// } +// } diff --git a/internal/modules/inventory/product-stocks/route.go b/internal/modules/inventory/product-stocks/route.go new file mode 100644 index 00000000..41714edc --- /dev/null +++ b/internal/modules/inventory/product-stocks/route.go @@ -0,0 +1,25 @@ +package productStocks + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/controllers" + productStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProductStockRoutes(v1 fiber.Router, u user.UserService, s productStock.ProductStockService) { + ctrl := controller.NewProductStockController(s) + + route := v1.Group("/product-stocks") +route.Use(m.Auth(u)) + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/",m.RequirePermissions(m.P_ProductStockGetAll), ctrl.GetAll) + route.Get("/:id",m.RequirePermissions(m.P_ProductStockGetOne), ctrl.GetOne) +} diff --git a/internal/modules/inventory/product-stocks/services/product-stock.service.go b/internal/modules/inventory/product-stocks/services/product-stock.service.go new file mode 100644 index 00000000..11475109 --- /dev/null +++ b/internal/modules/inventory/product-stocks/services/product-stock.service.go @@ -0,0 +1,98 @@ +package service + +import ( + "errors" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations" + productRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" + "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 ProductStockService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Product, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Product, error) +} + +type productStockService struct { + Log *logrus.Logger + Validate *validator.Validate + ProductRepository productRepository.ProductRepository +} + +func NewProductStockService( + productRepo productRepository.ProductRepository, + validate *validator.Validate, +) ProductStockService { + return &productStockService{ + Log: utils.Log, + Validate: validate, + ProductRepository: productRepo, + } +} + +func (s productStockService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Uom"). + Preload("ProductCategory"). + Preload("Flags"). + Preload("ProductWarehouses"). + Preload("ProductWarehouses.Warehouse"). + Preload("ProductWarehouses.Warehouse.Location"). + Preload("ProductWarehouses.Warehouse.Location.Area"). + Preload("ProductWarehouses.StockLogs", func(db *gorm.DB) *gorm.DB { + return db.Order("created_at ASC") + }). + Preload("ProductWarehouses.StockLogs.CreatedUser"). + Preload("ProductSuppliers"). + Preload("ProductSuppliers.Supplier", func(db *gorm.DB) *gorm.DB { + return db.Order("suppliers.name ASC") + }) +} + +func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Product, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = db.Where(`EXISTS ( + SELECT 1 + FROM product_warehouses pw + WHERE pw.product_id = products.id + AND pw.qty > 0 + )`) + + db = s.withRelations(db) + if params.Search != "" { + db = db.Where("products.name ILIKE ?", "%"+params.Search+"%") + } + return db.Order("products.created_at DESC").Order("products.updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get productStocks: %+v", err) + return nil, 0, err + } + return productStocks, total, nil +} + +func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) { + product, err := s.ProductRepository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + if err != nil { + s.Log.Errorf("Failed get product by id: %+v", err) + return nil, err + } + return product, nil +} diff --git a/internal/modules/inventory/product-stocks/validations/product-stock.validation.go b/internal/modules/inventory/product-stocks/validations/product-stock.validation.go new file mode 100644 index 00000000..7d16d3ee --- /dev/null +++ b/internal/modules/inventory/product-stocks/validations/product-stock.validation.go @@ -0,0 +1,15 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 06889670..57a13021 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -24,11 +24,12 @@ type ProductWarehousNestedDTO struct { type ProductWarehouseListDTO struct { ProductWarehouseRelationDTO - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` - CreatedUser *UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` + CreatedUser *UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type UserRelationDTO struct { @@ -71,6 +72,19 @@ type AreaRelationDTO struct { Name string `json:"name"` } +type ProjectFlockKandangRelationDTO struct { + Id uint `json:"id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Period int `json:"period"` + ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"` +} + +type ProjectFlockRelationDTO struct { + Id uint `json:"id"` + FlockName string `json:"flock_name"` +} + // === Mapper Functions === func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { @@ -98,13 +112,19 @@ func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNeste func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO { dto := ProductWarehouseListDTO{ ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, + // CreatedAt: e.CreatedAt, + // UpdatedAt: e.UpdatedAt, } // Map Product relation jika ada if e.Product.Id != 0 { product := productDTO.ToProductRelationDTO(e.Product) + + // Tambahkan flock name ke product name jika ada project flock + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { + product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + } + dto.Product = &product } @@ -139,15 +159,35 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT dto.Warehouse = &warehouse } - // Map CreatedUser relation jika ada - if e.CreatedUser.Id != 0 { - user := UserRelationDTO{ - Id: e.CreatedUser.Id, - Username: e.CreatedUser.Name, + // Map ProjectFlockKandang relation jika ada + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.Id != 0 { + pfkDTO := &ProjectFlockKandangRelationDTO{ + Id: e.ProjectFlockKandang.Id, + ProjectFlockId: e.ProjectFlockKandang.ProjectFlockId, + KandangId: e.ProjectFlockKandang.KandangId, + Period: e.ProjectFlockKandang.Period, } - dto.CreatedUser = &user + + // Map ProjectFlock jika ada + if e.ProjectFlockKandang.ProjectFlock.Id != 0 { + pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ + Id: e.ProjectFlockKandang.ProjectFlock.Id, + FlockName: e.ProjectFlockKandang.ProjectFlock.FlockName, + } + } + + dto.ProjectFlockKandang = pfkDTO } + // Map CreatedUser relation jika ada + // if e.CreatedUser.Id != 0 { + // user := UserRelationDTO{ + // Id: e.CreatedUser.Id, + // Username: e.CreatedUser.Name, + // } + // dto.CreatedUser = &user + // } + return dto } diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index b5685faa..a8a44eb7 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -18,6 +18,7 @@ type ProductWarehouseRepository interface { ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) ExistsByID(ctx context.Context, id uint) (bool, error) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) + FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error) GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) GetLatestByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint, db *gorm.DB) (*entity.ProductWarehouse, error) GetByFlagAndWarehouseID(ctx context.Context, flagName string, warehouseId uint) ([]entity.ProductWarehouse, error) @@ -27,7 +28,9 @@ type ProductWarehouseRepository interface { GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) IdExists(ctx context.Context, id uint) (bool, error) CleanupEmpty(ctx context.Context, affected map[uint]struct{}) error - EnsureProductWarehouse(ctx context.Context, productID, warehouseID uint, createdBy uint64) (uint, error) + EnsureProductWarehouse(ctx context.Context, productID, warehouseID uint, projectFlockKandangID *uint, createdBy uint) (uint, error) + GetByProductWarehouseAndProjectFlockKandang(ctx context.Context, productId, warehouseId, projectFlockKandangId uint) (*entity.ProductWarehouse, error) + DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error } type ProductWarehouseRepositoryImpl struct { @@ -81,9 +84,43 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) { var productWarehouse entity.ProductWarehouse - if err := r.DB().WithContext(ctx).Where("product_id = ? AND warehouse_id = ?", productId, warehouseId).First(&productWarehouse).Error; err != nil { + + err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId). + Order("id DESC"). + Preload("ProjectFlockKandang"). + First(&productWarehouse).Error + + if err == nil { + + if productWarehouse.ProjectFlockKandang.ClosedAt == nil { + return &productWarehouse, nil + } + + } + + err = r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId). + First(&productWarehouse).Error + + if err != nil { return nil, err } + + return &productWarehouse, nil +} + +func (r *ProductWarehouseRepositoryImpl) FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + + err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT DISTINCT FROM ?", productID, warehouseID, projectFlockKandangID). + First(&productWarehouse).Error + + if err != nil { + return nil, err + } + return &productWarehouse, nil } @@ -93,7 +130,7 @@ func (r *ProductWarehouseRepositoryImpl) GetByCategoryCodeAndWarehouseID(ctx con Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId). - Order("product_warehouses.created_at DESC") + Order("product_warehouses.id DESC") // preload relations so nested Product and Warehouse are populated err := q.Preload("Product").Preload("Warehouse").Find(&productWarehouses).Error @@ -151,7 +188,7 @@ func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, d } if err := base.Model(&entity.ProductWarehouse{}). Where("id = ?", id). - Update("quantity", gorm.Expr("COALESCE(quantity,0) + ?", delta)).Error; err != nil { + Update("qty", gorm.Expr("COALESCE(qty,0) + ?", delta)).Error; err != nil { return err } } @@ -171,7 +208,7 @@ func (r *ProductWarehouseRepositoryImpl) CleanupEmpty(ctx context.Context, affec var emptyIDs []uint if err := r.DB().WithContext(ctx). Model(&entity.ProductWarehouse{}). - Where("id IN ? AND COALESCE(quantity,0) <= 0", ids). + Where("id IN ? AND COALESCE(qty,0) <= 0", ids). Pluck("id", &emptyIDs).Error; err != nil { return err } @@ -179,6 +216,31 @@ func (r *ProductWarehouseRepositoryImpl) CleanupEmpty(ctx context.Context, affec return nil } + var inUseIDs []uint + if err := r.DB().WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("product_warehouse_id IN ?", emptyIDs). + Distinct(). + Pluck("product_warehouse_id", &inUseIDs).Error; err != nil { + return err + } + if len(inUseIDs) > 0 { + inUse := make(map[uint]struct{}, len(inUseIDs)) + for _, id := range inUseIDs { + inUse[id] = struct{}{} + } + filtered := make([]uint, 0, len(emptyIDs)) + for _, id := range emptyIDs { + if _, exists := inUse[id]; !exists { + filtered = append(filtered, id) + } + } + emptyIDs = filtered + } + if len(emptyIDs) == 0 { + return nil + } + if err := r.DB().WithContext(ctx). Model(&entity.PurchaseItem{}). Where("product_warehouse_id IN ?", emptyIDs). @@ -199,10 +261,21 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( ctx context.Context, productID uint, warehouseID uint, - createdBy uint64, + projectFlockKandangID *uint, + createdBy uint, ) (uint, error) { record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) if err == nil { + // Backfill project_flock_kandang_id when it's missing and caller provides one. + if projectFlockKandangID != nil && (record.ProjectFlockKandangId == nil || *record.ProjectFlockKandangId == 0) { + if err := r.DB().WithContext(ctx). + Model(&entity.ProductWarehouse{}). + Where("id = ?", record.Id). + Update("project_flock_kandang_id", *projectFlockKandangID).Error; err != nil { + return 0, err + } + record.ProjectFlockKandangId = projectFlockKandangID + } return record.Id, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -210,14 +283,15 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( } entity := &entity.ProductWarehouse{ - ProductId: productID, - WarehouseId: warehouseID, - Quantity: 0, - CreatedBy: uint(createdBy), - } - if entity.CreatedBy == 0 { - entity.CreatedBy = 1 + ProductId: productID, + WarehouseId: warehouseID, + ProjectFlockKandangId: projectFlockKandangID, + Quantity: 0, + // CreatedBy: uint(createdBy), } + // if entity.CreatedBy == 0 { + // entity.CreatedBy = 1 + // } if err := r.CreateOne(ctx, entity, nil); err != nil { return 0, err @@ -225,6 +299,30 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( return entity.Id, nil } +func (r *ProductWarehouseRepositoryImpl) GetByProductWarehouseAndProjectFlockKandang( + ctx context.Context, + productId uint, + warehouseId uint, + projectFlockKandangId uint, +) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + if err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id = ?", productId, warehouseId, projectFlockKandangId). + First(&productWarehouse).Error; err != nil { + return nil, err + } + return &productWarehouse, nil +} + +func (r *ProductWarehouseRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error { + if len(projectFlockKandangIDs) == 0 { + return nil + } + return r.DB().WithContext(ctx). + Where("project_flock_kandang_id IN ?", projectFlockKandangIDs). + Delete(&entity.ProductWarehouse{}).Error +} + func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) { var productWarehouse entity.ProductWarehouse err := r.DB().WithContext(ctx). @@ -232,6 +330,8 @@ func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id u Preload("Warehouse"). Preload("Warehouse.Area"). Preload("Warehouse.Location"). + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock"). First(&productWarehouse, id).Error if err != nil { return nil, err @@ -257,8 +357,11 @@ func (r *ProductWarehouseRepositoryImpl) GetByFlagAndWarehouseID(ctx context.Con Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). Where("flags.name = ? AND product_warehouses.warehouse_id = ?", flagName, warehouseId). - Order("product_warehouses.created_at DESC"). - Preload("Product").Preload("Warehouse"). + Order("product_warehouses.id DESC"). + Preload("Product"). + Preload("Product.ProductCategory"). + Preload("Product.Uom"). + Preload("Warehouse"). Find(&productWarehouses).Error if err != nil { return nil, err diff --git a/internal/modules/inventory/product-warehouses/route.go b/internal/modules/inventory/product-warehouses/route.go index 9c6c8e2b..81c06a08 100644 --- a/internal/modules/inventory/product-warehouses/route.go +++ b/internal/modules/inventory/product-warehouses/route.go @@ -15,7 +15,7 @@ func ProductWarehouseRoutes(v1 fiber.Router, u user.UserService, s productWareho route := v1.Group("/product-warehouses") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Get("/:id", ctrl.GetOne) + route.Get("/",m.RequirePermissions(m.P_ProductWarehousekGetAll), ctrl.GetAll) + route.Get("/:id",m.RequirePermissions(m.P_ProductWarehouseGetOne), ctrl.GetOne) } diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index cc7d5b85..152bfa24 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -44,7 +44,8 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Warehouse.Location"). Preload("Warehouse.Area"). Preload("Warehouse.Kandang"). - Preload("CreatedUser") + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { @@ -104,7 +105,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = s.Repository.ApplyFlagsFilter(db, cleanFlags) - return db.Order("created_at DESC").Order("updated_at DESC") + return db.Order("product_warehouses.id DESC") }) if err != nil { diff --git a/internal/modules/inventory/route.go b/internal/modules/inventory/route.go index a0e98154..0d4d2f4b 100644 --- a/internal/modules/inventory/route.go +++ b/internal/modules/inventory/route.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments" + productStocks "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks" productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers" // MODULE IMPORTS @@ -21,6 +22,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida adjustments.AdjustmentModule{}, transfers.TransferModule{}, + productStocks.ProductStockModule{}, // MODULE REGISTRY } diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index b53d6e9a..4f060dc2 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -68,7 +68,7 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } @@ -80,15 +80,19 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - // ambil file form, err := c.MultipartForm() if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") } - _ = form.File["documents"] - // todo: tunggu ada aws baru proses - result, err := u.TransferService.CreateOne(c, &req) + files := form.File["documents"] + + if len(files) != len(req.Deliveries) { + return fiber.NewError(fiber.StatusBadRequest, + fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message) + } + + result, err := u.TransferService.CreateOne(c, &req, files) if err != nil { return err } @@ -98,6 +102,6 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index fe97ce0f..8f075715 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -7,8 +7,6 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// === DTO Structs === - type TransferRelationDTO struct { Id uint64 `json:"id"` TransferReason string `json:"transfer_reason"` @@ -17,7 +15,6 @@ type TransferRelationDTO struct { DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` } -// Only id and name for warehouse simple view type WarehouseSimpleDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -43,6 +40,14 @@ type SupplierSimpleDTO struct { Name string `json:"name"` } +type DocumentDTO struct { + Id uint `json:"id"` + Path string `json:"path"` + Name string `json:"name"` + Ext string `json:"ext"` + Size float64 `json:"size"` +} + type WarehouseDetailDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -65,24 +70,22 @@ type TransferDetailDTO struct { Deliveries []TransferDeliveryDTO `json:"deliveries"` } -// Detail produk type TransferDetailItemDTO struct { Id uint64 `json:"id"` - Proudct ProductSimpleDTO `json:"product"` + Product ProductSimpleDTO `json:"product"` Quantity float64 `json:"quantity"` } -// Delivery ekspedisi type TransferDeliveryDTO struct { Id uint64 `json:"id"` Supplier SupplierSimpleDTO `json:"supplier"` VehiclePlate string `json:"vehicle_plate"` DriverName string `json:"driver_name"` DocumentNumber string `json:"document_number"` - DocumentPath string `json:"document_path"` ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` + Document *DocumentDTO `json:"document,omitempty"` } type TransferDeliveryItemDTO struct { @@ -91,10 +94,7 @@ type TransferDeliveryItemDTO struct { Quantity float64 `json:"quantity"` } -// === Mapper Functions === - func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { - var sourceWarehouse *WarehouseDetailDTO if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) @@ -140,7 +140,7 @@ func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { Id: w.Id, Name: w.Name, Location: toLocationDTO(w.Location), - Area: toAreaDTO(&w.Area), // Ambil area langsung dari warehouse (area_id) + Area: toAreaDTO(&w.Area), } } @@ -150,22 +150,21 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) createdUser = &mapped } - // Map details + var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ Id: d.Id, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, - Quantity: d.Quantity, + Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { - // Map delivery items var items []TransferDeliveryItemDTO for _, item := range del.Items { items = append(items, TransferDeliveryItemDTO{ @@ -174,6 +173,19 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { Quantity: item.Quantity, }) } + + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + } + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -183,12 +195,13 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, + Document: document, }) } + return TransferListDTO{ TransferRelationDTO: ToTransferRelationDTO(e), CreatedUser: createdUser, @@ -208,21 +221,32 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO { } func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { - // Map details var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ Id: d.Id, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, - Quantity: d.Quantity, + Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + } + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -232,11 +256,12 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, + Document: document, }) } + return TransferDetailDTO{ TransferListDTO: ToTransferListDTO(e), Details: details, diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 734f0f03..60d1764a 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -1,17 +1,25 @@ package transfers import ( + "context" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type TransferModule struct{} @@ -25,8 +33,54 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate supplierRepo := rSupplier.NewSupplierRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(err) + } + + // Initialize FIFO Service + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + // Register Transfer as Stockable (adds stock to destination warehouse) + err = fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("STOCK_TRANSFER_IN"), + Table: "stock_transfer_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "dest_product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic(err) + } + + // Register Transfer as Usable (consumes stock from source warehouse) + err = fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("STOCK_TRANSFER_OUT"), + Table: "stock_transfer_details", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic(err) + } + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/route.go b/internal/modules/inventory/transfers/route.go index f608af42..d24dbcb4 100644 --- a/internal/modules/inventory/transfers/route.go +++ b/internal/modules/inventory/transfers/route.go @@ -15,8 +15,8 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ route := v1.Group("/transfers") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) + route.Get("/",m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) } diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index dd6c0068..1ca35a71 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -1,28 +1,34 @@ package service import ( + "context" "errors" "fmt" + "mime/multipart" "strings" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" - rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" - rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" ) type TransferService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) - CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) + CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) } type transferService struct { @@ -35,9 +41,13 @@ type transferService struct { StockLogsRepository rStockLogs.StockLogRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository SupplierRepo rSupplier.SupplierRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService + FifoSvc commonSvc.FifoService } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -48,8 +58,13 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr StockLogsRepository: stockLogsRepo, ProductWarehouseRepo: productWarehouseRepo, SupplierRepo: supplierRepo, + WarehouseRepo: warehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, + FifoSvc: fifoSvc, } } + func (s transferService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). @@ -62,7 +77,10 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("Details"). Preload("Details.Product"). Preload("Deliveries.Items"). - Preload("Deliveries.Supplier") + Preload("Deliveries.Supplier"). + Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeTransfer)) + }) } func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { @@ -84,16 +102,11 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } - s.Log.Infof("Retrieved %d transfers", len(transfers)) - return transfers, total, nil - } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { - var transfer entity.StockTransfer - // gunakan repo secara langsung transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) }) @@ -101,18 +114,17 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") } - s.Log.Errorf("Failed to get transfer by ID: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") } - s.Log.Infof("Retrieved transfer: %+v", transfer) - return transferPtr, nil } -func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { +func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { + + // === VALIDASI SOURCE WAREHOUSE === + pwIDs := make([]uint, 0, len(req.Products)) - // Validasi stok di gudang asal harus exist dan mencukupi for _, product := range req.Products { sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), @@ -126,9 +138,37 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if sourcePW.Quantity < product.ProductQty { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) } + pwIDs = append(pwIDs, sourcePW.Id) + } + + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( + c.Context(), + s.StockTransferRepo.DB(), + pwIDs, + ); err != nil { + return nil, err + } + + destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID)) + if err != nil { + return nil, err + } + + if s.ProjectFlockKandangRepo != nil { + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + if projectFlockKandang.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") + } + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err } - // validasi total qty harus lebih besar dari atau sama dengan total qty di delivery compare berdasarkan productid deliveryQtyMap := make(map[uint]float64) for _, delivery := range req.Deliveries { for _, prod := range delivery.Products { @@ -136,7 +176,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } - // Cek: qty delivery tidak boleh melebihi qty di root for _, product := range req.Products { if deliveryQtyMap[product.ProductID] > product.ProductQty { return nil, fiber.NewError(fiber.StatusBadRequest, @@ -144,7 +183,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } - // cek suplier id caegory BOP cek by id for _, delivery := range req.Deliveries { supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil) if err != nil { @@ -158,11 +196,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } - // Generate movement number - // Format: PND-MBU-00001 seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) if err != nil { - s.Log.Errorf("Failed to get next movement number: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") } movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum) @@ -174,35 +209,75 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Reason: req.TransferReason, TransferDate: transferDate, MovementNumber: movementNumber, - CreatedBy: 1, //todo: get from token + CreatedBy: uint64(actorID), } - // Save the transfer entity to the database err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { - // Insert header if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer: %+v", err) return err } - s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) - // insert ke details - var details []*entity.StockTransferDetail + // Prepare details and fetch product warehouses + details := make([]*entity.StockTransferDetail, 0, len(req.Products)) + detailMap := make(map[uint64]*entity.StockTransferDetail) + for _, product := range req.Products { - details = append(details, &entity.StockTransferDetail{ + // Get source product warehouse + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), + ) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") + } + + // Get or create destination product warehouse + destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), + ) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse destination") + } + if errors.Is(err, gorm.ErrRecordNotFound) { + ctx := c.Context() + projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) + if err != nil { + return err + } + destPW = &entity.ProductWarehouse{ + ProductId: uint(product.ProductID), + WarehouseId: uint(req.DestinationWarehouseID), + Quantity: 0, + ProjectFlockKandangId: &projectFlockKandangID, + } + if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") + } + } + + detail := &entity.StockTransferDetail{ StockTransferId: entityTransfer.Id, ProductId: uint64(product.ProductID), - Quantity: product.ProductQty, - }) + + SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(), + UsageQty: 0, + PendingQty: 0, + + DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(), + TotalQty: 0, + TotalUsed: 0, + } + details = append(details, detail) + detailMap[uint64(product.ProductID)] = detail } + if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer details: %+v", err) return err } - s.Log.Infof("Stock transfer details created for transfer ID: %+v", entityTransfer.Id) - // Tambahkan proses insert delivery var deliveries []*entity.StockTransferDelivery for _, delivery := range req.Deliveries { deliveries = append(deliveries, &entity.StockTransferDelivery{ @@ -210,142 +285,143 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques SupplierId: uint64(delivery.SupplierID), VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, - DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", // todo: tunggu ada aws baru proses ShippingCostItem: delivery.DeliveryCostPerItem, ShippingCostTotal: delivery.DeliveryCost, }) } if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) return err } - // tambahkan insert ke delivery items sebagai pivot - detailMap := make(map[uint64]uint64) - for _, d := range details { - detailMap[d.ProductId] = d.Id - } var deliveryItems []*entity.StockTransferDeliveryItem for i, delivery := range deliveries { item := req.Deliveries[i] for _, prod := range item.Products { - detailID, ok := detailMap[uint64(prod.ProductID)] + detail, ok := detailMap[uint64(prod.ProductID)] if !ok { return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) } deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ StockTransferDeliveryId: delivery.Id, - StockTransferDetailId: detailID, + StockTransferDetailId: detail.Id, Quantity: prod.ProductQty, }) } } if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer delivery items: %+v", err) return err } - s.Log.Infof("Stock transfer delivery items created for transfer ID: %+v", entityTransfer.Id) - // Proses pengurangan stok di gudang asal dan penambahan stok di gudang tujuan + if s.DocumentSvc != nil && len(files) > 0 { + + for idx, file := range files { + documentFiles := []commonSvc.DocumentFile{ + { + File: file, + Type: string(utils.DocumentTypeTransfer), + Index: &idx, + }, + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeTransfer), + DocumentableID: deliveries[idx].Id, + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)", + idx+1, deliveries[idx].Id, file.Filename) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", idx+1, err)) + } + } + } + + // Execute FIFO operations for each product for _, product := range req.Products { - // Kurangi stok di gudang asal - sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) + detail := detailMap[uint64(product.ProductID)] + + // Step 1: Consume stock from source warehouse (STOCK_TRANSFER_OUT) + consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ + UsableKey: "STOCK_TRANSFER_OUT", + UsableID: uint(detail.Id), + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + Quantity: product.ProductQty, + AllowPending: false, // Don't allow pending, must have actual stock + Tx: tx, + }) if err != nil { - s.Log.Errorf("Failed to get source product warehouse: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err)) } - if sourcePW.Quantity < product.ProductQty { - s.Log.Errorf("Insufficient stock in source warehouse for product ID: %+v", product.ProductID) - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID)) - } - sourcePW.Quantity -= product.ProductQty - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil { - s.Log.Errorf("Failed to update source product warehouse: %+v", err) - return err - } - s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) - // create stock log for decrease (source) - beforeQty := sourcePW.Quantity + product.ProductQty // sourcePW already decreased - decreaseLog := &entity.StockLog{ - TransactionType: entity.TransactionTypeDecrease, + // Update usage tracking fields for source warehouse + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "usage_qty": consumeResult.UsageQuantity, + "pending_qty": consumeResult.PendingQuantity, + }).Error; err != nil { + return fmt.Errorf("gagal update usage tracking: %w", err) + } + + // Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN) + note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) + replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + StockableKey: "STOCK_TRANSFER_IN", + StockableID: uint(detail.Id), + ProductWarehouseID: uint(*detail.DestProductWarehouseID), Quantity: product.ProductQty, - BeforeQuantity: beforeQty, - AfterQuantity: sourcePW.Quantity, - LogType: entity.LogTypeTransfer, - LogId: uint(entityTransfer.Id), - Note: "", - ProductWarehouseId: sourcePW.Id, - CreatedBy: 1, - } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log decrease: %+v", err) - return err + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err)) } - // Tambah stok di gudang tujuan - destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( - c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), - ) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to get destination product warehouse: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") + // Update total tracking fields for destination warehouse + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }).Error; err != nil { + return fmt.Errorf("gagal update total tracking: %w", err) } - if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { - // Jika belum ada record untuk produk di gudang tujuan, buat baru - destPW = &entity.ProductWarehouse{ - ProductId: uint(product.ProductID), - WarehouseId: uint(req.DestinationWarehouseID), - Quantity: 0, - CreatedBy: 1, // TODO: should Get from auth middleware - } - if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { - s.Log.Errorf("Failed to create destination product warehouse: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") - } - s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) - } - // Update stok di gudang tujuan - destPW.Quantity += product.ProductQty - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { - s.Log.Errorf("Failed to update destination product warehouse: %+v", err) - return err - } - s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) - - // create stock log for increase (destination) - beforeDestQty := destPW.Quantity - product.ProductQty - increaseLog := &entity.StockLog{ - TransactionType: entity.TransactionTypeIncrease, - Quantity: product.ProductQty, - BeforeQuantity: beforeDestQty, - AfterQuantity: destPW.Quantity, - LogType: entity.LogTypeTransfer, - LogId: uint(entityTransfer.Id), - Note: "", - ProductWarehouseId: destPW.Id, - CreatedBy: 1, - } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log increase: %+v", err) - return err - } - } return nil }) if err != nil { - s.Log.Errorf("Transaction failed in CreateOne: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err)) } - // Ambil data lengkap hasil create dengan GetOne (agar preload relasi sama dengan GetOne) result, err := s.GetOne(c, uint(entityTransfer.Id)) if err != nil { return nil, err } return result, nil } + +func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { + warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) + } + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") + } + + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) + } + + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) + } + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") + } + + return uint(projectFlockKandang.Id), nil +} diff --git a/internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go b/internal/modules/marketing/controllers/deliveryorder.controller.go similarity index 92% rename from internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go rename to internal/modules/marketing/controllers/deliveryorder.controller.go index 292381d0..73904cc3 100644 --- a/internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go +++ b/internal/modules/marketing/controllers/deliveryorder.controller.go @@ -4,9 +4,9 @@ import ( "math" "strconv" - "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/validations" + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" "github.com/gofiber/fiber/v2" @@ -23,7 +23,7 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer } func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { - query := &validation.Query{ + query := &validation.DeliveryOrderQuery{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), MarketingId: uint(c.QueryInt("marketing_id", 0)), @@ -76,7 +76,7 @@ func (u *DeliveryOrdersController) GetOne(c *fiber.Ctx) error { } func (u *DeliveryOrdersController) CreateOne(c *fiber.Ctx) error { - req := new(validation.Create) + req := new(validation.DeliveryOrderCreate) if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") @@ -97,7 +97,7 @@ func (u *DeliveryOrdersController) CreateOne(c *fiber.Ctx) error { } func (u *DeliveryOrdersController) UpdateOne(c *fiber.Ctx) error { - req := new(validation.Update) + req := new(validation.DeliveryOrderUpdate) param := c.Params("id") id, err := strconv.Atoi(param) diff --git a/internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go b/internal/modules/marketing/controllers/salesorder.controller.go similarity index 95% rename from internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go rename to internal/modules/marketing/controllers/salesorder.controller.go index 16d3b5be..416af20f 100644 --- a/internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go +++ b/internal/modules/marketing/controllers/salesorder.controller.go @@ -3,9 +3,9 @@ package controller import ( "strconv" - "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/dto" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/validations" + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" "github.com/gofiber/fiber/v2" diff --git a/internal/modules/marketing/delivery-orderss/module.go b/internal/modules/marketing/delivery-orderss/module.go deleted file mode 100644 index 99bd8396..00000000 --- a/internal/modules/marketing/delivery-orderss/module.go +++ /dev/null @@ -1,39 +0,0 @@ -package delivery_orderss - -import ( - "fmt" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "gorm.io/gorm" - - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - rMarketingDeliveryProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" - sDeliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" - rMarketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) - -type DeliveryOrdersModule struct{} - -func (DeliveryOrdersModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - marketingRepo := rMarketing.NewMarketingRepository(db) - marketingProductRepo := rMarketing.NewMarketingProductRepository(db) - marketingDeliveryProductRepo := rMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(db) - userRepo := rUser.NewUserRepository(db) - approvalRepo := commonRepo.NewApprovalRepository(db) - approvalSvc := commonSvc.NewApprovalService(approvalRepo) - - // Register workflow steps for MARKETINGS approval - if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) - } - - deliveryOrdersService := sDeliveryOrders.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) - userService := sUser.NewUserService(userRepo, validate) - - DeliveryOrdersRoutes(router, userService, deliveryOrdersService) -} diff --git a/internal/modules/marketing/delivery-orderss/route.go b/internal/modules/marketing/delivery-orderss/route.go deleted file mode 100644 index 09e48f29..00000000 --- a/internal/modules/marketing/delivery-orderss/route.go +++ /dev/null @@ -1,30 +0,0 @@ -package delivery_orderss - -import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" - controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/controllers" - deliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" - user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - - "github.com/gofiber/fiber/v2" -) - -func DeliveryOrdersRoutes(v1 fiber.Router, u user.UserService, s deliveryOrders.DeliveryOrdersService) { - ctrl := controller.NewDeliveryOrdersController(s) - - v1.Get("/", ctrl.GetAll) - v1.Get("/:id", ctrl.GetOne) - - // Sisanya di group /delivery-orders - route := v1.Group("/delivery-orders") - - // 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.Post("/", ctrl.CreateOne) - route.Patch("/:id", ctrl.UpdateOne) - -} diff --git a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go similarity index 91% rename from internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go rename to internal/modules/marketing/dto/deliveryorder.dto.go index d2f29fe9..a6eea180 100644 --- a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -24,7 +24,7 @@ type MarketingListDTO struct { Customer customerDTO.CustomerRelationDTO `json:"customer"` SalesPerson userDTO.UserRelationDTO `json:"sales_person"` SoDocs string `json:"so_docs"` - SalesOrder []MarketingProductDTO `json:"sales_order"` + SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"` CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -36,13 +36,14 @@ type MarketingDetailDTO struct { Customer customerDTO.CustomerRelationDTO `json:"customer"` SalesPerson userDTO.UserRelationDTO `json:"sales_person"` SoDocs string `json:"so_docs"` - SalesOrder []MarketingProductDTO `json:"sales_order"` + SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"` DeliveryOrder []DeliveryGroupDTO `json:"delivery_order"` CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LatestApproval approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } + type MarketingDeliveryProductDTO struct { Id uint `json:"id"` MarketingProductId uint `json:"marketing_product_id"` @@ -73,7 +74,7 @@ type DeliveryGroupDTO struct { Deliveries []DeliveryItemDTO `json:"deliveries"` } -type MarketingProductDTO struct { +type DeliveryMarketingProductDTO struct { Id uint `json:"id"` MarketingId uint `json:"marketing_id"` ProductWarehouseId uint `json:"product_warehouse_id"` @@ -95,14 +96,14 @@ func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO { } } -func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO { +func ToDeliveryMarketingProductDTO(e entity.MarketingProduct) DeliveryMarketingProductDTO { var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO if e.ProductWarehouse.Id != 0 { mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse) productWarehouse = &mapped } - return MarketingProductDTO{ + return DeliveryMarketingProductDTO{ Id: e.Id, MarketingId: e.MarketingId, ProductWarehouseId: e.ProductWarehouseId, @@ -120,7 +121,7 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD return MarketingDeliveryProductDTO{ Id: e.Id, MarketingProductId: e.MarketingProductId, - Qty: e.Qty, + Qty: e.UsageQty, UnitPrice: e.UnitPrice, TotalWeight: e.TotalWeight, AvgWeight: e.AvgWeight, @@ -155,11 +156,11 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M latestApproval = mapped } - var salesOrderProducts []MarketingProductDTO + var salesOrderProducts []DeliveryMarketingProductDTO if len(marketing.Products) > 0 { - salesOrderProducts = make([]MarketingProductDTO, len(marketing.Products)) + salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) for i, product := range marketing.Products { - salesOrderProducts[i] = ToMarketingProductDTO(product) + salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) } } @@ -195,11 +196,11 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity salesPerson = mapped } - var salesOrderProducts []MarketingProductDTO + var salesOrderProducts []DeliveryMarketingProductDTO if len(marketing.Products) > 0 { - salesOrderProducts = make([]MarketingProductDTO, len(marketing.Products)) + salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) for i, product := range marketing.Products { - salesOrderProducts[i] = ToMarketingProductDTO(product) + salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) } } @@ -319,15 +320,20 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri }) for i := range groups { - if groups[i].DeliveryDate != nil { - dateStr := groups[i].DeliveryDate.Format("20060102") - groups[i].DoNumber = fmt.Sprintf("%s-%s-%d", soNumber, dateStr, groups[i].Warehouse.Id) - } + groups[i].DoNumber = GenerateDeliveryOrderNumber(soNumber, groups[i].DeliveryDate, groups[i].Warehouse.Id) } return groups } +func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, warehouseId uint) string { + dateStr := "" + if deliveryDate != nil { + dateStr = deliveryDate.Format("20060102") + } + return fmt.Sprintf("%s-%s-%d", soNumber, dateStr, warehouseId) +} + func getVehicleNumber(e entity.MarketingProduct) string { if e.DeliveryProduct != nil && e.DeliveryProduct.VehicleNumber != "" { return e.DeliveryProduct.VehicleNumber diff --git a/internal/modules/marketing/sales-orders/dto/sales-orders.dto.go b/internal/modules/marketing/dto/salesorder.dto.go similarity index 100% rename from internal/modules/marketing/sales-orders/dto/sales-orders.dto.go rename to internal/modules/marketing/dto/salesorder.dto.go diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 9bf4f018..b93c6129 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -1,13 +1,70 @@ package marketing import ( + "fmt" + "strings" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type MarketingModule struct{} func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - RegisterRoutes(router, db, validate) + // Initialize repositories + marketingRepo := repository.NewMarketingRepository(db) + marketingProductRepo := repository.NewMarketingProductRepository(db) + marketingDeliveryProductRepo := repository.NewMarketingDeliveryProductRepository(db) + userRepo := rUser.NewUserRepository(db) + customerRepo := rCustomer.NewCustomerRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyMarketingDelivery, + Table: "marketing_delivery_products", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register marketing delivery usable workflow: %v", err)) + } + } + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalSvc := commonSvc.NewApprovalService(approvalRepo) + + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) + } + + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate) + userService := sUser.NewUserService(userRepo, validate) + + RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) } diff --git a/internal/modules/marketing/sales-orders/repositories/marketings.repository.go b/internal/modules/marketing/repositories/salesorder.repository.go similarity index 70% rename from internal/modules/marketing/sales-orders/repositories/marketings.repository.go rename to internal/modules/marketing/repositories/salesorder.repository.go index dd0f99ab..51351e55 100644 --- a/internal/modules/marketing/sales-orders/repositories/marketings.repository.go +++ b/internal/modules/marketing/repositories/salesorder.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -12,6 +13,7 @@ type MarketingRepository interface { repository.BaseRepository[entity.Marketing] IdExists(ctx context.Context, id uint) (bool, error) GetNextSequence(ctx context.Context) (uint, error) + NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) } type MarketingRepositoryImpl struct { @@ -35,3 +37,21 @@ func (r *MarketingRepositoryImpl) GetNextSequence(ctx context.Context) (uint, er } return maxID + 1, nil } + +func (r *MarketingRepositoryImpl) NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) { + db := tx + if db == nil { + db = r.DB() + } + + var soNumber string + err := db.WithContext(ctx). + Raw("SELECT generate_so_number()"). + Scan(&soNumber).Error + + if err != nil { + return "", fmt.Errorf("failed to generate SO number: %w", err) + } + + return soNumber, nil +} diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go new file mode 100644 index 00000000..04051009 --- /dev/null +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -0,0 +1,276 @@ +package repository + +import ( + "context" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +type MarketingDeliveryProductRepository interface { + repository.BaseRepository[entity.MarketingDeliveryProduct] + GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) + GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) + GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) + GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) + UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error + GetUsageQty(ctx context.Context, id uint) (float64, error) + ResetFifoFields(ctx context.Context, id uint) error +} + +type MarketingDeliveryProductRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct] +} + +func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository { + return &MarketingDeliveryProductRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), + } +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + db := r.DB().WithContext(ctx). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Distinct("marketing_delivery_products.*") + + if callback != nil { + db = callback(db) + } + + if err := db.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + // JOIN untuk filter by marketing_id yang ada di related table + db := r.DB().WithContext(ctx). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Where("marketing_products.marketing_id = ?", marketingId) + + if err := db.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) { + var deliveryProduct entity.MarketingDeliveryProduct + + if err := r.DB().WithContext(ctx). + Where("marketing_product_id = ?", marketingProductID). + First(&deliveryProduct).Error; err != nil { + return nil, err + } + + return &deliveryProduct, nil +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + var total int64 + + db := r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Preload("MarketingProduct", func(db *gorm.DB) *gorm.DB { + return db. + Preload("Marketing"). + Preload("Marketing.Customer"). + Preload("Marketing.SalesPerson"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Product.Flags"). + Preload("ProductWarehouse.Warehouse"). + Preload("ProductWarehouse.ProjectFlockKandang"). + Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock") + }). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id") + + if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.Search != "" { + db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") + } + + if filters.ProductId > 0 || filters.Search != "" { + db = db.Joins("LEFT JOIN products ON products.id = product_warehouses.product_id") + } + + if filters.WarehouseId > 0 { + db = db.Joins("LEFT JOIN warehouses ON warehouses.id = product_warehouses.warehouse_id") + } + + if filters.Search != "" { + db = db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id") + } + + if filters.Search != "" { + searchPattern := "%" + filters.Search + "%" + db = db.Where("marketing_delivery_products.vehicle_number ILIKE ? OR marketings.so_number ILIKE ? OR customers.name ILIKE ? OR products.name ILIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern) + } + + if filters.CustomerId > 0 { + db = db.Where("marketings.customer_id = ?", filters.CustomerId) + } + + if filters.SalesPersonId > 0 { + db = db.Where("marketings.sales_person_id = ?", filters.SalesPersonId) + } + + if filters.ProductId > 0 { + db = db.Where("product_warehouses.product_id = ?", filters.ProductId) + } + + if filters.WarehouseId > 0 { + db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId) + } + + if filters.FilterBy != "" && (filters.StartDate != "" || filters.EndDate != "") { + if filters.FilterBy == "so_date" { + if filters.StartDate != "" { + if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where("marketings.so_date >= ?", startDate) + } + } + if filters.EndDate != "" { + if endDate, err := utils.ParseDateString(filters.EndDate); err == nil { + nextDate := endDate.AddDate(0, 0, 1) + db = db.Where("marketings.so_date < ?", nextDate) + } + } + } else if filters.FilterBy == "realization_date" { + if filters.StartDate != "" { + if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where("marketing_delivery_products.delivery_date >= ?", startDate) + } + } + if filters.EndDate != "" { + if endDate, err := utils.ParseDateString(filters.EndDate); err == nil { + nextDate := endDate.AddDate(0, 0, 1) + db = db.Where("marketing_delivery_products.delivery_date < ?", nextDate) + } + } + } + } + + sortColumn := "marketing_delivery_products.id" + sortOrder := "DESC" + + if filters.SortBy != "" { + switch filters.SortBy { + case "so_date": + sortColumn = "marketings.so_date" + case "realization_date": + sortColumn = "marketing_delivery_products.delivery_date" + case "customer": + sortColumn = "customers.name" + if !containsJoin(db, "customers") { + db = db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id") + } + case "warehouse": + sortColumn = "warehouses.name" + if !containsJoin(db, "warehouses") { + db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). + Joins("LEFT JOIN warehouses ON warehouses.id = product_warehouses.warehouse_id") + } + case "product": + sortColumn = "products.name" + if !containsJoin(db, "products") { + db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). + Joins("LEFT JOIN products ON products.id = product_warehouses.product_id") + } + case "sales_person": + sortColumn = "sales_users.name" + if !containsJoin(db, "sales_users") { + db = db.Joins("LEFT JOIN users AS sales_users ON sales_users.id = marketings.sales_person_id") + } + case "vehicle_number": + sortColumn = "marketing_delivery_products.vehicle_number" + case "sales_amount": + sortColumn = "marketing_delivery_products.total_price" + case "hpp_amount": + sortColumn = "marketing_delivery_products.total_price" + case "qty": + sortColumn = "marketing_delivery_products.qty" + case "average_weight": + sortColumn = "marketing_delivery_products.avg_weight" + case "total_weight": + sortColumn = "marketing_delivery_products.total_weight" + case "sales_price": + sortColumn = "marketing_delivery_products.unit_price" + case "hpp_price": + sortColumn = "marketing_delivery_products.unit_price" + case "aging_days": + sortColumn = "marketing_delivery_products.delivery_date" + } + } + + if filters.SortOrder != "" && (filters.SortOrder == "asc" || filters.SortOrder == "desc") { + sortOrder = strings.ToUpper(filters.SortOrder) + } + + db = db.Order(sortColumn + " " + sortOrder) + + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := db. + Offset(offset). + Limit(limit). + Find(&deliveryProducts).Error; err != nil { + return nil, 0, err + } + + return deliveryProducts, total, nil +} + +func containsJoin(db *gorm.DB, tableName string) bool { + statement := db.Statement + joinSQL := statement.SQL.String() + return strings.Contains(joinSQL, "JOIN "+tableName) +} + +func (r *MarketingDeliveryProductRepositoryImpl) UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": usageQty, + "pending_qty": pendingQty, + }).Error +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetUsageQty(ctx context.Context, id uint) (float64, error) { + var usageQty float64 + err := r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Select("usage_qty"). + Scan(&usageQty).Error + return usageQty, err +} + +func (r *MarketingDeliveryProductRepositoryImpl) ResetFifoFields(ctx context.Context, id uint) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_qty": 0, + }).Error +} diff --git a/internal/modules/marketing/sales-orders/repositories/marketing-products.repository.go b/internal/modules/marketing/repositories/salesorder_product.repository.go similarity index 100% rename from internal/modules/marketing/sales-orders/repositories/marketing-products.repository.go rename to internal/modules/marketing/repositories/salesorder_product.repository.go diff --git a/internal/modules/marketing/route.go b/internal/modules/marketing/route.go index 1ab03896..81402c7c 100644 --- a/internal/modules/marketing/route.go +++ b/internal/modules/marketing/route.go @@ -1,27 +1,30 @@ package marketing import ( - "gitlab.com/mbugroup/lti-api.git/internal/modules" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/controllers" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "gorm.io/gorm" - - salesOrderss "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders" - deliveryOrderss "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss" - // MODULE IMPORTS ) -func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - group := router.Group("/marketing") +func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrdersService service.SalesOrdersService, deliveryOrdersService service.DeliveryOrdersService) { + salesOrdersCtrl := controller.NewSalesOrdersController(salesOrdersService) + deliveryOrdersCtrl := controller.NewDeliveryOrdersController(deliveryOrdersService) - allModules := []modules.Module{ - salesOrderss.SalesOrdersModule{}, - deliveryOrderss.DeliveryOrdersModule{}, - // MODULE REGISTRY - } + route := router.Group("/marketing") + route.Use(m.Auth(userService)) + + route.Get("/", m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) + route.Get("/:id", m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) + route.Delete("/:id", m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) + + route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) + route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) + route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + + route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne) + route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne) - for _, m := range allModules { - m.RegisterRoutes(group, db, validate) - } } diff --git a/internal/modules/marketing/sales-orders/module.go b/internal/modules/marketing/sales-orders/module.go deleted file mode 100644 index 0d9583d0..00000000 --- a/internal/modules/marketing/sales-orders/module.go +++ /dev/null @@ -1,39 +0,0 @@ -package sales_orders - -import ( - "fmt" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "gorm.io/gorm" - - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rSalesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - sSalesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" - rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) - -type SalesOrdersModule struct{} - -func (SalesOrdersModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - marketingRepo := rSalesOrders.NewMarketingRepository(db) - userRepo := rUser.NewUserRepository(db) - customerRepo := rCustomer.NewCustomerRepository(db) - productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) - - if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) - } - - salesOrdersService := sSalesOrders.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, validate) - userService := sUser.NewUserService(userRepo, validate) - - SalesOrdersRoutes(router, userService, salesOrdersService) -} diff --git a/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go b/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go deleted file mode 100644 index 95e9b3bb..00000000 --- a/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go +++ /dev/null @@ -1,21 +0,0 @@ -package repository - -import ( - "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gorm.io/gorm" -) - -type MarketingDeliveryProductRepository interface { - repository.BaseRepository[entity.MarketingDeliveryProduct] -} - -type MarketingDeliveryProductRepositoryImpl struct { - *repository.BaseRepositoryImpl[entity.MarketingDeliveryProduct] -} - -func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository { - return &MarketingDeliveryProductRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[entity.MarketingDeliveryProduct](db), - } -} diff --git a/internal/modules/marketing/sales-orders/route.go b/internal/modules/marketing/sales-orders/route.go deleted file mode 100644 index ae6d7a81..00000000 --- a/internal/modules/marketing/sales-orders/route.go +++ /dev/null @@ -1,26 +0,0 @@ -package sales_orders - -import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" - controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/controllers" - salesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" - user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - - "github.com/gofiber/fiber/v2" -) - -func SalesOrdersRoutes(v1 fiber.Router, u user.UserService, s salesOrders.SalesOrdersService) { - ctrl := controller.NewSalesOrdersController(s) - - v1.Delete("/:id", ctrl.DeleteOne) - route := v1.Group("/sales-orders") - - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - - route.Post("/", ctrl.CreateOne) - route.Patch("/:id", ctrl.UpdateOne) - - route.Post("/approvals", ctrl.Approval) -} diff --git a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go b/internal/modules/marketing/services/deliveryorder.service.go similarity index 69% rename from internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go rename to internal/modules/marketing/services/deliveryorder.service.go index 712c6ace..a1f4e1dd 100644 --- a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -9,49 +9,50 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - marketingDeliveryProductRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/validations" - marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) type DeliveryOrdersService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.MarketingListDTO, int64, error) + GetAll(ctx *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) - CreateOne(ctx *fiber.Ctx, req *validation.Create) (*dto.MarketingDetailDTO, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*dto.MarketingDetailDTO, error) + CreateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) + UpdateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error) } type deliveryOrdersService struct { - Log *logrus.Logger Validate *validator.Validate MarketingRepo marketingRepo.MarketingRepository MarketingProductRepo marketingRepo.MarketingProductRepository - MarketingDeliveryProductRepo marketingDeliveryProductRepo.MarketingDeliveryProductRepository + MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewDeliveryOrdersService( marketingRepo marketingRepo.MarketingRepository, marketingProductRepo marketingRepo.MarketingProductRepository, - marketingDeliveryProductRepo marketingDeliveryProductRepo.MarketingDeliveryProductRepository, + marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) DeliveryOrdersService { return &deliveryOrdersService{ - Log: utils.Log, Validate: validate, MarketingRepo: marketingRepo, MarketingProductRepo: marketingProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, } } @@ -85,7 +86,7 @@ func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketin return &responseDTO, nil } -func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.MarketingListDTO, int64, error) { +func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -108,7 +109,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.Query) ([ }) if err != nil { - s.Log.Errorf("Failed to get marketings: %+v", err) return nil, 0, err } for i := range marketings { @@ -116,7 +116,7 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.Query) ([ return db.Preload("ActionUser") }) if err != nil { - s.Log.Warnf("Failed to load approval for marketing %d: %+v", marketings[i].Id, err) + continue } marketings[i].LatestApproval = latestApproval } @@ -164,7 +164,7 @@ func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDeta return &responseDTO, nil } -func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*dto.MarketingDetailDTO, error) { +func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -175,6 +175,11 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.MarketingRepo.DB())) latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowMarketing, req.MarketingId, nil) @@ -190,14 +195,11 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) if latestApproval.StepNumber >= uint16(utils.MarketingDeliveryOrder) { return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery order already exists for this marketing") } - if latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Marketing is not approved - current status: %v", *latestApproval.Action)) - } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) - marketingDeliveryProductRepositoryTx := marketingDeliveryProductRepo.NewMarketingDeliveryProductRepository(dbTransaction) + marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId) @@ -220,6 +222,14 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing product %d not found for this marketing", requestedProduct.MarketingProductId)) } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( + c.Context(), + dbTransaction, + []uint{foundMarketingProduct.ProductWarehouseId}, + ); err != nil { + return err + } + deliveryProduct, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(c.Context(), foundMarketingProduct.Id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -237,16 +247,21 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) itemDeliveryDate = &parsedDate } - deliveryProduct.Qty = requestedProduct.Qty + // Hitung total_weight dan total_price otomatis + totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight + totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty + + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight - deliveryProduct.TotalWeight = requestedProduct.TotalWeight - deliveryProduct.TotalPrice = requestedProduct.TotalPrice + deliveryProduct.TotalWeight = totalWeight + deliveryProduct.TotalPrice = totalPrice deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber if requestedProduct.Qty > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, requestedProduct.Qty); err != nil { + + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { return err } } @@ -256,7 +271,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) } - actorID := uint(1) // TODO: ambil dari auth context approvalAction := entity.ApprovalActionApproved if _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -284,7 +298,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) return s.getMarketingWithDeliveries(c, req.MarketingId) } -func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*dto.MarketingDetailDTO, error) { +func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -298,7 +312,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, i err := s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) - marketingDeliveryProductRepositoryTx := marketingDeliveryProductRepo.NewMarketingDeliveryProductRepository(dbTransaction) + marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -318,6 +332,13 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, i if foundMarketingProduct == nil { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing product %d not found for this marketing", requestedProduct.MarketingProductId)) } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( + c.Context(), + dbTransaction, + []uint{foundMarketingProduct.ProductWarehouseId}, + ); err != nil { + return err + } deliveryProduct, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(c.Context(), foundMarketingProduct.Id) if err != nil { @@ -338,23 +359,32 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, i itemDeliveryDate = deliveryProduct.DeliveryDate } - oldQty := deliveryProduct.Qty - deliveryProduct.Qty = requestedProduct.Qty + oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + + // Hitung total_weight dan total_price otomatis + totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight + totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty + + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight - deliveryProduct.TotalWeight = requestedProduct.TotalWeight - deliveryProduct.TotalPrice = requestedProduct.TotalPrice + deliveryProduct.TotalWeight = totalWeight + deliveryProduct.TotalPrice = totalPrice deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber - qtyChange := requestedProduct.Qty - oldQty - if qtyChange > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, qtyChange); err != nil { - return err + if requestedProduct.Qty != oldRequestedQty { + + if oldRequestedQty > 0 { + if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct); err != nil { + return err + } } - } else if qtyChange < 0 { - if err := s.restoreProductWarehouseStock(c.Context(), dbTransaction, foundMarketingProduct, -qtyChange); err != nil { - return err + + if requestedProduct.Qty > 0 { + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { + return err + } } } @@ -377,50 +407,79 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, i return s.getMarketingWithDeliveries(c, id) } -func (s deliveryOrdersService) validateAndReduceProductWarehouse(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyDeliver float64) error { +func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64) error { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") + } + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + ProductWarehouseID: marketingProduct.ProductWarehouseId, + Quantity: requestedQty, + AllowPending: false, + Tx: tx, + }) + + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + if err2 != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + + if pw == nil || pw.Quantity < requestedQty { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } }(), requestedQty)) + } + + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + } + return nil } - if pw.Quantity < qtyDeliver { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for warehouse - available: %.2f, requested: %.2f", pw.Quantity, qtyDeliver)) + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } - pw.Quantity = pw.Quantity - qtyDeliver - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") - } return nil } -func (s deliveryOrdersService) restoreProductWarehouseStock(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyRestore float64) error { +func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct) error { + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return nil + } + if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) + currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") - } - - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + currentUsage = 0 } - pw.Quantity = pw.Quantity + qtyRestore - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") + if currentUsage == 0 { + return nil + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + Tx: tx, + }); err != nil { + return err + } + + if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { + return err } return nil diff --git a/internal/modules/marketing/sales-orders/services/sales-orders.service.go b/internal/modules/marketing/services/salesorder.service.go similarity index 79% rename from internal/modules/marketing/sales-orders/services/sales-orders.service.go rename to internal/modules/marketing/services/salesorder.service.go index d750c4a4..d57b323e 100644 --- a/internal/modules/marketing/sales-orders/services/sales-orders.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -9,11 +9,13 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rInvMarketingDeliveryProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/marketing-delivery-products/repositories" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/validations" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -32,24 +34,29 @@ type SalesOrdersService interface { } type salesOrdersService struct { - Log *logrus.Logger - Validate *validator.Validate - MarketingRepo repository.MarketingRepository - CustomerRepo customerRepo.CustomerRepository - ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository - UserRepo userRepo.UserRepository - ApprovalSvc commonSvc.ApprovalService + Log *logrus.Logger + Validate *validator.Validate + MarketingRepo repository.MarketingRepository + CustomerRepo customerRepo.CustomerRepository + ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository + UserRepo userRepo.UserRepository + ApprovalSvc commonSvc.ApprovalService + WarehouseRepo warehouseRepo.WarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate) SalesOrdersService { +func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo warehouseRepo.WarehouseRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService { return &salesOrdersService{ - Log: utils.Log, - Validate: validate, - MarketingRepo: marketingRepo, - CustomerRepo: customerRepo, - ProductWarehouseRepo: productWarehouseRepo, - UserRepo: userRepo, - ApprovalSvc: approvalSvc, + Log: utils.Log, + Validate: validate, + MarketingRepo: marketingRepo, + CustomerRepo: customerRepo, + ProductWarehouseRepo: productWarehouseRepo, + UserRepo: userRepo, + ApprovalSvc: approvalSvc, + WarehouseRepo: warehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, } } @@ -68,7 +75,6 @@ func (s salesOrdersService) getOne(c *fiber.Ctx, id uint) (*entity.Marketing, er return nil, fiber.NewError(fiber.StatusNotFound, "SalesOrders not found") } if err != nil { - s.Log.Errorf("Failed get marketing by id: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order") } @@ -90,6 +96,11 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Customer", ID: &req.CustomerId, Exists: s.CustomerRepo.IdExists}, ); err != nil { @@ -109,18 +120,17 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format") } - nextSeq, err := s.MarketingRepo.GetNextSequence(c.Context()) + soNumber, err := s.MarketingRepo.NextSoNumber(context.Background(), nil) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate SO number") } - soNumber := fmt.Sprintf("SO-%05d", nextSeq) var marketing *entity.Marketing err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingRepoTx := repository.NewMarketingRepository(dbTransaction) marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) - invDeliveryRepoTx := rInvMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(dbTransaction) + invDeliveryRepoTx := repository.NewMarketingDeliveryProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) marketing = &entity.Marketing{ @@ -129,21 +139,28 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e SoDate: soDate, SalesPersonId: req.SalesPersonId, Notes: req.Notes, - CreatedBy: 1, + CreatedBy: actorID, } if err := marketingRepoTx.CreateOne(c.Context(), marketing, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create salesOrders") } if len(req.MarketingProducts) > 0 { + pwIDs := make([]uint, 0, len(req.MarketingProducts)) for _, product := range req.MarketingProducts { + if product.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, product.ProductWarehouseId) + } if err := s.createMarketingProductWithDelivery(c.Context(), marketing.Id, product, marketingProductRepoTx, invDeliveryRepoTx); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") } + + } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return err } } - actorID := uint(1) // TODO: ambil dari auth context approvalAction := entity.ApprovalActionCreated if _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -180,6 +197,11 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Marketing", ID: &id, Exists: s.MarketingRepo.IdExists}, commonSvc.RelationCheck{Name: "Customer", ID: &req.CustomerId, Exists: s.CustomerRepo.IdExists}, @@ -205,13 +227,25 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } } } + if len(req.MarketingProducts) > 0 { + pwIDs := make([]uint, 0, len(req.MarketingProducts)) + for _, item := range req.MarketingProducts { + if item.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, item.ProductWarehouseId) + } + } + + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return nil, err + } + } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingRepoTx := repository.NewMarketingRepository(dbTransaction) marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - invDeliveryRepoTx := rInvMarketingDeliveryProduct.NewMarketingDeliveryProductRepository(dbTransaction) + invDeliveryRepoTx := repository.NewMarketingDeliveryProductRepository(dbTransaction) updateBody := make(map[string]any) if req.CustomerId != 0 { @@ -258,13 +292,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u for _, rp := range req.MarketingProducts { if old, ok := oldByPW[rp.ProductWarehouseId]; ok { + // Hitung total_weight dan total_price otomatis + totalWeight := rp.Qty * rp.AvgWeight + totalPrice := rp.UnitPrice * rp.Qty + updateBody := map[string]any{ "product_warehouse_id": rp.ProductWarehouseId, "qty": rp.Qty, "unit_price": rp.UnitPrice, "avg_weight": rp.AvgWeight, - "total_weight": rp.TotalWeight, - "total_price": rp.TotalPrice, + "total_weight": totalWeight, + "total_price": totalPrice, } if err := marketingProductRepoTx.PatchOne(c.Context(), old.Id, updateBody, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product") @@ -272,15 +310,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + mdp := &entity.MarketingDeliveryProduct{ MarketingProductId: old.Id, - Qty: 0, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") @@ -305,7 +345,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } if err == nil { - if deliveryProduct.DeliveryDate != nil || deliveryProduct.Qty > 0 { + if deliveryProduct.DeliveryDate != nil || deliveryProduct.UsageQty > 0 || deliveryProduct.PendingQty > 0 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) } @@ -321,7 +361,6 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } } if latestApproval != nil { - actorID := uint(1) // todo: ambil dari auth context action := entity.ApprovalActionUpdated _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -360,7 +399,18 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order") } + if len(marketing.Products) > 0 { + pwIDs := make([]uint, 0, len(marketing.Products)) + for _, p := range marketing.Products { + if p.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, p.ProductWarehouseId) + } + } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return err + } + } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) @@ -405,6 +455,11 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.MarketingRepo.DB())) var action entity.ApprovalAction @@ -446,9 +501,30 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e fmt.Sprintf("Marketing %d cannot be approved - current step is %d", id, latestApproval.StepNumber)) } } + marketing, mErr := s.MarketingRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Products") + }) + if mErr != nil { + if errors.Is(mErr, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("SalesOrders %d not found", id)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order for project validation") + } + + if len(marketing.Products) > 0 { + pwIDs := make([]uint, 0, len(marketing.Products)) + for _, p := range marketing.Products { + if p.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, p.ProductWarehouseId) + } + } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return nil, err + } + } } - err := s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) @@ -479,7 +555,6 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e nextStep = approvalutils.ApprovalStep(currentStep) } - actorID := uint(1) // todo ambil dari auth context if _, err := approvalSvc.CreateApproval( c.Context(), utils.ApprovalWorkflowMarketing, @@ -515,7 +590,11 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e return updated, nil } -func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo rInvMarketingDeliveryProduct.MarketingDeliveryProductRepository) error { +func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { + + // Hitung total_weight dan total_price otomatis + totalWeight := rp.Qty * rp.AvgWeight + totalPrice := rp.UnitPrice * rp.Qty marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, @@ -523,8 +602,8 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont Qty: rp.Qty, UnitPrice: rp.UnitPrice, AvgWeight: rp.AvgWeight, - TotalWeight: rp.TotalWeight, - TotalPrice: rp.TotalPrice, + TotalWeight: totalWeight, + TotalPrice: totalPrice, } if err := marketingProductRepo.CreateOne(ctx, marketingProduct, nil); err != nil { return err @@ -532,13 +611,15 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ MarketingProductId: marketingProduct.Id, - Qty: 0, + ProductWarehouseId: marketingProduct.ProductWarehouseId, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { return err diff --git a/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go similarity index 84% rename from internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go rename to internal/modules/marketing/validations/deliveryorder.validation.go index 3317e952..a879db6f 100644 --- a/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -5,28 +5,26 @@ type DeliveryProduct struct { Qty float64 `json:"qty" validate:"omitempty,gte=0"` UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"` AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"` - TotalWeight float64 `json:"total_weight" validate:"omitempty,gte=0"` - TotalPrice float64 `json:"total_price" validate:"omitempty,gte=0"` DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"` VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"` } -type Create struct { +type DeliveryOrderCreate struct { MarketingId uint `json:"marketing_id" validate:"required,gt=0"` DeliveryProducts []DeliveryProduct `json:"delivery_products" validate:"required,min=1,dive"` } -type Update struct { +type DeliveryOrderUpdate struct { DeliveryProducts []DeliveryProduct `json:"delivery_products" validate:"omitempty,min=1,dive"` } -type Query struct { +type DeliveryOrderQuery struct { Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` } -type Approve struct { +type DeliveryOrderApprove struct { Action string `json:"action" validate:"required_strict"` ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` diff --git a/internal/modules/marketing/sales-orders/validations/sales-orders.validation.go b/internal/modules/marketing/validations/salesorder.validation.go similarity index 91% rename from internal/modules/marketing/sales-orders/validations/sales-orders.validation.go rename to internal/modules/marketing/validations/salesorder.validation.go index 47d2e616..b69da394 100644 --- a/internal/modules/marketing/sales-orders/validations/sales-orders.validation.go +++ b/internal/modules/marketing/validations/salesorder.validation.go @@ -12,10 +12,8 @@ type CreateMarketingProduct struct { VehicleNumber string `json:"vehicle_number" validate:"required,min=1,max=50"` ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,gt=0"` UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` - TotalWeight float64 `json:"total_weight" validate:"required,gt=0"` Qty float64 `json:"qty" validate:"required,gt=0"` AvgWeight float64 `json:"avg_weight" validate:"required,gt=0"` - TotalPrice float64 `json:"total_price" validate:"required,gt=0"` } type Update struct { diff --git a/internal/modules/master/areas/route.go b/internal/modules/master/areas/route.go index 755a542e..0d715fb7 100644 --- a/internal/modules/master/areas/route.go +++ b/internal/modules/master/areas/route.go @@ -15,9 +15,9 @@ func AreaRoutes(v1 fiber.Router, u user.UserService, s area.AreaService) { route := v1.Group("/areas") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_AreaGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_AreaCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_AreaGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_AreaUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_AreaDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index 1925a592..0a976567 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -5,6 +5,7 @@ import ( "fmt" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -87,10 +88,14 @@ func (s *areaService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.A return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Area with name %s already exists", req.Name)) } - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + createBody := &entity.Area{ Name: req.Name, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/areas/validations/area.validation.go b/internal/modules/master/areas/validations/area.validation.go index 56bbd601..a7004c26 100644 --- a/internal/modules/master/areas/validations/area.validation.go +++ b/internal/modules/master/areas/validations/area.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` } type Query struct { diff --git a/internal/modules/master/banks/route.go b/internal/modules/master/banks/route.go index 2e5bed3b..678a834c 100644 --- a/internal/modules/master/banks/route.go +++ b/internal/modules/master/banks/route.go @@ -14,10 +14,9 @@ func BankRoutes(v1 fiber.Router, u user.UserService, s bank.BankService) { route := v1.Group("/banks") route.Use(m.Auth(u)) - - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_BanksGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_BanksCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_BanksGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_BanksUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_BanksDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/banks/validations/bank.validation.go b/internal/modules/master/banks/validations/bank.validation.go index 9d2bd897..34f1db27 100644 --- a/internal/modules/master/banks/validations/bank.validation.go +++ b/internal/modules/master/banks/validations/bank.validation.go @@ -1,16 +1,16 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - Alias string `json:"alias" validate:"required_strict"` - Owner *string `json:"owner,omitempty" validate:"omitempty"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + Alias string `json:"alias" validate:"required_strict,max=5"` + Owner *string `json:"owner,omitempty" validate:"omitempty,max=50"` AccountNumber string `json:"account_number" validate:"required_strict,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` - Alias *string `json:"alias,omitempty" validate:"omitempty"` - Owner *string `json:"owner,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Alias *string `json:"alias,omitempty" validate:"omitempty,max=5"` + Owner *string `json:"owner,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` } diff --git a/internal/modules/master/customers/route.go b/internal/modules/master/customers/route.go index d361e167..92f8139e 100644 --- a/internal/modules/master/customers/route.go +++ b/internal/modules/master/customers/route.go @@ -15,9 +15,9 @@ func CustomerRoutes(v1 fiber.Router, u user.UserService, s customer.CustomerServ route := v1.Group("/customers") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_CustomerGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_CustomerCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_CustomerGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_CustomerUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_CustomerDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/customers/services/customer.service.go b/internal/modules/master/customers/services/customer.service.go index b2cc1e85..12a31441 100644 --- a/internal/modules/master/customers/services/customer.service.go +++ b/internal/modules/master/customers/services/customer.service.go @@ -3,13 +3,13 @@ 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" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" 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" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -81,6 +81,10 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti if err := s.Validate.Struct(req); err != nil { return nil, err } + actorID, err := m.ActorIDFromContext(c) + if 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) @@ -100,7 +104,6 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, err } - //TODO: created by dummy createBody := &entity.Customer{ Name: req.Name, PicId: req.PicId, @@ -109,7 +112,7 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti Phone: req.Phone, Email: req.Email, AccountNumber: req.AccountNumber, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/customers/validations/customer.validation.go b/internal/modules/master/customers/validations/customer.validation.go index a7a666ec..457bbf9a 100644 --- a/internal/modules/master/customers/validations/customer.validation.go +++ b/internal/modules/master/customers/validations/customer.validation.go @@ -1,23 +1,23 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` - Type string `json:"type" validate:"required_strict"` + Type string `json:"type" validate:"required_strict,max=50"` 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"` + Email string `json:"email" validate:"required_strict,email,max=50"` + AccountNumber string `json:"account_number" validate:"required_strict,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + 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"` + Type *string `json:"type,omitempty" validate:"omitempty,max=50"` 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"` + Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"` + Email *string `json:"email,omitempty" validate:"omitempty,max=50"` + AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` } type Query struct { diff --git a/internal/modules/master/employees/controllers/employees.controller.go b/internal/modules/master/employees/controllers/employees.controller.go new file mode 100644 index 00000000..3d0901c8 --- /dev/null +++ b/internal/modules/master/employees/controllers/employees.controller.go @@ -0,0 +1,161 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type EmployeesController struct { + EmployeesService service.EmployeesService +} + +func NewEmployeesController(employeesService service.EmployeesService) *EmployeesController { + return &EmployeesController{ + EmployeesService: employeesService, + } +} + +func (u *EmployeesController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if kandangParam := c.Query("kandang_id", ""); kandangParam != "" { + id, err := strconv.Atoi(kandangParam) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid kandang_id") + } + temp := uint(id) + query.KandangId = &temp + } + + if activeParam := c.Query("is_active", ""); activeParam != "" { + value, err := strconv.ParseBool(activeParam) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid is_active value") + } + query.IsActive = &value + } + + result, totalResults, err := u.EmployeesService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.EmployeesListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all employeess successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToEmployeesListDTOs(result), + }) +} + +func (u *EmployeesController) 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.EmployeesService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get employees successfully", + Data: dto.ToEmployeesListDTO(*result), + }) +} + +func (u *EmployeesController) 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.EmployeesService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create employees successfully", + Data: dto.ToEmployeesListDTO(*result), + }) +} + +func (u *EmployeesController) 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.EmployeesService.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 employees successfully", + Data: dto.ToEmployeesListDTO(*result), + }) +} + +func (u *EmployeesController) 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.EmployeesService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete employees successfully", + }) +} diff --git a/internal/modules/master/employees/dto/employees.dto.go b/internal/modules/master/employees/dto/employees.dto.go new file mode 100644 index 00000000..65b1b5ca --- /dev/null +++ b/internal/modules/master/employees/dto/employees.dto.go @@ -0,0 +1,70 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" +) + +// === DTO Structs === + +type EmployeesRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type EmployeesListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + IsActive bool `json:"is_active"` + Kandangs []kandangDTO.KandangRelationDTO `json:"kandangs"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type EmployeesDetailDTO struct { + EmployeesListDTO +} + +// === Mapper Functions === + +func ToEmployeesRelationDTO(e entity.Employees) EmployeesRelationDTO { + return EmployeesRelationDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToEmployeesListDTO(e entity.Employees) EmployeesListDTO { + kandangs := make([]kandangDTO.KandangRelationDTO, 0, len(e.EmployeeKandangs)) + for _, rel := range e.EmployeeKandangs { + if rel.Kandang.Id == 0 { + continue + } + kandangs = append(kandangs, kandangDTO.ToKandangRelationDTO(rel.Kandang)) + } + + return EmployeesListDTO{ + Id: e.Id, + Name: e.Name, + IsActive: e.IsActive, + Kandangs: kandangs, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToEmployeesListDTOs(e []entity.Employees) []EmployeesListDTO { + result := make([]EmployeesListDTO, len(e)) + for i, r := range e { + result[i] = ToEmployeesListDTO(r) + } + return result +} + +func ToEmployeesDetailDTO(e entity.Employees) EmployeesDetailDTO { + return EmployeesDetailDTO{ + EmployeesListDTO: ToEmployeesListDTO(e), + } +} diff --git a/internal/modules/master/employees/module.go b/internal/modules/master/employees/module.go new file mode 100644 index 00000000..a916ced6 --- /dev/null +++ b/internal/modules/master/employees/module.go @@ -0,0 +1,25 @@ +package employeess + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rEmployees "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/repositories" + sEmployees "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type EmployeesModule struct{} + +func (EmployeesModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + employeesRepo := rEmployees.NewEmployeesRepository(db) + userRepo := rUser.NewUserRepository(db) + + employeesService := sEmployees.NewEmployeesService(employeesRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + EmployeesRoutes(router, userService, employeesService) +} diff --git a/internal/modules/master/employees/repositories/employees.repository.go b/internal/modules/master/employees/repositories/employees.repository.go new file mode 100644 index 00000000..f10a5884 --- /dev/null +++ b/internal/modules/master/employees/repositories/employees.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type EmployeesRepository interface { + repository.BaseRepository[entity.Employees] +} + +type EmployeesRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Employees] +} + +func NewEmployeesRepository(db *gorm.DB) EmployeesRepository { + return &EmployeesRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Employees](db), + } +} diff --git a/internal/modules/master/employees/route.go b/internal/modules/master/employees/route.go new file mode 100644 index 00000000..53974814 --- /dev/null +++ b/internal/modules/master/employees/route.go @@ -0,0 +1,23 @@ +package employeess + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/controllers" + employees "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func EmployeesRoutes(v1 fiber.Router, u user.UserService, s employees.EmployeesService) { + ctrl := controller.NewEmployeesController(s) + + route := v1.Group("/employees") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/employees/services/employees.service.go b/internal/modules/master/employees/services/employees.service.go new file mode 100644 index 00000000..4998eaec --- /dev/null +++ b/internal/modules/master/employees/services/employees.service.go @@ -0,0 +1,265 @@ +package service + +import ( + "errors" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees/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 EmployeesService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Employees, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Employees, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Employees, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Employees, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type employeesService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.EmployeesRepository +} + +func NewEmployeesService(repo repository.EmployeesRepository, validate *validator.Validate) EmployeesService { + return &employeesService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s employeesService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("EmployeeKandangs.Kandang"). + Where("employees.deleted_at IS NULL") +} + +func (s employeesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Employees, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + employeess, 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("employees.name LIKE ?", "%"+params.Search+"%") + } + if params.KandangId != nil { + db = db.Joins("JOIN employee_kandangs ek ON ek.employee_id = employees.id"). + Where("ek.kandang_id = ?", *params.KandangId) + } + if params.IsActive != nil { + db = db.Where("employees.is_active = ?", *params.IsActive) + } + return db.Order("employees.created_at DESC").Order("employees.updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get employeess: %+v", err) + return nil, 0, err + } + return employeess, total, nil +} + +func (s employeesService) GetOne(c *fiber.Ctx, id uint) (*entity.Employees, error) { + employees, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Employees not found") + } + if err != nil { + s.Log.Errorf("Failed get employees by id: %+v", err) + return nil, err + } + return employees, nil +} + +func (s *employeesService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Employees, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty") + } + + kandangIDs := normalizeKandangIDs(req.KandangIDs) + if len(kandangIDs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id") + } + + if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("LOWER(name) = ?", strings.ToLower(name)) + }); err == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "employee already exists") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed checking employee uniqueness: %+v", err) + return nil, err + } + + createBody := &entity.Employees{ + Name: name, + IsActive: req.IsActive, + } + + if err := s.Repository.DB().Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + relations := make([]entity.EmployeeKandang, 0, len(kandangIDs)) + for _, kandangID := range kandangIDs { + relations = append(relations, entity.EmployeeKandang{ + EmployeeId: createBody.Id, + KandangId: kandangID, + }) + } + + if len(relations) > 0 { + if err := tx.WithContext(c.Context()).Create(&relations).Error; err != nil { + return err + } + } + + return nil + }); err != nil { + s.Log.Errorf("Failed to create employees: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s employeesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Employees, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + var ( + kandangIDs []uint + needKandangUpdate bool + ) + + if req.Name != nil { + trimmed := strings.TrimSpace(*req.Name) + if trimmed == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty") + } + + if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("LOWER(name) = ? AND id <> ?", strings.ToLower(trimmed), id) + }); err == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "employee already exists") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed checking employee uniqueness: %+v", err) + return nil, err + } + + updateBody["name"] = trimmed + } + + if req.IsActive != nil { + updateBody["is_active"] = *req.IsActive + } + + if req.KandangIDs != nil { + ids := normalizeKandangIDs(*req.KandangIDs) + if len(ids) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids must contain at least one valid id") + } + + kandangIDs = ids + needKandangUpdate = true + } + + if len(updateBody) == 0 && !needKandangUpdate { + return s.GetOne(c, id) + } + + if err := s.Repository.DB().Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if len(updateBody) > 0 { + if err := repoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return err + } + } + + if needKandangUpdate { + if err := tx.WithContext(c.Context()). + Where("employee_id = ?", id). + Delete(&entity.EmployeeKandang{}).Error; err != nil { + return err + } + + relations := make([]entity.EmployeeKandang, 0, len(kandangIDs)) + for _, kandangID := range kandangIDs { + relations = append(relations, entity.EmployeeKandang{ + EmployeeId: id, + KandangId: kandangID, + }) + } + + if len(relations) > 0 { + if err := tx.WithContext(c.Context()).Create(&relations).Error; err != nil { + return err + } + } + } + + return nil + }); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Employees not found") + } + s.Log.Errorf("Failed to update employees: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s employeesService) 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, "Employees not found") + } + s.Log.Errorf("Failed to delete employees: %+v", err) + return err + } + return nil +} + +func normalizeKandangIDs(ids []uint) []uint { + result := make([]uint, 0, len(ids)) + seen := make(map[uint]struct{}) + + for _, id := range ids { + if id == 0 { + continue + } + + if _, ok := seen[id]; ok { + continue + } + + seen[id] = struct{}{} + result = append(result, id) + } + + return result +} diff --git a/internal/modules/master/employees/validations/employees.validation.go b/internal/modules/master/employees/validations/employees.validation.go new file mode 100644 index 00000000..2e2cc879 --- /dev/null +++ b/internal/modules/master/employees/validations/employees.validation.go @@ -0,0 +1,21 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + KandangIDs []uint `json:"kandang_ids" validate:"required,min=1,dive,required"` + IsActive bool `json:"is_active"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` + KandangIDs *[]uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,required"` + IsActive *bool `json:"is_active,omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + KandangId *uint `query:"kandang_id" validate:"omitempty"` + IsActive *bool `query:"is_active" validate:"omitempty"` +} diff --git a/internal/modules/master/fcrs/route.go b/internal/modules/master/fcrs/route.go index 60633f16..06291ce4 100644 --- a/internal/modules/master/fcrs/route.go +++ b/internal/modules/master/fcrs/route.go @@ -15,9 +15,9 @@ func FcrRoutes(v1 fiber.Router, u user.UserService, s fcr.FcrService) { route := v1.Group("/fcrs") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_FcrGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_FcrCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_FcrGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_FcrUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_FcrDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/flocks/route.go b/internal/modules/master/flocks/route.go index 429d8dcd..046e014a 100644 --- a/internal/modules/master/flocks/route.go +++ b/internal/modules/master/flocks/route.go @@ -15,9 +15,9 @@ func FlockRoutes(v1 fiber.Router, u user.UserService, s flock.FlockService) { route := v1.Group("/flocks") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_FlocksGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_FlocksCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_FlocksGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_FlocksUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_FlocksDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index 1584b07f..baea9523 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -84,6 +84,7 @@ func ToKandangListDTO(e entity.Kandang) KandangListDTO { Name: e.Name, Status: e.Status, Location: location, + Capacity: e.Capacity, Pic: pic, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, diff --git a/internal/modules/master/kandangs/route.go b/internal/modules/master/kandangs/route.go index 1e384b1f..4cbf2793 100644 --- a/internal/modules/master/kandangs/route.go +++ b/internal/modules/master/kandangs/route.go @@ -1,7 +1,7 @@ package kandangs import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/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" @@ -13,11 +13,11 @@ func KandangRoutes(v1 fiber.Router, u user.UserService, s kandang.KandangService ctrl := controller.NewKandangController(s) route := v1.Group("/kandangs") - // route.Use(m.Auth(u)) + route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_KandangsGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_KandangsCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_KandangsGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_KandangsUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_KandangsDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index e65348fc..35fe2c30 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -3,13 +3,13 @@ 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" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" 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" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -130,14 +130,18 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + createBody := &entity.Kandang{ Name: req.Name, LocationId: req.LocationId, Capacity: req.Capacity, Status: status, PicId: req.PicId, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index 6d7c090b..f4adc55e 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -1,8 +1,8 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - Status string `json:"status,omitempty" validate:"omitempty,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + Status string `json:"status,omitempty" validate:"omitempty,min=3,max=50"` Capacity float64 `json:"capacity" validate:"required_strict,gt=0"` LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` @@ -10,8 +10,8 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` - Status *string `json:"status,omitempty" validate:"omitempty,min=3"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Status *string `json:"status,omitempty" validate:"omitempty,min=3,max=50"` Capacity *float64 `json:"capacity" validate:"omitempty,gt=0"` LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/master/locations/route.go b/internal/modules/master/locations/route.go index 68bce594..771e2d0d 100644 --- a/internal/modules/master/locations/route.go +++ b/internal/modules/master/locations/route.go @@ -15,9 +15,9 @@ func LocationRoutes(v1 fiber.Router, u user.UserService, s location.LocationServ route := v1.Group("/locations") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_LocationsGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_LocationsCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_LocationsGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_LocationsUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_LocationsDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 7b7599ea..19894d10 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -4,15 +4,15 @@ 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" + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + 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" "gorm.io/gorm" ) @@ -97,12 +97,16 @@ func (s *locationService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, err } - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + createBody := &entity.Location{ Name: req.Name, Address: req.Address, AreaId: req.AreaId, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/locations/validations/location.validation.go b/internal/modules/master/locations/validations/location.validation.go index 029953c0..61ab4125 100644 --- a/internal/modules/master/locations/validations/location.validation.go +++ b/internal/modules/master/locations/validations/location.validation.go @@ -1,13 +1,13 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` 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"` + 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"` } diff --git a/internal/modules/master/nonstocks/controllers/nonstock.controller.go b/internal/modules/master/nonstocks/controllers/nonstock.controller.go index d991c4da..2360bd09 100644 --- a/internal/modules/master/nonstocks/controllers/nonstock.controller.go +++ b/internal/modules/master/nonstocks/controllers/nonstock.controller.go @@ -23,10 +23,12 @@ func NewNonstockController(nonstockService service.NonstockService) *NonstockCon } func (u *NonstockController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + SupplierID: uint(c.QueryInt("supplier_id", 0)), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index dd187230..9954ee76 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -4,6 +4,7 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -18,13 +19,14 @@ type NonstockRelationDTO struct { } type NonstockListDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Flags []string `json:"flags"` - Uom *uomDTO.UomRelationDTO `json:"uom"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id uint `json:"id"` + Name string `json:"name"` + Flags []string `json:"flags"` + Uom *uomDTO.UomRelationDTO `json:"uom"` + Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type NonstockDetailDTO struct { @@ -76,6 +78,7 @@ func ToNonstockListDTO(e entity.Nonstock) NonstockListDTO { Name: e.Name, Flags: flags, Uom: uomRef, + Suppliers: toNonstockSupplierDTOs(e.NonstockSuppliers), CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, @@ -95,3 +98,23 @@ func ToNonstockDetailDTO(e entity.Nonstock) NonstockDetailDTO { NonstockListDTO: ToNonstockListDTO(e), } } + +func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.SupplierRelationDTO { + if len(relations) == 0 { + return make([]supplierDTO.SupplierRelationDTO, 0) + } + + result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations)) + for _, relation := range relations { + if relation.Supplier.Id == 0 { + continue + } + result = append(result, supplierDTO.ToSupplierRelationDTO(relation.Supplier)) + } + + if len(result) == 0 { + return make([]supplierDTO.SupplierRelationDTO, 0) + } + + return result +} diff --git a/internal/modules/master/nonstocks/route.go b/internal/modules/master/nonstocks/route.go index 2aa7b838..6f2a2016 100644 --- a/internal/modules/master/nonstocks/route.go +++ b/internal/modules/master/nonstocks/route.go @@ -15,9 +15,9 @@ func NonstockRoutes(v1 fiber.Router, u user.UserService, s nonstock.NonstockServ route := v1.Group("/nonstocks") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_NonstocksGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_NonstocksCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_NonstocksGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_NonstocksUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_NonstocksDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/nonstocks/services/nonstock.service.go b/internal/modules/master/nonstocks/services/nonstock.service.go index c0001a52..876d4c1e 100644 --- a/internal/modules/master/nonstocks/services/nonstock.service.go +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -58,7 +58,15 @@ func (s nonstockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit offset := (params.Page - 1) * params.Limit nonstocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + + if params.SupplierID > 0 { + db = db.Joins("INNER JOIN nonstock_suppliers ON nonstock_suppliers.nonstock_id = nonstocks.id"). + Where("nonstock_suppliers.supplier_id = ?", params.SupplierID). + Distinct() + } + db = s.withRelations(db) + if params.Search != "" { return db.Where("name LIKE ?", "%"+params.Search+"%") } diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index 9d93ce3d..62a41197 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -1,21 +1,22 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` UomID uint `json:"uom_id" validate:"required,gt=0"` - SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` - Flags []string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` + SupplierIDs []uint `json:"supplier_ids" validate:"dive,gt=0"` + Flags []string `json:"flags" validate:"dive,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,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"` + 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"` + SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` } diff --git a/internal/modules/master/phase-activities/controllers/phase-activity.controller.go b/internal/modules/master/phase-activities/controllers/phase-activity.controller.go new file mode 100644 index 00000000..455ff1e4 --- /dev/null +++ b/internal/modules/master/phase-activities/controllers/phase-activity.controller.go @@ -0,0 +1,153 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PhaseActivityController struct { + PhaseActivityService service.PhaseActivityService +} + +func NewPhaseActivityController(phaseActivityService service.PhaseActivityService) *PhaseActivityController { + return &PhaseActivityController{ + PhaseActivityService: phaseActivityService, + } +} + +func (u *PhaseActivityController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if phaseParam := c.Query("phase_id", ""); phaseParam != "" { + id, err := strconv.Atoi(phaseParam) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid phase_id") + } + temp := uint(id) + query.PhaseId = &temp + } + + result, totalResults, err := u.PhaseActivityService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.PhaseActivityListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all phaseActivitys successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToPhaseActivityListDTOs(result), + }) +} + +func (u *PhaseActivityController) 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.PhaseActivityService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get phaseActivity successfully", + Data: dto.ToPhaseActivityListDTO(*result), + }) +} + +func (u *PhaseActivityController) 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.PhaseActivityService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create phaseActivity successfully", + Data: dto.ToPhaseActivityListDTO(*result), + }) +} + +func (u *PhaseActivityController) 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.PhaseActivityService.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 phaseActivity successfully", + Data: dto.ToPhaseActivityListDTO(*result), + }) +} + +func (u *PhaseActivityController) 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.PhaseActivityService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete phaseActivity successfully", + }) +} diff --git a/internal/modules/master/phase-activities/dto/phase-activity.dto.go b/internal/modules/master/phase-activities/dto/phase-activity.dto.go new file mode 100644 index 00000000..ee5942d5 --- /dev/null +++ b/internal/modules/master/phase-activities/dto/phase-activity.dto.go @@ -0,0 +1,72 @@ +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 PhaseActivityRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type PhaseActivityListDTO struct { + Id uint `json:"id"` + PhaseId uint `json:"phase_id"` + Name string `json:"name"` + Description *string `json:"description"` + TimeType *string `json:"time_type"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type PhaseActivityDetailDTO struct { + PhaseActivityListDTO +} + +// === Mapper Functions === + +func ToPhaseActivityRelationDTO(e entity.PhaseActivity) PhaseActivityRelationDTO { + return PhaseActivityRelationDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToPhaseActivityListDTO(e entity.PhaseActivity) PhaseActivityListDTO { + var createdUser *userDTO.UserRelationDTO + // if e.CreatedUser.Id != 0 { + // mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + // createdUser = &mapped + // } + + return PhaseActivityListDTO{ + Id: e.Id, + PhaseId: e.PhaseId, + Name: e.Name, + Description: e.Description, + TimeType: e.TimeType, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToPhaseActivityListDTOs(e []entity.PhaseActivity) []PhaseActivityListDTO { + result := make([]PhaseActivityListDTO, len(e)) + for i, r := range e { + result[i] = ToPhaseActivityListDTO(r) + } + return result +} + +func ToPhaseActivityDetailDTO(e entity.PhaseActivity) PhaseActivityDetailDTO { + return PhaseActivityDetailDTO{ + PhaseActivityListDTO: ToPhaseActivityListDTO(e), + } +} diff --git a/internal/modules/master/phase-activities/module.go b/internal/modules/master/phase-activities/module.go new file mode 100644 index 00000000..22d25189 --- /dev/null +++ b/internal/modules/master/phase-activities/module.go @@ -0,0 +1,27 @@ +package phaseActivity + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rPhaseActivity "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/repositories" + sPhaseActivity "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/services" + rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type PhaseActivityModule struct{} + +func (PhaseActivityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + phaseActivityRepo := rPhaseActivity.NewPhaseActivityRepository(db) + phasesRepo := rPhases.NewPhasesRepository(db) + userRepo := rUser.NewUserRepository(db) + + phaseActivityService := sPhaseActivity.NewPhaseActivityService(phaseActivityRepo, phasesRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + PhaseActivityRoutes(router, userService, phaseActivityService) +} diff --git a/internal/modules/master/phase-activities/repositories/phase-activity.repository.go b/internal/modules/master/phase-activities/repositories/phase-activity.repository.go new file mode 100644 index 00000000..cc5eaae5 --- /dev/null +++ b/internal/modules/master/phase-activities/repositories/phase-activity.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type PhaseActivityRepository interface { + repository.BaseRepository[entity.PhaseActivity] +} + +type PhaseActivityRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.PhaseActivity] +} + +func NewPhaseActivityRepository(db *gorm.DB) PhaseActivityRepository { + return &PhaseActivityRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.PhaseActivity](db), + } +} diff --git a/internal/modules/master/phase-activities/route.go b/internal/modules/master/phase-activities/route.go new file mode 100644 index 00000000..6fcef558 --- /dev/null +++ b/internal/modules/master/phase-activities/route.go @@ -0,0 +1,23 @@ +package phaseActivity + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/controllers" + phaseActivity "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func PhaseActivityRoutes(v1 fiber.Router, u user.UserService, s phaseActivity.PhaseActivityService) { + ctrl := controller.NewPhaseActivityController(s) + + route := v1.Group("/phase-activities") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/phase-activities/services/phase-activity.service.go b/internal/modules/master/phase-activities/services/phase-activity.service.go new file mode 100644 index 00000000..3426eab4 --- /dev/null +++ b/internal/modules/master/phase-activities/services/phase-activity.service.go @@ -0,0 +1,167 @@ +package service + +import ( + "errors" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities/validations" + phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + "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 PhaseActivityService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.PhaseActivity, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.PhaseActivity, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.PhaseActivity, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.PhaseActivity, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type phaseActivityService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.PhaseActivityRepository + PhaseRepo phaseRepo.PhasesRepository +} + +func NewPhaseActivityService(repo repository.PhaseActivityRepository, phaseRepo phaseRepo.PhasesRepository, validate *validator.Validate) PhaseActivityService { + return &phaseActivityService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + PhaseRepo: phaseRepo, + } +} + +func (s phaseActivityService) withRelations(db *gorm.DB) *gorm.DB { + return db +} + +func (s phaseActivityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.PhaseActivity, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + phaseActivitys, 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+"%") + } + if params.PhaseId != nil { + db = db.Where("phase_id = ?", *params.PhaseId) + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get phaseActivitys: %+v", err) + return nil, 0, err + } + return phaseActivitys, total, nil +} + +func (s phaseActivityService) GetOne(c *fiber.Ctx, id uint) (*entity.PhaseActivity, error) { + phaseActivity, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "PhaseActivity not found") + } + if err != nil { + s.Log.Errorf("Failed get phaseActivity by id: %+v", err) + return nil, err + } + return phaseActivity, nil +} + +func (s *phaseActivityService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.PhaseActivity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + phase, err := s.PhaseRepo.GetByID(c.Context(), req.PhaseId, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, "phase not found") + } + if err != nil { + s.Log.Errorf("Failed to get phase: %+v", err) + return nil, err + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty") + } + + timeType := strings.TrimSpace(req.TimeType) + if timeType == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty") + } + + createBody := &entity.PhaseActivity{ + PhaseId: phase.Id, + Name: name, + Description: req.Description, + TimeType: &timeType, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create phaseActivity: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s phaseActivityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.PhaseActivity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + trimmedName := strings.TrimSpace(req.Name) + if trimmedName == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "name cannot be empty") + } + + trimmedTimeType := strings.TrimSpace(req.TimeType) + if trimmedTimeType == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty") + } + + updateBody := map[string]any{ + "name": trimmedName, + "time_type": trimmedTimeType, + } + + if req.Description != nil { + updateBody["description"] = *req.Description + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "PhaseActivity not found") + } + s.Log.Errorf("Failed to update phaseActivity: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s phaseActivityService) 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, "PhaseActivity not found") + } + s.Log.Errorf("Failed to delete phaseActivity: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/phase-activities/validations/phase-activity.validation.go b/internal/modules/master/phase-activities/validations/phase-activity.validation.go new file mode 100644 index 00000000..a2ab8e1b --- /dev/null +++ b/internal/modules/master/phase-activities/validations/phase-activity.validation.go @@ -0,0 +1,21 @@ +package validation + +type Create struct { + PhaseId uint `json:"phase_id" validate:"required"` + Name string `json:"name" validate:"required_strict,min=3"` + Description *string `json:"description,omitempty"` + TimeType string `json:"time_type" validate:"required"` +} + +type Update struct { + Name string `json:"name" validate:"required_strict,min=3"` + Description *string `json:"description,omitempty"` + TimeType string `json:"time_type" validate:"required"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + PhaseId *uint `query:"phase_id" validate:"omitempty"` +} diff --git a/internal/modules/master/phasess/controllers/phases.controller.go b/internal/modules/master/phasess/controllers/phases.controller.go new file mode 100644 index 00000000..c9d9d349 --- /dev/null +++ b/internal/modules/master/phasess/controllers/phases.controller.go @@ -0,0 +1,148 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PhasesController struct { + PhasesService service.PhasesService +} + +func NewPhasesController(phasesService service.PhasesService) *PhasesController { + return &PhasesController{ + PhasesService: phasesService, + } +} + +func (u *PhasesController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if category := c.Query("category", ""); category != "" { + query.Category = &category + } + + result, totalResults, err := u.PhasesService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.PhasesListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all phasess successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToPhasesListDTOs(result), + }) +} + +func (u *PhasesController) 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.PhasesService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get phases successfully", + Data: dto.ToPhasesListDTO(*result), + }) +} + +func (u *PhasesController) 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.PhasesService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create phases successfully", + Data: dto.ToPhasesListDTO(*result), + }) +} + +func (u *PhasesController) 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.PhasesService.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 phases successfully", + Data: dto.ToPhasesListDTO(*result), + }) +} + +func (u *PhasesController) 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.PhasesService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete phases successfully", + }) +} diff --git a/internal/modules/master/phasess/dto/phases.dto.go b/internal/modules/master/phasess/dto/phases.dto.go new file mode 100644 index 00000000..51724556 --- /dev/null +++ b/internal/modules/master/phasess/dto/phases.dto.go @@ -0,0 +1,68 @@ +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 PhasesRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type PhasesListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + IsActive bool `json:"is_active"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` +} + +type PhasesDetailDTO struct { + PhasesListDTO +} + +// === Mapper Functions === + +func ToPhasesRelationDTO(e entity.Phases) PhasesRelationDTO { + return PhasesRelationDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToPhasesListDTO(e entity.Phases) PhasesListDTO { + var createdUser *userDTO.UserRelationDTO + // if e.CreatedUser.Id != 0 { + // mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + // createdUser = &mapped + // } + + return PhasesListDTO{ + Id: e.Id, + Name: e.Name, + Category: e.Category, + IsActive: e.IsActive, + CreatedAt: e.CreatedAt, + CreatedUser: createdUser, + } +} + +func ToPhasesListDTOs(e []entity.Phases) []PhasesListDTO { + result := make([]PhasesListDTO, len(e)) + for i, r := range e { + result[i] = ToPhasesListDTO(r) + } + return result +} + +func ToPhasesDetailDTO(e entity.Phases) PhasesDetailDTO { + return PhasesDetailDTO{ + PhasesListDTO: ToPhasesListDTO(e), + } +} diff --git a/internal/modules/master/phasess/module.go b/internal/modules/master/phasess/module.go new file mode 100644 index 00000000..3f44c220 --- /dev/null +++ b/internal/modules/master/phasess/module.go @@ -0,0 +1,25 @@ +package phases + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + sPhases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type PhasesModule struct{} + +func (PhasesModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + phasesRepo := rPhases.NewPhasesRepository(db) + userRepo := rUser.NewUserRepository(db) + + phasesService := sPhases.NewPhasesService(phasesRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + PhasesRoutes(router, userService, phasesService) +} diff --git a/internal/modules/master/phasess/repositories/phases.repository.go b/internal/modules/master/phasess/repositories/phases.repository.go new file mode 100644 index 00000000..d243ca2e --- /dev/null +++ b/internal/modules/master/phasess/repositories/phases.repository.go @@ -0,0 +1,21 @@ +package repository + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +type PhasesRepository interface { + repository.BaseRepository[entity.Phases] +} + +type PhasesRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Phases] +} + +func NewPhasesRepository(db *gorm.DB) PhasesRepository { + return &PhasesRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Phases](db), + } +} diff --git a/internal/modules/master/phasess/route.go b/internal/modules/master/phasess/route.go new file mode 100644 index 00000000..b4ca202d --- /dev/null +++ b/internal/modules/master/phasess/route.go @@ -0,0 +1,23 @@ +package phases + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/controllers" + phases "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func PhasesRoutes(v1 fiber.Router, u user.UserService, s phases.PhasesService) { + ctrl := controller.NewPhasesController(s) + + route := v1.Group("/phases") + route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/phasess/services/phases.service.go b/internal/modules/master/phasess/services/phases.service.go new file mode 100644 index 00000000..98e73bef --- /dev/null +++ b/internal/modules/master/phasess/services/phases.service.go @@ -0,0 +1,158 @@ +package service + +import ( + "errors" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/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 PhasesService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Phases, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Phases, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Phases, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Phases, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type phasesService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.PhasesRepository +} + +func NewPhasesService(repo repository.PhasesRepository, validate *validator.Validate) PhasesService { + return &phasesService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s phasesService) withRelations(db *gorm.DB) *gorm.DB { + return db +} + +func (s phasesService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Phases, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + phasess, 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+"%") + } + if params.Category != nil { + db = db.Where("category = ?", *params.Category) + } + return db.Order("created_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get phasess: %+v", err) + return nil, 0, err + } + return phasess, total, nil +} + +func (s phasesService) GetOne(c *fiber.Ctx, id uint) (*entity.Phases, error) { + phases, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Phases not found") + } + if err != nil { + s.Log.Errorf("Failed get phases by id: %+v", err) + return nil, err + } + return phases, nil +} + +func (s *phasesService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Phases, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("LOWER(name) = ? AND category = ?", strings.ToLower(req.Name), req.Category) + }); err == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "phase already exists") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed checking phase uniqueness: %+v", err) + return nil, err + } + + createBody := &entity.Phases{ + Name: req.Name, + Category: req.Category, + IsActive: true, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create phases: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s phasesService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Phases, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + existing, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Phases not found") + } + if err != nil { + s.Log.Errorf("Failed get phases by id: %+v", err) + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + if _, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("LOWER(name) = ? AND category = ? AND id <> ?", strings.ToLower(*req.Name), existing.Category, id) + }); err == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "phase already exists") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed checking phase uniqueness: %+v", err) + return nil, err + } + + updateBody["name"] = strings.TrimSpace(*req.Name) + } + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + s.Log.Errorf("Failed to update phases: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s phasesService) 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, "Phases not found") + } + s.Log.Errorf("Failed to delete phases: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/phasess/validations/phases.validation.go b/internal/modules/master/phasess/validations/phases.validation.go new file mode 100644 index 00000000..c22d4208 --- /dev/null +++ b/internal/modules/master/phasess/validations/phases.validation.go @@ -0,0 +1,17 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + Category string `json:"category" validate:"required"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + Category *string `query:"category" validate:"omitempty"` +} diff --git a/internal/modules/master/product-categories/route.go b/internal/modules/master/product-categories/route.go index 4a2262f9..1fa0532f 100644 --- a/internal/modules/master/product-categories/route.go +++ b/internal/modules/master/product-categories/route.go @@ -15,9 +15,9 @@ func ProductCategoryRoutes(v1 fiber.Router, u user.UserService, s productCategor route := v1.Group("/product-categories") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_ProductCategoriesGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_ProductCategoriesCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_ProductCategoriesGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_ProductCategoriesUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_ProductCategoriesDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/product-categories/validations/product-category.validation.go b/internal/modules/master/product-categories/validations/product-category.validation.go index 7a7d6e40..46cfaedb 100644 --- a/internal/modules/master/product-categories/validations/product-category.validation.go +++ b/internal/modules/master/product-categories/validations/product-category.validation.go @@ -1,12 +1,12 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` Code string `json:"code" validate:"required_strict,max=10"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` Code *string `json:"code,omitempty" validate:"omitempty,max=10"` } diff --git a/internal/modules/master/production-standards/controllers/production-standard.controller.go b/internal/modules/master/production-standards/controllers/production-standard.controller.go new file mode 100644 index 00000000..d7794e41 --- /dev/null +++ b/internal/modules/master/production-standards/controllers/production-standard.controller.go @@ -0,0 +1,145 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProductionStandardController struct { + ProductionStandardService service.ProductionStandardService +} + +func NewProductionStandardController(productionStandardService service.ProductionStandardService) *ProductionStandardController { + return &ProductionStandardController{ + ProductionStandardService: productionStandardService, + } +} + +func (u *ProductionStandardController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + ProjectCategory: c.Query("project_category", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ProductionStandardService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductionStandardRelationDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all productionStandards successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProductionStandardListDTOs(result), + }) +} + +func (u *ProductionStandardController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ProductionStandardService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) CreateOne(c *fiber.Ctx) error { + req := new(validation.Create) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProductionStandardService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) UpdateOne(c *fiber.Ctx) error { + req := new(validation.Update) + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProductionStandardService.UpdateOne(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) DeleteOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := u.ProductionStandardService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete productionStandard successfully", + }) +} diff --git a/internal/modules/master/production-standards/dto/production-standard.dto.go b/internal/modules/master/production-standards/dto/production-standard.dto.go new file mode 100644 index 00000000..a77cdf8b --- /dev/null +++ b/internal/modules/master/production-standards/dto/production-standard.dto.go @@ -0,0 +1,166 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ProductionStandardRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + ProjectCategory string `json:"project_category"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` +} + +type ProductionStandardDetailDTO struct { + ProductionStandardRelationDTO + Details []WeeklyProductionStandardDTO `json:"details"` +} + +type GrowthStandardDetailDTO struct { + Id uint `json:"id"` + TargetMeanBW *float64 `json:"target_mean_bw"` + MaxDepletion *float64 `json:"max_depletion"` + MinUniformity float64 `json:"min_uniformity"` + FeedIntake *float64 `json:"feed_intake"` +} + +type EggProductionStandardDetailDTO struct { + Id uint `json:"id"` + TargetHenDayProduction *float64 `json:"target_hen_day_production"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production"` + TargetEggWeight *float64 `json:"target_egg_weight"` + TargetEggMass *float64 `json:"target_egg_mass"` + StandardFCR *float64 `json:"standard_fcr"` +} + +type WeeklyProductionStandardDTO struct { + Week int `json:"week"` + GrowthStandardDetail GrowthStandardDetailDTO `json:"growth_standard_detail"` + EggProductionStandardDetailDTO *EggProductionStandardDetailDTO `json:"egg_production_standard_detail,omitempty"` +} + +// === Mapper Functions === + +func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardRelationDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return ProductionStandardRelationDTO{ + Id: e.Id, + Name: e.Name, + ProjectCategory: e.ProjectCategory, + CreatedUser: createdUser, + } +} + +func ToProductionStandardRelationDTO(e entity.ProductionStandard) ProductionStandardRelationDTO { + return ProductionStandardRelationDTO{ + Id: e.Id, + Name: e.Name, + ProjectCategory: e.ProjectCategory, + } +} + +func ToProductionStandardListDTOs(e []entity.ProductionStandard) []ProductionStandardRelationDTO { + result := make([]ProductionStandardRelationDTO, len(e)) + for i, r := range e { + result[i] = ToProductionStandardListDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTO(e entity.StandardGrowthDetail) WeeklyProductionStandardDTO { + return WeeklyProductionStandardDTO{ + Week: e.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: e.Id, + TargetMeanBW: e.TargetMeanBw, + MaxDepletion: e.MaxDepletion, + MinUniformity: e.MinUniformity, + FeedIntake: e.FeedIntake, + }, + EggProductionStandardDetailDTO: nil, // GROWING category - no egg production details + } +} + +func ToWeeklyProductionStandardDTOWithDetails(growth entity.StandardGrowthDetail, detail entity.ProductionStandardDetail) WeeklyProductionStandardDTO { + eggDetail := &EggProductionStandardDetailDTO{ + Id: detail.Id, + TargetHenDayProduction: detail.TargetHenDayProduction, + TargetHenHouseProduction: detail.TargetHenHouseProduction, + TargetEggWeight: detail.TargetEggWeight, + TargetEggMass: detail.TargetEggMass, + StandardFCR: detail.StandardFCR, + } + + return WeeklyProductionStandardDTO{ + Week: growth.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: growth.Id, + TargetMeanBW: growth.TargetMeanBw, + MaxDepletion: growth.MaxDepletion, + MinUniformity: growth.MinUniformity, + FeedIntake: growth.FeedIntake, + }, + EggProductionStandardDetailDTO: eggDetail, // LAYING category - with egg production details + } +} + +func ToWeeklyProductionStandardDTOs(e []entity.StandardGrowthDetail) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(e)) + for i, r := range e { + result[i] = ToWeeklyProductionStandardDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTOsWithDetails( + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(growthDetails)) + + // Create map for production standard details by week + prodDetailMap := make(map[int]entity.ProductionStandardDetail) + for _, detail := range productionStandardDetails { + prodDetailMap[detail.Week] = detail + } + + // Map growth details and combine with production standard details + for i, growth := range growthDetails { + if prodDetail, exists := prodDetailMap[growth.Week]; exists { + result[i] = ToWeeklyProductionStandardDTOWithDetails(growth, prodDetail) + } else { + result[i] = ToWeeklyProductionStandardDTO(growth) + } + } + + return result +} + +func ToEggProductionStandardDetailDTO(e entity.ProductionStandardDetail) EggProductionStandardDetailDTO { + return EggProductionStandardDetailDTO{ + TargetHenDayProduction: e.TargetHenDayProduction, + TargetHenHouseProduction: e.TargetHenHouseProduction, + TargetEggWeight: e.TargetEggWeight, + TargetEggMass: e.TargetEggMass, + StandardFCR: e.StandardFCR, + } +} + +func ToProductionStandardDetailDTO( + standard entity.ProductionStandard, + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) ProductionStandardDetailDTO { + return ProductionStandardDetailDTO{ + ProductionStandardRelationDTO: ToProductionStandardRelationDTO(standard), + Details: ToWeeklyProductionStandardDTOsWithDetails(growthDetails, productionStandardDetails), + } +} diff --git a/internal/modules/master/production-standards/module.go b/internal/modules/master/production-standards/module.go new file mode 100644 index 00000000..bd08ee88 --- /dev/null +++ b/internal/modules/master/production-standards/module.go @@ -0,0 +1,33 @@ +package productionstandards + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProductionStandardModule struct{} + +func (ProductionStandardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + userRepo := rUser.NewUserRepository(db) + + productionStandardService := sProductionStandard.NewProductionStandardService( + productionStandardRepo, + productionStandardDetailRepo, + standardGrowthDetailRepo, + validate, + ) + userService := sUser.NewUserService(userRepo, validate) + + ProductionStandardRoutes(router, userService, productionStandardService) +} + diff --git a/internal/modules/master/production-standards/repositories/production_standard.repository.go b/internal/modules/master/production-standards/repositories/production_standard.repository.go new file mode 100644 index 00000000..26330d63 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard.repository.go @@ -0,0 +1,103 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProductionStandardRepository interface { + repository.BaseRepository[entity.ProductionStandard] + GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) + GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) + GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) +} + +type ProductionStandardRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandard] + db *gorm.DB +} + +func NewProductionStandardRepository(db *gorm.DB) ProductionStandardRepository { + return &ProductionStandardRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandard](db), + db: db, + } +} + +func (r *ProductionStandardRepositoryImpl) GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) { + var standards []entity.ProductionStandard + var total int64 + + // Build base query + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier for filters + if modifier != nil { + q = modifier(q) + } + + // Count total + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Re-apply modifier and add preloads for Find + q = r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + if modifier != nil { + q = modifier(q) + } + q = q.Preload("CreatedUser") + + // Find with offset and limit + if err := q.Offset(offset).Limit(limit).Find(&standards).Error; err != nil { + return nil, 0, err + } + + return standards, total, nil +} + +func (r *ProductionStandardRepositoryImpl) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) { + var standard entity.ProductionStandard + + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier + if modifier != nil { + q = modifier(q) + } + + // Ensure CreatedUser is preloaded + q = q.Preload("CreatedUser") + + if err := q.First(&standard, id).Error; err != nil { + return nil, err + } + + return &standard, nil +} + +func (r *ProductionStandardRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.ProductionStandard](ctx, r.db, name, excludeID) +} + +func (r *ProductionStandardRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandard](ctx, r.db, id) +} + +func (r *ProductionStandardRepositoryImpl) GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) { + var standards []entity.ProductionStandard + err := r.db.WithContext(ctx). + Preload("CreatedUser"). + Where("project_category = ?", projectCategory). + Where("deleted_at IS NULL"). + Find(&standards).Error + if err != nil { + return nil, err + } + return standards, nil +} diff --git a/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go new file mode 100644 index 00000000..ad680c01 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go @@ -0,0 +1,63 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProductionStandardDetailRepository interface { + repository.BaseRepository[entity.ProductionStandardDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type ProductionStandardDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandardDetail] + db *gorm.DB +} + +func NewProductionStandardDetailRepository(db *gorm.DB) ProductionStandardDetailRepository { + return &ProductionStandardDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandardDetail](db), + db: db, + } +} + +func (r *ProductionStandardDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandardDetail](ctx, r.db, id) +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) { + var details []entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) { + var detail entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.ProductionStandardDetail{}).Error +} diff --git a/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go new file mode 100644 index 00000000..afe93d81 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go @@ -0,0 +1,63 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StandardGrowthDetailRepository interface { + repository.BaseRepository[entity.StandardGrowthDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type StandardGrowthDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StandardGrowthDetail] + db *gorm.DB +} + +func NewStandardGrowthDetailRepository(db *gorm.DB) StandardGrowthDetailRepository { + return &StandardGrowthDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StandardGrowthDetail](db), + db: db, + } +} + +func (r *StandardGrowthDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.StandardGrowthDetail](ctx, r.db, id) +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) { + var details []entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) { + var detail entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.StandardGrowthDetail{}).Error +} diff --git a/internal/modules/master/production-standards/route.go b/internal/modules/master/production-standards/route.go new file mode 100644 index 00000000..e0296fd3 --- /dev/null +++ b/internal/modules/master/production-standards/route.go @@ -0,0 +1,23 @@ +package productionstandards + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/controllers" + productionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProductionStandardRoutes(v1 fiber.Router, u user.UserService, s productionStandard.ProductionStandardService) { + ctrl := controller.NewProductionStandardController(s) + + route := v1.Group("/production-standards") + route.Use(m.Auth(u)) + + route.Get("/", m.RequirePermissions(m.P_Production_Standart_GetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_Production_Standart_CreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_Production_Standart_GetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_Production_Standart_UpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_Production_Standart_DeleteOne), ctrl.DeleteOne) +} diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go new file mode 100644 index 00000000..4005b014 --- /dev/null +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -0,0 +1,301 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type ProductionStandardService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProductionStandard, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type productionStandardService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProductionStandardRepository + ProductionStandardDetailRepo repository.ProductionStandardDetailRepository + StandardGrowthDetailRepo repository.StandardGrowthDetailRepository +} + +func NewProductionStandardService( + repo repository.ProductionStandardRepository, + productionStandardDetailRepo repository.ProductionStandardDetailRepository, + standardGrowthDetailRepo repository.StandardGrowthDetailRepository, + validate *validator.Validate, +) ProductionStandardService { + return &productionStandardService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ProductionStandardDetailRepo: productionStandardDetailRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + } +} + +func (s productionStandardService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProductionStandardDetails"). + Preload("StandardGrowthDetails") +} + +func (s productionStandardService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + productionStandards, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + if params.ProjectCategory != "" { + return db.Where("project_category = ?", params.ProjectCategory) + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get productionStandards: %+v", err) + return nil, 0, err + } + return productionStandards, total, nil +} + +func (s productionStandardService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductionStandard, error) { + productionStandard, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + if err != nil { + return nil, err + } + return productionStandard, nil +} + +func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + nameExists, err := s.Repository.NameExists(c.Context(), req.Name, nil) + if err != nil { + return nil, err + } + if nameExists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", req.Name)) + } + + var createdStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + newStandard := &entity.ProductionStandard{ + Name: req.Name, + ProjectCategory: req.ProjectCategory, + CreatedBy: actorID, + } + + if err := standardRepoTx.CreateOne(c.Context(), newStandard, nil); err != nil { + return fmt.Errorf("failed to create production standard: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if req.ProjectCategory == string(utils.ProjectFlockCategoryLaying) { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + StandardFCR: detailReq.ProductionStandardDetails.StandardFCR, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + + createdStandard = newStandard + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to create production standard: %+v", err) + return nil, err + } + + return s.GetOne(c, createdStandard.Id) +} + +func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + var updatedStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + existingStandard, err := standardRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + return fmt.Errorf("failed to get production standard: %w", err) + } + + updateBody := make(map[string]any) + if req.Name != nil { + + nameExists, err := s.Repository.NameExists(c.Context(), *req.Name, &id) + if err != nil { + return err + } + if nameExists { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + if req.ProjectCategory != nil { + updateBody["project_category"] = *req.ProjectCategory + } + + if len(updateBody) > 0 { + if err := standardRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return fmt.Errorf("failed to update production standard: %w", err) + } + } + + if req.Details != nil && len(req.Details) > 0 { + + projectCategory := existingStandard.ProjectCategory + if req.ProjectCategory != nil { + projectCategory = *req.ProjectCategory + } + + if err := productionStandardDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old production standard details: %w", err) + } + if err := standardGrowthDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old standard growth details: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if projectCategory == "LAYING" { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + StandardFCR: detailReq.ProductionStandardDetails.StandardFCR, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + } + + updatedStandard = existingStandard + return nil + }) + + if err != nil { + return nil, err + } + + return s.GetOne(c, updatedStandard.Id) +} + +func (s productionStandardService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + return err + } + return nil +} diff --git a/internal/modules/master/production-standards/validations/production-standard.validation.go b/internal/modules/master/production-standards/validations/production-standard.validation.go new file mode 100644 index 00000000..cdc321f8 --- /dev/null +++ b/internal/modules/master/production-standards/validations/production-standard.validation.go @@ -0,0 +1,42 @@ +package validation + +type ProductionStandardDetailItem struct { + TargetHenDayProduction *float64 `json:"target_hen_day_production" validate:"omitempty,gte=0"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production" validate:"omitempty,gte=0"` + TargetEggWeight *float64 `json:"target_egg_weight" validate:"omitempty,gte=0"` + TargetEggMass *float64 `json:"target_egg_mass" validate:"omitempty,gte=0"` + StandardFCR *float64 `json:"standard_fcr" validate:"omitempty,gte=0"` +} + +type StandardGrowthDetailItem struct { + TargetMeanBw *float64 `json:"target_mean_bw" validate:"omitempty,gte=0"` + MaxDepletion *float64 `json:"max_depletion" validate:"omitempty,gte=0,lte=100"` + MinUniformity float64 `json:"min_uniformity" validate:"required,gte=0,lte=100"` + FeedIntake *float64 `json:"feed_intake" validate:"omitempty,gte=0"` +} + +type DetailItem struct { + Week int `json:"week" validate:"required,gte=1"` + ProductionStandardDetails *ProductionStandardDetailItem `json:"production_standard_details,omitempty"` + ProductionStandardUniformityDetails *StandardGrowthDetailItem `json:"production_standard_uniformity_details" validate:"required"` +} + + +type Create struct { + Name string `json:"name" validate:"required,min=3"` + ProjectCategory string `json:"project_category" validate:"required,oneof=GROWING LAYING"` + Details []DetailItem `json:"details" validate:"required,min=1,dive"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + ProjectCategory *string `json:"project_category,omitempty" validate:"omitempty,oneof=GROWING LAYING"` + Details []DetailItem `json:"details,omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + ProjectCategory string `query:"project_category" validate:"omitempty,oneof=GROWING LAYING"` +} diff --git a/internal/modules/master/products/dto/product.dto.go b/internal/modules/master/products/dto/product.dto.go index 3b2370b2..dfd4c86f 100644 --- a/internal/modules/master/products/dto/product.dto.go +++ b/internal/modules/master/products/dto/product.dto.go @@ -12,12 +12,13 @@ import ( // === DTO Structs === type ProductRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - ProductPrice float64 `gorm:"type:numeric(15,3);not null"` - SellingPrice *float64 `gorm:"type:numeric(15,3)"` - Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` - Flags *[]string `json:"flags,omitempty"` + Id uint `json:"id"` + Name string `json:"name"` + ProductPrice float64 `gorm:"type:numeric(15,3);not null"` + SellingPrice *float64 `gorm:"type:numeric(15,3)"` + Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` + Flags *[]string `json:"flags,omitempty"` + ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` } type ProductListDTO struct { @@ -55,13 +56,20 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO { uomRef = &mapped } + var categoryRef *productCategoryDTO.ProductCategoryRelationDTO + if e.ProductCategory.Id != 0 { + mapped := productCategoryDTO.ToProductCategoryRelationDTO(e.ProductCategory) + categoryRef = &mapped + } + return ProductRelationDTO{ - Id: e.Id, - Name: e.Name, - ProductPrice: e.ProductPrice, - SellingPrice: e.SellingPrice, - Flags: &flags, - Uom: uomRef, + Id: e.Id, + Name: e.Name, + ProductPrice: e.ProductPrice, + SellingPrice: e.SellingPrice, + Flags: &flags, + Uom: uomRef, + ProductCategory: categoryRef, } } diff --git a/internal/modules/master/products/route.go b/internal/modules/master/products/route.go index 369d6ea8..04431bd4 100644 --- a/internal/modules/master/products/route.go +++ b/internal/modules/master/products/route.go @@ -15,9 +15,9 @@ func ProductRoutes(v1 fiber.Router, u user.UserService, s product.ProductService route := v1.Group("/products") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_ProductsGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_ProductsCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_ProductsGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_ProductsUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_ProductsDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index 35e24927..f40d92be 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -70,6 +70,7 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = db.Where("is_visible = ?", true) if params.Search != "" { return db.Where("name LIKE ?", "%"+params.Search+"%") } diff --git a/internal/modules/master/products/validations/product.validation.go b/internal/modules/master/products/validations/product.validation.go index 70e23a74..e732d054 100644 --- a/internal/modules/master/products/validations/product.validation.go +++ b/internal/modules/master/products/validations/product.validation.go @@ -1,9 +1,9 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - Brand string `json:"brand" validate:"required_strict,min=2"` - Sku *string `json:"sku,omitempty" validate:"omitempty"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + Brand string `json:"brand" validate:"required_strict,min=2,max=50"` + Sku *string `json:"sku,omitempty" validate:"omitempty,max=100"` UomID uint `json:"uom_id" validate:"required,gt=0"` ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"` ProductPrice float64 `json:"product_price" validate:"required"` diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 44702e1a..f9bc7b13 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -10,12 +10,16 @@ import ( areas "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas" banks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks" customers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers" + employeess "gitlab.com/mbugroup/lti-api.git/internal/modules/master/employees" fcrs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs" flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks" kandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs" locations "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations" nonstocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks" + phaseActivitys "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phase-activities" + phasess "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess" productcategories "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories" + productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards" products "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products" suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" @@ -40,6 +44,10 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida products.ProductModule{}, banks.BankModule{}, flocks.FlockModule{}, + productionStandards.ProductionStandardModule{}, + employeess.EmployeesModule{}, + phasess.PhasesModule{}, + phaseActivitys.PhaseActivityModule{}, // MODULE REGISTRY } diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index 6b5a0ae2..c4c892b5 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -12,6 +12,7 @@ type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) } type SupplierRepositoryImpl struct { @@ -33,3 +34,7 @@ func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, ex func (r *SupplierRepositoryImpl) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) { return repository.ExistsByField[entity.Supplier](ctx, r.db, "alias", alias, excludeID) } + +func (r *SupplierRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, id) +} diff --git a/internal/modules/master/suppliers/route.go b/internal/modules/master/suppliers/route.go index 3a57f645..564ac725 100644 --- a/internal/modules/master/suppliers/route.go +++ b/internal/modules/master/suppliers/route.go @@ -1,7 +1,7 @@ package suppliers import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/controllers" supplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,11 +13,11 @@ func SupplierRoutes(v1 fiber.Router, u user.UserService, s supplier.SupplierServ ctrl := controller.NewSupplierController(s) route := v1.Group("/suppliers") - // route.Use(m.Auth(u)) + route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_SuppliersGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_SuppliersCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_SuppliersGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_SuppliersUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_SuppliersDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index 30ff4b9b..75d8fa04 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -5,14 +5,14 @@ import ( "fmt" "strings" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/validations" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -124,8 +124,10 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti } alias := strings.TrimSpace(strings.ToUpper(req.Alias)) - - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } createBody := &entity.Supplier{ Name: req.Name, Alias: alias, @@ -139,7 +141,7 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti Npwp: req.Npwp, AccountNumber: req.AccountNumber, DueDate: req.DueDate, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/suppliers/validations/supplier.validation.go b/internal/modules/master/suppliers/validations/supplier.validation.go index fa1d135d..ec02cd8e 100644 --- a/internal/modules/master/suppliers/validations/supplier.validation.go +++ b/internal/modules/master/suppliers/validations/supplier.validation.go @@ -1,14 +1,14 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` Alias string `json:"alias" validate:"required_strict,max=5"` - Pic string `json:"pic" validate:"required_strict"` - Type string `json:"type" validate:"required_strict"` - Category string `json:"category" validate:"required_strict"` - Hatchery *string `json:"hatchery,omitempty" validate:"omitempty"` + Pic string `json:"pic" validate:"required_strict,max=50"` + Type string `json:"type" validate:"required_strict,max=50"` + Category string `json:"category" validate:"required_strict,max=20"` + Hatchery *string `json:"hatchery,omitempty" validate:"omitempty,max=50"` Phone string `json:"phone" validate:"required_strict,max=20"` - Email string `json:"email" validate:"required_strict,email"` + Email string `json:"email" validate:"required_strict,email,max=50"` Address string `json:"address" validate:"required_strict"` Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` @@ -16,14 +16,14 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` Alias *string `json:"alias,omitempty" validate:"omitempty,max=5"` - Pic *string `json:"pic,omitempty" validate:"omitempty"` - Type *string `json:"type,omitempty" validate:"omitempty"` - Category *string `json:"category,omitempty" validate:"omitempty"` - Hatchery *string `json:"hatchery,omitempty" validate:"omitempty"` + Pic *string `json:"pic,omitempty" validate:"omitempty,max=50"` + Type *string `json:"type,omitempty" validate:"omitempty,max=50"` + Category *string `json:"category,omitempty" validate:"omitempty,max=20"` + Hatchery *string `json:"hatchery,omitempty" validate:"omitempty,max=50"` Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"` - Email *string `json:"email,omitempty" validate:"omitempty,email"` + Email *string `json:"email,omitempty" validate:"omitempty,email,max=50"` Address *string `json:"address,omitempty" validate:"omitempty"` Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` diff --git a/internal/modules/master/uoms/route.go b/internal/modules/master/uoms/route.go index 53faa239..8ffbcb62 100644 --- a/internal/modules/master/uoms/route.go +++ b/internal/modules/master/uoms/route.go @@ -20,4 +20,10 @@ func UomRoutes(v1 fiber.Router, u user.UserService, s uom.UomService) { route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) route.Delete("/:id", ctrl.DeleteOne) + + route.Get("/",m.RequirePermissions(m.P_AreaGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_AreaCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_AreaGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_AreaUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_AreaDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/uoms/services/uom.service.go b/internal/modules/master/uoms/services/uom.service.go index b0888751..5396849b 100644 --- a/internal/modules/master/uoms/services/uom.service.go +++ b/internal/modules/master/uoms/services/uom.service.go @@ -4,14 +4,14 @@ import ( "errors" "fmt" - 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" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -87,10 +87,13 @@ func (s *uomService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Uo return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Uom with name %s already exists", req.Name)) } - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } createBody := &entity.Uom{ Name: req.Name, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { diff --git a/internal/modules/master/uoms/validations/uom.validation.go b/internal/modules/master/uoms/validations/uom.validation.go index 56bbd601..a7004c26 100644 --- a/internal/modules/master/uoms/validations/uom.validation.go +++ b/internal/modules/master/uoms/validations/uom.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` } type Query struct { diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go index afa90660..a7cfac94 100644 --- a/internal/modules/master/warehouses/controllers/warehouse.controller.go +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -24,10 +24,11 @@ func NewWarehouseController(warehouseService service.WarehouseService) *Warehous 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", ""), - AreaId: c.QueryInt("area_id", 0), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + AreaId: c.QueryInt("area_id", 0), + ActiveProjectFlockOnly: c.QueryBool("active_project_flock", false), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/warehouses/repositories/warehouse.repository.go b/internal/modules/master/warehouses/repositories/warehouse.repository.go index ff05b3a1..e879e01a 100644 --- a/internal/modules/master/warehouses/repositories/warehouse.repository.go +++ b/internal/modules/master/warehouses/repositories/warehouse.repository.go @@ -17,7 +17,6 @@ type WarehouseRepository interface { IdExists(ctx context.Context, id uint) (bool, error) GetByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) - GetDetailByID(ctx context.Context, id uint) (*entity.Warehouse, error) } type WarehouseRepositoryImpl struct { @@ -63,18 +62,6 @@ func (r *WarehouseRepositoryImpl) GetByKandangID(ctx context.Context, kandangId return &warehouse, nil } -func (r *WarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id uint) (*entity.Warehouse, error) { - var warehouse entity.Warehouse - err := r.db.WithContext(ctx). - Preload("Area"). - Preload("Location"). - First(&warehouse, id).Error - if err != nil { - return nil, err - } - return &warehouse, nil -} - func (r *WarehouseRepositoryImpl) GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) { var warehouse entity.Warehouse err := r.db.WithContext(ctx). diff --git a/internal/modules/master/warehouses/route.go b/internal/modules/master/warehouses/route.go index 8acf4452..a08b04a5 100644 --- a/internal/modules/master/warehouses/route.go +++ b/internal/modules/master/warehouses/route.go @@ -15,9 +15,9 @@ func WarehouseRoutes(v1 fiber.Router, u user.UserService, s warehouse.WarehouseS route := v1.Group("/warehouses") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_WarehousesGetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_WarehousesCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_WarehousesGetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_WarehousesUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_WarehousesDeleteOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 6cf45e0a..79c41284 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -3,13 +3,13 @@ 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" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" 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" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -53,11 +53,28 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti 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+"%") + db = db.Where("warehouses.name LIKE ?", "%"+params.Search+"%") } if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) } + if params.ActiveProjectFlockOnly { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM kandangs k + JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id + JOIN ( + SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at + FROM approvals + WHERE approvable_type = 'PROJECT_FLOCKS' + ORDER BY approvable_id, action_at DESC + ) latest_approval ON latest_approval.approvable_id = pfk.project_flock_id + WHERE k.id = warehouses.kandang_id + AND LOWER(latest_approval.step_name) = LOWER(?) + ) + `, "Aktif") + } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -105,13 +122,15 @@ func (s *warehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent ); err != nil { return nil, err } - - //TODO: created by dummy + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } createBody := &entity.Warehouse{ Name: req.Name, Type: typ, AreaId: req.AreaId, - CreatedBy: 1, + CreatedBy: actorID, } if req.LocationId != nil { createBody.LocationId = req.LocationId diff --git a/internal/modules/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go index 809ef0c4..1e305520 100644 --- a/internal/modules/master/warehouses/validations/warehouse.validation.go +++ b/internal/modules/master/warehouses/validations/warehouse.validation.go @@ -1,24 +1,25 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - Type string `json:"type" validate:"required_strict"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + Type string `json:"type" validate:"required_strict,max=50"` 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"` - Type *string `json:"type,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Type *string `json:"type,omitempty" validate:"omitempty,max=50"` 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"` - AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + 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"` + AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + ActiveProjectFlockOnly bool `query:"active_project_flock"` } diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index f6dd554b..6c9b8984 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -2,6 +2,7 @@ package chickins import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -9,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" @@ -36,16 +38,43 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyProjectChickin, + Table: "project_chickins", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_usage_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register project flock kandang approval workflow: %v", err)) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } - chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, validate) + chickinService := sChickin.NewChickinService( + chickinRepo, + kandangRepo, + warehouseRepo, + productWarehouseRepo, + projectFlockRepo, + projectflockkandangrepo, + projectflockpopulationrepo, + chickinDetailRepo, + validate, + fifoService) userService := sUser.NewUserService(userRepo, validate) ChickinRoutes(router, userService, chickinService) diff --git a/internal/modules/production/chickins/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go index a98dab67..43cafaac 100644 --- a/internal/modules/production/chickins/repositories/project_chickin.repository.go +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -11,9 +11,11 @@ import ( type ProjectChickinRepository interface { repository.BaseRepository[entity.ProjectChickin] GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.ProjectChickin, error) + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectChickin, error) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) GetPendingByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) GetTotalPendingUsageQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + GetTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) } type ChickinRepositoryImpl struct { @@ -40,6 +42,16 @@ func (r *ChickinRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, pr return &chickin, nil } +func (r *ChickinRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectChickin, error) { + var chickins []entity.ProjectChickin + err := r.db.WithContext(ctx). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = project_chickins.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Order("project_chickins.created_at DESC"). + Find(&chickins).Error + return chickins, err +} + func (r *ChickinRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) { var chickins []entity.ProjectChickin err := r.db.WithContext(ctx). @@ -79,3 +91,14 @@ func (r *ChickinRepositoryImpl) GetTotalPendingUsageQtyByProjectFlockKandangID(c } return total, nil } + +func (r *ChickinRepositoryImpl) GetTotalChickinQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { + var result float64 + err := r.db.WithContext(ctx). + Table("project_chickins"). + Select("COALESCE(SUM(project_chickins.usage_qty), 0)"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = project_chickins.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Scan(&result).Error + return result, err +} diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go index a558dd29..103a3655 100644 --- a/internal/modules/production/chickins/route.go +++ b/internal/modules/production/chickins/route.go @@ -16,9 +16,9 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService route.Use(m.Auth(u)) // route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) + route.Post("/",m.RequirePermissions(m.P_ChickinsCreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_ChickinsGetOne), ctrl.GetOne) // route.Patch("/:id", ctrl.UpdateOne) // route.Delete("/:id", ctrl.DeleteOne) - route.Post("/approvals", ctrl.Approval) + route.Post("/approvals",m.RequirePermissions(m.P_ChickinsApproval), ctrl.Approval) } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index a130740a..871c8fce 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "strings" @@ -8,13 +9,16 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" KandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -22,6 +26,8 @@ import ( "gorm.io/gorm" ) +var chickinUsableKey = fifo.UsableKeyProjectChickin + type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) @@ -42,9 +48,11 @@ type chickinService struct { ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository + FifoSvc commonSvc.FifoService + StockLogRepo rStockLogs.StockLogRepository } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -56,6 +64,8 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, + FifoSvc: fifoSvc, + StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), } } @@ -123,12 +133,14 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse for Kandang not found") } - category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - - actorID := uint(1) // todo nanti ambil dari auth context + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } newChikins := make([]*entity.ProjectChickin, 0) + chickinQtyMap := make(map[uint]float64) - for _, chickinReq := range req.ChickinRequests { + for idx, chickinReq := range req.ChickinRequests { productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, nil) if err != nil { @@ -139,31 +151,32 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d is not bound to kandang's warehouse", chickinReq.ProductWarehouseId)) } + if productWarehouse.ProjectFlockKandangId == nil || *productWarehouse.ProjectFlockKandangId != req.ProjectFlockKandangId { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d is not attached to project_flock_kandang %d. Only product warehouses with matching project_flock_kandang_id can be chickin-ed", chickinReq.ProductWarehouseId, req.ProjectFlockKandangId)) + } + chickinDate, err := utils.ParseDateString(chickinReq.ChickInDate) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) } - availableQty, err := s.calculateAvailableQuantity(c, req.ProjectFlockKandangId, productWarehouse, category) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to calculate available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) - } - + availableQty := productWarehouse.Quantity if availableQty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin", chickinReq.ProductWarehouseId)) } newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, UsageQty: 0, - PendingUsageQty: availableQty, + PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, CreatedBy: actorID, } newChikins = append(newChikins, newChickin) + chickinQtyMap[uint(idx)] = availableQty } if len(newChikins) == 0 { @@ -180,28 +193,21 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) if err := s.Repository.WithTx(dbTransaction).CreateMany(c.Context(), newChikins, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } - latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandang.Id, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") + for idx, chickin := range newChikins { + desiredQty := chickinQtyMap[uint(idx)] + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { + return err + } } - if category == string(utils.ProjectFlockCategoryLaying) { - for _, chickin := range newChikins { - updates := map[string]any{"quantity": gorm.Expr("quantity - ?", chickin.PendingUsageQty)} - - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update product warehouse quantity") - } - } + latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") } var approvalAction entity.ApprovalAction @@ -214,9 +220,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if latest == nil { if _, err := approvalSvcTx.CreateApproval( c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, + utils.ApprovalWorkflowChickin, projectFlockKandang.Id, - utils.ProjectFlockKandangStepPengajuan, + utils.ChickinStepPengajuan, &approvalAction, actorID, nil); err != nil { @@ -224,12 +230,12 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") } } - } else if latest.StepNumber != uint16(utils.ProjectFlockKandangStepPengajuan) { + } else if latest.StepNumber != uint16(utils.ChickinStepPengajuan) { if _, err := approvalSvcTx.CreateApproval( c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, + utils.ApprovalWorkflowChickin, projectFlockKandang.Id, - utils.ProjectFlockKandangStepPengajuan, + utils.ChickinStepPengajuan, &approvalAction, actorID, nil); err != nil { @@ -293,6 +299,32 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { + chickin, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + return err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return err + } + + if chickin.UsageQty > 0 { + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { + return err + } + + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for deleted chickin %d: %+v", chickin.Id, err) + return err + } + } + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Chickin not found") @@ -303,59 +335,16 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func (s chickinService) calculateAvailableQuantity(ctx *fiber.Ctx, projectFlockKandangID uint, productWarehouse *entity.ProductWarehouse, category string) (float64, error) { - availableQty := productWarehouse.Quantity - - if category == string(utils.ProjectFlockCategoryGrowing) { - var totalPendingQty float64 - - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - - availableQty = productWarehouse.Quantity - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } else if category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - - populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(ctx.Context(), projectFlockKandangID, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } - - return availableQty, nil -} - func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.Repository.DB())) var action entity.ApprovalAction @@ -374,12 +363,11 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } for _, id := range approvableIDs { - idCopy := id - if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &idCopy, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { + + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { return nil, err } - - latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) + latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") @@ -387,27 +375,25 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if latestApproval == nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for ProjectFlockKandang %d - chickins must be created first", id)) } - if latestApproval.StepNumber != uint16(utils.ProjectFlockKandangStepPengajuan) { + if latestApproval.StepNumber != uint16(utils.ChickinStepPengajuan) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ProjectFlockKandang %d cannot be approved - current status is not in PENGAJUAN stage", id)) } } - step := utils.ProjectFlockKandangStepPengajuan + step := utils.ChickinStepPengajuan if action == entity.ApprovalActionApproved { - step = utils.ProjectFlockKandangStepDisetujui + step = utils.ChickinStepDisetujui } - err := s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { - actorID := uint(1) // todo nanti ambil dari auth context if _, err := approvalSvc.CreateApproval( c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, + utils.ApprovalWorkflowChickin, approvableID, step, &action, @@ -442,7 +428,8 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") } - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID) + pfkID := approvableID + targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID, &pfkID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") } @@ -458,9 +445,10 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") } - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID) + pfkID := approvableID + targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "LAYER", dbTransaction, actorID, &pfkID) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create LAYER product warehouse") } if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") @@ -477,27 +465,17 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit continue } - kandangForRejection, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("ProjectFlockKandang %d not found", approvableID)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get ProjectFlockKandang") - } - - categoryForRejection := strings.ToUpper(strings.TrimSpace(kandangForRejection.ProjectFlock.Category)) - for _, chickin := range chickins { - if categoryForRejection == string(utils.ProjectFlockCategoryGrowing) { - updates := map[string]any{"quantity": gorm.Expr("quantity + ?", chickin.PendingUsageQty)} + if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) + } - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found during rejection", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to restore product warehouse quantity for chickin %d", chickin.Id)) - } + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for rejected chickin %d: %+v", chickin.Id, err) + return err } if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { @@ -530,11 +508,19 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return updated, nil } -func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId uint, categoryCode string, dbTransaction *gorm.DB, actorID uint) (*entity.ProductWarehouse, error) { +func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId uint, categoryCode string, dbTransaction *gorm.DB, actorID uint, projectFlockKandangId *uint) (*entity.ProductWarehouse, error) { products, err := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(ctx.Context(), categoryCode, warehouseId) if err == nil && len(products) > 0 { - return &products[0], nil + existingPW := &products[0] + + if existingPW.ProjectFlockKandangId == nil && projectFlockKandangId != nil { + existingPW.ProjectFlockKandangId = projectFlockKandangId + if err := s.ProductWarehouseRepo.WithTx(dbTransaction).UpdateOne(ctx.Context(), existingPW.Id, existingPW, nil); err != nil { + return nil, fmt.Errorf("failed to update %s product warehouse with project_flock_kandang_id: %w", categoryCode, err) + } + } + return existingPW, nil } product, err := s.ProductWarehouseRepo.GetFirstProductByFlag(ctx.Context(), categoryCode) @@ -546,10 +532,10 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId } newPW := &entity.ProductWarehouse{ - ProductId: product.Id, - WarehouseId: warehouseId, - Quantity: 0, - CreatedBy: actorID, + ProductId: product.Id, + WarehouseId: warehouseId, + ProjectFlockKandangId: projectFlockKandangId, + Quantity: 0, } if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { @@ -565,10 +551,10 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return fmt.Errorf("invalid target product warehouse") } - chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + var totalQuantityAdded float64 + for _, chickin := range chickins { populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(ctx.Context(), chickin.Id) @@ -581,34 +567,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti continue } - quantityToConvert := chickin.PendingUsageQty - - if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ - "usage_qty": quantityToConvert, - "pending_usage_qty": 0, - }, nil); err != nil { - return fmt.Errorf("failed to update chickin %d qty: %w", chickin.Id, err) - } - - if chickin.ProductWarehouseId != targetPW.Id { - if err := productWarehouseTx.PatchOne(ctx.Context(), chickin.ProductWarehouseId, map[string]any{ - "quantity": gorm.Expr("quantity - ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Source product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fmt.Errorf("failed to deduct source warehouse quantity for chickin %d: %w", chickin.Id, err) - } - } - - if err := productWarehouseTx.PatchOne(ctx.Context(), targetPW.Id, map[string]any{ - "quantity": gorm.Expr("quantity + ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Target product warehouse %d not found", targetPW.Id)) - } - return fmt.Errorf("failed to update target warehouse quantity: %w", err) - } + quantityToConvert := chickin.UsageQty population := &entity.ProjectFlockPopulation{ ProjectChickinId: chickin.Id, @@ -621,7 +580,121 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti if err := ProjectFlockPopulationRepotx.CreateOne(ctx.Context(), population, nil); err != nil { return err } + + totalQuantityAdded += quantityToConvert + } + + if totalQuantityAdded > 0 { + if err := s.ProductWarehouseRepo.AdjustQuantities(ctx.Context(), map[uint]float64{ + targetPW.Id: totalQuantityAdded, + }, func(db *gorm.DB) *gorm.DB { + return dbTransaction + }); err != nil { + return fmt.Errorf("failed to update target product warehouse quantity: %w", err) + } } return nil } + +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + s.Log.Infof("ConsumeChickinStocks: chickin_id=%d, product_warehouse_id=%d, desired_qty=%.3f", + chickin.Id, chickin.ProductWarehouseId, desiredQty) + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + ProductWarehouseID: chickin.ProductWarehouseId, + Quantity: desiredQty, + AllowPending: true, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + s.Log.Infof("ConsumeChickinStocks result: usage_qty=%.3f, pending_qty=%.3f, allocated_allocations=%d", + result.UsageQuantity, result.PendingQuantity, len(result.AddedAllocations)) + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": result.UsageQuantity, + "pending_usage_qty": result.PendingQuantity, + }).Error; err != nil { + return err + } + + if result.UsageQuantity > 0 { + decreaseLog := &entity.StockLog{ + Decrease: result.UsageQuantity, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, decreaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + + } + } + + return nil +} + +func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + var currentUsage float64 + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { + s.Log.Warnf("Failed to get current usage for chickin %d: %+v", chickin.Id, err) + currentUsage = 0 + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_usage_qty": 0, + }).Error; err != nil { + return err + } + + // Create stock log for the restoration + if currentUsage > 0 { + increaseLog := &entity.StockLog{ + Increase: currentUsage, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, increaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + // Don't return error here, stock already released + } + } + + return nil +} + +func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { + if len(deltas) == 0 { + return nil + } + return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) +} diff --git a/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go index 4b6e605a..32ac0e38 100644 --- a/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go +++ b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go @@ -84,3 +84,57 @@ func (u *ProjectFlockKandangController) GetOne(c *fiber.Ctx) error { Data: dto.ToProjectFlockKandangDetailDTOWithAvailableQty(*result, availableQtys, productWarehouses), }) } + +func (u *ProjectFlockKandangController) Closing(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + req := new(validation.Closing) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProjectFlockKandangService.Closing(c, uint(id), req) + if err != nil { + return err + } + + detail, availableQtys, productWarehouses, err := u.ProjectFlockKandangService.GetOne(c, result.Id) + if err != nil { + return err + } + + detailDTO := dto.ToProjectFlockKandangDetailDTOWithAvailableQty(*detail, availableQtys, productWarehouses) + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Status closing kandang diperbarui", + Data: fiber.Map{ + "detail": detailDTO, + "approval": detailDTO.Approval, + }, + }) +} + +func (u *ProjectFlockKandangController) CheckClosing(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ProjectFlockKandangService.CheckClosing(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Cek persyaratan closing kandang", + Data: result, + }) +} diff --git a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go index 677f527b..a2ba8ad2 100644 --- a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go +++ b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go @@ -10,6 +10,7 @@ import ( fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" chickinDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/dto" @@ -30,6 +31,7 @@ type ProjectFlockDTO struct { Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Category string `json:"category"` Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -82,6 +84,7 @@ func toProjectFlockDTO(pf *projectFlockDTO.ProjectFlockListDTO) *ProjectFlockDTO Area: pf.Area, Category: pf.Category, Fcr: pf.Fcr, + ProductionStandard: pf.ProductionStandard, Location: pf.Location, CreatedUser: pf.CreatedUser, CreatedAt: pf.CreatedAt, diff --git a/internal/modules/production/project-flock-kandangs/module.go b/internal/modules/production/project-flock-kandangs/module.go index 160cec5e..00ae03ff 100644 --- a/internal/modules/production/project-flock-kandangs/module.go +++ b/internal/modules/production/project-flock-kandangs/module.go @@ -9,9 +9,10 @@ import ( sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" - + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -28,15 +29,17 @@ func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB userRepo := rUser.NewUserRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - // register workflow steps for project flock kandang approvals + // register workflow steps for chickin approvals if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register project flock kandang approval workflow: %v", err)) + panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } - projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, validate) + expenseRepo := rExpense.NewExpenseRepository(db) + projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo,kandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) ProjectFlockKandangRoutes(router, userService, projectFlockKandangService) diff --git a/internal/modules/production/project-flock-kandangs/route.go b/internal/modules/production/project-flock-kandangs/route.go index 8057e847..d48d9990 100644 --- a/internal/modules/production/project-flock-kandangs/route.go +++ b/internal/modules/production/project-flock-kandangs/route.go @@ -1,7 +1,7 @@ package project_flock_kandangs import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/controllers" projectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,14 +13,11 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo ctrl := controller.NewProjectFlockKandangController(s) route := v1.Group("/project-flock-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.Get("/:id", ctrl.GetOne) - + route.Use(m.Auth(u)) + route.Get("/",m.RequirePermissions(m.P_ProjectFlockKandangsGetAll), ctrl.GetAll) + route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne) + // route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing) + // route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing) + route.Post("/:id/closing",m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing) + route.Get("/:id/closing/check", m.RequirePermissions(m.P_ProjectFlockKandangsCheckClosing), ctrl.CheckClosing) } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 11e8b0d5..66fee8ce 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -2,47 +2,84 @@ package service import ( "errors" - - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/validations" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" - "gitlab.com/mbugroup/lti-api.git/internal/utils" + "fmt" + "strings" + "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/validations" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) type ProjectFlockKandangService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) + CheckClosing(ctx *fiber.Ctx, id uint) (*ClosingCheckResult, error) + Closing(ctx *fiber.Ctx, id uint, req *validation.Closing) (*entity.ProjectFlockKandang, error) + GetProjectFlockKandangClosingDate(c *fiber.Ctx, id uint) (*time.Time, error) } -// Note: map[uint]float64 adalah mapping dari ProductWarehouse ID ke calculated available quantity - type projectFlockKandangService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService + ExpenseRepo expenseRepo.ExpenseRepository WarehouseRepo rWarehouse.WarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository PopulationRepo repository.ProjectFlockPopulationRepository + KandangRepo kandangRepo.KandangRepository } -func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, validate *validator.Validate) ProjectFlockKandangService { +type ClosingCheckResult struct { + UnfinishedExpenses int64 `json:"unfinished_expenses"` + StockRemaining []StockRemainingDetail `json:"stock_remaining"` + Expenses []ExpenseSummary `json:"expenses"` +} + +type StockRemainingDetail struct { + FlagName string `json:"flag_name"` + ProductWarehouseId uint `json:"product_warehouse_id"` + ProductId uint `json:"product_id"` + ProductName string `json:"product_name"` + ProductCategory string `json:"product_category"` + Uom string `json:"uom"` + Quantity float64 `json:"quantity"` +} + +type ExpenseSummary struct { + Id uint64 `json:"id"` + PoNumber string `json:"po_number"` + Category string `json:"category"` + Total float64 `json:"total"` + Status string `json:"status"` + StepName string `json:"step_name"` + Step uint16 `json:"step"` + Reference string `json:"reference_number"` +} + +func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService { return &projectFlockKandangService{ Log: utils.Log, Validate: validate, Repository: repo, ApprovalSvc: approvalSvc, + ExpenseRepo: expenseRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, PopulationRepo: populationRepo, + KandangRepo: kandangRepo, } } @@ -153,23 +190,372 @@ func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, project result := make(map[uint]float64) for _, pw := range products { - availableQty, err := s.calculateAvailableQuantityForProductWarehouse(c, projectFlockKandang, &pw) - if err != nil { - s.Log.Warnf("Failed to calculate available quantity for product warehouse %d: %v", pw.Id, err) - } - if availableQty > 0 { - result[pw.Id] = availableQty + if pw.ProjectFlockKandangId != nil && *pw.ProjectFlockKandangId == projectFlockKandang.Id { + availableQty, err := s.calculateAvailableQuantityForProductWarehouse(c, projectFlockKandang, &pw) + if err != nil { + s.Log.Warnf("Failed to calculate available quantity for product warehouse %d: %v", pw.Id, err) + } + + if availableQty > 0 { + result[pw.Id] = availableQty + } } } return result, nil } +func (s projectFlockKandangService) CheckClosing(c *fiber.Ctx, id uint) (*ClosingCheckResult, error) { + pfk, err := s.Repository.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + return nil, err + } + + var unfinished int64 + if s.ExpenseRepo != nil && s.ApprovalSvc != nil { + count, err := s.ExpenseRepo.CountUnfinishedByProjectFlockKandang(c.Context(), pfk.Id, pfk.KandangId, func(appr *entity.Approval) bool { + return appr != nil && appr.StepNumber == uint16(utils.ExpenseStepSelesai) && appr.Action != nil && *appr.Action == entity.ApprovalActionApproved + }) + if err != nil { + return nil, err + } + unfinished = count + } + + stockRemain := make([]StockRemainingDetail, 0) + if s.WarehouseRepo != nil && s.ProductWarehouseRepo != nil { + warehouse, werr := s.WarehouseRepo.GetByKandangID(c.Context(), pfk.KandangId) + if werr != nil { + return nil, werr + } + + for _, flagName := range []utils.FlagType{utils.FlagPakan, utils.FlagOVK} { + productWarehouses, pwErr := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(c.Context(), string(flagName), warehouse.Id) + if pwErr != nil { + return nil, pwErr + } + + for _, pw := range productWarehouses { + if pw.Quantity > 0 { + category := "" + if pw.Product.ProductCategory.Id != 0 { + category = pw.Product.ProductCategory.Name + } + uomName := "" + if pw.Product.Uom.Id != 0 { + uomName = pw.Product.Uom.Name + } + stockRemain = append(stockRemain, StockRemainingDetail{ + FlagName: string(flagName), + ProductWarehouseId: pw.Id, + ProductId: pw.ProductId, + ProductName: pw.Product.Name, + ProductCategory: category, + Uom: uomName, + Quantity: pw.Quantity, + }) + } + } + } + } + + expenseSummaries := make([]ExpenseSummary, 0) + if s.ExpenseRepo != nil { + var expenses []entity.Expense + if err := s.ExpenseRepo.DB().WithContext(c.Context()). + Scopes(s.ExpenseRepo.WithProjectFlockKandangFilter(pfk.Id, pfk.KandangId)). + Preload("Nonstocks"). + Find(&expenses).Error; err != nil { + return nil, err + } + + if len(expenses) > 0 && s.ApprovalSvc != nil { + ids := make([]uint, 0, len(expenses)) + for _, e := range expenses { + ids = append(ids, uint(e.Id)) + } + latestMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowExpense, ids, nil) + if err == nil { + for i := range expenses { + if latest, ok := latestMap[uint(expenses[i].Id)]; ok { + expenses[i].LatestApproval = latest + } + } + } + } + + for _, exp := range expenses { + total := 0.0 + for _, ns := range exp.Nonstocks { + total += ns.Qty * ns.Price + } + + status := "Pending" + stepName := "" + stepNum := uint16(0) + if exp.LatestApproval != nil { + stepName = exp.LatestApproval.StepName + stepNum = exp.LatestApproval.StepNumber + if exp.LatestApproval.Action != nil { + status = string(*exp.LatestApproval.Action) + } else if stepName != "" { + status = stepName + } + } + + expenseSummaries = append(expenseSummaries, ExpenseSummary{ + Id: exp.Id, + PoNumber: exp.PoNumber, + Category: exp.Category, + Total: total, + Status: status, + StepName: stepName, + Step: stepNum, + Reference: exp.ReferenceNumber, + }) + } + } + + return &ClosingCheckResult{ + UnfinishedExpenses: unfinished, + StockRemaining: stockRemain, + Expenses: expenseSummaries, + }, nil +} + +// getProjectFlockKandangClosingDate mengembalikan tanggal closing PFK jika sudah di-close. +func (s projectFlockKandangService) GetProjectFlockKandangClosingDate(c *fiber.Ctx, id uint) (*time.Time, error) { + if id == 0 { + return nil, nil + } + pfk, err := s.Repository.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return pfk.ClosedAt, nil +} + +func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validation.Closing) (*entity.ProjectFlockKandang, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + pfk, err := s.Repository.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + return nil, err + } + + if s.ApprovalSvc != nil { + latest, aerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlock, pfk.ProjectFlockId, nil) + if aerr != nil { + return nil, aerr + } + if latest == nil || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock belum berstatus aktif") + } + if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) && latest.StepNumber != uint16(utils.ProjectFlockStepSelesai) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock belum berstatus aktif") + } + } + + action := strings.ToLower(strings.TrimSpace(req.Action)) + now := time.Now() + + switch action { + case "close": + if pfk.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusConflict, "Kandang sudah closed") + } + if s.ExpenseRepo != nil && s.ApprovalSvc != nil { + unfinished, err := s.ExpenseRepo.CountUnfinishedByProjectFlockKandang(c.Context(), pfk.Id, pfk.KandangId, func(appr *entity.Approval) bool { + return appr != nil && appr.StepNumber == uint16(utils.ExpenseStepSelesai) && appr.Action != nil && *appr.Action == entity.ApprovalActionApproved + }) + if err != nil { + return nil, err + } + if unfinished > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Masih ada expense belum selesai untuk kandang ini") + } + } + + if s.WarehouseRepo != nil && s.ProductWarehouseRepo != nil { + warehouse, werr := s.WarehouseRepo.GetByKandangID(c.Context(), pfk.KandangId) + if werr != nil { + return nil, werr + } + + for _, flagName := range []utils.FlagType{utils.FlagPakan, utils.FlagOVK} { + productWarehouses, pwErr := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(c.Context(), string(flagName), warehouse.Id) + if pwErr != nil { + return nil, pwErr + } + + for _, pw := range productWarehouses { + if pw.Quantity > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok %s masih tersedia (product warehouse %d: %.2f)", flagName, pw.Id, pw.Quantity)) + } + } + } + } + + closeTime := now + if req.ClosedDate != nil { + parsed, perr := utils.ParseDateString(strings.TrimSpace(*req.ClosedDate)) + if perr != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "closed_date tidak valid, gunakan format YYYY-MM-DD") + } + closeTime = parsed + } + if err := s.Repository.UpdateClosedAt(c.Context(), id, &closeTime); err != nil { + return nil, err + } + if s.KandangRepo != nil { + if err := s.KandangRepo.UpdateStatusByIDs( + c.Context(), + []uint{pfk.KandangId}, + utils.KandangStatusNonActive, + ); err != nil { + return nil, err + } + } + if s.ApprovalSvc != nil { + closeAction := entity.ApprovalActionApproved + // Hindari duplikasi jika approval terakhir sudah Closed + Approved + latestPFK, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) + if lerr != nil { + return nil, lerr + } + shouldCreate := true + if latestPFK != nil && + latestPFK.StepNumber == uint16(utils.ProjectFlockKandangStepClosed) && + latestPFK.Action != nil && *latestPFK.Action == closeAction { + shouldCreate = false + } + + if shouldCreate { + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + id, + utils.ProjectFlockKandangStepClosed, + &closeAction, + actorID, + nil, + ); aerr != nil { + return nil, aerr + } + } + + // Jika semua kandang dalam project sudah ditutup, set approval project flock ke SELESAI. + pfks, ferr := s.Repository.GetByProjectFlockID(c.Context(), pfk.ProjectFlockId) + if ferr != nil { + return nil, ferr + } + allClosed := true + for _, item := range pfks { + if item.ClosedAt == nil { + allClosed = false + break + } + } + if allClosed { + latestPF, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlock, pfk.ProjectFlockId, nil) + if lerr != nil { + return nil, lerr + } + if latestPF == nil || latestPF.StepNumber != uint16(utils.ProjectFlockStepSelesai) { + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + pfk.ProjectFlockId, + utils.ProjectFlockStepSelesai, + &closeAction, + actorID, + nil, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return nil, aerr + } + } + } + } + case "unclose": + if pfk.ClosedAt == nil { + return nil, fiber.NewError(fiber.StatusConflict, "Kandang belum closed") + } + openNewer, err := s.Repository.HasOpenNewerPeriod(c.Context(), pfk.KandangId, pfk.Period, &pfk.Id) + if err != nil { + return nil, err + } + if openNewer { + return nil, fiber.NewError(fiber.StatusBadRequest, "Tidak dapat un-close: ada periode yang sedang berjalan") + } + if err := s.Repository.UpdateClosedAt(c.Context(), id, nil); err != nil { + return nil, err + } + if s.KandangRepo != nil { + if err := s.KandangRepo.UpdateStatusByIDs( + c.Context(), + []uint{pfk.KandangId}, + utils.KandangStatusActive, + ); err != nil { + return nil, err + } + } + if s.ApprovalSvc != nil { + reopenAction := entity.ApprovalActionUpdated + // Hindari duplikasi jika approval terakhir sudah Disetujui + Updated + latestPFK, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) + if lerr != nil { + return nil, lerr + } + shouldCreate := true + if latestPFK != nil && + latestPFK.StepNumber == uint16(utils.ProjectFlockKandangStepDisetujui) && + latestPFK.Action != nil && *latestPFK.Action == reopenAction { + shouldCreate = false + } + + if shouldCreate { + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + id, + utils.ProjectFlockKandangStepDisetujui, + &reopenAction, + actorID, + nil, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return nil, aerr + } + } + } + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action harus close atau unclose") + } + + return s.Repository.GetByID(c.Context(), id) +} + func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehouse(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang, productWarehouse *entity.ProductWarehouse) (float64, error) { availableQty := productWarehouse.Quantity if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { @@ -183,15 +569,8 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty = 0 } } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - populations, err := s.PopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(c.Context(), projectFlockKandang.Id, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { @@ -199,7 +578,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous } } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty + availableQty = productWarehouse.Quantity - totalPendingQty if availableQty < 0 { availableQty = 0 } diff --git a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go index 93e0256a..729d8329 100644 --- a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go +++ b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go @@ -22,3 +22,8 @@ type Query struct { SortOrder string `query:"sort_order" validate:"omitempty,oneof=ASC DESC"` StepName string `query:"step_name" validate:"omitempty,max=50"` } + +type Closing struct { + Action string `json:"action" validate:"required,oneof=close unclose"` + ClosedDate *string `json:"closed_date,omitempty"` +} \ No newline at end of file diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 6d78520e..4315b948 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -165,33 +165,6 @@ func (u *ProjectflockController) CreateOne(c *fiber.Ctx) error { }) } -func (u *ProjectflockController) 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.ProjectflockService.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 projectflock successfully", - Data: dto.ToProjectFlockListDTO(*result), - }) -} - func (u *ProjectflockController) DeleteOne(c *fiber.Ctx) error { param := c.Params("id") @@ -295,6 +268,7 @@ func (u *ProjectflockController) GetPeriodSummary(c *fiber.Ctx) error { func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { projectFlockId := c.QueryInt("project_flock_id", 0) kandangId := c.QueryInt("kandang_id", 0) + withPopulation := c.QueryBool("withpopulation", false) if projectFlockId == 0 || kandangId == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id") @@ -307,8 +281,14 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { dtoResult := dto.ToProjectFlockKandangDTO(*result) dtoResult.AvailableQuantity = float64(availableStock) + if withPopulation { + population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id) + if err != nil { + return err + } + dtoResult.Population = &population + } - // populate available quantity for each kandang inside project_flock if dtoResult.ProjectFlock != nil { for i := range dtoResult.ProjectFlock.Kandangs { kand := &dtoResult.ProjectFlock.Kandangs[i] @@ -319,7 +299,7 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { kand.AvailableQuantity = q } } - // remove inner kandangs from project_flock to avoid duplication + dtoResult.ProjectFlock.Kandangs = nil } @@ -329,3 +309,29 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { Message: "Get projectflock kandang successfully", Data: dtoResult}) } + +func (u *ProjectflockController) Resubmit(c *fiber.Ctx) error { + param := c.Params("id") + req := new(validation.Resubmit) + + 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.ProjectflockService.Resubmit(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Resubmit projectflock successfully", + Data: dto.ToProjectFlockListDTO(*result), + }) +} diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index 8324dd71..504d439c 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -9,6 +9,8 @@ import ( fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" + productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" // pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" @@ -24,15 +26,17 @@ type ProjectFlockRelationDTO struct { type ProjectFlockListDTO struct { ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Approval approvalDTO.ApprovalRelationDTO `json:"approval"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` + ProjectBudgets []ProjectBudgetDTO `json:"project_budgets,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } type KandangWithProjectFlockIdDTO struct { @@ -51,6 +55,13 @@ type KandangPeriodSummaryDTO struct { Period int `json:"period"` } +type ProjectBudgetDTO struct { + Id uint `json:"id"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` +} + func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectFlockListDTO { var createdUser *userDTO.UserRelationDTO if e.CreatedUser.Id != 0 { @@ -94,6 +105,12 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF fcrSummary = &mapped } + var productionStandardSummary *productionStandardDTO.ProductionStandardRelationDTO + if e.ProductionStandard.Id != 0 { + mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProductionStandard) + productionStandardSummary = &mapped + } + var locationSummary *locationDTO.LocationRelationDTO if e.Location.Id != 0 { mapped := locationDTO.ToLocationRelationDTO(e.Location) @@ -110,8 +127,10 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF ProjectFlockRelationDTO: createProjectFlockRelationDTO(e, period), Area: areaSummary, Kandangs: kandangSummaries, + ProjectBudgets: ToProjectBudgetDTOs(e.Budgets), Category: e.Category, Fcr: fcrSummary, + ProductionStandard: productionStandardSummary, Location: locationSummary, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, @@ -184,3 +203,26 @@ func createProjectFlockRelationDTO(e entity.ProjectFlock, period int) ProjectFlo FlockName: e.FlockName, } } + +func ToProjectBudgetDTO(e entity.ProjectBudget) ProjectBudgetDTO { + var nonstockRef *nonstockDTO.NonstockRelationDTO + if e.Nonstock != nil && e.Nonstock.Id != 0 { + mapped := nonstockDTO.ToNonstockRelationDTO(*e.Nonstock) + nonstockRef = &mapped + } + + return ProjectBudgetDTO{ + Id: e.Id, + Qty: e.Qty, + Price: e.Price, + Nonstock: nonstockRef, + } +} + +func ToProjectBudgetDTOs(e []entity.ProjectBudget) []ProjectBudgetDTO { + result := make([]ProjectBudgetDTO, len(e)) + for i, r := range e { + result[i] = ToProjectBudgetDTO(r) + } + return result +} diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index d5ed5a46..8dedaf15 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -6,6 +6,7 @@ import ( fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -19,6 +20,7 @@ type ProjectFlockWithPivotDTO struct { Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Category string `json:"category"` Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` @@ -32,6 +34,7 @@ type ProjectFlockKandangDTO struct { Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` AvailableQuantity float64 `json:"available_quantity"` + Population *float64 `json:"population,omitempty"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { @@ -61,6 +64,10 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD mapped := fcrDTO.ToFcrRelationDTO(e.ProjectFlock.Fcr) pfLocal.Fcr = &mapped } + if e.ProjectFlock.ProductionStandard.Id != 0 { + mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProjectFlock.ProductionStandard) + pfLocal.ProductionStandard = &mapped + } if e.ProjectFlock.Location.Id != 0 { mapped := locationDTO.ToLocationRelationDTO(e.ProjectFlock.Location) pfLocal.Location = &mapped diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 4fd932a4..98e4a630 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -12,8 +12,11 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectBudget "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -27,10 +30,14 @@ type ProjectflockModule struct{} func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { flockRepo := rFlock.NewFlockRepository(db) kandangRepo := rKandang.NewKandangRepository(db) + nonstockRepo := rNonstock.NewNonstockRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) + projectFlockPopulationRepo := rProjectflock.NewProjectFlockPopulationRepository(db) + recordingRepo := rRecording.NewRecordingRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db) userRepo := rUser.NewUserRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) @@ -39,7 +46,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) } - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, approvalService, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, projectBudgetRepo, nonstockRepo, projectFlockPopulationRepo, recordingRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/repositories/project_budget.repository.go b/internal/modules/production/project_flocks/repositories/project_budget.repository.go new file mode 100644 index 00000000..720bfc40 --- /dev/null +++ b/internal/modules/production/project_flocks/repositories/project_budget.repository.go @@ -0,0 +1,36 @@ +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 ProjectBudgetRepository interface { + repository.BaseRepository[entity.ProjectBudget] + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectBudget, error) +} + +type ProjectBudgetRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectBudget] + db *gorm.DB +} + +func NewProjectBudgetRepository(db *gorm.DB) ProjectBudgetRepository { + return &ProjectBudgetRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectBudget](db), + db: db, + } +} + +func (r *ProjectBudgetRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectBudget, error) { + var budgets []entity.ProjectBudget + err := r.db.WithContext(ctx). + Where("project_flock_id = ?", projectFlockID). + Preload("Nonstock"). + Preload("Nonstock.Uom"). + Find(&budgets).Error + return budgets, err +} diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go index a2b56dce..fd263b27 100644 --- a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -15,6 +15,7 @@ type ProjectFlockPopulationRepository interface { GetByProjectChickinIDAndProductWarehouseID(ctx context.Context, projectChickinID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) GetByProjectFlockKandangIDAndProductWarehouseID(ctx context.Context, projectFlockKandangID uint, productWarehouseID uint) ([]entity.ProjectFlockPopulation, error) GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) // subset of base repository methods used by services CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error @@ -106,3 +107,20 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProjectFlockKandangI } return total, nil } + +func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + var total float64 + err := r.DB().WithContext(ctx). + Table("project_flock_populations"). + Select("COALESCE(SUM(total_qty - total_used_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Scan(&total).Error + if err != nil { + return 0, err + } + if total < 0 { + total = 0 + } + return total, nil +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index de4df25d..6cd98a8f 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -11,7 +11,6 @@ import ( "gorm.io/gorm" ) - type ProjectflockRepository interface { repository.BaseRepository[entity.ProjectFlock] GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) @@ -20,8 +19,10 @@ type ProjectflockRepository interface { GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error) GetCurrentProjectPeriod(ctx context.Context, projectFlockID uint) (int, error) GetKandangPeriodSummaryRows(ctx context.Context, locationID uint) ([]KandangPeriodRow, error) + GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) AreaExists(ctx context.Context, id uint) (bool, error) FcrExists(ctx context.Context, id uint) (bool, error) + ProductionStandardExists(ctx context.Context, id uint) (bool, error) LocationExists(ctx context.Context, id uint) (bool, error) } type KandangPeriodRow struct { @@ -42,24 +43,27 @@ func NewProjectflockRepository(db *gorm.DB) ProjectflockRepository { func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) { return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { - return r.applyQueryFilters(db, params) + return r.applyQueryFilters(r.WithDefaultRelations()(db), params) }) } func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { return db. - Preload("CreatedUser"). - Preload("Area"). - Preload("Fcr"). - Preload("Location"). - Preload("Kandangs"). - Preload("KandangHistory"). - Preload("KandangHistory.Kandang") + Preload("CreatedUser"). + Preload("Area"). + Preload("Fcr"). + Preload("ProductionStandard"). + Preload("Location"). + Preload("Kandangs"). + Preload("KandangHistory"). + Preload("KandangHistory.Kandang"). + Preload("Budgets"). + Preload("Budgets.Nonstock"). + Preload("Budgets.Nonstock.Uom") } } - func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *validation.Query) *gorm.DB { if params == nil { return db @@ -116,12 +120,14 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s return db. Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id"). + Joins("LEFT JOIN production_standards ON production_standards.id = project_flocks.production_standard_id"). Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id"). Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). Where(` LOWER(areas.name) LIKE ? OR LOWER(project_flocks.category) LIKE ? OR LOWER(fcrs.name) LIKE ? + OR LOWER(production_standards.name) LIKE ? OR LOWER(locations.name) LIKE ? OR LOWER(locations.address) LIKE ? OR LOWER(created_users.name) LIKE ? @@ -151,6 +157,7 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s likeQuery, likeQuery, likeQuery, + likeQuery, ) } @@ -162,6 +169,10 @@ func (r *ProjectflockRepositoryImpl) FcrExists(ctx context.Context, id uint) (bo return repository.Exists[entity.Fcr](ctx, r.DB(), id) } +func (r *ProjectflockRepositoryImpl) ProductionStandardExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandard](ctx, r.DB(), id) +} + func (r *ProjectflockRepositoryImpl) LocationExists(ctx context.Context, id uint) (bool, error) { return repository.Exists[entity.Location](ctx, r.DB(), id) } @@ -294,3 +305,17 @@ func (r *ProjectflockRepositoryImpl) ExistsByFlockName(ctx context.Context, floc } return count > 0, nil } + +func (r *ProjectflockRepositoryImpl) GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) { + var projectFlocks []entity.ProjectFlock + err := r.DB().WithContext(ctx). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.project_flock_id = project_flocks.id"). + Where("project_flocks.location_id = ?", locationID). + Where("project_flock_kandangs.closed_at IS NULL"). + Group("project_flocks.id"). + Find(&projectFlocks).Error + if err != nil { + return nil, err + } + return projectFlocks, nil +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 76f23b39..42dcafd9 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -3,6 +3,7 @@ package repository import ( "context" "strings" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -14,18 +15,22 @@ type ProjectFlockKandangRepository interface { GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) + UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) FindKandangsWithRecordings(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]entity.Kandang, error) MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) ProjectPeriodsByProjectIDs(ctx context.Context, projectIDs []uint) (map[uint]int, error) + HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) + ListIDsByProjectAndKandang(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository - IdExists(ctx context.Context, id uint) (bool, error) DB() *gorm.DB + IdExists(ctx context.Context, id uint) (bool, error) } type projectFlockKandangRepositoryImpl struct { @@ -75,6 +80,30 @@ func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context, offset i return records, total, nil } +func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) { + var records []entity.ProjectFlockKandang + if err := r.db.WithContext(ctx). + Where("project_flock_id = ?", projectFlockID). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + +func (r *projectFlockKandangRepositoryImpl) ListIDsByProjectAndKandang(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) { + if len(kandangIDs) == 0 { + return []uint{}, nil + } + var ids []uint + if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs). + Pluck("id", &ids).Error; err != nil { + return nil, err + } + return ids, nil +} + func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) { var records []entity.ProjectFlockKandang var total int64 @@ -104,10 +133,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex AND "approvals"."approvable_type" = ? AND LOWER("approvals"."step_name") = LOWER(?) AND "approvals"."id" IN ( - SELECT "id" FROM "approvals" - WHERE "approvable_id" = "project_flock_kandangs"."id" - AND "approvable_type" = ? - ORDER BY "action_at" DESC + SELECT "approvals"."id" FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + ORDER BY "approvals"."id" DESC LIMIT 1 ) ) @@ -223,32 +252,57 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont } func (r *projectFlockKandangRepositoryImpl) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) { - record := new(entity.ProjectFlockKandang) + latestApprovalSubQuery := r.db. + Table("approvals"). + Select("DISTINCT ON (approvable_id) approvable_id, step_name, id"). + Where("approvable_type = ?", "PROJECT_FLOCKS"). + Order("approvable_id, id DESC") + + var pfkID uint if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). - Joins(` - INNER JOIN ( - SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at - FROM approvals - WHERE approvable_type = 'PROJECT_FLOCKS' - ORDER BY approvable_id, action_at DESC - ) latest_approval ON latest_approval.approvable_id = project_flocks.id - `). + Joins("JOIN (?) AS latest_approval ON latest_approval.approvable_id = project_flocks.id", latestApprovalSubQuery). Where("project_flock_kandangs.kandang_id = ?", kandangID). + Where("project_flock_kandangs.closed_at IS NULL"). Where("LOWER(latest_approval.step_name) = LOWER(?)", "Aktif"). Order("project_flock_kandangs.id DESC"). - Preload("ProjectFlock"). - Preload("ProjectFlock.Fcr"). - Preload("ProjectFlock.Area"). - Preload("ProjectFlock.Location"). - Preload("ProjectFlock.CreatedUser"). - Preload("ProjectFlock.Kandangs"). - Preload("ProjectFlock.KandangHistory"). - Preload("Kandang"). - First(record).Error; err != nil { + Limit(1). + Pluck("project_flock_kandangs.id", &pfkID).Error; err != nil { return nil, err } - return record, nil + + if pfkID == 0 { + return nil, gorm.ErrRecordNotFound + } + + return r.GetByID(ctx, pfkID) +} + +func (r *projectFlockKandangRepositoryImpl) UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error { + return r.db.WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). + Where("id = ?", id). + Update("closed_at", t).Error +} + +func (r *projectFlockKandangRepositoryImpl) HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) { + if kandangID == 0 { + return false, nil + } + q := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("kandang_id = ?", kandangID). + Where("period > ?", currentPeriod). + Where("closed_at IS NULL") + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + var count int64 + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil } func (r *projectFlockKandangRepositoryImpl) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) { @@ -269,7 +323,8 @@ func (r *projectFlockKandangRepositoryImpl) HasKandangsLinkedToOtherProject(ctx } q := r.db.WithContext(ctx). Table("project_flock_kandangs"). - Where("kandang_id IN ?", kandangIDs) + Where("kandang_id IN ?", kandangIDs). + Where("closed_at IS NULL") if exceptProjectID != nil { q = q.Where("project_flock_id <> ?", *exceptProjectID) } diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index eb806129..c0eb8657 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -1,7 +1,7 @@ package project_flocks import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/controllers" projectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,15 +13,15 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj ctrl := controller.NewProjectflockController(s) route := v1.Group("/project-flocks") - // route.Use(m.Auth(u)) + route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) - route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) - route.Post("/approvals", ctrl.Approval) - route.Get("/locations/:location_id/periods", ctrl.GetPeriodSummary) + route.Get("/",m.RequirePermissions(m.P_ProjectFlockGetAll),ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_ProjectFlockCreate), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockGetOne), ctrl.GetOne) + route.Delete("/:id",m.RequirePermissions(m.P_ProjectFlockGetAll), ctrl.DeleteOne) + route.Get("/kandangs/lookup",m.RequirePermissions(m.P_ProjectFlockLookup), ctrl.LookupProjectFlockKandang) + route.Post("/approvals",m.RequirePermissions(m.P_ProjectFlockApprove), ctrl.Approval) + route.Get("/locations/:location_id/periods",m.RequirePermissions(m.P_ProjectFlockNextPeriod), ctrl.GetPeriodSummary) + route.Put("/:id/resubmit",m.RequirePermissions(m.P_ProjectFlockResubmit), ctrl.Resubmit) } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 19b07447..5f643dee 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -10,16 +10,19 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - - // authmiddleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + nonstockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" warehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectBudgetRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" + recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -33,13 +36,14 @@ type ProjectflockService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) DeleteOne(ctx *fiber.Ctx, id uint) error GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) + GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) + Resubmit(ctx *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) } type projectflockService struct { @@ -48,9 +52,13 @@ type projectflockService struct { Repository repository.ProjectflockRepository FlockRepo flockRepository.FlockRepository KandangRepo kandangRepository.KandangRepository + NonstockRepo nonstockRepository.NonstockRepository WarehouseRepo warehouseRepository.WarehouseRepository ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository + ProjectBudgetRepo projectBudgetRepository.ProjectBudgetRepository PivotRepo repository.ProjectFlockKandangRepository + PopulationRepo repository.ProjectFlockPopulationRepository + RecordingRepo recordingRepo.RecordingRepository ApprovalSvc commonSvc.ApprovalService approvalWorkflow approvalutils.ApprovalWorkflowKey } @@ -68,8 +76,13 @@ func NewProjectflockService( pivotRepo repository.ProjectFlockKandangRepository, warehouseRepo warehouseRepository.WarehouseRepository, productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository, + projectBudgetRepo projectBudgetRepository.ProjectBudgetRepository, + nonstockRepo nonstockRepository.NonstockRepository, + populationRepo repository.ProjectFlockPopulationRepository, + recordingRepo recordingRepo.RecordingRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, + ) ProjectflockService { return &projectflockService{ Log: utils.Log, @@ -77,26 +90,29 @@ func NewProjectflockService( Repository: repo, FlockRepo: flockRepo, KandangRepo: kandangRepo, + NonstockRepo: nonstockRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, + ProjectBudgetRepo: projectBudgetRepo, PivotRepo: pivotRepo, + PopulationRepo: populationRepo, + RecordingRepo: recordingRepo, ApprovalSvc: approvalSvc, approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } } +func (s projectflockService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, nil, err } - if params.Page <= 0 { - params.Page = 1 - } - if params.Limit <= 0 { - params.Limit = 10 - } - offset := (params.Page - 1) * params.Limit projectflocks, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) @@ -112,7 +128,7 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e ids[i] = item.Id } - latestMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), s.approvalWorkflow, ids, s.Repository.WithDefaultRelations()) + latestMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), s.approvalWorkflow, ids, s.approvalQueryModifier()) if err != nil { s.Log.Warnf("Unable to load latest approvals for projectflocks: %+v", err) } else if len(latestMap) > 0 { @@ -156,7 +172,7 @@ func (s projectflockService) getOneEntityOnly(c *fiber.Ctx, id uint) (*entity.Pr } if s.ApprovalSvc != nil { - approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, s.Repository.WithDefaultRelations()) + approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) if err != nil { s.Log.Warnf("Unable to load approvals for projectflock %d: %+v", id, err) } else if len(approvals) > 0 { @@ -183,7 +199,7 @@ func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock } if s.ApprovalSvc != nil { - approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, s.Repository.WithDefaultRelations()) + approvals, err := s.ApprovalSvc.ListByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) if err != nil { s.Log.Warnf("Unable to load approvals for projectflock %d: %+v", id, err) } else if len(approvals) > 0 { @@ -221,7 +237,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } - actorID, err := actorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } @@ -243,11 +259,22 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists}, commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: s.Repository.FcrExists}, + commonSvc.RelationCheck{Name: "Production Standard", ID: &req.ProductionStandardId, Exists: s.Repository.ProductionStandardExists}, commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists}, ); err != nil { return nil, err } + var location entity.Location + if err := s.Repository.DB().WithContext(c.Context()). + Where("id = ? AND area_id = ?", req.LocationId, req.AreaId). + First(&location).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Lokasi tidak berada pada area yang diminta") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi area-lokasi") + } + canonicalBase := baseName if s.FlockRepo != nil { baseFlock, err := s.ensureFlockByName(c.Context(), actorID, baseName) @@ -284,6 +311,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* AreaId: req.AreaId, Category: cat, FcrId: req.FcrId, + ProductionStandardId: req.ProductionStandardId, LocationId: req.LocationId, CreatedBy: actorID, } @@ -291,7 +319,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { projectRepo := repository.NewProjectflockRepository(dbTransaction) - // Generate unique flock name (sequential per base name, starting from 1) generatedName, _, err := s.generateSequentialFlockName(c.Context(), projectRepo, canonicalBase, 1, nil) if err != nil { return err @@ -302,7 +329,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return err } - // Compute period per kandang so every kandang maintains its own cycle history. periods, err := projectRepo.GetNextPeriodsForKandangs(c.Context(), kandangIDs) if err != nil { return err @@ -311,6 +337,10 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return err } + if err := s.UpsertProjectBudget(c.Context(), dbTransaction, createBody.Id, req.ProjectBudgets); err != nil { + return err + } + action := entity.ApprovalActionCreated approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) _, err = approvalSvcTx.CreateApproval( @@ -339,365 +369,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return s.getOneEntityOnly(c, createBody.Id) } -func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - - actorID, err := actorIDFromContext(c) - if err != nil { - return nil, err - } - - existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") - } - if err != nil { - s.Log.Errorf("Failed to fetch projectflock %d before update: %+v", id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") - } - updateBody := make(map[string]any) - hasBodyChanges := false - var relationChecks []commonSvc.RelationCheck - existingBase := pfutils.DeriveBaseName(existing.FlockName) - targetBaseName := existingBase - needFlockNameRegenerate := false - - if req.FlockName != nil { - trimmed := strings.TrimSpace(*req.FlockName) - if trimmed == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty") - } - canonicalBase := trimmed - if s.FlockRepo != nil { - flockEntity, err := s.ensureFlockByName(c.Context(), actorID, trimmed) - if err != nil { - return nil, err - } - canonicalBase = flockEntity.Name - } - if !strings.EqualFold(canonicalBase, existingBase) { - needFlockNameRegenerate = true - targetBaseName = canonicalBase - hasBodyChanges = true - } - } - if req.AreaId != nil { - updateBody["area_id"] = *req.AreaId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "Area", - ID: req.AreaId, - Exists: s.Repository.AreaExists, - }) - } - if req.Category != nil { - cat := strings.ToUpper(*req.Category) - if !utils.IsValidProjectFlockCategory(cat) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") - } - - updateBody["category"] = cat - } - if req.FcrId != nil { - updateBody["fcr_id"] = *req.FcrId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "FCR", - ID: req.FcrId, - Exists: s.Repository.FcrExists, - }) - } - if req.LocationId != nil { - updateBody["location_id"] = *req.LocationId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "Location", - ID: req.LocationId, - Exists: s.Repository.LocationExists, - }) - } - - if len(relationChecks) > 0 { - if err := commonSvc.EnsureRelations(c.Context(), relationChecks...); err != nil { - return nil, err - } - } - - var newKandangIDs []uint - hasKandangChanges := false - if req.KandangIds != nil { - hasKandangChanges = true - if len(req.KandangIds) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids cannot be empty") - } - newKandangIDs = uniqueUintSlice(req.KandangIds) - kandangs, err := s.KandangRepo.GetByIDs(c.Context(), newKandangIDs, nil) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") - } - if len(kandangs) != len(newKandangIDs) { - return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") - } - targetLocationID := existing.LocationId - if req.LocationId != nil && *req.LocationId > 0 { - targetLocationID = *req.LocationId - } - for _, kandang := range kandangs { - if kandang.LocationId != targetLocationID { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d tidak berada pada lokasi yang sama dengan project flock", kandang.Id)) - } - } - if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), newKandangIDs, &id); err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") - } else if linked { - return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") - } - - } - - hasChanges := hasBodyChanges || hasKandangChanges - if !hasChanges { - return s.getOneEntityOnly(c, id) - } - - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - projectRepo := repository.NewProjectflockRepository(dbTransaction) - - baseForGeneration := targetBaseName - if strings.TrimSpace(baseForGeneration) == "" { - baseForGeneration = existingBase - } - if strings.TrimSpace(baseForGeneration) == "" { - baseForGeneration = strings.TrimSpace(existing.FlockName) - } - - if needFlockNameRegenerate { - newName, _, err := s.generateSequentialFlockName(c.Context(), projectRepo, baseForGeneration, 1, &id) - if err != nil { - return err - } - updateBody["flock_name"] = newName - } - - if len(updateBody) > 0 { - if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { - return err - } - } else { - if _, err := projectRepo.GetByID(c.Context(), id, nil); err != nil { - return err - } - } - - if req.KandangIds != nil { - existingIDs := make(map[uint]struct{}, len(existing.Kandangs)) - for _, k := range existing.Kandangs { - existingIDs[k.Id] = struct{}{} - } - newSet := make(map[uint]struct{}, len(newKandangIDs)) - for _, kid := range newKandangIDs { - newSet[kid] = struct{}{} - } - - var toDetach []uint - for kid := range existingIDs { - if _, ok := newSet[kid]; !ok { - toDetach = append(toDetach, kid) - } - } - - var toAttach []uint - for kid := range newSet { - if _, ok := existingIDs[kid]; !ok { - toAttach = append(toAttach, kid) - } - } - - if len(toDetach) > 0 { - if err := s.detachKandangs(c.Context(), dbTransaction, id, toDetach, true); err != nil { - return err - } - } - - if len(toAttach) > 0 { - currentPeriod, err := projectRepo.GetCurrentProjectPeriod(c.Context(), id) - if err != nil { - return err - } - - periods := make(map[uint]int, len(toAttach)) - if currentPeriod > 0 { - for _, kid := range toAttach { - periods[kid] = currentPeriod - } - } else { - periods, err = projectRepo.GetNextPeriodsForKandangs(c.Context(), toAttach) - if err != nil { - return err - } - } - - if err := s.attachKandangs(c.Context(), dbTransaction, id, toAttach, periods); err != nil { - return err - } - } - } - - if hasChanges { - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - if approvalSvc != nil { - latestBeforeReset, err := approvalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, nil) - if err != nil { - return err - } - shouldRecordUpdate := latestBeforeReset == nil || - latestBeforeReset.StepNumber != uint16(utils.ProjectFlockStepPengajuan) || - latestBeforeReset.Action == nil || - (latestBeforeReset.Action != nil && *latestBeforeReset.Action != entity.ApprovalActionUpdated) - - if shouldRecordUpdate { - action := entity.ApprovalActionUpdated - if _, err := approvalSvc.CreateApproval( - c.Context(), - utils.ApprovalWorkflowProjectFlock, - id, - utils.ProjectFlockStepPengajuan, - &action, - actorID, - nil, - ); err != nil { - return err - } - } - } - } - - return nil - }) - - if err != nil { - if fiberErr, ok := err.(*fiber.Error); ok { - return nil, fiberErr - } - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") - } - s.Log.Errorf("Failed to update projectflock %d: %+v", id, err) - if errors.Is(err, gorm.ErrDuplicatedKey) { - return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock") - } - - return s.getOneEntityOnly(c, id) -} - -func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - - actorID, err := actorIDFromContext(c) - if err != nil { - return nil, err - } - - var action entity.ApprovalAction - switch strings.ToUpper(strings.TrimSpace(req.Action)) { - case string(entity.ApprovalActionRejected): - action = entity.ApprovalActionRejected - case string(entity.ApprovalActionApproved): - action = entity.ApprovalActionApproved - default: - return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") - } - - approvableIDs := uniqueUintSlice(req.ApprovableIds) - if len(approvableIDs) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") - } - - step := utils.ProjectFlockStepPengajuan - if action == entity.ApprovalActionApproved { - step = utils.ProjectFlockStepAktif - } - - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) - projectRepoTx := repository.NewProjectflockRepository(dbTransaction) - - for _, approvableID := range approvableIDs { - if _, err := projectRepoTx.GetByID(c.Context(), approvableID, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Projectflock %d not found", approvableID)) - } - return err - } - - if _, err := approvalSvc.CreateApproval( - c.Context(), - utils.ApprovalWorkflowProjectFlock, - approvableID, - step, - &action, - actorID, - req.Notes, - ); err != nil { - return err - } - - switch action { - case entity.ApprovalActionApproved: - if err := kandangRepoTx.UpdateStatusByProjectFlockID( - c.Context(), - approvableID, - utils.KandangStatusActive, - ); err != nil { - return err - } - case entity.ApprovalActionRejected: - if err := kandangRepoTx.UpdateStatusByProjectFlockID( - c.Context(), - approvableID, - utils.KandangStatusNonActive, - ); err != nil { - return err - } - } - } - - return nil - }) - - if err != nil { - if fiberErr, ok := err.(*fiber.Error); ok { - return nil, fiberErr - } - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") - } - s.Log.Errorf("Failed to record approval for projectflocks %+v: %+v", approvableIDs, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") - } - - updated := make([]entity.ProjectFlock, 0, len(approvableIDs)) - for _, approvableID := range approvableIDs { - project, err := s.getOneEntityOnly(c, approvableID) - if err != nil { - return nil, err - } - updated = append(updated, *project) - } - - return updated, nil -} - func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { @@ -758,6 +429,34 @@ func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fibe return pfk, availableQuantity, nil } +func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) { + if s.PopulationRepo == nil { + return 0, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured") + } + if projectFlockKandangID == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + if s.RecordingRepo != nil { + latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch latest recording for project flock kandang %d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population") + } + if latest != nil && latest.TotalChickQty != nil && *latest.TotalChickQty > 0 { + return *latest.TotalChickQty, nil + } + } + + total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population") + } + + return total, nil +} + func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) { idStr = strings.TrimSpace(idStr) projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) @@ -825,6 +524,133 @@ func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) return s.pivotRepo().ProjectPeriodsByProjectIDs(c.Context(), projectIDs) } +func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + var action entity.ApprovalAction + switch strings.ToUpper(strings.TrimSpace(req.Action)) { + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + approvableIDs := uniqueUintSlice(req.ApprovableIds) + if len(approvableIDs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + step := utils.ProjectFlockStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.ProjectFlockStepAktif + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) + projectRepoTx := repository.NewProjectflockRepository(dbTransaction) + projectFlockKandangRepoTx := repository.NewProjectFlockKandangRepository(dbTransaction) + + for _, approvableID := range approvableIDs { + if _, err := projectRepoTx.GetByID(c.Context(), approvableID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Projectflock %d not found", approvableID)) + } + return err + } + + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + approvableID, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + + switch action { + case entity.ApprovalActionApproved: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + approvableID, + utils.KandangStatusActive, + ); err != nil { + return err + } + + pfks, err := projectFlockKandangRepoTx.GetByProjectFlockID(c.Context(), approvableID) + if err != nil { + return err + } + for _, pfk := range pfks { + latest, lerr := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, pfk.Id, nil) + if lerr != nil { + return lerr + } + if latest != nil && latest.StepNumber == uint16(utils.ProjectFlockKandangStepDisetujui) { + continue + } + if _, aerr := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + pfk.Id, + utils.ProjectFlockKandangStepDisetujui, + &action, + actorID, + req.Notes, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return aerr + } + } + case entity.ApprovalActionRejected: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + approvableID, + utils.KandangStatusNonActive, + ); err != nil { + return err + } + } + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to record approval for projectflocks %+v: %+v", approvableIDs, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") + } + + updated := make([]entity.ProjectFlock, 0, len(approvableIDs)) + for _, approvableID := range approvableIDs { + project, err := s.getOneEntityOnly(c, approvableID) + if err != nil { + return nil, err + } + updated = append(updated, *project) + } + + return updated, nil +} + func (s projectflockService) GetPeriodSummary(c *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) { if locationID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "location_id is required") @@ -847,7 +673,7 @@ func (s projectflockService) GetPeriodSummary(c *fiber.Ctx, locationID uint) ([] summaries := make([]KandangPeriodSummary, 0, len(rows)) for _, row := range rows { - nextPeriod := 0 + nextPeriod := 1 if row.LatestPeriod > 0 { nextPeriod = row.LatestPeriod + 1 } @@ -986,6 +812,9 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * } return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } + if err := s.ensureProjectFlockKandangProductWarehouses(ctx, dbTransaction, records); err != nil { + return err + } return nil } @@ -1011,6 +840,31 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak dapat melepas kandang karena sudah memiliki recording: %s", strings.Join(names, ", "))) } + pfkIDs, err := s.pivotRepoWithTx(dbTransaction).ListIDsByProjectAndKandang(ctx, projectFlockID, kandangIDs) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to load project flock kandang ids") + } + + if len(pfkIDs) > 0 { + uniformityRepo := uniformityRepository.NewUniformityRepository(s.Repository.DB()) + if dbTransaction != nil { + uniformityRepo = uniformityRepository.NewUniformityRepository(dbTransaction) + } + if err := uniformityRepo.DeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove uniformity data for project flock kandang") + } + + pwRepo := s.ProductWarehouseRepo + if dbTransaction != nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction) + } else if pwRepo == nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(s.Repository.DB()) + } + if err := pwRepo.DeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove product warehouses for project flock kandang") + } + } + if resetStatus { if err := s.kandangRepoWithTx(dbTransaction).UpdateStatusByIDs(ctx, kandangIDs, utils.KandangStatusNonActive); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") @@ -1047,11 +901,213 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka return kandangRepository.NewKandangRepository(s.Repository.DB()) } -func actorIDFromContext(_ *fiber.Ctx) (uint, error) { - // user, ok := authmiddleware.AuthenticatedUser(c) - // if !ok || user == nil || user.Id == 0 { - // return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - // } - // return user.Id, nil - return 1, nil +func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx context.Context, dbTransaction *gorm.DB, records []*entity.ProjectFlockKandang) error { + if len(records) == 0 { + return nil + } + + pwRepo := s.ProductWarehouseRepo + if dbTransaction != nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction) + } else if pwRepo == nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(s.Repository.DB()) + } + + warehouseRepo := s.WarehouseRepo + if dbTransaction != nil { + warehouseRepo = warehouseRepository.NewWarehouseRepository(dbTransaction) + } else if warehouseRepo == nil { + warehouseRepo = warehouseRepository.NewWarehouseRepository(s.Repository.DB()) + } + + flags := []utils.FlagType{ + utils.FlagAyamAfkir, + utils.FlagAyamCulling, + utils.FlagAyamMati, + utils.FlagTelurPecah, + utils.FlagTelurUtuh, + } + + productIDs := make(map[utils.FlagType]uint, len(flags)) + for _, flag := range flags { + product, err := pwRepo.GetFirstProductByFlag(ctx, string(flag)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product untuk flag %s tidak ditemukan", flag)) + } + return err + } + productIDs[flag] = product.Id + } + + for _, record := range records { + if record == nil || record.Id == 0 { + continue + } + + warehouse, err := warehouseRepo.GetByKandangID(ctx, record.KandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse untuk kandang %d belum tersedia", record.KandangId)) + } + return err + } + + for _, flag := range flags { + productID := productIDs[flag] + if _, err := pwRepo.GetByProductWarehouseAndProjectFlockKandang(ctx, productID, warehouse.Id, record.Id); err == nil { + continue + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + newPW := entity.ProductWarehouse{ + ProductId: productID, + WarehouseId: warehouse.Id, + ProjectFlockKandangId: &record.Id, + Quantity: 0, + } + if err := pwRepo.CreateOne(ctx, &newPW, nil); err != nil { + return err + } + } + } + + return nil +} + +func (s projectflockService) Resubmit(c *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + + kandangIDs := uniqueUintSlice(req.KandangIds) + kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Beberapa kandang tidak ditemukan") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data kandang") + } + if len(kandangs) != len(kandangIDs) { + return nil, fiber.NewError(fiber.StatusNotFound, "Beberapa kandang tidak ditemukan") + } + + for _, pb := range req.ProjectBudgets { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Nonstock", ID: &pb.NonstockId, Exists: s.NonstockRepo.IdExists}, + ); err != nil { + return nil, err + } + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + + var period int = 1 + if len(existing.KandangHistory) > 0 { + period = existing.KandangHistory[0].Period + } + + periods := make(map[uint]int, len(kandangIDs)) + for _, kandangID := range kandangIDs { + periods[kandangID] = period + } + + if err := s.attachKandangs(c.Context(), dbTransaction, existing.Id, kandangIDs, periods); err != nil { + return err + } + if err := s.UpsertProjectBudget(c.Context(), dbTransaction, existing.Id, req.ProjectBudgets); err != nil { + return err + } + + action := entity.ApprovalActionUpdated + _, err = approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + existing.Id, + utils.ProjectFlockStepPengajuan, + &action, + actorID, + nil, + ) + return err + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengajukan ulang project flock") + } + + return s.getOneEntityOnly(c, id) +} + +func (s projectflockService) UpsertProjectBudget(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, budgets []validation.ProjectBudget) error { + + if len(budgets) == 0 { + return nil + } + budgetRepo := projectBudgetRepository.NewProjectBudgetRepository(dbTransaction) + + nonstockMap := make(map[uint]bool) + relationChecks := make([]commonSvc.RelationCheck, 0, len(budgets)) + for _, b := range budgets { + if nonstockMap[b.NonstockId] { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate nonstock_id: %d", b.NonstockId)) + } + nonstockMap[b.NonstockId] = true + nonstockID := b.NonstockId + relationChecks = append(relationChecks, commonSvc.RelationCheck{ + Name: "Nonstock", + ID: &nonstockID, + Exists: s.NonstockRepo.IdExists, + }) + } + + if err := commonSvc.EnsureRelations(ctx, relationChecks...); err != nil { + return err + } + + if err := budgetRepo.DeleteMany(ctx, func(q *gorm.DB) *gorm.DB { + return q.Where("project_flock_id = ?", projectFlockID) + }); err != nil && err != gorm.ErrRecordNotFound { + return err + } + + records := make([]*entity.ProjectBudget, 0, len(budgets)) + for _, b := range budgets { + records = append(records, &entity.ProjectBudget{ + ProjectFlockId: projectFlockID, + NonstockId: b.NonstockId, + Price: b.Price, + Qty: b.Qty, + }) + } + + if err := budgetRepo.CreateMany(ctx, records, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to save project budgets") + } + + return nil + } diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 33f20725..5b2a9407 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,21 +1,14 @@ package validation type Create struct { - FlockName string `json:"flock_name" validate:"required_strict"` - AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` - Category string `json:"category" validate:"required_strict"` - FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` - LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` -} - -type Update struct { - FlockName *string `json:"flock_name,omitempty" validate:"omitempty"` - AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` - Category *string `json:"category,omitempty" validate:"omitempty"` - FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` - LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` - KandangIds []uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,gt=0"` + FlockName string `json:"flock_name" validate:"required_strict"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` + Category string `json:"category" validate:"required_strict"` + FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` + ProductionStandardId uint `json:"production_standard_id" validate:"required_strict,number,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` + ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"` } type Query struct { @@ -36,3 +29,14 @@ type Approve struct { ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } + +type ProjectBudget struct { + NonstockId uint `json:"nonstock_id" validate:"required_strict,number,gt=0"` + Price float64 `json:"price" validate:"required_strict,number,gt=0"` + Qty float64 `json:"qty" validate:"required_strict,number,gt=0"` +} + +type Resubmit struct { + KandangIds []uint `json:"kandang_ids" validate:"required_strict,min=1,dive,gt=0"` + ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required_strict,min=1,dive"` +} diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index c348a454..c0f1737b 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -146,27 +146,6 @@ func (u *RecordingController) UpdateOne(c *fiber.Ctx) error { }) } -func (u *RecordingController) SubmitGrading(c *fiber.Ctx) error { - req := new(validation.SubmitGrading) - - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") - } - - result, err := u.RecordingService.SubmitGrading(c, req) - if err != nil { - return err - } - - return c.Status(fiber.StatusOK). - JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Submit grading eggs successfully", - Data: dto.ToRecordingDetailDTO(*result), - }) -} - func (u *RecordingController) Approve(c *fiber.Ctx) error { req := new(validation.Approve) diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index f7cc4ee2..c34651ba 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -1,7 +1,6 @@ package dto import ( - "math" "strings" "time" @@ -16,22 +15,29 @@ import ( // === DTO Structs === type RecordingRelationDTO struct { - Id uint `json:"id"` - ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - RecordDatetime time.Time `json:"record_datetime"` - Day int `json:"day"` - ProjectFlockCategory string `json:"project_flock_category"` - TotalDepletionQty float64 `json:"total_depletion_qty"` - CumDepletionRate float64 `json:"cum_depletion_rate"` - DailyGain float64 `json:"daily_gain"` - AvgDailyGain float64 `json:"avg_daily_gain"` - CumIntake int `json:"cum_intake"` - FcrValue float64 `json:"fcr_value"` - TotalChickQty float64 `json:"total_chick_qty"` - Approval approvalDTO.ApprovalRelationDTO `json:"approval"` - EggGradingStatus *string `json:"egg_grading_status"` - EggGradingPendingQty *int `json:"egg_grading_pending_qty"` - EggGradingCompletedQty *int `json:"egg_grading_completed_qty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + RecordDatetime time.Time `json:"record_datetime"` + Day int `json:"day"` + ProjectFlockCategory string `json:"project_flock_category"` + TotalDepletionQty float64 `json:"total_depletion_qty"` + CumDepletionRate float64 `json:"cum_depletion_rate"` + CumIntake int `json:"cum_intake"` + FcrValue float64 `json:"fcr_value"` + TotalChickQty float64 `json:"total_chick_qty"` + HandDay float64 `json:"hand_day"` + HandHouse float64 `json:"hand_house"` + FeedIntake float64 `json:"feed_intake"` + EggMesh float64 `json:"egg_mesh"` + EggWeight float64 `json:"egg_weight"` + StandardHandDay *float64 `json:"hand_day_std,omitempty"` + StandardHandHouse *float64 `json:"hand_house_std,omitempty"` + StandardFeedIntake *float64 `json:"feed_intake_std,omitempty"` + StandardMaxDepletion *float64 `json:"max_depletion_std,omitempty"` + StandardEggMesh *float64 `json:"egg_mesh_std,omitempty"` + StandardEggWeight *float64 `json:"egg_weight_std,omitempty"` + StandardFcr *float64 `json:"fcr_std,omitempty"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } type RecordingListDTO struct { @@ -43,16 +49,9 @@ type RecordingListDTO struct { type RecordingDetailDTO struct { RecordingListDTO - BodyWeights []RecordingBodyWeightDTO `json:"body_weights"` - Depletions []RecordingDepletionDTO `json:"depletions"` - Stocks []RecordingStockDTO `json:"stocks"` - Eggs []RecordingEggDTO `json:"eggs"` -} - -type RecordingBodyWeightDTO struct { - AvgWeight float64 `json:"avg_weight"` - Qty float64 `json:"qty"` - TotalWeight float64 `json:"total_weight"` + Depletions []RecordingDepletionDTO `json:"depletions"` + Stocks []RecordingStockDTO `json:"stocks"` + Eggs []RecordingEggDTO `json:"eggs"` } type RecordingDepletionDTO struct { @@ -72,8 +71,8 @@ type RecordingEggDTO struct { Id uint `json:"id"` ProductWarehouseId uint `json:"product_warehouse_id"` Qty int `json:"qty"` + Weight *float64 `json:"weight,omitempty"` ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"` - Gradings []RecordingEggGradingDTO `json:"gradings,omitempty"` } type RecordingProductWarehouseDTO struct { @@ -84,11 +83,6 @@ type RecordingProductWarehouseDTO struct { WarehouseName string `json:"warehouse_name"` } -type RecordingEggGradingDTO struct { - Grade string `json:"grade,omitempty"` - Qty float64 `json:"qty"` -} - // === Mapper Functions === func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { @@ -97,11 +91,14 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { day int totalDepletionQty float64 cumDepletionRate float64 - dailyGain float64 - avgDailyGain float64 cumIntake int fcrValue float64 totalChickQty float64 + handDay float64 + handHouse float64 + feedIntake float64 + eggMesh float64 + eggWeight float64 ) if e.Day != nil { @@ -113,12 +110,6 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { if e.CumDepletionRate != nil { cumDepletionRate = *e.CumDepletionRate } - if e.DailyGain != nil { - dailyGain = *e.DailyGain - } - if e.AvgDailyGain != nil { - avgDailyGain = *e.AvgDailyGain - } if e.CumIntake != nil { cumIntake = *e.CumIntake } @@ -128,6 +119,21 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { if e.TotalChickQty != nil { totalChickQty = *e.TotalChickQty } + if e.HandDay != nil { + handDay = *e.HandDay + } + if e.HandHouse != nil { + handHouse = *e.HandHouse + } + if e.FeedIntake != nil { + feedIntake = *e.FeedIntake + } + if e.EggMesh != nil { + eggMesh = *e.EggMesh + } + if e.EggWeight != nil { + eggWeight = *e.EggWeight + } if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { category := e.ProjectFlockKandang.ProjectFlock.Category @@ -140,25 +146,30 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { latestApproval = snapshot } - gradingStatus, gradingPending, gradingCompleted := computeEggGradingStatus(e) - return RecordingRelationDTO{ - Id: e.Id, - ProjectFlockKandangId: e.ProjectFlockKandangId, - RecordDatetime: e.RecordDatetime, - Day: day, - ProjectFlockCategory: projectFlockCategory, - TotalDepletionQty: totalDepletionQty, - CumDepletionRate: cumDepletionRate, - DailyGain: dailyGain, - AvgDailyGain: avgDailyGain, - CumIntake: cumIntake, - FcrValue: fcrValue, - TotalChickQty: totalChickQty, - Approval: latestApproval, - EggGradingStatus: gradingStatus, - EggGradingPendingQty: gradingPending, - EggGradingCompletedQty: gradingCompleted, + Id: e.Id, + ProjectFlockKandangId: e.ProjectFlockKandangId, + RecordDatetime: e.RecordDatetime, + Day: day, + ProjectFlockCategory: projectFlockCategory, + TotalDepletionQty: totalDepletionQty, + CumDepletionRate: cumDepletionRate, + CumIntake: cumIntake, + FcrValue: fcrValue, + TotalChickQty: totalChickQty, + HandDay: handDay, + HandHouse: handHouse, + FeedIntake: feedIntake, + EggMesh: eggMesh, + EggWeight: eggWeight, + StandardHandDay: e.StandardHandDay, + StandardHandHouse: e.StandardHandHouse, + StandardFeedIntake: e.StandardFeedIntake, + StandardMaxDepletion: e.StandardMaxDepletion, + StandardEggMesh: e.StandardEggMesh, + StandardEggWeight: e.StandardEggWeight, + StandardFcr: e.StandardFcr, + Approval: latestApproval, } } @@ -197,25 +208,12 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { return RecordingDetailDTO{ RecordingListDTO: listDTO, - BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights), Depletions: ToRecordingDepletionDTOs(e.Depletions), Stocks: ToRecordingStockDTOs(e.Stocks), Eggs: eggs, } } -func ToRecordingBodyWeightDTOs(bodyWeights []entity.RecordingBW) []RecordingBodyWeightDTO { - result := make([]RecordingBodyWeightDTO, len(bodyWeights)) - for i, bw := range bodyWeights { - result[i] = RecordingBodyWeightDTO{ - AvgWeight: bw.AvgWeight, - Qty: bw.Qty, - TotalWeight: bw.TotalWeight, - } - } - return result -} - func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []RecordingDepletionDTO { result := make([]RecordingDepletionDTO, len(depletions)) for i, d := range depletions { @@ -253,29 +251,13 @@ func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { Id: egg.Id, ProductWarehouseId: egg.ProductWarehouseId, Qty: egg.Qty, + Weight: egg.Weight, ProductWarehouse: mapProductWarehouseDTO(&egg.ProductWarehouse), - Gradings: ToRecordingEggGradingDTOs(egg.GradingEggs), } } return result } -func ToRecordingEggGradingDTOs(gradings []entity.GradingEgg) []RecordingEggGradingDTO { - if len(gradings) == 0 { - return nil - } - - result := make([]RecordingEggGradingDTO, len(gradings)) - for i, grading := range gradings { - result[i] = RecordingEggGradingDTO{ - Grade: grading.Grade, - Qty: grading.Qty, - } - } - - return result -} - func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.ProductWarehouseDTO { if pw == nil { return productWarehouseDTO.ProductWarehouseDTO{} @@ -289,61 +271,6 @@ func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.Pro return *mapped } -const goodEggProductWarehouseID uint = 5 - -func computeEggGradingStatus(e entity.Recording) (*string, *int, *int) { - goodEggs := filterGoodEggs(e.Eggs) - if len(goodEggs) == 0 { - return nil, nil, nil - } - - totalEggs := 0 - totalGraded := 0.0 - for _, egg := range goodEggs { - totalEggs += egg.Qty - for _, grading := range egg.GradingEggs { - totalGraded += grading.Qty - } - } - - if totalEggs == 0 { - return nil, nil, nil - } - - pendingFloat := float64(totalEggs) - totalGraded - if pendingFloat < 0 { - pendingFloat = 0 - } - pendingInt := int(math.Round(pendingFloat)) - completedInt := int(math.Round(totalGraded)) - if completedInt < 0 { - completedInt = 0 - } - - if pendingInt > 0 { - status := "GRADING_TELUR" - return &status, &pendingInt, &completedInt - } - - status := "GRADING_SELESAI" - zero := 0 - return &status, &zero, &completedInt -} - -func filterGoodEggs(eggs []entity.RecordingEgg) []entity.RecordingEgg { - if len(eggs) == 0 { - return nil - } - - result := make([]entity.RecordingEgg, 0, len(eggs)) - for _, egg := range eggs { - if egg.ProductWarehouseId == goodEggProductWarehouseID { - result = append(result, egg) - } - } - return result -} - func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO { result := approvalDTO.ApprovalRelationDTO{} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index ff6b4ea0..a19faa33 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -2,6 +2,7 @@ package recordings import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -14,6 +15,7 @@ import ( rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -26,6 +28,25 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyRecordingStock, + Table: "recording_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "id", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register recording usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil { @@ -41,6 +62,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockPopulationRepo, approvalRepo, approvalService, + fifoService, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/recordings/permissions.go b/internal/modules/production/recordings/permissions.go deleted file mode 100644 index 00f9bd48..00000000 --- a/internal/modules/production/recordings/permissions.go +++ /dev/null @@ -1,8 +0,0 @@ -package recordings - -const ( - PermissionRecordingRead = "recording.read" - PermissionRecordingCreate = "recording.write" - PermissionRecordingUpdate = "recording.update" - PermissionRecordingDelete = "recording.delete" -) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index d9512edd..941d4507 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -17,14 +17,13 @@ type RecordingRepository interface { repository.BaseRepository[entity.Recording] WithRelations(db *gorm.DB) *gorm.DB + GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) - CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error - DeleteBodyWeights(tx *gorm.DB, recordingID uint) error - CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error DeleteStocks(tx *gorm.DB, recordingID uint) error ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) + UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error DeleteDepletions(tx *gorm.DB, recordingID uint) error @@ -34,18 +33,21 @@ type RecordingRepository interface { DeleteEggs(tx *gorm.DB, recordingID uint) error ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) - CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error - DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) - GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) + GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) - GetFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) - GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) + GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) + GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) + GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) + GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) + GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error) + GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) + GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) } type RecordingRepositoryImpl struct { @@ -63,7 +65,6 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser"). Preload("ProjectFlockKandang"). Preload("ProjectFlockKandang.ProjectFlock"). - Preload("BodyWeights"). Preload("Depletions"). Preload("Depletions.ProductWarehouse"). Preload("Depletions.ProductWarehouse.Product"). @@ -75,8 +76,28 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Eggs"). Preload("Eggs.ProductWarehouse"). Preload("Eggs.ProductWarehouse.Product"). - Preload("Eggs.ProductWarehouse.Warehouse"). - Preload("Eggs.GradingEggs") + Preload("Eggs.ProductWarehouse.Warehouse") +} + +func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) { + if projectFlockKandangId == 0 { + return nil, errors.New("project_flock_kandang_id is required") + } + + var record entity.Recording + err := r.DB().WithContext(ctx). + Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Order("record_datetime DESC"). + Order("created_at DESC"). + Limit(1). + Find(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) || record.Id == 0 { + return nil, nil + } + if err != nil { + return nil, err + } + return &record, nil } func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { @@ -90,17 +111,6 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda return nextRecordingDay(days), nil } -func (r *RecordingRepositoryImpl) CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error { - if len(bodyWeights) == 0 { - return nil - } - return tx.Create(&bodyWeights).Error -} - -func (r *RecordingRepositoryImpl) DeleteBodyWeights(tx *gorm.DB, recordingID uint) error { - return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingBW{}).Error -} - func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error { if len(stocks) == 0 { return nil @@ -120,6 +130,15 @@ func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]e return items, nil } +func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error { + return tx.Model(&entity.RecordingStock{}). + Where("id = ?", stockID). + Updates(map[string]any{ + "usage_qty": usageQty, + "pending_qty": pendingQty, + }).Error +} + func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { if len(depletions) == 0 { return nil @@ -178,7 +197,6 @@ func (r *RecordingRepositoryImpl) GetRecordingEggByID( Preload("Recording.ProjectFlockKandang"). Preload("Recording.ProjectFlockKandang.ProjectFlock"). Preload("ProductWarehouse"). - Preload("GradingEggs"). Where("id = ?", id) if err := query.First(&egg).Error; err != nil { @@ -187,17 +205,6 @@ func (r *RecordingRepositoryImpl) GetRecordingEggByID( return &egg, nil } -func (r *RecordingRepositoryImpl) CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error { - if len(gradings) == 0 { - return nil - } - return tx.Create(&gradings).Error -} - -func (r *RecordingRepositoryImpl) DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error { - return tx.Where("recording_egg_id = ?", recordingEggID).Delete(&entity.GradingEgg{}).Error -} - func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) { if projectFlockKandangId == 0 { return false, nil @@ -272,35 +279,33 @@ func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandang return int64(math.Round(total)), nil } -func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) { - var result struct { - TotalWeight float64 - TotalQty float64 - } - if err := tx.Model(&entity.RecordingBW{}). - Select("COALESCE(SUM(total_weight), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty"). - Where("recording_id = ?", recordingID). - Scan(&result).Error; err != nil { - return 0, err - } - if result.TotalQty == 0 { +func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) { + if projectFlockKandangId == 0 { return 0, nil } - return result.TotalWeight / result.TotalQty, nil + + var result float64 + err := tx. + Table("project_chickins"). + Select("COALESCE(SUM(project_chickins.usage_qty), 0)"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId). + Scan(&result).Error + return result, err } func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { var rows []struct { - UsageQty float64 + TotalQty float64 UomName string } if err := tx. Table("recording_stocks"). - Select("COALESCE(recording_stocks.usage_qty, 0) AS usage_qty, LOWER(uoms.name) AS uom_name"). + Select("COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0) AS total_qty, LOWER(uoms.name) AS uom_name"). Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id"). Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN uoms ON uoms.id = products.uom_id"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ? AND UPPER(flags.name) = ?", entity.FlagableTypeProduct, "PAKAN"). Where("recording_stocks.recording_id = ?", recordingID). Scan(&rows).Error; err != nil { return 0, err @@ -308,37 +313,63 @@ func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID u var total float64 for _, row := range rows { - if row.UsageQty <= 0 { + if row.TotalQty <= 0 { continue } switch strings.TrimSpace(row.UomName) { case "kilogram", "kg", "kilograms", "kilo": - total += row.UsageQty * 1000 + total += row.TotalQty * 1000 case "gram", "g", "grams": - total += row.UsageQty + total += row.TotalQty default: - total += row.UsageQty + total += row.TotalQty } } return total, nil } -func (r *RecordingRepositoryImpl) GetFcrID(tx *gorm.DB, projectFlockKandangId uint) (uint, error) { +func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) { + if recordingID == 0 { + return 0, 0, nil + } + var result struct { - FcrID uint + TotalQty float64 + TotalWeightGrams float64 } - if err := tx.Table("project_flock_kandangs"). - Select("project_flocks.fcr_id AS fcr_id"). - Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). - Where("project_flock_kandangs.id = ?", projectFlockKandangId). - Scan(&result).Error; err != nil { - return 0, err + err = tx. + Table("recording_eggs"). + Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(recording_eggs.qty * COALESCE(recording_eggs.weight, 0)), 0) AS total_weight_grams"). + Where("recording_eggs.recording_id = ?", recordingID). + Scan(&result).Error + if err != nil { + return 0, 0, err } - return result.FcrID, nil + return result.TotalQty, result.TotalWeightGrams, nil } -func (r *RecordingRepositoryImpl) GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) { - if fcrId == 0 { +func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang( + tx *gorm.DB, + projectFlockKandangId uint, + recordTime time.Time, +) (float64, error) { + if projectFlockKandangId == 0 { + return 0, nil + } + + var result float64 + err := tx. + Table("recording_eggs"). + Select("COALESCE(SUM(recording_eggs.qty), 0)"). + Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). + Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("recordings.record_datetime <= ?", recordTime). + Scan(&result).Error + return result, err +} + +func (r *RecordingRepositoryImpl) GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) { + if fcrId == 0 || currentWeightKg <= 0 { return 0, false, nil } @@ -361,11 +392,45 @@ func (r *RecordingRepositoryImpl) GetFcrStandardWeightKg(tx *gorm.DB, fcrId uint return 0, false, err } - weight := standard.Weight - if weight > 10 { - return weight / 1000, true, nil + return standard.FcrNumber, true, nil +} + +func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) { + // Body-weight tracking is removed; keep stub for report compatibility. + return 0, 0, nil +} + +func (r *RecordingRepositoryImpl) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { + var result float64 + err := r.DB().WithContext(ctx). + Table("recording_depletions"). + Select("COALESCE(SUM(recording_depletions.qty), 0)"). + Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Scan(&result).Error + return result, err +} + +func (r *RecordingRepositoryImpl) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { + // Body-weight tracking is removed; keep stub for report compatibility. + return 0, nil +} + +func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { + if projectFlockID == 0 { + return 0, nil } - return weight, true, nil + + var result float64 + err := r.DB().WithContext(ctx). + Table("recording_eggs"). + Select("COALESCE(SUM(recording_eggs.qty * recording_eggs.weight), 0) / 1000"). + Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Scan(&result).Error + return result, err } func nextRecordingDay(days []int) int { diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go index c492c39f..f05d054d 100644 --- a/internal/modules/production/recordings/route.go +++ b/internal/modules/production/recordings/route.go @@ -15,12 +15,11 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS route := v1.Group("/recordings") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Get("/next-day", ctrl.GetNextDay) - route.Post("/", ctrl.CreateOne) - route.Post("/gradings", ctrl.SubmitGrading) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Post("/approvals", ctrl.Approve) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/",m.RequirePermissions(m.P_RecordingGetAll), ctrl.GetAll) + route.Get("/:id",m.RequirePermissions(m.P_RecordingGetOne), ctrl.GetOne) + route.Post("/",m.RequirePermissions(m.P_RecordingCreateOne), ctrl.CreateOne) + route.Patch("/:id",m.RequirePermissions(m.P_RecordingUpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_RecordingDeleteOne), ctrl.DeleteOne) + route.Get("/next-day",m.RequirePermissions(m.P_RecordingNextDay), ctrl.GetNextDay) + route.Post("/approvals",m.RequirePermissions(m.P_RecordingApproval), ctrl.Approve) } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index b31a90c0..54052518 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -4,20 +4,22 @@ import ( "context" "errors" "fmt" - "math" - "strings" - "time" - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" + "math" + "strings" + "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -32,10 +34,16 @@ type RecordingService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) DeleteOne(ctx *fiber.Ctx, id uint) error - SubmitGrading(ctx *fiber.Ctx, req *validation.SubmitGrading) (*entity.Recording, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) } +type RecordingFIFOIntegrationService interface { + ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error + ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error +} + +var recordingStockUsableKey = fifo.UsableKeyRecordingStock + type recordingService struct { Log *logrus.Logger Validate *validator.Validate @@ -45,6 +53,7 @@ type recordingService struct { ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewRecordingService( @@ -54,6 +63,7 @@ func NewRecordingService( projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -65,6 +75,20 @@ func NewRecordingService( ProjectFlockPopulationRepo: projectFlockPopulationRepo, ApprovalRepo: approvalRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, + } +} + +func NewRecordingFIFOIntegrationService( + repo repository.RecordingRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + fifoSvc commonSvc.FifoService, +) RecordingFIFOIntegrationService { + return &recordingService{ + Log: utils.Log, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + FifoSvc: fifoSvc, } } @@ -98,6 +122,9 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if err := s.attachLatestApprovals(c.Context(), recordings); err != nil { return nil, 0, err } + if err := s.attachProductionStandards(c.Context(), recordings); err != nil { + return nil, 0, err + } return recordings, total, nil } @@ -115,6 +142,9 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro if err := s.attachLatestApproval(c.Context(), recording); err != nil { return nil, err } + if err := s.attachProductionStandard(c.Context(), recording); err != nil { + return nil, err + } return recording, nil } @@ -169,7 +199,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err } - + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } var createdRecording entity.Recording transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { nextDay, err := s.Repository.GenerateNextDay(tx, req.ProjectFlockKandangId) @@ -193,10 +226,16 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent ProjectFlockKandangId: req.ProjectFlockKandangId, RecordDatetime: recordTime, Day: &day, - CreatedBy: 1, // TODO: replace with authenticated user + CreatedBy: actorID, } - if err := s.Repository.CreateOne(ctx, &createdRecording, func(*gorm.DB) *gorm.DB { return tx }); err != nil { + createTx := tx.WithContext(ctx).Select( + "ProjectFlockKandangId", + "RecordDatetime", + "Day", + "CreatedBy", + ) + if err := createTx.Create(&createdRecording).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return fiber.NewError( fiber.StatusBadRequest, @@ -207,15 +246,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - mappedBodyWeights := recordingutil.MapBodyWeights(createdRecording.Id, req.BodyWeights) - if err := s.Repository.CreateBodyWeights(tx, mappedBodyWeights); err != nil { - s.Log.Errorf("Failed to persist body weights: %+v", err) + mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) + stockDesired := resetStockQuantitiesForFIFO(mappedStocks, s.FifoSvc != nil) + if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { + s.Log.Errorf("Failed to persist stocks: %+v", err) return err } - mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) - if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { - s.Log.Errorf("Failed to persist stocks: %+v", err) + applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil) + if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { return err } @@ -231,7 +270,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedStocks, nil, mappedEggs)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err } @@ -242,7 +281,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } action := entity.ApprovalActionCreated - if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepGradingTelur, action, createdRecording.CreatedBy, nil); err != nil { + if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil { s.Log.Errorf("Failed to create recording approval for %d: %+v", createdRecording.Id, err) return err } @@ -261,16 +300,18 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return nil, err } - if req.BodyWeights == nil && req.Stocks == nil && req.Depletions == nil && req.Eggs == nil { + if req.Stocks == nil && req.Depletions == nil && req.Eggs == nil { return s.GetOne(c, id) } ctx := c.Context() var recordingEntity *entity.Recording + var updatedRecording *entity.Recording transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - recording, err := s.Repository.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { - return s.Repository.WithRelations(tx) + repoTx := s.Repository.WithTx(tx) + recording, err := repoTx.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(db) }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -281,12 +322,47 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } recordingEntity = recording - hasBodyChanges := req.BodyWeights != nil hasStockChanges := req.Stocks != nil hasDepletionChanges := req.Depletions != nil hasEggChanges := req.Eggs != nil - if !hasBodyChanges && !hasStockChanges && !hasDepletionChanges && !hasEggChanges { + var existingStocks []entity.RecordingStock + if hasStockChanges { + existingStocks, err = s.Repository.ListStocks(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing stocks: %+v", err) + return err + } + if stocksMatch(existingStocks, req.Stocks) { + hasStockChanges = false + } + } + + var existingDepletions []entity.RecordingDepletion + if hasDepletionChanges { + existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing depletions: %+v", err) + return err + } + if depletionsMatch(existingDepletions, req.Depletions) { + hasDepletionChanges = false + } + } + + var existingEggs []entity.RecordingEgg + if hasEggChanges { + existingEggs, err = s.Repository.ListEggs(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing eggs: %+v", err) + return err + } + if eggsMatch(existingEggs, req.Eggs) { + hasEggChanges = false + } + } + + if !hasStockChanges && !hasDepletionChanges && !hasEggChanges { return nil } @@ -316,58 +392,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - hasExistingGradings := false - for _, egg := range recordingEntity.Eggs { - if len(egg.GradingEggs) > 0 { - hasExistingGradings = true - break - } - } - - hasEggsAfterUpdate := len(recordingEntity.Eggs) > 0 - - if hasBodyChanges { - if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil { - s.Log.Errorf("Failed to clear body weights: %+v", err) - return err - } - if err := s.Repository.CreateBodyWeights(tx, recordingutil.MapBodyWeights(recordingEntity.Id, req.BodyWeights)); err != nil { - s.Log.Errorf("Failed to update body weights: %+v", err) - return err - } - } - if hasStockChanges { - existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing stocks: %+v", err) - return err - } - - if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil { - s.Log.Errorf("Failed to clear stocks: %+v", err) - return err - } - - mappedStocks := recordingutil.MapStocks(recordingEntity.Id, req.Stocks) - if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { - s.Log.Errorf("Failed to update stocks: %+v", err) - return err - } - - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingStocks, mappedStocks, nil, nil)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for stocks: %+v", err) + if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks); err != nil { return err } } if hasDepletionChanges { - existingDepletions, err := s.Repository.ListDepletions(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing depletions: %+v", err) - return err - } - if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear depletions: %+v", err) return err @@ -379,19 +410,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil, nil, nil)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) return err } } if hasEggChanges { - existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id) - if err != nil { - s.Log.Errorf("Failed to list existing eggs: %+v", err) - return err - } - if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear eggs: %+v", err) return err @@ -403,16 +428,13 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, nil, nil, existingEggs, mappedEggs)); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) return err } - - hasExistingGradings = false - hasEggsAfterUpdate = len(req.Eggs) > 0 } - if hasBodyChanges || hasStockChanges || hasDepletionChanges { + if hasStockChanges || hasDepletionChanges || hasEggChanges { if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { s.Log.Errorf("Failed to recompute recording metrics: %+v", err) return err @@ -422,23 +444,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin action := entity.ApprovalActionUpdated actorID := recordingEntity.CreatedBy if actorID == 0 { - actorID = 1 + return fiber.NewError(fiber.StatusBadRequest, "Actor Id tidak valid untuk approval") } - var step approvalutils.ApprovalStep - if isLaying { - if !hasEggsAfterUpdate { - step = utils.RecordingStepGradingTelur - } else if hasEggChanges { - step = utils.RecordingStepGradingTelur - } else if hasExistingGradings { - step = utils.RecordingStepPengajuan - } else { - step = utils.RecordingStepGradingTelur - } - } else { - step = utils.RecordingStepPengajuan - } + step := utils.RecordingStepPengajuan latestApproval := recordingEntity.LatestApproval if latestApproval == nil { @@ -474,116 +483,31 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } + updated, err := repoTx.GetByID(ctx, recordingEntity.Id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(db) + }) + if err != nil { + s.Log.Errorf("Failed to reload recording %d after update: %+v", recordingEntity.Id, err) + return err + } + updatedRecording = updated + return nil }) if transactionErr != nil { return nil, transactionErr } - return s.GetOne(c, id) -} - -func (s *recordingService) SubmitGrading(c *fiber.Ctx, req *validation.SubmitGrading) (*entity.Recording, error) { - if err := s.Validate.Struct(req); err != nil { + if updatedRecording == nil { + return s.GetOne(c, id) + } + if err := s.attachLatestApproval(ctx, updatedRecording); err != nil { return nil, err } - - if len(req.EggsGrading) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "eggs_grading must contain at least one item") + if err := s.attachProductionStandard(ctx, updatedRecording); err != nil { + return nil, err } - - recordingEggID := req.EggsGrading[0].RecordingEggId - for _, grading := range req.EggsGrading[1:] { - if grading.RecordingEggId != recordingEggID { - return nil, fiber.NewError(fiber.StatusBadRequest, "semua grading harus untuk recording egg yang sama") - } - } - - ctx := c.Context() - var recordingID uint - transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - recordingEgg, err := s.Repository.GetRecordingEggByID(ctx, recordingEggID, func(db *gorm.DB) *gorm.DB { - return tx - }) - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Recording egg not found") - } - if err != nil { - s.Log.Errorf("Failed to get recording egg %d: %+v", recordingEggID, err) - return err - } - - var category string - if recordingEgg.Recording.ProjectFlockKandang != nil && recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Id != 0 { - category = strings.ToUpper(recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Category) - } - if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { - return fiber.NewError(fiber.StatusBadRequest, "Grading eggs hanya diperbolehkan pada project flock dengan kategori laying") - } - - totalGradingQty := 0.0 - for _, grading := range req.EggsGrading { - totalGradingQty += grading.Qty - } - - availableRecorded := float64(recordingEgg.Qty) - if totalGradingQty > availableRecorded { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Total grading (%.2f) melebihi jumlah telur tercatat (%.2f)", totalGradingQty, availableRecorded), - ) - } - - if recordingEgg.ProductWarehouse.Id != 0 { - availableWarehouse := recordingEgg.ProductWarehouse.Quantity - if totalGradingQty > availableWarehouse { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Total grading (%.2f) melebihi stok telur baik (%.2f)", totalGradingQty, availableWarehouse), - ) - } - } - - if err := s.Repository.DeleteGradingEggs(tx, recordingEgg.Id); err != nil { - s.Log.Errorf("Failed to clear grading eggs for recording egg %d: %+v", recordingEgg.Id, err) - return err - } - - gradings := make([]entity.GradingEgg, 0, len(req.EggsGrading)) - createdBy := recordingEgg.CreatedBy - if createdBy == 0 { - createdBy = recordingEgg.Recording.CreatedBy - } - for _, item := range req.EggsGrading { - gradings = append(gradings, entity.GradingEgg{ - RecordingEggId: recordingEgg.Id, - Grade: strings.TrimSpace(item.Grade), - Qty: item.Qty, - CreatedBy: createdBy, - }) - } - - if len(gradings) > 0 { - if err := s.Repository.CreateGradingEggs(tx, gradings); err != nil { - s.Log.Errorf("Failed to persist grading eggs for recording egg %d: %+v", recordingEgg.Id, err) - return err - } - } - - action := entity.ApprovalActionUpdated - if err := s.createRecordingApproval(ctx, tx, recordingEgg.RecordingId, utils.RecordingStepPengajuan, action, createdBy, nil); err != nil { - s.Log.Errorf("Failed to create approval after grading for recording %d: %+v", recordingEgg.RecordingId, err) - return err - } - - recordingID = recordingEgg.RecordingId - return nil - }) - if transactionErr != nil { - return nil, transactionErr - } - - return s.GetOne(c, recordingID) + return updatedRecording, nil } func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) { @@ -613,7 +537,10 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent } ctx := c.Context() - actorID := uint(1) // TODO: replace with authenticated user once auth is integrated + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { repoTx := s.Repository.WithTx(tx) @@ -685,7 +612,11 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldStocks, nil, oldEggs, nil)); err != nil { + if err := s.releaseRecordingStocks(ctx, tx, oldStocks); err != nil { + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldEggs, nil)); err != nil { return err } @@ -740,9 +671,84 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v return nil } +func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + if len(stocks) == 0 || s.FifoSvc == nil { + return nil + } + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + + var desired float64 + if stock.UsageQty != nil { + desired = *stock.UsageQty + } + var pending float64 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + desiredTotal := desired + pending + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + ProductWarehouseID: stock.ProductWarehouseId, + Quantity: desiredTotal, + AllowPending: true, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err) + return err + } + + if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + return s.consumeRecordingStocks(ctx, tx, stocks) +} + +func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + if len(stocks) == 0 || s.FifoSvc == nil { + return nil + } + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err) + return err + } + + if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { + return s.releaseRecordingStocks(ctx, tx, stocks) +} + func buildWarehouseDeltas( oldDepletions, newDepletions []entity.RecordingDepletion, - oldStocks, newStocks []entity.RecordingStock, oldEggs, newEggs []entity.RecordingEgg, ) map[uint]float64 { deltas := make(map[uint]float64) @@ -752,12 +758,6 @@ func buildWarehouseDeltas( for _, item := range newDepletions { accumulateWarehouseDelta(deltas, item.ProductWarehouseId, item.Qty) } - for _, item := range oldStocks { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, usageQtyValue(item.UsageQty)) - } - for _, item := range newStocks { - accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -usageQtyValue(item.UsageQty)) - } for _, item := range oldEggs { accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty)) } @@ -767,13 +767,6 @@ func buildWarehouseDeltas( return deltas } -func usageQtyValue(val *float64) float64 { - if val == nil { - return 0 - } - return *val -} - func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) { if id == 0 || value == 0 { return @@ -788,6 +781,288 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) } +type desiredStock struct { + Usage float64 + Pending float64 +} + +func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { + desired := make([]desiredStock, len(stocks)) + for i := range stocks { + if stocks[i].UsageQty != nil { + desired[i].Usage = *stocks[i].UsageQty + } + if stocks[i].PendingQty != nil { + desired[i].Pending = *stocks[i].PendingQty + } + if !enabled { + continue + } + zero := 0.0 + stocks[i].UsageQty = &zero + stocks[i].PendingQty = &zero + } + return desired +} + +func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) { + if !enabled { + return + } + for i := range stocks { + if i >= len(desired) { + break + } + usage := desired[i].Usage + pending := desired[i].Pending + stocks[i].UsageQty = &usage + stocks[i].PendingQty = &pending + } +} + +func (s *recordingService) syncRecordingStocks( + ctx context.Context, + tx *gorm.DB, + recordingID uint, + existing []entity.RecordingStock, + incoming []validation.Stock, +) error { + if s.FifoSvc == nil { + if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { + return err + } + mapped := recordingutil.MapStocks(recordingID, incoming) + return s.Repository.CreateStocks(tx, mapped) + } + + existingByWarehouse := make(map[uint][]entity.RecordingStock) + for _, stock := range existing { + existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) + } + + stocksToConsume := make([]entity.RecordingStock, 0, len(incoming)) + for _, item := range incoming { + list := existingByWarehouse[item.ProductWarehouseId] + var stock entity.RecordingStock + if len(list) > 0 { + stock = list[0] + existingByWarehouse[item.ProductWarehouseId] = list[1:] + } else { + zero := 0.0 + stock = entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + UsageQty: &zero, + PendingQty: &zero, + } + if err := tx.Create(&stock).Error; err != nil { + return err + } + } + + desired := item.Qty + stock.UsageQty = &desired + if item.PendingQty != nil { + pending := *item.PendingQty + stock.PendingQty = &pending + } + stocksToConsume = append(stocksToConsume, stock) + } + + var leftovers []entity.RecordingStock + for _, list := range existingByWarehouse { + leftovers = append(leftovers, list...) + } + if len(leftovers) > 0 { + if err := s.releaseRecordingStocks(ctx, tx, leftovers); err != nil { + return err + } + ids := make([]uint, 0, len(leftovers)) + for _, stock := range leftovers { + if stock.Id != 0 { + ids = append(ids, stock.Id) + } + } + if len(ids) > 0 { + if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil { + return err + } + } + } + + if len(stocksToConsume) == 0 { + return nil + } + return s.consumeRecordingStocks(ctx, tx, stocksToConsume) +} + +type eggTotals struct { + Qty int + Weight float64 +} + +type stockTotals struct { + Usage float64 + Pending float64 + Total float64 +} + +func summarizeExistingStocks(stocks []entity.RecordingStock) map[uint]stockTotals { + totals := make(map[uint]stockTotals) + for _, stock := range stocks { + var usage float64 + var pending float64 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + current := totals[stock.ProductWarehouseId] + current.Usage += usage + current.Pending += pending + current.Total += usage + pending + totals[stock.ProductWarehouseId] = current + } + return totals +} + +func summarizeIncomingStocks(stocks []validation.Stock) map[uint]stockTotals { + totals := make(map[uint]stockTotals) + for _, stock := range stocks { + var pending float64 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + current := totals[stock.ProductWarehouseId] + current.Usage += stock.Qty + current.Pending += pending + current.Total += stock.Qty + pending + totals[stock.ProductWarehouseId] = current + } + return totals +} + +func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { + hasPending := false + for _, item := range incoming { + if item.PendingQty != nil { + hasPending = true + break + } + } + + existingUsage := make(map[uint]float64) + existingTotal := make(map[uint]float64) + for _, stock := range existing { + var usage float64 + var pending float64 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + existingUsage[stock.ProductWarehouseId] += usage + existingTotal[stock.ProductWarehouseId] += usage + pending + } + + incomingUsage := make(map[uint]float64) + incomingTotal := make(map[uint]float64) + for _, item := range incoming { + var pending float64 + if item.PendingQty != nil { + pending = *item.PendingQty + } + incomingUsage[item.ProductWarehouseId] += item.Qty + incomingTotal[item.ProductWarehouseId] += item.Qty + pending + } + + if hasPending { + return floatMapsMatch(existingTotal, incomingTotal) + } + return floatMapsMatch(existingUsage, incomingUsage) +} + +func depletionsMatch(existing []entity.RecordingDepletion, incoming []validation.Depletion) bool { + existingTotals := make(map[uint]float64) + for _, dep := range existing { + existingTotals[dep.ProductWarehouseId] += dep.Qty + } + + incomingTotals := make(map[uint]float64) + for _, dep := range incoming { + incomingTotals[dep.ProductWarehouseId] += dep.Qty + } + + return floatMapsMatch(existingTotals, incomingTotals) +} + +func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { + existingTotals := make(map[uint]eggTotals) + for _, egg := range existing { + weight := 0.0 + if egg.Weight != nil { + weight = *egg.Weight + } + current := existingTotals[egg.ProductWarehouseId] + current.Qty += egg.Qty + current.Weight += float64(egg.Qty) * weight + existingTotals[egg.ProductWarehouseId] = current + } + + incomingTotals := make(map[uint]eggTotals) + for _, egg := range incoming { + weight := 0.0 + if egg.Weight != nil { + weight = *egg.Weight + } + current := incomingTotals[egg.ProductWarehouseId] + current.Qty += egg.Qty + current.Weight += float64(egg.Qty) * weight + incomingTotals[egg.ProductWarehouseId] = current + } + + if len(existingTotals) != len(incomingTotals) { + return false + } + + for key, existingTotal := range existingTotals { + incomingTotal, ok := incomingTotals[key] + if !ok { + return false + } + if existingTotal.Qty != incomingTotal.Qty { + return false + } + if !floatNearlyEqual(existingTotal.Weight, incomingTotal.Weight) { + return false + } + } + + return true +} + +func floatMapsMatch(a, b map[uint]float64) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + other, ok := b[key] + if !ok { + return false + } + if !floatNearlyEqual(value, other) { + return false + } + } + return true +} + +func floatNearlyEqual(a, b float64) bool { + return math.Abs(a-b) <= 0.000001 +} + func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error { day := 0 if recording.Day != nil { @@ -806,7 +1081,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var prevCumDepletionQty float64 var prevCumIntake float64 - var prevAvgWeight float64 if prevRecording != nil { if prevRecording.TotalDepletionQty != nil { prevCumDepletionQty = *prevRecording.TotalDepletionQty @@ -814,10 +1088,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm if prevRecording.CumIntake != nil { prevCumIntake = float64(*prevRecording.CumIntake) } - prevAvgWeight, err = s.Repository.GetAverageBodyWeight(tx, prevRecording.Id) - if err != nil { - return fmt.Errorf("getAverageBodyWeight(prev): %w", err) - } } totalChick, err := s.Repository.GetTotalChick(tx, recording.ProjectFlockKandangId) @@ -825,24 +1095,25 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return fmt.Errorf("getTotalChick: %w", err) } - currentAvgWeight, err := s.Repository.GetAverageBodyWeight(tx, recording.Id) - if err != nil { - return fmt.Errorf("getAverageBodyWeight(current): %w", err) - } - usageInGrams, err := s.Repository.GetFeedUsageInGrams(tx, recording.Id) if err != nil { return fmt.Errorf("getFeedUsageInGrams: %w", err) } - fcrId, err := s.Repository.GetFcrID(tx, recording.ProjectFlockKandangId) + totalEggQty, totalEggWeightGrams, err := s.Repository.GetEggSummaryByRecording(tx, recording.Id) if err != nil { - return fmt.Errorf("getFcrID: %w", err) + return fmt.Errorf("getEggSummaryByRecording: %w", err) } - currentAvgGrams := recordingutil.ToGrams(currentAvgWeight) - currentAvgKg := recordingutil.GramsToKg(currentAvgGrams) - prevAvgGrams := recordingutil.ToGrams(prevAvgWeight) + cumulativeEggQty, err := s.Repository.GetCumulativeEggQtyByProjectFlockKandang(tx, recording.ProjectFlockKandangId, recording.RecordDatetime) + if err != nil { + return fmt.Errorf("getCumulativeEggQtyByProjectFlockKandang: %w", err) + } + + initialChickin, err := s.Repository.GetTotalChickinByProjectFlockKandang(tx, recording.ProjectFlockKandangId) + if err != nil { + return fmt.Errorf("getTotalChickinByProjectFlockKandang: %w", err) + } currentDepletion := float64(totalDepletionQty) cumDepletionQty := prevCumDepletionQty + currentDepletion @@ -852,9 +1123,10 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm } recording.TotalDepletionQty = &cumDepletionQty + var remainingChick float64 if totalChick > 0 { totalChickFloat := float64(totalChick) - remainingChick := totalChickFloat - cumDepletionQty + remainingChick = totalChickFloat - cumDepletionQty if remainingChick < 0 { remainingChick = 0 } @@ -874,29 +1146,64 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.CumDepletionRate = nil } - if currentAvgGrams > 0 && prevAvgGrams > 0 { - dailyGainKg := (currentAvgGrams - prevAvgGrams) / 1000 - updates["daily_gain"] = dailyGainKg - recording.DailyGain = &dailyGainKg + var feedIntake float64 + if remainingChick > 0 && usageInGrams > 0 { + feedIntake = (usageInGrams / remainingChick) * 1000 + updates["feed_intake"] = feedIntake + recording.FeedIntake = &feedIntake } else { - updates["daily_gain"] = gorm.Expr("NULL") - recording.DailyGain = nil + updates["feed_intake"] = gorm.Expr("NULL") + recording.FeedIntake = nil } - if fcrId != 0 && currentAvgKg > 0 && day > 0 { - if fcrWeightKg, ok, err := s.Repository.GetFcrStandardWeightKg(tx, fcrId, currentAvgKg); err != nil { - return fmt.Errorf("getFcrStandardWeightKg: %w", err) - } else if ok { - avgDailyGain := (currentAvgKg - fcrWeightKg) / float64(day) - updates["avg_daily_gain"] = avgDailyGain - recording.AvgDailyGain = &avgDailyGain - } else { - updates["avg_daily_gain"] = gorm.Expr("NULL") - recording.AvgDailyGain = nil - } + var handDay float64 + if remainingChick > 0 && totalEggQty >= 0 { + handDay = (totalEggQty / remainingChick) * 100 + updates["hand_day"] = handDay + recording.HandDay = &handDay } else { - updates["avg_daily_gain"] = gorm.Expr("NULL") - recording.AvgDailyGain = nil + updates["hand_day"] = gorm.Expr("NULL") + recording.HandDay = nil + } + + var handHouse float64 + if initialChickin > 0 && cumulativeEggQty >= 0 { + handHouse = cumulativeEggQty / initialChickin + updates["hand_house"] = handHouse + recording.HandHouse = &handHouse + } else { + updates["hand_house"] = gorm.Expr("NULL") + recording.HandHouse = nil + } + + var eggMesh float64 + if remainingChick > 0 && totalEggWeightGrams > 0 { + eggMesh = (totalEggWeightGrams / remainingChick) * 1000 + updates["egg_mesh"] = eggMesh + recording.EggMesh = &eggMesh + } else { + updates["egg_mesh"] = gorm.Expr("NULL") + recording.EggMesh = nil + } + + var eggWeight float64 + if totalEggQty > 0 && totalEggWeightGrams > 0 { + eggWeight = (totalEggWeightGrams / totalEggQty) * 1000 + updates["egg_weight"] = eggWeight + recording.EggWeight = &eggWeight + } else { + updates["egg_weight"] = gorm.Expr("NULL") + recording.EggWeight = nil + } + + var fcrValue float64 + if usageInGrams > 0 && totalEggWeightGrams > 0 { + fcrValue = totalEggWeightGrams / usageInGrams + updates["fcr_value"] = fcrValue + recording.FcrValue = &fcrValue + } else { + updates["fcr_value"] = gorm.Expr("NULL") + recording.FcrValue = nil } if usageInGrams > 0 && totalChick > 0 { @@ -921,16 +1228,6 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.CumIntake = nil } - if usageInGrams > 0 && currentAvgKg > 0 { - feedUsageKg := usageInGrams / 1000 - fcrValue := feedUsageKg / currentAvgKg - updates["fcr_value"] = fcrValue - recording.FcrValue = &fcrValue - } else { - updates["fcr_value"] = gorm.Expr("NULL") - recording.FcrValue = nil - } - if err := s.Repository.WithTx(tx).PatchOne(ctx, recording.Id, updates, nil); err != nil { return err } @@ -951,7 +1248,7 @@ func (s *recordingService) createRecordingApproval( return fiber.NewError(fiber.StatusBadRequest, "Recording tidak valid untuk approval") } if actorID == 0 { - actorID = 1 + return fiber.NewError(fiber.StatusBadRequest, "Actor Id tidak valid untuk approval") } var svc commonSvc.ApprovalService @@ -1036,6 +1333,104 @@ func (s *recordingService) attachLatestApproval(ctx context.Context, item *entit return nil } +type productionStandardValues struct { + HandDay *float64 + HandHouse *float64 + FeedIntake *float64 + MaxDepletion *float64 + EggMesh *float64 + EggWeight *float64 +} + +func (s *recordingService) attachProductionStandards(ctx context.Context, items []entity.Recording) error { + if len(items) == 0 { + return nil + } + + for i := range items { + if err := s.attachProductionStandard(ctx, &items[i]); err != nil { + s.Log.Warnf("Unable to load production standard for recording %d: %+v", items[i].Id, err) + } + } + return nil +} + +func (s *recordingService) attachProductionStandard(ctx context.Context, item *entity.Recording) error { + if item == nil || item.Id == 0 { + return nil + } + if item.Day == nil || *item.Day <= 0 { + return nil + } + if item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { + return nil + } + + standardID := item.ProjectFlockKandang.ProjectFlock.ProductionStandardId + if standardID == 0 { + return nil + } + + week := ((int(*item.Day) - 1) / 7) + 1 + if week <= 0 { + return nil + } + + category := strings.ToUpper(item.ProjectFlockKandang.ProjectFlock.Category) + db := s.Repository.DB() + standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + + var standard productionStandardValues + var standardFcr *float64 + if category == string(utils.ProjectFlockCategoryLaying) { + detail, err := standardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if detail != nil { + standard.HandDay = detail.TargetHenDayProduction + standard.HandHouse = detail.TargetHenHouseProduction + standard.EggWeight = detail.TargetEggWeight + standard.EggMesh = detail.TargetEggMass + } + } + + growthDetail, err := growthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if growthDetail != nil { + standard.FeedIntake = growthDetail.FeedIntake + standard.MaxDepletion = growthDetail.MaxDepletion + if category == string(utils.ProjectFlockCategoryLaying) && growthDetail.TargetMeanBw != nil && item.ProjectFlockKandang.ProjectFlock.FcrId > 0 { + targetWeight := *growthDetail.TargetMeanBw + if targetWeight > 10 { + targetWeight = targetWeight / 1000 + } + if targetWeight > 0 { + fcrStd, ok, err := s.Repository.GetFcrStandardNumber(db, item.ProjectFlockKandang.ProjectFlock.FcrId, targetWeight) + if err != nil { + return err + } + if ok { + standardFcr = &fcrStd + } + } + } + } + + item.StandardHandDay = standard.HandDay + item.StandardHandHouse = standard.HandHouse + item.StandardFeedIntake = standard.FeedIntake + item.StandardMaxDepletion = standard.MaxDepletion + item.StandardEggMesh = standard.EggMesh + item.StandardEggWeight = standard.EggWeight + item.StandardFcr = standardFcr + + return nil +} + func uniqueUintSlice(values []uint) []uint { if len(values) == 0 { return nil diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 28ea8a9f..a1d6aaf7 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -1,12 +1,6 @@ package validation type ( - BodyWeight struct { - AvgWeight float64 `json:"avg_weight" validate:"required"` - Qty float64 `json:"qty" validate:"required,gt=0"` - TotalWeight *float64 `json:"total_weight,omitempty" validate:"omitempty,gte=0"` - } - Stock struct { ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` Qty float64 `json:"qty" validate:"required,gte=0"` @@ -19,21 +13,20 @@ type ( } Egg struct { - ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - Qty int `json:"qty" validate:"required,number,min=0"` + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Qty int `json:"qty" validate:"required,number,min=0"` + Weight *float64 `json:"weight,omitempty" validate:"omitempty,gte=0"` } ) type Create struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` - BodyWeights []BodyWeight `json:"body_weights" validate:"dive"` Stocks []Stock `json:"stocks" validate:"dive"` Depletions []Depletion `json:"depletions" validate:"dive"` Eggs []Egg `json:"eggs" validate:"omitempty,dive"` } type Update struct { - BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"` Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` @@ -45,16 +38,6 @@ type Query struct { ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` } -type EggGrading struct { - RecordingEggId uint `json:"recording_egg_id" validate:"required,number,min=1"` - Grade string `json:"grade" validate:"required"` - Qty float64 `json:"qty" validate:"required,gte=0"` -} - -type SubmitGrading struct { - EggsGrading []EggGrading `json:"eggs_grading" validate:"required,dive"` -} - type Approve struct { Action string `json:"action" validate:"required_strict"` ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` diff --git a/internal/modules/production/route.go b/internal/modules/production/route.go index d1425b7c..4066121a 100644 --- a/internal/modules/production/route.go +++ b/internal/modules/production/route.go @@ -8,10 +8,11 @@ import ( "gorm.io/gorm" chickins "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins" + projectFlockKandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs" projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks" recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" transferLayings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings" - projectFlockKandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs" + uniformitys "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities" // MODULE IMPORTS ) @@ -24,8 +25,9 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida chickins.ChickinModule{}, transferLayings.TransferLayingModule{}, projectFlockKandangs.ProjectFlockKandangModule{}, + uniformitys.UniformityModule{}, // MODULE REGISTRY -} + } for _, m := range allModules { m.RegisterRoutes(group, db, validate) diff --git a/internal/modules/production/transfer_layings/route.go b/internal/modules/production/transfer_layings/route.go index ad0cb9e1..8f7a62c0 100644 --- a/internal/modules/production/transfer_layings/route.go +++ b/internal/modules/production/transfer_layings/route.go @@ -1,7 +1,7 @@ package transfer_layings import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/controllers" transferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying. ctrl := controller.NewTransferLayingController(s) route := v1.Group("/transfer_layings") - + route.Use(m.Auth(u)) // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Get("/:id", m.Auth(u), ctrl.GetOne) @@ -21,11 +21,11 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying. // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) // route.Post("/approval", m.Auth(u), ctrl.Approval) - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) - route.Post("/approvals", ctrl.Approval) - route.Get("/project-flocks/:project_flock_id/available-qty", ctrl.GetAvailableQtyPerKandang) + route.Get("/",m.RequirePermissions(m.P_TransferToLaying_GetAll), ctrl.GetAll) + route.Post("/",m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.CreateOne) + route.Get("/:id",m.RequirePermissions(m.P_TransferToLaying_GetOne), ctrl.GetOne) + route.Patch("/:id",m.RequirePermissions(m.P_TransferToLaying_UpdateOne), ctrl.UpdateOne) + route.Delete("/:id",m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne) + route.Post("/approvals",m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval) + route.Get("/project-flocks/:project_flock_id/available-qty",m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang) } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 2aa7129c..bf2c2ae3 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -10,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" ProjectFlockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" @@ -154,6 +155,11 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + if _, err := s.ProjectFlockRepo.GetByID(c.Context(), req.SourceProjectFlockId, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Source Project Flock not found") @@ -259,7 +265,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) ToProjectFlockId: req.TargetProjectFlockId, TransferDate: transferDate, PendingUsageQty: &totalSourceQty, - CreatedBy: 1, //todo : harus diambil dari auth + CreatedBy: actorID, } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -592,7 +598,11 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return nil, err } - actorID := uint(1) // TODO: change from auth context + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + var action entity.ApprovalAction switch strings.ToUpper(strings.TrimSpace(req.Action)) { case string(entity.ApprovalActionRejected): @@ -613,7 +623,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( step = utils.TransferToLayingStepDisetujui } - err := s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) @@ -768,7 +778,7 @@ func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, ProductId: productID, WarehouseId: warehouseID, Quantity: quantity, - CreatedBy: actorID, + // CreatedBy: actorID, } if err := productWarehouseRepoTx.CreateOne(ctx, newWarehouse, nil); err != nil { diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go new file mode 100644 index 00000000..e18e7dce --- /dev/null +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -0,0 +1,301 @@ +package controller + +import ( + "math" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type UniformityController struct { + UniformityService service.UniformityService +} + +func NewUniformityController(uniformityService service.UniformityService) *UniformityController { + return &UniformityController{ + UniformityService: uniformityService, + } +} + +func (u *UniformityController) GetAll(c *fiber.Ctx) error { + query, err := validation.ParseQuery(c) + if err != nil { + return err + } + + result, totalResults, err := u.UniformityService.GetAll(c, query) + if err != nil { + return err + } + standards, err := u.UniformityService.MapStandards(c, result) + if err != nil { + return err + } + documents, err := u.UniformityService.MapDocuments(c, result) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all production uniformities successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + Filters: fiber.Map{ + "location_id": "", + "project_flock_id": "", + "status": "Pengajuan", + }, + }, + Data: dto.ToUniformityListDTOsWithStandard(result, standards, documents), + }) +} + +func (u *UniformityController) GetOne(c *fiber.Ctx) error { + id, err := validation.ParseIDParam(c, "id") + if err != nil { + return err + } + + result, err := u.UniformityService.GetOne(c, id) + if err != nil { + return err + } + + withDetails := c.QueryBool("with_details", false) + calculation := service.UniformityCalculation{} + var document *entity.Document + var documentURL string + var meanWeight float64 + if result.MeanUp > 0 { + meanWeight = math.Round(result.MeanUp / 1.10) + } + if withDetails { + var err error + calculation, document, documentURL, err = u.UniformityService.CalculateUniformityFromDocument(c, id) + if err != nil { + return err + } + } else { + calculation = service.UniformityCalculation{ + ChickQtyOfWeight: result.ChickQtyOfWeight, + MeanWeight: meanWeight, + MeanDown: result.MeanDown, + MeanUp: result.MeanUp, + UniformQty: result.UniformQty, + OutsideQty: result.NotUniformQty, + Uniformity: result.Uniformity, + Cv: result.Cv, + } + document, documentURL, err = u.UniformityService.GetDocumentInfo(c, id) + if err != nil { + return err + } + } + + standard, err := u.UniformityService.GetStandard(c, result) + if err != nil { + return err + } + var standardDTO *dto.UniformityStandardDTO + if standard != nil { + standardDTO = &dto.UniformityStandardDTO{ + MeanWeight: standard.MeanWeight, + Uniformity: standard.Uniformity, + } + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get production uniformity successfully", + Data: dto.ToUniformityDetailDTO(*result, calculation, document, documentURL, standardDTO), + }) +} + +func (u *UniformityController) CreateOne(c *fiber.Ctx) error { + req, file, err := validation.ParseCreate(c) + if err != nil { + return err + } + + rows, err := u.UniformityService.ParseBodyWeightExcel(c, file) + if err != nil { + return err + } + + calculation, err := u.UniformityService.ComputeUniformity(rows) + if err != nil { + return err + } + + result, err := u.UniformityService.CreateOne(c, req, file, rows) + if err != nil { + return err + } + + document := dto.NewDocumentForResponse(file.Filename) + standard, err := u.UniformityService.GetStandard(c, result) + if err != nil { + return err + } + var standardDTO *dto.UniformityStandardDTO + if standard != nil { + standardDTO = &dto.UniformityStandardDTO{ + MeanWeight: standard.MeanWeight, + Uniformity: standard.Uniformity, + } + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create uniformity successfully", + Data: dto.ToUniformityDetailDTO(*result, calculation, document, "", standardDTO), + }) +} + +func (u *UniformityController) UploadBodyWeightExcel(c *fiber.Ctx) error { + files, err := validation.ParseUploadFiles(c) + if err != nil { + return err + } + + rows, err := u.UniformityService.ParseBodyWeightExcel(c, files[0]) + if err != nil { + return err + } + + calculation, err := u.UniformityService.ComputeUniformity(rows) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Uniformity verified successfully", + Data: dto.ToUniformityVerificationDTO(calculation), + }) +} + +func (u *UniformityController) UpdateOne(c *fiber.Ctx) error { + id, err := validation.ParseIDParam(c, "id") + if err != nil { + return err + } + + req, file, err := validation.ParseUpdate(c) + if err != nil { + return err + } + + var rows []service.BodyWeightExcelRow + if file != nil { + parsed, err := u.UniformityService.ParseBodyWeightExcel(c, file) + if err != nil { + return err + } + rows = parsed + } + + result, err := u.UniformityService.UpdateOne(c, req, id, file, rows) + if err != nil { + return err + } + + standard, err := u.UniformityService.GetStandard(c, result) + if err != nil { + return err + } + var standardDTO *dto.UniformityStandardDTO + if standard != nil { + standardDTO = &dto.UniformityStandardDTO{ + MeanWeight: standard.MeanWeight, + Uniformity: standard.Uniformity, + } + } + + calculation := service.UniformityCalculation{ + ChickQtyOfWeight: result.ChickQtyOfWeight, + MeanWeight: math.Round(result.MeanUp / 1.10), + MeanDown: result.MeanDown, + MeanUp: result.MeanUp, + UniformQty: result.UniformQty, + OutsideQty: result.NotUniformQty, + Uniformity: result.Uniformity, + Cv: result.Cv, + } + var document *entity.Document + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update uniformity successfully", + Data: dto.ToUniformityDetailDTO(*result, calculation, document, "", standardDTO), + }) +} + +func (u *UniformityController) DeleteOne(c *fiber.Ctx) error { + id, err := validation.ParseIDParam(c, "id") + if err != nil { + return err + } + + if err := u.UniformityService.DeleteOne(c, id); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete uniformity successfully", + }) +} + +func (u *UniformityController) Approve(c *fiber.Ctx) error { + req, err := validation.ParseApprove(c) + if err != nil { + return err + } + + results, err := u.UniformityService.Approval(c, req) + if err != nil { + return err + } + + var ( + data interface{} + message = "Submit uniformity approvals successfully" + ) + + if len(results) == 1 { + message = "Submit uniformity approval successfully" + data = dto.ToUniformityListDTOs(results)[0] + } else { + data = dto.ToUniformityListDTOs(results) + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: message, + Data: data, + }) +} diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go new file mode 100644 index 00000000..af401a54 --- /dev/null +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -0,0 +1,258 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" +) + +type UniformitySamplingDTO struct { + ChickQtyOfWeight float64 `json:"chick_qty_of_weight"` + MeanWeight float64 `json:"mean_weight"` + MeanDown float64 `json:"mean_down"` + MeanUp float64 `json:"mean_up"` +} + +type UniformityResultDTO struct { + UniformQty float64 `json:"uniform_qty"` + OutsideQty float64 `json:"outside_qty"` + Uniformity float64 `json:"uniformity"` + Cv float64 `json:"cv"` +} + +type UniformityStandardDTO struct { + MeanWeight *float64 `json:"mean_weight"` + Uniformity *float64 `json:"uniformity"` +} + +type UniformityDetailItemDTO struct { + Id int `json:"id"` + Weight float64 `json:"weight"` + Range string `json:"range"` +} + +type UniformityVerificationDTO struct { + Sampling UniformitySamplingDTO `json:"sampling"` + Result UniformityResultDTO `json:"result"` + UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` +} + +type UniformityInfoDTO struct { + Tanggal string `json:"tanggal"` + LokasiFarm string `json:"lokasi_farm"` + ProjectFlock string `json:"project_flock"` + Kandang string `json:"kandang"` + FileName string `json:"file_name"` + FileURL string `json:"file_url"` +} + +type UniformityDetailDTO struct { + Id uint `json:"id"` + InfoUmum UniformityInfoDTO `json:"info_umum"` + Sampling UniformitySamplingDTO `json:"sampling"` + Result UniformityResultDTO `json:"result"` + Standard *UniformityStandardDTO `json:"standard"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` + UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` +} + +type UniformityListDTO struct { + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + LocationName string `json:"location_name"` + FlockName string `json:"flock_name"` + KandangName string `json:"kandang_name"` + FileName string `json:"file_name"` + AppliedAt *time.Time `json:"applied_at"` + Week int `json:"week"` + Status string `json:"status"` + Uniformity float64 `json:"uniformity"` + Cv float64 `json:"cv"` + ChickQtyOfWeight float64 `json:"chick_qty_of_weight"` + UniformQty float64 `json:"uniform_qty"` + MeanUp float64 `json:"mean_up"` + MeanDown float64 `json:"mean_down"` + StandardMeanWeight *float64 `json:"standard_mean_weight"` + StandardUniformity *float64 `json:"standard_uniformity"` + CreatedBy uint `json:"created_by"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` +} + +func NewDocumentForResponse(name string) *entity.Document { + if name == "" { + return nil + } + return &entity.Document{Name: name} +} + +func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityVerificationDTO { + return UniformityVerificationDTO{ + Sampling: toUniformitySamplingDTO(calc), + Result: toUniformityResultDTO(calc), + UniformityDetails: toUniformityDetailItemsDTO(calc), + } +} + +func ToUniformityDetailDTO( + entityData entity.ProjectFlockKandangUniformity, + calc service.UniformityCalculation, + document *entity.Document, + documentURL string, + standard *UniformityStandardDTO, +) UniformityDetailDTO { + info := UniformityInfoDTO{ + Tanggal: formatUniformityDate(entityData.UniformDate), + LokasiFarm: resolveLocationName(entityData.ProjectFlockKandang), + ProjectFlock: resolveProjectFlockName(entityData.ProjectFlockKandang), + Kandang: resolveKandangName(entityData.ProjectFlockKandang), + FileName: "", + FileURL: "", + } + if document != nil { + info.FileName = document.Name + } + if documentURL != "" { + info.FileURL = documentURL + } + + var latestApproval *approvalDTO.ApprovalRelationDTO + if entityData.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*entityData.LatestApproval) + latestApproval = &mapped + } + + return UniformityDetailDTO{ + Id: entityData.Id, + InfoUmum: info, + Sampling: toUniformitySamplingDTO(calc), + Result: toUniformityResultDTO(calc), + Standard: standard, + LatestApproval: latestApproval, + UniformityDetails: toUniformityDetailItemsDTO(calc), + } +} + +func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []UniformityListDTO { + result := make([]UniformityListDTO, len(items)) + for i, item := range items { + var latestApproval *approvalDTO.ApprovalRelationDTO + status := "Pengajuan" + if item.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*item.LatestApproval) + latestApproval = &mapped + if mapped.StepName != "" { + status = mapped.StepName + } + } + + result[i] = UniformityListDTO{ + Id: item.Id, + ProjectFlockKandangId: item.ProjectFlockKandangId, + LocationName: resolveLocationName(item.ProjectFlockKandang), + FlockName: resolveProjectFlockName(item.ProjectFlockKandang), + KandangName: resolveKandangName(item.ProjectFlockKandang), + AppliedAt: item.UniformDate, + Week: item.Week, + Status: status, + Uniformity: item.Uniformity, + Cv: item.Cv, + ChickQtyOfWeight: item.ChickQtyOfWeight, + UniformQty: item.UniformQty, + MeanUp: item.MeanUp, + MeanDown: item.MeanDown, + CreatedBy: item.CreatedBy, + LatestApproval: latestApproval, + } + } + return result +} + +func ToUniformityListDTOsWithStandard( + items []entity.ProjectFlockKandangUniformity, + standards map[uint]service.UniformityStandard, + documentNames map[uint]string, +) []UniformityListDTO { + result := ToUniformityListDTOs(items) + if len(result) == 0 || len(standards) == 0 { + for i := range result { + if name, ok := documentNames[result[i].Id]; ok { + result[i].FileName = name + } + } + return result + } + + for i := range result { + if std, ok := standards[result[i].Id]; ok { + result[i].StandardMeanWeight = std.MeanWeight + result[i].StandardUniformity = std.Uniformity + } + if name, ok := documentNames[result[i].Id]; ok { + result[i].FileName = name + } + } + return result +} + +func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySamplingDTO { + return UniformitySamplingDTO{ + ChickQtyOfWeight: calc.ChickQtyOfWeight, + MeanWeight: calc.MeanWeight, + MeanDown: calc.MeanDown, + MeanUp: calc.MeanUp, + } +} + +func toUniformityResultDTO(calc service.UniformityCalculation) UniformityResultDTO { + return UniformityResultDTO{ + UniformQty: calc.UniformQty, + OutsideQty: calc.OutsideQty, + Uniformity: calc.Uniformity, + Cv: calc.Cv, + } +} + +func toUniformityDetailItemsDTO(calc service.UniformityCalculation) []UniformityDetailItemDTO { + result := make([]UniformityDetailItemDTO, len(calc.Details)) + for i, item := range calc.Details { + result[i] = UniformityDetailItemDTO{ + Id: item.Id, + Weight: item.Weight, + Range: item.Range, + } + } + return result +} + +func resolveLocationName(pfk entity.ProjectFlockKandang) string { + if pfk.Kandang.Id != 0 && pfk.Kandang.Location.Id != 0 { + return pfk.Kandang.Location.Name + } + if pfk.ProjectFlock.Id != 0 && pfk.ProjectFlock.Location.Id != 0 { + return pfk.ProjectFlock.Location.Name + } + return "" +} + +func resolveProjectFlockName(pfk entity.ProjectFlockKandang) string { + if pfk.ProjectFlock.Id != 0 { + return pfk.ProjectFlock.FlockName + } + return "" +} + +func resolveKandangName(pfk entity.ProjectFlockKandang) string { + if pfk.Kandang.Id != 0 { + return pfk.Kandang.Name + } + return "" +} + +func formatUniformityDate(date *time.Time) string { + if date == nil || date.IsZero() { + return "" + } + return date.Format("2006-01-02") +} diff --git a/internal/modules/production/uniformities/module.go b/internal/modules/production/uniformities/module.go new file mode 100644 index 00000000..b3162940 --- /dev/null +++ b/internal/modules/production/uniformities/module.go @@ -0,0 +1,57 @@ +package uniformitys + +import ( + "context" + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" + sUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type UniformityModule struct{} + +func (UniformityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + uniformityRepo := rUniformity.NewUniformityRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + userRepo := rUser.NewUserRepository(db) + + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } + + approvalSvc := commonSvc.NewApprovalService(approvalRepo) + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowUniformity, utils.UniformityApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register uniformity approval workflow: %v", err)) + } + + uniformityService := sUniformity.NewUniformityService( + uniformityRepo, + documentSvc, + approvalRepo, + approvalSvc, + projectFlockKandangRepo, + productionStandardRepo, + standardGrowthDetailRepo, + validate, + ) + userService := sUser.NewUserService(userRepo, validate) + + UniformityRoutes(router, userService, uniformityService) +} diff --git a/internal/modules/production/uniformities/repositories/uniformity.repository.go b/internal/modules/production/uniformities/repositories/uniformity.repository.go new file mode 100644 index 00000000..241dea49 --- /dev/null +++ b/internal/modules/production/uniformities/repositories/uniformity.repository.go @@ -0,0 +1,34 @@ +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 UniformityRepository interface { + repository.BaseRepository[entity.ProjectFlockKandangUniformity] + DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error +} + +type UniformityRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectFlockKandangUniformity] +} + +func NewUniformityRepository(db *gorm.DB) UniformityRepository { + return &UniformityRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockKandangUniformity](db), + } +} + +func (r *UniformityRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error { + if len(projectFlockKandangIDs) == 0 { + return nil + } + return r.DB().WithContext(ctx). + Unscoped(). + Where("project_flock_kandang_id IN ?", projectFlockKandangIDs). + Delete(&entity.ProjectFlockKandangUniformity{}).Error +} diff --git a/internal/modules/production/uniformities/route.go b/internal/modules/production/uniformities/route.go new file mode 100644 index 00000000..ff2b1805 --- /dev/null +++ b/internal/modules/production/uniformities/route.go @@ -0,0 +1,25 @@ +package uniformitys + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/controllers" + uniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func UniformityRoutes(v1 fiber.Router, u user.UserService, s uniformity.UniformityService) { + ctrl := controller.NewUniformityController(s) + + route := v1.Group("/uniformities") + route.Use(m.Auth(u)) + + route.Get("/", m.RequirePermissions(m.P_Uniformities_GetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_Uniformities_CreateOne), ctrl.CreateOne) + route.Post("/verify", m.RequirePermissions(m.P_Uniformities_Verify), ctrl.UploadBodyWeightExcel) + route.Post("/approvals", m.RequirePermissions(m.P_Uniformities_Approval), ctrl.Approve) + route.Get("/:id", m.RequirePermissions(m.P_Uniformities_GetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_Uniformities_UpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_Uniformities_DeleteOne), ctrl.DeleteOne) +} diff --git a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go new file mode 100644 index 00000000..4e87f0cc --- /dev/null +++ b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go @@ -0,0 +1,200 @@ +package service + +import ( + "io" + "mime/multipart" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +type BodyWeightExcelRow struct { + No int `json:"no"` + Weight float64 `json:"weight"` + Range string `json:"range,omitempty"` +} + +func (s uniformityService) ParseBodyWeightExcel(_ *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) { + if file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "file is required") + } + + reader, err := file.Open() + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to open file") + } + defer reader.Close() + + rows, err := parseBodyWeightExcelReader(reader) + if err != nil { + return nil, err + } + + return rows, nil +} + +func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) { + xlsx, err := excelize.OpenReader(reader) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read excel file") + } + defer func() { + _ = xlsx.Close() + }() + + sheets := xlsx.GetSheetList() + if len(sheets) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file") + } + + sheetName := sheets[0] + if len(sheets) > 1 { + sheetName = sheets[1] + } + + rows, err := xlsx.GetRows(sheetName, excelize.Options{RawCellValue: true}) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows") + } + + return parseBodyWeightRows(rows) +} + +func parseBodyWeightRows(rows [][]string) ([]BodyWeightExcelRow, error) { + headerRowIdx, noCol, bwCol, rangeCol := findBodyWeightHeader(rows) + if headerRowIdx < 0 || bwCol < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "header BW not found") + } + + result := make([]BodyWeightExcelRow, 0) + lastNo := 0 + + for i := headerRowIdx + 1; i < len(rows); i++ { + row := rows[i] + weightStr := cellAt(row, bwCol) + weightVal, ok := parseNumber(weightStr) + if !ok { + continue + } + + noVal := 0 + if noCol >= 0 { + if parsed, ok := parseNumber(cellAt(row, noCol)); ok { + noVal = int(parsed) + } + } + if noVal <= 0 { + noVal = lastNo + 1 + } + if noVal > lastNo { + lastNo = noVal + } + + rangeVal := "" + if rangeCol >= 0 { + rangeVal = strings.TrimSpace(cellAt(row, rangeCol)) + } + + rowPayload := BodyWeightExcelRow{ + No: noVal, + Weight: weightVal, + Range: rangeVal, + } + if rowPayload.No <= 0 || rowPayload.Weight <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid body weight row data") + } + + result = append(result, rowPayload) + } + + if len(result) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + return result, nil +} + +func findBodyWeightHeader(rows [][]string) (rowIdx int, noCol int, bwCol int, rangeCol int) { + rowIdx = -1 + noCol = -1 + bwCol = -1 + rangeCol = -1 + + for i, row := range rows { + tempNo := -1 + tempBW := -1 + tempRange := -1 + for j, cell := range row { + label := normalizeHeader(cell) + switch label { + case "no": + tempNo = j + case "bw": + tempBW = j + case "outsiderange": + tempRange = j + default: + if strings.HasPrefix(label, "bw") { + tempBW = j + } else if strings.HasPrefix(label, "no") { + tempNo = j + } else if strings.Contains(label, "range") { + tempRange = j + } + } + } + if tempBW >= 0 { + rowIdx = i + bwCol = tempBW + noCol = tempNo + rangeCol = tempRange + break + } + } + + return rowIdx, noCol, bwCol, rangeCol +} + +func cellAt(row []string, idx int) string { + if idx < 0 || idx >= len(row) { + return "" + } + return strings.TrimSpace(row[idx]) +} + +func normalizeHeader(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + var b strings.Builder + for _, r := range trimmed { + if r >= 'a' && r <= 'z' { + b.WriteRune(r) + } + } + return b.String() +} + +func parseNumber(value string) (float64, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, false + } + + if strings.Contains(trimmed, ",") { + if strings.Contains(trimmed, ".") { + trimmed = strings.ReplaceAll(trimmed, ",", "") + } else { + trimmed = strings.ReplaceAll(trimmed, ",", ".") + } + } + + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, false + } + return parsed, true +} diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go new file mode 100644 index 00000000..747eb965 --- /dev/null +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -0,0 +1,1006 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "mime/multipart" + "net/http" + "strings" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type UniformityService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) + GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) + GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) + MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) + MapDocuments(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) + ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) + ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) + GetDocumentInfo(ctx *fiber.Ctx, uniformityID uint) (*entity.Document, string, error) + CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) +} + +type uniformityService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.UniformityRepository + DocumentSvc commonSvc.DocumentService + ApprovalRepo commonRepo.ApprovalRepository + ApprovalSvc commonSvc.ApprovalService + ProjectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository + ProductionStandardRepo rProductionStandard.ProductionStandardRepository + StandardGrowthDetailRepo rProductionStandard.StandardGrowthDetailRepository +} + +func NewUniformityService( + repo repository.UniformityRepository, + documentSvc commonSvc.DocumentService, + approvalRepo commonRepo.ApprovalRepository, + approvalSvc commonSvc.ApprovalService, + projectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository, + productionStandardRepo rProductionStandard.ProductionStandardRepository, + standardGrowthDetailRepo rProductionStandard.StandardGrowthDetailRepository, + validate *validator.Validate, +) UniformityService { + return &uniformityService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + DocumentSvc: documentSvc, + ApprovalRepo: approvalRepo, + ApprovalSvc: approvalSvc, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ProductionStandardRepo: productionStandardRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + } +} + +func (s uniformityService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("ProjectFlockKandang.ProjectFlock.Location"). + Preload("ProjectFlockKandang.Kandang.Location") +} + +func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + uniformitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.ProjectFlockKandangId != 0 { + db = db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) + } + if params.Week != 0 { + db = db.Where("week = ?", params.Week) + } + return db.Order("uniform_date DESC").Order("id DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get uniformitys: %+v", err) + return nil, 0, err + } + if err := s.attachLatestApprovals(c.Context(), uniformitys); err != nil { + return nil, 0, err + } + return uniformitys, total, nil +} + +func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { + uniformity, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + if err != nil { + s.Log.Errorf("Failed get uniformity by id: %+v", err) + return nil, err + } + if err := s.attachLatestApproval(c.Context(), uniformity); err != nil { + return nil, err + } + return uniformity, nil +} + +func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { + return s.GetOne(c, id) +} + +func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { + if uniformity == nil { + return nil, nil + } + return s.resolveUniformityStandard(c.Context(), *uniformity) +} + +func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) { + if len(items) == 0 { + return nil, nil + } + if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil { + return nil, nil + } + + categoryStandard := make(map[string]*entity.ProductionStandard) + detailCache := make(map[uint]map[int]entity.StandardGrowthDetail) + result := make(map[uint]UniformityStandard, len(items)) + + for _, item := range items { + if item.Id == 0 { + continue + } + standard, err := s.resolveCategoryStandard(c.Context(), item.ProjectFlockKandang.ProjectFlock.Category, categoryStandard) + if err != nil { + return nil, err + } + if standard == nil { + continue + } + + weekMap, ok := detailCache[standard.Id] + if !ok { + details, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(c.Context(), standard.Id) + if err != nil { + return nil, err + } + weekMap = make(map[int]entity.StandardGrowthDetail, len(details)) + for _, detail := range details { + weekMap[detail.Week] = detail + } + detailCache[standard.Id] = weekMap + } + + detail, ok := weekMap[item.Week] + if !ok { + continue + } + standardDTO := UniformityStandard{ + MeanWeight: cloneFloat64(detail.TargetMeanBw), + Uniformity: float64Ptr(detail.MinUniformity), + } + result[item.Id] = standardDTO + } + + return result, nil +} + +func (s uniformityService) MapDocuments(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]string, error) { + if s.DocumentSvc == nil || len(items) == 0 { + return map[uint]string{}, nil + } + + result := make(map[uint]string, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(item.Id)) + if err != nil { + return nil, err + } + if len(documents) == 0 { + continue + } + result[item.Id] = documents[len(documents)-1].Name + } + + return result, nil +} + +func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + if s.ProjectFlockKandangRepo == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available") + } + + if file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") + } + + uniformDate, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "date must be in YYYY-MM-DD format") + } + + if err := commonSvc.EnsureRelations( + c.Context(), + commonSvc.RelationCheck{Name: "Project Flock Kandang", ID: &req.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists}, + ); err != nil { + return nil, err + } + if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week, &uniformDate); err != nil { + return nil, err + } + + if len(rows) == 0 { + parsedRows, err := s.ParseBodyWeightExcel(c, file) + if err != nil { + return nil, err + } + rows = parsedRows + } + + calculation, err := s.ComputeUniformity(rows) + if err != nil { + return nil, err + } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + createBody := &entity.ProjectFlockKandangUniformity{ + Uniformity: calculation.Uniformity, + Week: req.Week, + Cv: calculation.Cv, + ChickQtyOfWeight: calculation.ChickQtyOfWeight, + MeanUp: calculation.MeanUp, + MeanDown: calculation.MeanDown, + ProjectFlockKandangId: req.ProjectFlockKandangId, + UniformQty: calculation.UniformQty, + NotUniformQty: calculation.OutsideQty, + UniformDate: &uniformDate, + CreatedBy: actorID, + } + + if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + if err := s.createUniformityApproval( + c.Context(), + tx, + createBody.Id, + utils.UniformityStepPengajuan, + entity.ApprovalActionCreated, + actorID, + nil, + ); err != nil { + return err + } + return nil + }); err != nil { + s.Log.Errorf("Failed to create uniformity: %+v", err) + return nil, err + } + + if s.DocumentSvc != nil { + actorIDCopy := actorID + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: "UNIFORMITY", + DocumentableID: uint64(createBody.Id), + CreatedBy: &actorIDCopy, + Files: []commonSvc.DocumentFile{ + { + File: file, + Type: "UNIFORMITY", + }, + }, + }) + if err != nil { + s.rollbackUniformityCreate(c.Context(), createBody.Id) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document") + } + } + + return s.GetOne(c, createBody.Id) +} + +func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + var uniformDate *time.Time + + if req.Date != nil { + parsed, err := time.Parse("2006-01-02", *req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "date must be in YYYY-MM-DD format") + } + updateBody["uniform_date"] = parsed + uniformDate = &parsed + } + if req.ProjectFlockKandangId != nil { + if s.ProjectFlockKandangRepo == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available") + } + if err := commonSvc.EnsureRelations( + c.Context(), + commonSvc.RelationCheck{Name: "Project Flock Kandang", ID: req.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists}, + ); err != nil { + return nil, err + } + updateBody["project_flock_kandang_id"] = *req.ProjectFlockKandangId + } + if req.Week != nil { + updateBody["week"] = *req.Week + } + + if req.Date != nil || req.ProjectFlockKandangId != nil || req.Week != nil { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + return nil, err + } + targetDate := uniformDate + if targetDate == nil { + targetDate = current.UniformDate + } + targetWeek := current.Week + if req.Week != nil { + targetWeek = *req.Week + } + targetPFKID := current.ProjectFlockKandangId + if req.ProjectFlockKandangId != nil { + targetPFKID = *req.ProjectFlockKandangId + } + if targetDate != nil { + if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek, targetDate); err != nil { + return nil, err + } + } + } + + if file != nil { + if s.DocumentSvc == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } + + if len(rows) == 0 { + parsedRows, err := s.ParseBodyWeightExcel(c, file) + if err != nil { + return nil, err + } + rows = parsedRows + } + + calculation, err := s.ComputeUniformity(rows) + if err != nil { + return nil, err + } + + updateBody["uniformity"] = calculation.Uniformity + updateBody["cv"] = calculation.Cv + updateBody["chick_qty_of_weight"] = calculation.ChickQtyOfWeight + updateBody["mean_up"] = calculation.MeanUp + updateBody["mean_down"] = calculation.MeanDown + updateBody["uniform_qty"] = calculation.UniformQty + updateBody["not_uniform_qty"] = calculation.OutsideQty + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if file == nil { + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + s.Log.Errorf("Failed to update uniformity: %+v", err) + return nil, err + } + + return s.GetOne(c, id) + } + + existingDocs, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(id)) + if err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + actorIDCopy := actorID + uploadResults, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: "UNIFORMITY", + DocumentableID: uint64(id), + CreatedBy: &actorIDCopy, + Files: []commonSvc.DocumentFile{ + { + File: file, + Type: "UNIFORMITY", + }, + }, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document") + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if len(uploadResults) > 0 { + ids := make([]uint, 0, len(uploadResults)) + for _, result := range uploadResults { + if result.Document.Id != 0 { + ids = append(ids, result.Document.Id) + } + } + if len(ids) > 0 { + _ = s.DocumentSvc.DeleteDocuments(c.Context(), ids, true) + } + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + s.Log.Errorf("Failed to update uniformity: %+v", err) + return nil, err + } + + if len(existingDocs) > 0 { + oldIDs := make([]uint, 0, len(existingDocs)) + for _, doc := range existingDocs { + if doc.Id != 0 { + oldIDs = append(oldIDs, doc.Id) + } + } + if len(oldIDs) > 0 { + _ = s.DocumentSvc.DeleteDocuments(c.Context(), oldIDs, true) + } + } + + return s.GetOne(c, id) +} + +func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int, uniformDate *time.Time) error { + if projectFlockKandangID == 0 || week == 0 || uniformDate == nil || uniformDate.IsZero() { + return nil + } + + query := s.Repository.DB().WithContext(ctx). + Model(&entity.ProjectFlockKandangUniformity{}). + Where("project_flock_kandang_id = ? AND week = ? AND uniform_date = ?", projectFlockKandangID, week, *uniformDate) + if id != 0 { + query = query.Where("id <> ?", id) + } + + var count int64 + if err := query.Count(&count).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity uniqueness") + } + if count > 0 { + return fiber.NewError(fiber.StatusConflict, "Uniformity already exists for the same project flock kandang, week, and date") + } + return nil +} + +func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error { + if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + s.Log.Errorf("Failed to delete uniformity: %+v", err) + return err + } + return nil +} + +func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actionValue := strings.ToUpper(strings.TrimSpace(req.Action)) + var action entity.ApprovalAction + switch actionValue { + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + ids := uniqueUintSlice(req.ApprovableIds) + if len(ids) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + step := utils.UniformityStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.UniformityStepDisetujui + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + ctx := c.Context() + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + + for _, id := range ids { + if _, err := repoTx.GetByID(ctx, id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Uniformity %d not found", id)) + } + return err + } + + if _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowUniformity, + id, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + } + + return nil + }) + if transactionErr != nil { + if fiberErr, ok := transactionErr.(*fiber.Error); ok { + return nil, fiberErr + } + s.Log.Errorf("Failed to record approvals for uniformities %+v: %+v", ids, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to submit uniformity approval") + } + + results := make([]entity.ProjectFlockKandangUniformity, 0, len(ids)) + for _, id := range ids { + loaded, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + results = append(results, *loaded) + } + + return results, nil +} + +type UniformityDetailItem struct { + Id int + Weight float64 + Range string +} + +type UniformityCalculation struct { + ChickQtyOfWeight float64 + MeanWeight float64 + MeanDown float64 + MeanUp float64 + UniformQty float64 + OutsideQty float64 + Uniformity float64 + Cv float64 + Details []UniformityDetailItem +} + +type UniformityStandard struct { + MeanWeight *float64 + Uniformity *float64 +} + +func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { + return computeUniformity(rows) +} + +func (s uniformityService) GetDocumentInfo(c *fiber.Ctx, uniformityID uint) (*entity.Document, string, error) { + return s.fetchUniformityDocument(c.Context(), uniformityID, true) +} + +func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, string, error) { + document, url, err := s.fetchUniformityDocument(c.Context(), uniformityID, false) + if err != nil { + return UniformityCalculation{}, nil, "", err + } + if document == nil || url == "" { + return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") + } + + req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil) + if err != nil { + return UniformityCalculation{}, nil, "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return UniformityCalculation{}, nil, "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return UniformityCalculation{}, nil, "", fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") + } + + rows, err := parseBodyWeightExcelReader(resp.Body) + if err != nil { + return UniformityCalculation{}, nil, "", err + } + + calculation, err := computeUniformity(rows) + if err != nil { + return UniformityCalculation{}, nil, "", err + } + + return calculation, document, url, nil +} + +func (s uniformityService) fetchUniformityDocument(ctx context.Context, uniformityID uint, allowMissing bool) (*entity.Document, string, error) { + if s.DocumentSvc == nil { + return nil, "", fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } + + documents, err := s.DocumentSvc.ListByTarget(ctx, "UNIFORMITY", uint64(uniformityID)) + if err != nil { + return nil, "", err + } + if len(documents) == 0 { + if allowMissing { + return nil, "", nil + } + return nil, "", fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") + } + + document := documents[len(documents)-1] + url, err := s.DocumentSvc.PresignURL(ctx, document, 15*time.Minute) + if err != nil { + return nil, "", err + } + if url == "" { + return nil, "", fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available") + } + + return &document, url, nil +} + +func (s *uniformityService) createUniformityApproval( + ctx context.Context, + db *gorm.DB, + uniformityID uint, + step approvalutils.ApprovalStep, + action entity.ApprovalAction, + actorID uint, + notes *string, +) error { + if uniformityID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Uniformity tidak valid untuk approval") + } + if actorID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Actor Id tidak valid untuk approval") + } + + var svc commonSvc.ApprovalService + if db != nil { + svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + } else if s.ApprovalSvc != nil { + svc = s.ApprovalSvc + } else { + svc = commonSvc.NewApprovalService(s.ApprovalRepo) + } + + _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowUniformity, uniformityID, step, &action, actorID, notes) + return err +} + +func (s *uniformityService) attachLatestApprovals(ctx context.Context, items []entity.ProjectFlockKandangUniformity) error { + if len(items) == 0 || s.ApprovalSvc == nil { + return nil + } + + ids := make([]uint, 0, len(items)) + visited := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + if _, ok := visited[item.Id]; ok { + continue + } + visited[item.Id] = struct{}{} + ids = append(ids, item.Id) + } + + if len(ids) == 0 { + return nil + } + + latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowUniformity, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for uniformities: %+v", err) + return nil + } + + if len(latestMap) == 0 { + return nil + } + + for i := range items { + if items[i].Id == 0 { + continue + } + if approval, ok := latestMap[items[i].Id]; ok { + items[i].LatestApproval = approval + } + } + + return nil +} + +func (s *uniformityService) attachLatestApproval(ctx context.Context, item *entity.ProjectFlockKandangUniformity) error { + if item == nil || item.Id == 0 || s.ApprovalSvc == nil { + return nil + } + + approvals, err := s.ApprovalSvc.ListByTarget(ctx, utils.ApprovalWorkflowUniformity, item.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load approvals for uniformity %d: %+v", item.Id, err) + return nil + } + + if len(approvals) == 0 { + item.LatestApproval = nil + return nil + } + + latest := approvals[len(approvals)-1] + item.LatestApproval = &latest + return nil +} + +func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { + if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil { + return nil, nil + } + + standard, err := s.resolveCategoryStandard(ctx, item.ProjectFlockKandang.ProjectFlock.Category, nil) + if err != nil || standard == nil { + return nil, err + } + + detail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standard.Id, item.Week) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + return &UniformityStandard{ + MeanWeight: cloneFloat64(detail.TargetMeanBw), + Uniformity: float64Ptr(detail.MinUniformity), + }, nil +} + +func (s *uniformityService) resolveCategoryStandard( + ctx context.Context, + category string, + cache map[string]*entity.ProductionStandard, +) (*entity.ProductionStandard, error) { + category = strings.TrimSpace(category) + if category == "" { + return nil, nil + } + if cache != nil { + if cached, ok := cache[category]; ok { + return cached, nil + } + } + + var standard entity.ProductionStandard + err := s.ProductionStandardRepo.DB().WithContext(ctx). + Where("project_category = ?", category). + Where("deleted_at IS NULL"). + Order("created_at DESC"). + First(&standard).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + if cache != nil { + cache[category] = nil + } + return nil, nil + } + return nil, err + } + + standardCopy := standard + if cache != nil { + cache[category] = &standardCopy + } + return &standardCopy, nil +} + +func cloneFloat64(value *float64) *float64 { + if value == nil { + return nil + } + copy := *value + return © +} + +func float64Ptr(value float64) *float64 { + copy := value + return © +} + +func (s *uniformityService) rollbackUniformityCreate(ctx context.Context, uniformityID uint) { + if uniformityID == 0 { + return + } + + if s.ApprovalRepo != nil { + if err := s.ApprovalRepo.DeleteByTarget(ctx, utils.ApprovalWorkflowUniformity.String(), uniformityID); err != nil { + s.Log.WithError(err).Warnf("Failed to rollback uniformity approvals for %d", uniformityID) + } + } + + if err := s.Repository.DeleteOne(ctx, uniformityID); err != nil { + s.Log.WithError(err).Warnf("Failed to rollback uniformity %d", uniformityID) + } +} + +func uniqueUintSlice(values []uint) []uint { + if len(values) == 0 { + return nil + } + + seen := make(map[uint]struct{}, len(values)) + result := make([]uint, 0, len(values)) + for _, v := range values { + if v == 0 { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + return result +} + +func computeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { + weights := make([]float64, 0, len(rows)) + details := make([]UniformityDetailItem, 0, len(rows)) + hasRangeLabels := false + for idx, row := range rows { + if row.Weight <= 0 { + continue + } + id := row.No + if id <= 0 { + id = idx + 1 + } + weights = append(weights, row.Weight) + rangeLabel := strings.TrimSpace(row.Range) + if rangeLabel != "" { + upper := strings.ToUpper(rangeLabel) + if upper == "HIGH" || upper == "LOW" { + hasRangeLabels = true + } + rangeLabel = upper + } + details = append(details, UniformityDetailItem{ + Id: id, + Weight: row.Weight, + Range: rangeLabel, + }) + } + + total := float64(len(weights)) + if total == 0 { + return UniformityCalculation{}, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + var sum float64 + for _, w := range weights { + sum += w + } + mean := sum / total + meanUpThreshold := roundToPrecision(mean*1.10, 3) + meanDownThreshold := roundToPrecision(mean*0.90, 3) + + var uniformCount float64 + for i := range details { + if hasRangeLabels { + if details[i].Range == "HIGH" || details[i].Range == "LOW" { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + continue + } + + w := details[i].Weight + if w > meanUpThreshold || w < meanDownThreshold { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + } + outsideCount := total - uniformCount + + var cv float64 + if mean > 0 && total > 1 { + stddevWeights := weights + stddevCount := float64(len(stddevWeights)) + if stddevCount > 1 { + var stddevSum float64 + for _, w := range stddevWeights { + stddevSum += w + } + stddevMean := stddevSum / stddevCount + var sumSquares float64 + for _, w := range stddevWeights { + diff := w - stddevMean + sumSquares += diff * diff + } + stddev := math.Sqrt(sumSquares / (stddevCount - 1)) + cv = (stddev / mean) * 100 + } + } + + uniformity := (uniformCount / total) * 100 + + return UniformityCalculation{ + ChickQtyOfWeight: total, + MeanWeight: roundToPrecision(mean, 0), + MeanDown: roundToPrecision(mean*0.90, 0), + MeanUp: roundToPrecision(mean*1.10, 0), + UniformQty: uniformCount, + OutsideQty: outsideCount, + Uniformity: roundToPrecision(uniformity, 0), + Cv: roundToPrecision(cv, 1), + Details: details, + }, nil +} + +func roundToPrecision(value float64, precision int) float64 { + if precision < 0 { + return value + } + scale := math.Pow10(precision) + scaled := value * scale + fraction := scaled - math.Floor(scaled) + if fraction >= 0.5 { + return math.Ceil(scaled) / scale + } + return math.Floor(scaled) / scale +} diff --git a/internal/modules/production/uniformities/validations/uniformity.validation.go b/internal/modules/production/uniformities/validations/uniformity.validation.go new file mode 100644 index 00000000..b2aeaf26 --- /dev/null +++ b/internal/modules/production/uniformities/validations/uniformity.validation.go @@ -0,0 +1,164 @@ +package validation + +import ( + "mime/multipart" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type Create struct { + Date string `form:"date" validate:"required"` + ProjectFlockKandangId uint `form:"project_flock_kandang_id" validate:"required,number,min=1"` + Week int `form:"week" validate:"required,min=1"` +} + +type Update struct { + Date *string `json:"date,omitempty" form:"date" validate:"omitempty"` + ProjectFlockKandangId *uint `json:"project_flock_kandang_id,omitempty" form:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Week *int `json:"week,omitempty" form:"week" validate:"omitempty,min=1"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Week int `query:"week" validate:"omitempty,min=1"` +} + +type UploadExcelRequest struct { + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` +} + +type Approve struct { + Action string `json:"action" validate:"required_strict"` + ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +func ParseIDParam(c *fiber.Ctx, name string) (uint, error) { + raw := strings.TrimSpace(c.Params(name)) + if raw == "" { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + return uint(id), nil +} + +func ParseQuery(c *fiber.Ctx) (*Query, error) { + query := &Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), + Week: c.QueryInt("week", 0), + } + + if query.Page < 1 || query.Limit < 1 { + return nil, fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + return query, nil +} + +func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) { + date := strings.TrimSpace(c.FormValue("date")) + if date == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "date is required") + } + + projectFlockKandangIDStr := strings.TrimSpace(c.FormValue("project_flock_kandang_id")) + if projectFlockKandangIDStr == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + projectFlockKandangID, err := strconv.Atoi(projectFlockKandangIDStr) + if err != nil || projectFlockKandangID <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + weekStr := strings.TrimSpace(c.FormValue("week")) + if weekStr == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required") + } + + week, err := strconv.Atoi(weekStr) + if err != nil || week <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required") + } + + file, err := c.FormFile("document") + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "document is required") + } + + return &Create{ + Date: date, + ProjectFlockKandangId: uint(projectFlockKandangID), + Week: week, + }, file, nil +} + +func ParseUpdate(c *fiber.Ctx) (*Update, *multipart.FileHeader, error) { + contentType := strings.ToLower(c.Get("Content-Type")) + if strings.Contains(contentType, "multipart/form-data") { + req := &Update{} + + date := strings.TrimSpace(c.FormValue("date")) + if date != "" { + req.Date = &date + } + + projectFlockKandangIDStr := strings.TrimSpace(c.FormValue("project_flock_kandang_id")) + if projectFlockKandangIDStr != "" { + projectFlockKandangID, err := strconv.Atoi(projectFlockKandangIDStr) + if err != nil || projectFlockKandangID <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is invalid") + } + idCopy := uint(projectFlockKandangID) + req.ProjectFlockKandangId = &idCopy + } + + weekStr := strings.TrimSpace(c.FormValue("week")) + if weekStr != "" { + week, err := strconv.Atoi(weekStr) + if err != nil || week <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is invalid") + } + req.Week = &week + } + + file, err := c.FormFile("document") + if err != nil { + file = nil + } + + return req, file, nil + } + + req := new(Update) + if err := c.BodyParser(req); err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + return req, nil, nil +} + +func ParseUploadFiles(c *fiber.Ctx) ([]*multipart.FileHeader, error) { + file, err := c.FormFile("document") + if err != nil || file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") + } + + return []*multipart.FileHeader{file}, nil +} + +func ParseApprove(c *fiber.Ctx) (*Approve, error) { + req := new(Approve) + if err := c.BodyParser(req); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + return req, nil +} diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index d10f42af..977b4ac1 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/json" "fmt" "math" "strconv" @@ -23,21 +24,19 @@ func NewPurchaseController(s service.PurchaseService) *PurchaseController { } func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { - query := &validation.PurchaseQuery{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: strings.TrimSpace(c.Query("search")), - PrNumber: strings.TrimSpace(c.Query("pr_number")), - CreatedFrom: strings.TrimSpace(c.Query("created_from")), - CreatedTo: strings.TrimSpace(c.Query("created_to")), + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + CreatedFrom: strings.TrimSpace(c.Query("created_from")), + CreatedTo: strings.TrimSpace(c.Query("created_to")), + SupplierID: uint(c.QueryInt("supplier_id", 0)), + AreaID: uint(c.QueryInt("area_id", 0)), + LocationID: uint(c.QueryInt("location_id", 0)), + ProductCategoryID: uint(c.QueryInt("product_category_id", 0)), } - if supplierID := c.QueryInt("supplier_id", 0); supplierID > 0 { - query.SupplierID = uint(supplierID) - } - - if status := strings.TrimSpace(c.Query("status")); status != "" { - query.Status = strings.ToUpper(status) + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } results, total, err := ctrl.service.GetAll(c, query) @@ -45,24 +44,15 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { return err } - limit := query.Limit - if limit <= 0 { - limit = 10 - } - page := query.Page - if page <= 0 { - page = 1 - } - return c.Status(fiber.StatusOK). - JSON(response.SuccessWithPaginate[dto.PurchaseListItemDTO]{ + JSON(response.SuccessWithPaginate[dto.PurchaseListDTO]{ Code: fiber.StatusOK, Status: "success", Message: "Purchase fetched successfully", Meta: response.Meta{ - Page: page, - Limit: limit, - TotalPages: int64(math.Ceil(float64(total) / float64(limit))), + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(total) / float64(query.Limit))), TotalResults: total, }, Data: dto.ToPurchaseListDTOs(results), @@ -71,12 +61,13 @@ func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { func (ctrl *PurchaseController) GetOne(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } - result, err := ctrl.service.GetOne(c, id) + result, err := ctrl.service.GetOne(c, uint(id)) if err != nil { return err } @@ -96,7 +87,6 @@ func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error { if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - result, err := ctrl.service.CreateOne(c, req) if err != nil { return err @@ -113,7 +103,7 @@ func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error { func (ctrl *PurchaseController) ApproveStaffPurchase(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } @@ -123,7 +113,7 @@ func (ctrl *PurchaseController) ApproveStaffPurchase(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) } - result, err := ctrl.service.ApproveStaffPurchase(c, id, req) + result, err := ctrl.service.ApproveStaffPurchase(c, uint(id), req) if err != nil { return err } @@ -137,10 +127,9 @@ func (ctrl *PurchaseController) ApproveStaffPurchase(c *fiber.Ctx) error { }) } - func (ctrl *PurchaseController) ApproveManagerPurchase(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } @@ -150,7 +139,7 @@ func (ctrl *PurchaseController) ApproveManagerPurchase(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - result, err := ctrl.service.ApproveManagerPurchase(c, id, req) + result, err := ctrl.service.ApproveManagerPurchase(c, uint(id), req) if err != nil { return err } @@ -166,17 +155,36 @@ func (ctrl *PurchaseController) ApproveManagerPurchase(c *fiber.Ctx) error { func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } req := new(validation.ReceivePurchaseRequest) - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + req.Action = c.FormValue("action") + if notes := strings.TrimSpace(c.FormValue("notes")); notes != "" { + req.Notes = ¬es } - result, err := ctrl.service.ReceiveProducts(c, id, req) + itemsJSON := c.FormValue("items") + if strings.TrimSpace(itemsJSON) != "" { + if err := json.Unmarshal([]byte(itemsJSON), &req.Items); err != nil { + var singleItem validation.ReceivePurchaseItemRequest + if err := json.Unmarshal([]byte(itemsJSON), &singleItem); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid items JSON") + } + req.Items = []validation.ReceivePurchaseItemRequest{singleItem} + } + } + req.TravelDocuments = form.File["travel_documents"] + if len(req.TravelDocuments) == 0 { + req.TravelDocuments = form.File["documents"] + } + result, err := ctrl.service.ReceiveProducts(c, uint(id), req) if err != nil { return err } @@ -192,7 +200,7 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } @@ -202,7 +210,7 @@ func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - result, err := ctrl.service.DeleteItems(c, id, req) + result, err := ctrl.service.DeleteItems(c, uint(id), req) if err != nil { return err } @@ -218,12 +226,12 @@ func (ctrl *PurchaseController) DeleteItems(c *fiber.Ctx) error { func (ctrl *PurchaseController) DeletePurchase(c *fiber.Ctx) error { param := c.Params("id") - id, err := strconv.ParseUint(param, 10, 64) + id, err := strconv.Atoi(param) if err != nil || id == 0 { return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") } - if err := ctrl.service.DeletePurchase(c, id); err != nil { + if err := ctrl.service.DeletePurchase(c, uint(id)); err != nil { return err } diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index bbd59fdd..1956729c 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -10,46 +10,46 @@ import ( productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -type PurchaseListItemDTO struct { - Id uint64 `json:"id"` - PrNumber string `json:"pr_number"` - PoNumber *string `json:"po_number"` - Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - CreditTerm *int `json:"credit_term"` - DueDate *time.Time `json:"due_date"` - PoDate *time.Time `json:"po_date"` - GrandTotal float64 `json:"grand_total"` - Notes *string `json:"notes"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Approval *approvalDTO.ApprovalRelationDTO `json:"approval"` +type PurchaseRelationDTO struct { + Id uint `json:"id"` + PrNumber string `json:"pr_number"` + PoNumber *string `json:"po_number"` + PoDate *time.Time `json:"po_date"` + CreditTerm int `json:"credit_term"` + Notes *string `json:"notes"` +} + +type PurchaseListDTO struct { + PurchaseRelationDTO + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` + DueDate *time.Time `json:"due_date"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } type PurchaseDetailDTO struct { - Id uint64 `json:"id"` - PrNumber string `json:"pr_number"` - PoNumber *string `json:"po_number"` - Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - CreditTerm *int `json:"credit_term"` - DueDate *time.Time `json:"due_date"` - PoDate *time.Time `json:"po_date"` - GrandTotal float64 `json:"grand_total"` - Notes *string `json:"notes"` - Items []PurchaseItemDTO `json:"items"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Approval *approvalDTO.ApprovalRelationDTO `json:"approval"` + PurchaseRelationDTO + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` + DueDate *time.Time `json:"due_date"` + Items []PurchaseItemDTO `json:"items"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } type PurchaseItemDTO struct { - Id uint64 `json:"id"` - ProductID uint64 `json:"product_id"` + Id uint `json:"id"` + ProductID uint `json:"product_id"` Product *productDTO.ProductRelationDTO `json:"product"` - WarehouseID uint64 `json:"warehouse_id"` + WarehouseID uint `json:"warehouse_id"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse"` - ProductWarehouseID *uint64 `json:"product_warehouse_id"` + ProductWarehouseID *uint `json:"product_warehouse_id"` SubQty float64 `json:"sub_qty"` TotalQty float64 `json:"total_qty"` TotalUsed float64 `json:"total_used"` @@ -59,6 +59,19 @@ type PurchaseItemDTO struct { TravelNumber *string `json:"travel_number"` TravelDocumentPath *string `json:"travel_document_path"` VehicleNumber *string `json:"vehicle_number"` + TransportPerItem *float64 `json:"transport_per_item,omitempty"` + ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"` +} + +func ToPurchaseRelationDTO(p *entity.Purchase) PurchaseRelationDTO { + return PurchaseRelationDTO{ + Id: p.Id, + PrNumber: p.PrNumber, + PoNumber: p.PoNumber, + PoDate: p.PoDate, + CreditTerm: p.CreditTerm, + Notes: p.Notes, + } } func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { @@ -77,10 +90,12 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { TravelDocumentPath: item.TravelNumberDocs, VehicleNumber: item.VehicleNumber, } + if item.Product != nil && item.Product.Id != 0 { summary := productDTO.ToProductRelationDTO(*item.Product) dto.Product = &summary } + if item.Warehouse != nil && item.Warehouse.Id != 0 { summary := warehouseDTO.ToWarehouseRelationDTO(*item.Warehouse) if item.Warehouse.Area.Id != 0 { @@ -93,6 +108,21 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { } dto.Warehouse = &summary } + + if item.ExpenseNonstock != nil { + priceCopy := item.ExpenseNonstock.Price + dto.TransportPerItem = &priceCopy + + if item.ExpenseNonstock.Expense != nil { + exp := item.ExpenseNonstock.Expense + + if exp.Supplier != nil && exp.Supplier.Id != 0 { + supplierSummary := supplierDTO.ToSupplierRelationDTO(*exp.Supplier) + dto.ExpeditionVendor = &supplierSummary + } + } + } + return dto } @@ -104,70 +134,74 @@ func ToPurchaseItemDTOs(items []entity.PurchaseItem) []PurchaseItemDTO { return result } -func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO { - dto := PurchaseDetailDTO{ - Id: p.Id, - PrNumber: p.PrNumber, - PoNumber: p.PoNumber, - Supplier: mapSupplier(p.Supplier), - CreditTerm: p.CreditTerm, - DueDate: p.DueDate, - PoDate: p.PoDate, - GrandTotal: p.GrandTotal, - Notes: p.Notes, - Items: ToPurchaseItemDTOs(p.Items), - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, +func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO { + var supplier *supplierDTO.SupplierRelationDTO + if p.Supplier.Id != 0 { + mapped := supplierDTO.ToSupplierRelationDTO(p.Supplier) + supplier = &mapped } - if approval := toPurchaseApprovalDTO(p); approval != nil { - dto.Approval = approval + + var createdUser *userDTO.UserRelationDTO + if p.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(p.CreatedUser) + createdUser = &mapped + } + + var latestApproval *approvalDTO.ApprovalRelationDTO + if p.LatestApproval != nil && p.LatestApproval.Id != 0 { + mapped := approvalDTO.ToApprovalDTO(*p.LatestApproval) + latestApproval = &mapped + } + + return PurchaseListDTO{ + PurchaseRelationDTO: ToPurchaseRelationDTO(&p), + Supplier: supplier, + DueDate: p.DueDate, + CreatedUser: createdUser, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + LatestApproval: latestApproval, } - return dto } -func ToPurchaseListDTO(p entity.Purchase) PurchaseListItemDTO { - dto := PurchaseListItemDTO{ - Id: p.Id, - PrNumber: p.PrNumber, - PoNumber: p.PoNumber, - Supplier: mapSupplier(p.Supplier), - CreditTerm: p.CreditTerm, - DueDate: p.DueDate, - PoDate: p.PoDate, - GrandTotal: p.GrandTotal, - Notes: p.Notes, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - } - if approval := toPurchaseApprovalDTO(p); approval != nil { - dto.Approval = approval - } - return dto -} - -func mapSupplier(s entity.Supplier) *supplierDTO.SupplierRelationDTO { - if s.Id == 0 { - return nil - } - summary := supplierDTO.ToSupplierRelationDTO(s) - return &summary -} - -func ToPurchaseListDTOs(items []entity.Purchase) []PurchaseListItemDTO { +func ToPurchaseListDTOs(items []entity.Purchase) []PurchaseListDTO { if len(items) == 0 { - return nil + return make([]PurchaseListDTO, 0) } - result := make([]PurchaseListItemDTO, len(items)) + result := make([]PurchaseListDTO, len(items)) for i, item := range items { result[i] = ToPurchaseListDTO(item) } return result } -func toPurchaseApprovalDTO(p entity.Purchase) *approvalDTO.ApprovalRelationDTO { - if p.LatestApproval == nil || p.LatestApproval.Id == 0 { - return nil +func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO { + var supplier *supplierDTO.SupplierRelationDTO + if p.Supplier.Id != 0 { + mapped := supplierDTO.ToSupplierRelationDTO(p.Supplier) + supplier = &mapped + } + + var createdUser *userDTO.UserRelationDTO + if p.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(p.CreatedUser) + createdUser = &mapped + } + + var latestApproval *approvalDTO.ApprovalRelationDTO + if p.LatestApproval != nil && p.LatestApproval.Id != 0 { + mapped := approvalDTO.ToApprovalDTO(*p.LatestApproval) + latestApproval = &mapped + } + + return PurchaseDetailDTO{ + PurchaseRelationDTO: ToPurchaseRelationDTO(&p), + Supplier: supplier, + DueDate: p.DueDate, + Items: ToPurchaseItemDTOs(p.Items), + CreatedUser: createdUser, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + LatestApproval: latestApproval, } - mapped := approvalDTO.ToApprovalDTO(*p.LatestApproval) - return &mapped } diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index 1911e364..7e80de38 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -1,6 +1,7 @@ package purchases import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -8,15 +9,21 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + expenseService "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -28,13 +35,57 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) supplierRepo := rSupplier.NewSupplierRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + nonstockRepo := rNonstock.NewNonstockRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) + expenseRepository := expenseRepo.NewExpenseRepository(db) + expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) + projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) + documentRepo := commonRepo.NewDocumentRepository(db) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) } - expenseBridge := service.NewNoopPurchaseExpenseBridge() + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) + } + expenseServiceInstance := expenseService.NewExpenseService( + expenseRepository, + supplierRepo, + nonstockRepo, + approvalService, + expenseRealizationRepo, + projectFlockKandangRepository, + documentSvc, + validate, + ) + expenseBridge := service.NewExpenseBridge( + db, + purchaseRepo, + projectFlockKandangRepository, + kandangRepo, + expenseServiceInstance, + ) + + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + _ = fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("PURCHASE_ITEMS"), + Table: "purchase_items", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "id", + }, + OrderBy: []string{"id ASC"}, + }) purchaseService := service.NewPurchaseService( validate, @@ -43,9 +94,11 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo, supplierRepo, productWarehouseRepo, - approvalRepo, + projectFlockKandangRepository, approvalService, expenseBridge, + fifoService, + documentSvc, ) userRepo := rUser.NewUserRepository(db) diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index bc1c038a..fc599877 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -18,16 +18,15 @@ import ( type PurchaseRepository interface { repository.BaseRepository[entity.Purchase] CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error - CreateItems(ctx context.Context, purchaseID uint64, items []*entity.PurchaseItem) error - GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) - GetAllWithFilters(ctx context.Context, offset, limit int, filter *PurchaseListFilter) ([]entity.Purchase, int64, error) - UpdatePricing(ctx context.Context, purchaseID uint64, updates []PurchasePricingUpdate, grandTotal float64) error - UpdateReceivingDetails(ctx context.Context, purchaseID uint64, updates []PurchaseReceivingUpdate) error - DeleteItems(ctx context.Context, purchaseID uint64, itemIDs []uint64) error - WithListRelations() func(*gorm.DB) *gorm.DB - UpdateGrandTotal(ctx context.Context, purchaseID uint64, grandTotal float64) error + CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error + UpdatePricing(ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate) error + UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error + DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) + BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error + GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) + GetItemsByWarehouseKandang(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) } type PurchaseRepositoryImpl struct { @@ -40,19 +39,10 @@ func NewPurchaseRepository(db *gorm.DB) PurchaseRepository { } } -type PurchaseListFilter struct { - SupplierID uint - Search string - PrNumber string - CreatedFrom *time.Time - CreatedTo *time.Time - Status *entity.ApprovalAction - CompletedOnly bool -} - func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error { db := r.DB().WithContext(ctx) + //ambil dari base repository if err := db.Create(purchase).Error; err != nil { return err } @@ -71,7 +61,35 @@ func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase * return nil } -func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint64, items []*entity.PurchaseItem) error { +func (r *PurchaseRepositoryImpl) BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error { + if purchaseID == 0 { + return nil + } + + query := ` +WITH latest_pfk AS ( + SELECT pfk.id, pfk.kandang_id + FROM project_flock_kandangs pfk + JOIN ( + SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at + FROM approvals + WHERE approvable_type = 'PROJECT_FLOCKS' + ORDER BY approvable_id, action_at DESC + ) latest_approval ON latest_approval.approvable_id = pfk.project_flock_id + WHERE LOWER(latest_approval.step_name) = LOWER('Aktif') +) +UPDATE purchase_items pi +SET project_flock_kandang_id = lp.id +FROM warehouses w +JOIN latest_pfk lp ON lp.kandang_id = w.kandang_id +WHERE pi.purchase_id = ? + AND pi.project_flock_kandang_id IS NULL + AND pi.warehouse_id = w.id; +` + return r.DB().WithContext(ctx).Exec(query, purchaseID).Error +} + +func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error { if len(items) == 0 { return nil } @@ -86,52 +104,9 @@ func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uin return r.DB().WithContext(ctx).Create(&items).Error } -func (r *PurchaseRepositoryImpl) GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) { - var purchase entity.Purchase - err := r.DB().WithContext(ctx). - Scopes(r.withDetailRelations). - First(&purchase, id).Error - if err != nil { - return nil, err - } - return &purchase, nil -} - -func (r *PurchaseRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filter *PurchaseListFilter) ([]entity.Purchase, int64, error) { - return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { - db = r.withListRelations(db) - return r.applyListFilters(db, filter) - }) -} - -func (r *PurchaseRepositoryImpl) WithListRelations() func(*gorm.DB) *gorm.DB { - return func(db *gorm.DB) *gorm.DB { - return r.withListRelations(db) - } -} - -func (r *PurchaseRepositoryImpl) withDetailRelations(db *gorm.DB) *gorm.DB { - return db. - Preload("Supplier"). - Preload("Items", func(db *gorm.DB) *gorm.DB { - return db.Order("id ASC") - }). - Preload("Items.Product"). - Preload("Items.Warehouse"). - Preload("Items.Warehouse.Area"). - Preload("Items.Warehouse.Location"). - Preload("Items.ProductWarehouse") -} - -func (r *PurchaseRepositoryImpl) WithDetailRelations() func(*gorm.DB) *gorm.DB { - return func(db *gorm.DB) *gorm.DB { - return r.withDetailRelations(db) - } -} - type PurchasePricingUpdate struct { - ItemID uint64 - ProductID *uint64 + ItemID uint + ProductID *uint Price float64 TotalPrice float64 Quantity *float64 @@ -139,7 +114,7 @@ type PurchasePricingUpdate struct { } type PurchaseReceivingUpdate struct { - ItemID uint64 + ItemID uint ReceivedDate *time.Time TravelNumber *string TravelDocumentPath *string @@ -152,9 +127,8 @@ type PurchaseReceivingUpdate struct { func (r *PurchaseRepositoryImpl) UpdatePricing( ctx context.Context, - purchaseID uint64, + purchaseID uint, updates []PurchasePricingUpdate, - grandTotal float64, ) error { if len(updates) == 0 { return errors.New("pricing updates cannot be empty") @@ -188,21 +162,12 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( } } - if err := db.Model(&entity.Purchase{}). - Where("id = ?", purchaseID). - Updates(map[string]interface{}{ - "grand_total": grandTotal, - "updated_at": gorm.Expr("NOW()"), - }).Error; err != nil { - return err - } - return nil } func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( ctx context.Context, - purchaseID uint64, + purchaseID uint, updates []PurchaseReceivingUpdate, ) error { if len(updates) == 0 { @@ -226,9 +191,6 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( if upd.VehicleNumber != nil { data["vehicle_number"] = upd.VehicleNumber } - if upd.ReceivedQty != nil { - data["total_qty"] = upd.ReceivedQty - } if upd.WarehouseID != nil && *upd.WarehouseID != 0 { data["warehouse_id"] = upd.WarehouseID } @@ -257,21 +219,7 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( return nil } -func (r *PurchaseRepositoryImpl) UpdateGrandTotal( - ctx context.Context, - purchaseID uint64, - grandTotal float64, -) error { - return r.DB().WithContext(ctx). - Model(&entity.Purchase{}). - Where("id = ?", purchaseID). - Updates(map[string]interface{}{ - "grand_total": grandTotal, - "updated_at": gorm.Expr("NOW()"), - }).Error -} - -func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint64, itemIDs []uint64) error { +func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error { if len(itemIDs) == 0 { return errors.New("itemIDs cannot be empty") } @@ -343,6 +291,38 @@ func (r *PurchaseRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, return count > 0, nil } +func (r *PurchaseRepositoryImpl) GetItemsByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) { + + return r.GetItemsByWarehouseKandang(ctx, projectFlockID) +} + +func (r *PurchaseRepositoryImpl) GetItemsByWarehouseKandang(ctx context.Context, projectFlockID uint) ([]entity.PurchaseItem, error) { + var items []entity.PurchaseItem + + var kandangIDs []uint + err := r.DB().WithContext(ctx). + Table("project_flock_kandangs"). + Where("project_flock_id = ?", projectFlockID). + Pluck("kandang_id", &kandangIDs).Error + + if err != nil { + return nil, err + } + + if len(kandangIDs) == 0 { + return []entity.PurchaseItem{}, nil + } + + err = r.DB().WithContext(ctx). + Preload("Product"). + Preload("Product.Flags"). + Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). + Where("warehouses.kandang_id IN ?", kandangIDs). + Find(&items).Error + + return items, err +} + func parseNumericSuffix(value, prefix string) (int, bool) { if !strings.HasPrefix(value, prefix) { return 0, false @@ -361,63 +341,3 @@ func parseNumericSuffix(value, prefix string) (int, bool) { } return number, true } - -func (r *PurchaseRepositoryImpl) withListRelations(db *gorm.DB) *gorm.DB { - return db.Preload("Supplier") -} - -func (r *PurchaseRepositoryImpl) applyListFilters(db *gorm.DB, filter *PurchaseListFilter) *gorm.DB { - if filter == nil { - return db - } - - if filter.SupplierID > 0 { - db = db.Where("purchases.supplier_id = ?", filter.SupplierID) - } - - if search := strings.ToLower(strings.TrimSpace(filter.Search)); search != "" { - like := "%" + search + "%" - db = db.Where("(LOWER(purchases.pr_number) LIKE ? OR LOWER(COALESCE(purchases.notes, '')) LIKE ?)", like, like) - } - - if pr := strings.TrimSpace(filter.PrNumber); pr != "" { - db = db.Where("purchases.pr_number ILIKE ?", "%"+pr+"%") - } - - if filter.CreatedFrom != nil { - db = db.Where("purchases.created_at >= ?", *filter.CreatedFrom) - } - - if filter.CreatedTo != nil { - db = db.Where("purchases.created_at < ?", *filter.CreatedTo) - } - - if filter.CompletedOnly { - step := uint16(utils.PurchaseStepCompleted) - db = r.applyLatestApprovalFilter(db, entity.ApprovalActionApproved, &step) - } else if filter.Status != nil { - db = r.applyLatestApprovalFilter(db, *filter.Status, nil) - } - - return db.Order("purchases.created_at DESC").Order("purchases.id DESC") -} - -func (r *PurchaseRepositoryImpl) applyLatestApprovalFilter(db *gorm.DB, action entity.ApprovalAction, minStep *uint16) *gorm.DB { - latestSub := r.DB(). - Model(&entity.Approval{}). - Select("approvable_id, MAX(action_at) AS latest_action_at"). - Where("approvable_type = ?", utils.ApprovalWorkflowPurchase.String()). - Group("approvable_id") - - db = db. - Joins("LEFT JOIN (?) AS latest_purchase_approvals ON latest_purchase_approvals.approvable_id = purchases.id", latestSub). - Joins( - "LEFT JOIN approvals ON approvals.approvable_id = purchases.id AND approvals.approvable_type = ? AND approvals.action_at = latest_purchase_approvals.latest_action_at", - utils.ApprovalWorkflowPurchase.String(), - ). - Where("approvals.action = ?", string(action)) - if minStep != nil { - db = db.Where("approvals.step_number >= ?", *minStep) - } - return db -} diff --git a/internal/modules/purchases/route.go b/internal/modules/purchases/route.go index aedc3ee8..0fe038c3 100644 --- a/internal/modules/purchases/route.go +++ b/internal/modules/purchases/route.go @@ -3,7 +3,7 @@ package purchases import ( "github.com/gofiber/fiber/v2" - middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/controllers" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,14 +13,14 @@ func Routes(router fiber.Router, purchaseService service.PurchaseService, userSe ctrl := controller.NewPurchaseController(purchaseService) route := router.Group("/purchases") - route.Use(middleware.Auth(userService)) + route.Use(m.Auth(userService)) - route.Get("/", ctrl.GetAll) - route.Get("/:id", ctrl.GetOne) + route.Get("/",m.RequirePermissions(m.P_PurchaseGetAll), ctrl.GetAll) + route.Get("/:id",m.RequirePermissions(m.P_PurchaseGetOne), ctrl.GetOne) route.Post("/", ctrl.CreateOne) route.Post("/:id/approvals/staff", ctrl.ApproveStaffPurchase) route.Post("/:id/approvals/manager", ctrl.ApproveManagerPurchase) - route.Post("/:id/receipts", ctrl.ReceiveProducts) + route.Post("/:id/receipts",ctrl.ReceiveProducts) route.Delete("/:id", ctrl.DeletePurchase) route.Delete("/:id/items", ctrl.DeleteItems) } diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index b7c96d03..6c74a1fc 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -2,42 +2,681 @@ package service import ( "context" + "errors" + "fmt" + "strconv" + "strings" "time" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" + expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" + expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" + kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) -// PurchaseExpenseBridge defines hooks that allow purchase flows to stay in sync with expense data once it exists. type PurchaseExpenseBridge interface { - OnItemsCreated(ctx context.Context, purchaseID uint64, items []entity.PurchaseItem) error - OnItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) error - OnItemsReceived(ctx context.Context, purchaseID uint64, updates []ExpenseReceivingPayload) error + OnItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error + OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error } -// ExpenseReceivingPayload captures the minimum data expense integration will need once available. type ExpenseReceivingPayload struct { - PurchaseItemID uint64 - ProductID uint64 - WarehouseID uint64 - ReceivedQty float64 - ReceivedDate *time.Time + PurchaseItemID uint + ProductID uint + WarehouseID uint + SupplierID uint + TransportPerItem *float64 + ReceivedQty float64 + ReceivedDate *time.Time } -// noopPurchaseExpenseBridge is the default implementation until the expense module is ready. -type noopPurchaseExpenseBridge struct{} - -func NewNoopPurchaseExpenseBridge() PurchaseExpenseBridge { - return &noopPurchaseExpenseBridge{} +type groupedItem struct { + item *entity.PurchaseItem + payload ExpenseReceivingPayload + projectFK *uint + kandangID *uint + totalPrice float64 } -func (n *noopPurchaseExpenseBridge) OnItemsCreated(_ context.Context, _ uint64, _ []entity.PurchaseItem) error { +func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { + return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID) +} + +type expenseBridge struct { + db *gorm.DB + purchaseRepo rPurchase.PurchaseRepository + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + kandangRepo kandangRepo.KandangRepository + expenseSvc expenseSvc.ExpenseService +} + +func NewExpenseBridge( + db *gorm.DB, + purchaseRepo rPurchase.PurchaseRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, + kandangRepo kandangRepo.KandangRepository, + expenseSvc expenseSvc.ExpenseService, +) PurchaseExpenseBridge { + return &expenseBridge{ + db: db, + purchaseRepo: purchaseRepo, + projectFlockKandangRepo: projectFlockKandangRepo, + kandangRepo: kandangRepo, + expenseSvc: expenseSvc, + } +} + +func (b *expenseBridge) OnItemsDeleted(ctx context.Context, _ uint, items []entity.PurchaseItem) error { + if len(items) == 0 { + return nil + } + + return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + expenseIDs := make(map[uint64]struct{}) + expenseNonstockIDs := make([]uint64, 0) + for _, item := range items { + if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 { + expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId) + } + } + if len(expenseNonstockIDs) > 0 { + for _, nsID := range expenseNonstockIDs { + var expenseID uint64 + if err := tx. + Model(&entity.ExpenseNonstock{}). + Select("expense_id"). + Where("id = ?", nsID). + Scan(&expenseID).Error; err != nil { + return err + } + if expenseID != 0 { + expenseIDs[expenseID] = struct{}{} + } + } + if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil { + return err + } + } + + var links []struct { + ItemID uint + ExpenseNonstockID *uint64 + } + if err := tx. + Model(&entity.PurchaseItem{}). + Select("id as item_id, expense_nonstock_id"). + Where("id IN ?", extractIDs(items)). + Scan(&links).Error; err != nil { + return err + } + + for _, link := range links { + if link.ExpenseNonstockID == nil || *link.ExpenseNonstockID == 0 { + continue + } + var expenseID uint64 + if err := tx. + Model(&entity.ExpenseNonstock{}). + Select("expense_id"). + Where("id = ?", *link.ExpenseNonstockID). + Scan(&expenseID).Error; err != nil { + return err + } + if expenseID != 0 { + expenseIDs[expenseID] = struct{}{} + } + if err := tx.Delete(&entity.ExpenseNonstock{}, *link.ExpenseNonstockID).Error; err != nil { + return err + } + } + + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + for expenseID := range expenseIDs { + var count int64 + if err := tx.Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", expenseID). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { + return err + } + if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[uint64]struct{}, actorID uint) error { + if len(expenseIDs) == 0 { + return nil + } + if actorID == 0 { + actorID = 1 + } + svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) + action := entity.ApprovalActionUpdated + for id := range expenseIDs { + if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return err + } + } return nil } -func (n *noopPurchaseExpenseBridge) OnItemsDeleted(_ context.Context, _ uint64, _ []uint64) error { +func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error { + if purchaseID == 0 || len(updates) == 0 { + return nil + } + + ctx := c.Context() + + // Load current links to decide whether to update in place or recreate. + type itemLink struct { + ExpenseNonstockID uint64 + ExpenseID uint64 + SupplierID uint + TransactionDate time.Time + Qty float64 + Price float64 + } + + purchase, err := b.purchaseRepo.GetByID(ctx, purchaseID, func(db *gorm.DB) *gorm.DB { + return db. + Preload("Items"). + Preload("Items.Warehouse"). + Preload("Items.Warehouse.Kandang") + }) + if err != nil { + return err + } + + itemLinks := make(map[uint]itemLink) + updatedExpenses := make(map[uint64]struct{}) + if len(updates) > 0 { + ids := make([]uint, 0, len(updates)) + for _, upd := range updates { + if upd.PurchaseItemID != 0 { + ids = append(ids, upd.PurchaseItemID) + } + } + if len(ids) > 0 { + rows := make([]struct { + ItemID uint + ExpenseNonstockID uint64 + ExpenseID uint64 + SupplierID uint + TransactionDate time.Time + Qty float64 + Price float64 + }, 0) + if err := b.db.WithContext(ctx). + Table("purchase_items pi"). + Select("pi.id as item_id, en.id as expense_nonstock_id, en.expense_id, e.supplier_id, e.transaction_date, en.qty, en.price"). + Joins("LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id"). + Joins("LEFT JOIN expenses e ON e.id = en.expense_id"). + Where("pi.id IN ?", ids). + Scan(&rows).Error; err != nil { + return err + } + // Build quick lookup per item and per group key for existing expenses. + for _, row := range rows { + itemLinks[row.ItemID] = itemLink{ + ExpenseNonstockID: row.ExpenseNonstockID, + ExpenseID: row.ExpenseID, + SupplierID: row.SupplierID, + TransactionDate: row.TransactionDate, + Qty: row.Qty, + Price: row.Price, + } + } + } + } + + itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) + for i := range purchase.Items { + itemMap[purchase.Items[i].Id] = &purchase.Items[i] + } + + groups := make(map[string][]groupedItem) + + for _, payload := range updates { + if payload.ReceivedDate == nil { + return fiber.NewError(fiber.StatusBadRequest, "received_date is required") + } + item := itemMap[payload.PurchaseItemID] + if item == nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) + } + if payload.ReceivedQty <= 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d must be greater than 0", payload.PurchaseItemID)) + } + + receivedDate := payload.ReceivedDate.UTC().Truncate(24 * time.Hour) + supplierID := payload.SupplierID + if supplierID == 0 { + supplierID = purchase.SupplierId + } + + // Decide whether to update existing expense_nonstock or create new. + link, hasLink := itemLinks[payload.PurchaseItemID] + if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 { + oldDate := link.TransactionDate.UTC().Truncate(24 * time.Hour) + newDate := receivedDate + oldSupplier := link.SupplierID + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } + + // If supplier/date unchanged, update nonstock in place. + if oldSupplier == supplierID && oldDate.Equal(newDate) { + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(map[string]interface{}{ + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + }).Error; err != nil { + return err + } + if link.ExpenseID != 0 { + updatedExpenses[link.ExpenseID] = struct{}{} + } + continue + } + + // Supplier/date changed: if the linked expense has only this nonstock, update it in place. + if link.ExpenseID != 0 { + var cnt int64 + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", link.ExpenseID). + Count(&cnt).Error; err != nil { + return err + } + if cnt == 1 { + newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) + if err != nil { + return err + } + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + if err := b.db.WithContext(ctx). + Model(&entity.Expense{}). + Where("id = ?", link.ExpenseID). + Updates(map[string]interface{}{ + "transaction_date": newDate, + "supplier_id": supplierID, + }).Error; err != nil { + return err + } + updateBody := map[string]interface{}{ + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + "nonstock_id": newNonstockID, + } + if item.Warehouse != nil && item.Warehouse.KandangId != nil && *item.Warehouse.KandangId != 0 { + updateBody["kandang_id"] = uint64(*item.Warehouse.KandangId) + } + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(updateBody).Error; err != nil { + return err + } + updatedExpenses[link.ExpenseID] = struct{}{} + continue + } + + // Expense has multiple nonstocks: create new expense header for this item, then move existing nonstock to it. + var kandangID *uint + var projectFK *uint + if item.Warehouse != nil && item.Warehouse.KandangId != nil { + id := uint(*item.Warehouse.KandangId) + kandangID = &id + if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil { + pid := uint(project.Id) + projectFK = &pid + } + } + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } + totalPrice := pricePerItem * payload.ReceivedQty + + gItem := groupedItem{ + item: item, + payload: payload, + projectFK: projectFK, + kandangID: kandangID, + totalPrice: totalPrice, + } + + newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) + if err != nil { + return err + } + + expenseDetail, err := b.createExpenseViaService(c, purchase, []groupedItem{gItem}, newDate, newNonstockID, purchase.PoNumber, supplierID) + if err != nil { + return err + } + + var createdNonstockID uint64 + if expenseDetail != nil { + noteMap := mapExpenseNotes(expenseDetail) + createdNonstockID = noteMap[payload.PurchaseItemID] + } + + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + updateBody := map[string]interface{}{ + "expense_id": expenseDetail.Id, + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + "nonstock_id": newNonstockID, + } + if kandangID != nil { + updateBody["kandang_id"] = uint64(*kandangID) + } else { + updateBody["kandang_id"] = nil + } + if projectFK != nil { + updateBody["project_flock_kandang_id"] = uint64(*projectFK) + } else { + updateBody["project_flock_kandang_id"] = nil + } + + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(updateBody).Error; err != nil { + return err + } + + if createdNonstockID != 0 { + if err := b.db.WithContext(ctx).Delete(&entity.ExpenseNonstock{}, createdNonstockID).Error; err != nil { + return err + } + } + + if link.ExpenseID != 0 { + updatedExpenses[link.ExpenseID] = struct{}{} + } + if expenseDetail != nil && expenseDetail.Id != 0 { + updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} + } + continue + } + + // Otherwise create new expense/nonstock in grouping flow. + } + + baseKey := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID) + key := baseKey + if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 { + key = fmt.Sprintf("%s:%d", baseKey, payload.PurchaseItemID) + } + + var kandangID *uint + var projectFK *uint + if item.Warehouse != nil && item.Warehouse.KandangId != nil { + id := uint(*item.Warehouse.KandangId) + kandangID = &id + if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil { + pid := uint(project.Id) + projectFK = &pid + } + } + + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } + totalPrice := pricePerItem * payload.ReceivedQty + + groups[key] = append(groups[key], groupedItem{ + item: item, + payload: payload, + projectFK: projectFK, + kandangID: kandangID, + totalPrice: totalPrice, + }) + } + + for key, items := range groups { + if len(items) == 0 { + continue + } + parts := strings.Split(key, ":") + if len(parts) < 3 { + return errors.New("invalid expense grouping key") + } + expenseDate, err := utils.ParseDateString(parts[1]) + if err != nil { + return err + } + + supplierID, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + return err + } + + expeditionNonstockID, err := b.findExpeditionNonstockID(ctx, uint(supplierID)) + if err != nil { + return err + } + + expenseDetail, err := b.createExpenseViaService(c, purchase, items, expenseDate, expeditionNonstockID, purchase.PoNumber, uint(supplierID)) + if err != nil { + return err + } + if err := b.linkExpenseNonstocksToItems(ctx, expenseDetail, items); err != nil { + return err + } + if expenseDetail != nil && expenseDetail.Id != 0 { + updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} + } + } + + if len(updatedExpenses) > 0 { + if err := b.markExpensesUpdated(ctx, updatedExpenses, purchase.CreatedBy); err != nil { + return err + } + } + return nil } -func (n *noopPurchaseExpenseBridge) OnItemsReceived(_ context.Context, _ uint64, _ []ExpenseReceivingPayload) error { +func (b *expenseBridge) findExpeditionNonstockID(ctx context.Context, supplierID uint) (uint64, error) { + var id uint64 + err := b.db.WithContext(ctx). + Table("nonstocks AS ns"). + Select("ns.id"). + Joins("JOIN nonstock_suppliers nss ON nss.nonstock_id = ns.id"). + Joins("JOIN flags f ON f.flagable_id = ns.id AND f.flagable_type = ?", entity.FlagableTypeNonstock). + Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))). + Where("nss.supplier_id = ?", supplierID). + Order("ns.id"). + Limit(1). + Scan(&id).Error + if err != nil { + return 0, err + } + if id == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "supplier id tidak sesuai dengan expedisi") + } + return id, nil +} + +func extractIDs(items []entity.PurchaseItem) []uint { + result := make([]uint, 0, len(items)) + for _, item := range items { + if item.Id != 0 { + result = append(result, item.Id) + } + } + return result +} + +func (b *expenseBridge) createExpenseViaService( + c *fiber.Ctx, + purchase *entity.Purchase, + items []groupedItem, + expenseDate time.Time, + expeditionNonstockID uint64, + poNumber *string, + supplierID uint, +) (*expenseDto.ExpenseDetailDTO, error) { + ctx := c.Context() + if b.expenseSvc == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "expense service not available") + } + if len(items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no items to create expense") + } + + kandangID := items[0].kandangID + var locationID uint64 + var expenseKandangID *uint64 + if kandangID != nil && *kandangID != 0 { + kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB { + return db.Select("id, location_id") + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + } + if kandang == nil { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + } + locationID = uint64(kandang.LocationId) + id := uint64(*kandangID) + expenseKandangID = &id + } else { + warehouse := items[0].item.Warehouse + if warehouse == nil || warehouse.LocationId == nil || *warehouse.LocationId == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse location is required for expense") + } + locationID = uint64(*warehouse.LocationId) + } + + costItems := make([]expenseValidation.CostItem, 0, len(items)) + for _, gi := range items { + note := fmt.Sprintf("purchase_item:%d", gi.payload.PurchaseItemID) + price := gi.item.Price + if gi.payload.TransportPerItem != nil { + price = *gi.payload.TransportPerItem + } + costItems = append(costItems, expenseValidation.CostItem{ + NonstockID: expeditionNonstockID, + Quantity: gi.payload.ReceivedQty, + Price: price, + Notes: note, + }) + } + + req := &expenseValidation.Create{ + PoNumber: "", + TransactionDate: utils.FormatDate(expenseDate), + Category: "BOP", + SupplierID: uint64(supplierID), + LocationID: locationID, + ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ + KandangID: expenseKandangID, + CostItems: costItems, + }}, + } + if poNumber != nil { + req.PoNumber = *poNumber + } + + detail, err := b.expenseSvc.CreateOne(c, req) + if err != nil { + return nil, err + } + + // Mark approvals up to Finance so latest is Manager Finance + action := entity.ApprovalActionApproved + actorID := uint(purchase.CreatedBy) + if actorID == 0 { + actorID = 1 + } + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { + return nil, err + } + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return nil, err + } + + return detail, nil +} + +func (b *expenseBridge) linkExpenseNonstocksToItems(ctx context.Context, detail *expenseDto.ExpenseDetailDTO, items []groupedItem) error { + if detail == nil || len(items) == 0 { + return nil + } + + noteToExpenseNonstock := mapExpenseNotes(detail) + + if len(noteToExpenseNonstock) == 0 { + return nil + } + + for _, gi := range items { + expenseNonstockID, ok := noteToExpenseNonstock[gi.payload.PurchaseItemID] + if !ok { + continue + } + if err := b.db.WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("id = ?", gi.payload.PurchaseItemID). + Update("expense_nonstock_id", expenseNonstockID).Error; err != nil { + return err + } + } + return nil } + +func mapExpenseNotes(detail *expenseDto.ExpenseDetailDTO) map[uint]uint64 { + result := make(map[uint]uint64) + if detail == nil { + return result + } + for _, kandang := range detail.Kandangs { + for _, pengajuan := range kandang.Pengajuans { + note := strings.TrimSpace(pengajuan.Notes) + if note == "" { + continue + } + const prefix = "purchase_item:" + if !strings.HasPrefix(note, prefix) { + continue + } + idStr := strings.TrimPrefix(note, prefix) + var itemID uint + if _, err := fmt.Sscanf(idStr, "%d", &itemID); err != nil { + continue + } + result[itemID] = pengajuan.Id + } + } + return result +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index b0d5311d..68b21d6a 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -5,21 +5,24 @@ import ( "errors" "fmt" "math" + "mime/multipart" "strings" "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - authmiddleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -28,38 +31,40 @@ import ( ) type PurchaseService interface { - GetAll(ctx *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) - GetOne(ctx *fiber.Ctx, id uint64) (*entity.Purchase, error) + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Purchase, error) CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) - ApproveStaffPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) - ApproveManagerPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) - ReceiveProducts(ctx *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) - DeleteItems(ctx *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) - DeletePurchase(ctx *fiber.Ctx, id uint64) error + ApproveStaffPurchase(ctx *fiber.Ctx, id uint, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) + ApproveManagerPurchase(ctx *fiber.Ctx, id uint, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) + ReceiveProducts(ctx *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) + DeleteItems(ctx *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) + DeletePurchase(ctx *fiber.Ctx, id uint) error } const ( - priceTolerance = 0.0001 - queryDateLayout = "2006-01-02" + priceTolerance = 0.0001 + purchaseStockableKey = fifo.StockableKey("PURCHASE_ITEMS") ) type purchaseService struct { - Log *logrus.Logger - Validate *validator.Validate - PurchaseRepo rPurchase.PurchaseRepository - ProductRepo rProduct.ProductRepository - WarehouseRepo rWarehouse.WarehouseRepository - SupplierRepo rSupplier.SupplierRepository - ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository - ApprovalRepo commonRepo.ApprovalRepository - ApprovalSvc commonSvc.ApprovalService - ExpenseBridge PurchaseExpenseBridge + Log *logrus.Logger + Validate *validator.Validate + PurchaseRepo rPurchase.PurchaseRepository + ProductRepo rProduct.ProductRepository + WarehouseRepo rWarehouse.WarehouseRepository + SupplierRepo rSupplier.SupplierRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + ExpenseBridge PurchaseExpenseBridge + FifoSvc commonSvc.FifoService + DocumentSvc commonSvc.DocumentService + approvalWorkflow approvalutils.ApprovalWorkflowKey } type staffAdjustmentPayload struct { PricingUpdates []rPurchase.PurchasePricingUpdate NewItems []*entity.PurchaseItem - GrandTotal float64 } func NewPurchaseService( @@ -69,98 +74,134 @@ func NewPurchaseService( warehouseRepo rWarehouse.WarehouseRepository, supplierRepo rSupplier.SupplierRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, - approvalRepo commonRepo.ApprovalRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, + fifoSvc commonSvc.FifoService, + documentSvc commonSvc.DocumentService, ) PurchaseService { - if expenseBridge == nil { - expenseBridge = NewNoopPurchaseExpenseBridge() - } return &purchaseService{ - Log: utils.Log, - Validate: validate, - PurchaseRepo: purchaseRepo, - ProductRepo: productRepo, - WarehouseRepo: warehouseRepo, - SupplierRepo: supplierRepo, - ProductWarehouseRepo: productWarehouseRepo, - ApprovalRepo: approvalRepo, - ApprovalSvc: approvalSvc, - ExpenseBridge: expenseBridge, + Log: utils.Log, + Validate: validate, + PurchaseRepo: purchaseRepo, + ProductRepo: productRepo, + WarehouseRepo: warehouseRepo, + SupplierRepo: supplierRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ApprovalSvc: approvalSvc, + ExpenseBridge: expenseBridge, + FifoSvc: fifoSvc, + DocumentSvc: documentSvc, + approvalWorkflow: utils.ApprovalWorkflowPurchase, } } +func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { + if db == nil { + return db + } -func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.PurchaseQuery) ([]entity.Purchase, int64, error) { + return db. + Preload("Supplier"). + Preload("CreatedUser"). + Preload("Items", func(db *gorm.DB) *gorm.DB { + return db.Order("id ASC") + }). + Preload("Items.Product"). + Preload("Items.Product.Uom"). + Preload("Items.Product.ProductCategory"). + Preload("Items.Warehouse"). + Preload("Items.Product.Flags"). + Preload("Items.Warehouse.Area"). + Preload("Items.Warehouse.Location"). + Preload("Items.ProductWarehouse"). + Preload("Items.ExpenseNonstock"). + Preload("Items.ExpenseNonstock.Expense"). + Preload("Items.ExpenseNonstock.Expense.Supplier") +} + +func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } - limit := params.Limit - if limit <= 0 { - limit = 10 - } - page := params.Page - if page <= 0 { - page = 1 - } - offset := (page - 1) * limit + offset := (params.Page - 1) * params.Limit - ctx := c.Context() - - createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo) + createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) if err != nil { - return nil, 0, err + return nil, 0, utils.BadRequest(err.Error()) } - statusAction, completedOnly, err := parseApprovalAction(params.Status) - if err != nil { - return nil, 0, err - } + purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) - filter := &rPurchase.PurchaseListFilter{ - SupplierID: params.SupplierID, - Search: params.Search, - PrNumber: params.PrNumber, - CreatedFrom: createdFrom, - CreatedTo: createdTo, - Status: statusAction, - CompletedOnly: completedOnly, - } + if params.SupplierID > 0 { + db = db.Where("supplier_id = ?", params.SupplierID) + } + + if createdFrom != nil { + db = db.Where("created_at >= ?", *createdFrom) + } + + if createdTo != nil { + db = db.Where("created_at < ?", *createdTo) + } + + if params.AreaID > 0 { + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + WHERE pi.purchase_id = purchases.id AND w.area_id = ? + )`, + params.AreaID, + ) + } + + if params.LocationID > 0 { + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + WHERE pi.purchase_id = purchases.id AND w.location_id = ? + )`, + params.LocationID, + ) + } + + if params.ProductCategoryID > 0 { + db = db.Where( + `EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN products p ON p.id = pi.product_id + WHERE pi.purchase_id = purchases.id AND p.product_category_id = ? + )`, + params.ProductCategoryID, + ) + } + + return db.Order("created_at DESC").Order("purchases.id DESC") + }) - purchases, total, err := s.PurchaseRepo.GetAllWithFilters(ctx, offset, limit, filter) if err != nil { s.Log.Errorf("Failed to get purchases: %+v", err) - return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchases") + return nil, 0, utils.Internal("Failed to get purchases") } - if err := s.attachLatestApprovals(ctx, purchases); err != nil { - s.Log.Warnf("Unable to attach latest approvals to purchases: %+v", err) + for i := range purchases { + if err := s.attachLatestApproval(c.Context(), &purchases[i]); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", purchases[i].Id, err) + } } return purchases, total, nil } -func (s *purchaseService) GetOne(c *fiber.Ctx, id uint64) (*entity.Purchase, error) { - if id == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") - } - - ctx := c.Context() - - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - s.Log.Errorf("Failed to get purchase: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") - } - - if err := s.attachLatestApproval(ctx, purchase); err != nil { - s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) - } - - return purchase, nil +func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { + return s.loadPurchase(c.Context(), id) } func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { @@ -168,74 +209,96 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return nil, err } - actorID, err := actorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } - ctx := c.Context() - - if _, err := s.SupplierRepo.GetByID(ctx, req.SupplierID, nil); err != nil { + if _, err := s.SupplierRepo.GetByID(c.Context(), req.SupplierID, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") + return nil, utils.NotFound("Supplier not found") } s.Log.Errorf("Failed to get supplier: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") + return nil, utils.Internal("Failed to get supplier") } type aggregatedItem struct { - productId uint64 - warehouseId uint64 + productId uint + warehouseId uint subQty float64 + pfkID *uint } if len(req.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + return nil, utils.BadRequest("Items must not be empty") } warehouseCache := make(map[uint]*entity.Warehouse) productSupplierCache := make(map[uint]bool) - - getWarehouse := func(id uint) (*entity.Warehouse, error) { + getWarehouse := func(id uint) (*entity.Warehouse, *uint, error) { if warehouse, ok := warehouseCache[id]; ok { - return warehouse, nil + return warehouse, nil, nil } + warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Area").Preload("Location") + }) - warehouse, err := s.WarehouseRepo.GetDetailByID(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) + return nil, nil, utils.NotFound(fmt.Sprintf("Warehouse %d not found", id)) } s.Log.Errorf("Failed to get warehouse %d: %+v", id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + return nil, nil, utils.Internal("Failed to get warehouse") + } + var pfkID *uint + isKandang := strings.EqualFold(strings.TrimSpace(warehouse.Type), "KANDANG") + if isKandang { + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return nil, nil, utils.BadRequest(fmt.Sprintf("%s is not linked to a kandang", warehouse.Name)) + } + if s.ProjectFlockKandangRepo != nil { + if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil { + if pfk.ClosedAt != nil { + return nil, nil, utils.BadRequest("Project sudah closing") + } + idCopy := uint(pfk.Id) + pfkID = &idCopy + } else if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, utils.BadRequest(fmt.Sprintf("%s has no active project flock", warehouse.Name)) + } else if err != nil { + s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err) + return nil, nil, utils.Internal("Failed to validate project flock") + } + } } warehouseCache[id] = warehouse - return warehouse, nil + return warehouse, pfkID, nil } aggregated := make([]*aggregatedItem, 0, len(req.Items)) indexMap := make(map[string]int) for _, item := range req.Items { - if _, err := getWarehouse(item.WarehouseID); err != nil { + _, pfkID, err := getWarehouse(item.WarehouseID) + if err != nil { return nil, err } if _, checked := productSupplierCache[item.ProductID]; !checked { - linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, item.ProductID, req.SupplierID) + linked, err := s.ProductRepo.IsLinkedToSupplier(c.Context(), item.ProductID, req.SupplierID) if err != nil { s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", item.ProductID, req.SupplierID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + return nil, utils.Internal("Failed to validate product for supplier") } if !linked { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID)) + return nil, utils.BadRequest(fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID)) } productSupplierCache[item.ProductID] = true } - productId := uint64(item.ProductID) - warehouseId := uint64(item.WarehouseID) + productId := uint(item.ProductID) + warehouseId := uint(item.WarehouseID) key := fmt.Sprintf("%d:%d", productId, warehouseId) if idx, ok := indexMap[key]; ok { @@ -247,57 +310,60 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase productId: productId, warehouseId: warehouseId, subQty: item.Quantity, + pfkID: pfkID, } aggregated = append(aggregated, entry) indexMap[key] = len(aggregated) - 1 } - creditTermValue := req.CreditTerm - creditTerm := &creditTermValue - dueDateValue := time.Now().UTC().AddDate(0, 0, creditTermValue) - dueDate := &dueDateValue + var dueDate *time.Time + now := time.Now().UTC() + d := now.AddDate(0, 0, req.CreditTerm) + dueDate = &d purchase := &entity.Purchase{ - SupplierId: uint64(req.SupplierID), - CreditTerm: creditTerm, + SupplierId: uint(req.SupplierID), + CreditTerm: req.CreditTerm, DueDate: dueDate, - GrandTotal: 0, Notes: req.Notes, - CreatedBy: uint64(actorID), + CreatedBy: uint(actorID), } items := make([]*entity.PurchaseItem, 0, len(aggregated)) + emptyVehicle := "" for _, item := range aggregated { items = append(items, &entity.PurchaseItem{ - ProductId: item.productId, - WarehouseId: item.warehouseId, - SubQty: item.subQty, - TotalQty: 0, - TotalUsed: 0, - Price: 0, - TotalPrice: 0, + ProductId: item.productId, + WarehouseId: item.warehouseId, + ProjectFlockKandangId: item.pfkID, + SubQty: item.subQty, + TotalQty: 0, + TotalUsed: 0, + Price: 0, + TotalPrice: 0, + VehicleNumber: &emptyVehicle, }) } - transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) - code, err := purchaseRepoTx.NextPrNumber(ctx, tx) + code, err := purchaseRepoTx.NextPrNumber(c.Context(), tx) if err != nil { return err } purchase.PrNumber = code - if err := purchaseRepoTx.CreateWithItems(ctx, purchase, items); err != nil { + if err := purchaseRepoTx.CreateWithItems(c.Context(), purchase, items); err != nil { + return err + } + + if err := purchaseRepoTx.BackfillProjectFlockKandang(c.Context(), purchase.Id); err != nil { return err } actorID := uint(purchase.CreatedBy) - if actorID == 0 { - actorID = 1 - } - action := entity.ApprovalActionCreated - if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepPengajuan, action, actorID, nil, false); err != nil { + if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, actorID, nil, false); err != nil { return err } @@ -305,49 +371,47 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase }) if transactionErr != nil { s.Log.Errorf("Failed to create purchase: %+v", transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase") + return nil, utils.Internal("Failed to create purchase") } - created, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + created, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { s.Log.Errorf("Failed to load created purchase: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } - if err := s.attachLatestApproval(ctx, created); err != nil { + if err := s.attachLatestApproval(c.Context(), created); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err) } - s.notifyExpenseItemsCreated(ctx, created.Id, created.Items) - return created, nil } -func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { - return s.processStaffPurchaseApproval(c, id, req, false) -} - -func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest, requireStaffApproval bool) (*entity.Purchase, error) { +func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - actorID, err := actorIDFromContext(c) + ctx := c.Context() + + action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err } - ctx := c.Context() - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + actorID, err := m.ActorIDFromContext(c) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + return nil, err + } + purchase, err := s.loadPurchase(ctx, id) + if err != nil { + return nil, err } - if err := s.attachLatestApproval(ctx, purchase); err != nil { - s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + if action == entity.ApprovalActionApproved { + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return nil, err + } } var latestStep uint16 @@ -355,13 +419,13 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, latestStep = purchase.LatestApproval.StepNumber } - if requireStaffApproval && latestStep < uint16(utils.PurchaseStepStaffPurchase) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot be edited before staff approval") + if action == entity.ApprovalActionRejected { + return s.rejectAndReload(c, utils.PurchaseStepStaffPurchase, purchase.Id, actorID, req.Notes) } isInitialApproval := latestStep < uint16(utils.PurchaseStepStaffPurchase) if isInitialApproval && latestStep != uint16(utils.PurchaseStepPengajuan) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase is not ready for staff approval") + return nil, utils.BadRequest("Purchase is not ready for staff approval") } hasReceivingData := false @@ -374,68 +438,48 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, syncReceiving := !isInitialApproval && hasReceivingData - payload, err := s.buildStaffAdjustmentPayload(ctx, purchase, req, syncReceiving) + if action == entity.ApprovalActionApproved && len(req.Items) == 0 { + return nil, utils.BadRequest("Items must not be empty for staff approval") + } + + payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving) if err != nil { return nil, err } - transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) - grandTotalUpdated := false if len(payload.PricingUpdates) > 0 { - if err := purchaseRepoTx.UpdatePricing(ctx, purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil { + if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates); err != nil { return err } - grandTotalUpdated = true } if len(payload.NewItems) > 0 { - if err := purchaseRepoTx.CreateItems(ctx, purchase.Id, payload.NewItems); err != nil { - return err - } - } - - if !grandTotalUpdated { - if err := purchaseRepoTx.UpdateGrandTotal(ctx, purchase.Id, payload.GrandTotal); err != nil { + if err := purchaseRepoTx.CreateItems(c.Context(), purchase.Id, payload.NewItems); err != nil { return err } } if isInitialApproval { - action := entity.ApprovalActionApproved - if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil { + if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil { return err } return nil } if len(payload.PricingUpdates) > 0 || len(payload.NewItems) > 0 { - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) - if approvalSvc != nil { - latest, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), nil) - if err != nil { - return err - } - - shouldRecordStaffUpdate := latest == nil || - latest.StepNumber != uint16(utils.PurchaseStepStaffPurchase) || - latest.Action == nil || - (latest.Action != nil && *latest.Action != entity.ApprovalActionUpdated) - - if shouldRecordStaffUpdate { - action := entity.ApprovalActionUpdated - if _, err := approvalSvc.CreateApproval( - ctx, - utils.ApprovalWorkflowPurchase, - uint(purchase.Id), - utils.PurchaseStepStaffPurchase, - &action, - actorID, - req.Notes, - ); err != nil { - return err - } - } + if err := s.createPurchaseApproval( + c.Context(), + tx, + purchase.Id, + utils.PurchaseStepStaffPurchase, + entity.ApprovalActionUpdated, + actorID, + req.Notes, + true, // allowDuplicate = true supaya boleh UPDATED berkali2 + ); err != nil { + return err } } @@ -443,77 +487,69 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") + return nil, utils.NotFound("Purchase item not found") } if isInitialApproval { s.Log.Errorf("Failed to approve purchase %d: %+v", purchase.Id, transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase") + return nil, utils.Internal("Failed to approve purchase") } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update purchase pricing") + return nil, utils.Internal("Failed to update purchase pricing") } - updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } - if err := s.attachLatestApproval(ctx, updated); err != nil { + if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } - if len(payload.NewItems) > 0 { - newItems := make([]entity.PurchaseItem, len(payload.NewItems)) - for i, item := range payload.NewItems { - if item == nil { - continue - } - newItems[i] = *item - } - s.notifyExpenseItemsCreated(ctx, purchase.Id, newItems) - } - return updated, nil } -func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) { +func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *validation.ApproveManagerPurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - actorID, err := actorIDFromContext(c) + action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err } - ctx := c.Context() - - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + actorID, err := m.ActorIDFromContext(c) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") + return nil, err + } + + purchase, err := s.loadPurchase(c.Context(), id) + if err != nil { + return nil, err + } + + if action == entity.ApprovalActionApproved { + if err := s.ensureProjectFlockNotClosedForPurchase(c.Context(), purchase); err != nil { + return nil, err } - s.Log.Errorf("Failed to get purchase: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - - if err := s.attachLatestApproval(ctx, purchase); err != nil { - s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) - } - if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepStaffPurchase) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must reach staff purchase step before manager approval") + return nil, utils.BadRequest("Purchase must reach staff purchase step before manager approval") + } + + if action == entity.ApprovalActionRejected { + return s.rejectAndReload(c, utils.PurchaseStepManager, purchase.Id, actorID, req.Notes) } - action := entity.ApprovalActionApproved now := time.Now().UTC() hasExistingPO := purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" var generatedNumber string - transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { updateData := map[string]any{} if !hasExistingPO { repoTx := rPurchase.NewPurchaseRepository(tx) - code, err := repoTx.NextPoNumber(ctx, tx) + code, err := repoTx.NextPoNumber(c.Context(), tx) if err != nil { return err } @@ -524,7 +560,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v if len(updateData) > 0 { repoTx := rPurchase.NewPurchaseRepository(tx) - if err := repoTx.PatchOne(ctx, uint(purchase.Id), updateData, nil); err != nil { + if err := repoTx.PatchOne(c.Context(), uint(purchase.Id), updateData, nil); err != nil { return err } } @@ -537,11 +573,11 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v return db.Where("step_number = ?", uint16(step)) } } - latestStaff, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase)) + latestStaff, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepStaffPurchase)) if err != nil { return err } - latestManager, err := approvalSvcTx.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepManager)) + latestManager, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterByStep(utils.PurchaseStepManager)) if err != nil { return err } @@ -550,7 +586,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v } } - if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepManager, action, actorID, req.Notes, forceManagerApproval); err != nil { + if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepManager, action, actorID, req.Notes, forceManagerApproval); err != nil { return err } @@ -558,7 +594,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v }) if transactionErr != nil { s.Log.Errorf("Failed to approve manager purchase %d: %+v", purchase.Id, transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate purchase order") + return nil, utils.Internal("Failed to generate purchase order") } if generatedNumber != "" { @@ -566,53 +602,95 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v purchase.PoDate = &now } - updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { s.Log.Errorf("Failed to load purchase after manager approval: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } - if err := s.attachLatestApproval(ctx, updated); err != nil { + if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } return updated, nil } -func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) { +func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation.ReceivePurchaseRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } - - actorID, err := actorIDFromContext(c) + ctx := c.Context() + action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err } - ctx := c.Context() - - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + actorID, err := m.ActorIDFromContext(c) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase ") + return nil, err + } + + purchase, err := s.loadPurchase(ctx, id) + if err != nil { + return nil, err } if purchase.PoNumber == nil || strings.TrimSpace(*purchase.PoNumber) == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase order has not been generated") - } - - if err := s.attachLatestApproval(ctx, purchase); err != nil { - s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + return nil, utils.BadRequest("Purchase order has not been generated") } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepManager) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must be approved by manager before receiving products") + return nil, utils.BadRequest("Purchase must be approved by manager before receiving products") + } + if action == entity.ApprovalActionApproved { + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return nil, err + } + } + if action == entity.ApprovalActionApproved && len(req.Items) == 0 { + return nil, utils.BadRequest("Receiving data must not be empty") } - itemMap := make(map[uint64]*entity.PurchaseItem, len(purchase.Items)) + if action == entity.ApprovalActionRejected { + if err := s.createPurchaseApproval(ctx, nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil { + return nil, err + } + updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) + if err != nil { + return nil, utils.Internal("Failed to load purchase") + } + if err := s.attachLatestApproval(ctx, updated); err != nil { + s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + } + return updated, nil + } + + if action == entity.ApprovalActionApproved && len(req.TravelDocuments) > 0 { + if len(req.TravelDocuments) > len(req.Items) { + return nil, utils.BadRequest("Travel documents exceed total receiving items") + } + for idx, file := range req.TravelDocuments { + if file == nil { + continue + } + if idx >= len(req.Items) { + break + } + itemID := req.Items[idx].PurchaseItemID + if itemID == 0 { + return nil, utils.BadRequest("Purchase item id is required for travel document upload") + } + uploadedURL, err := s.uploadTravelDocument(ctx, actorID, itemID, file) + if err != nil { + s.Log.Errorf("Failed to upload travel document for item %d: %+v", itemID, err) + return nil, utils.Internal("Failed to upload travel document") + } + req.Items[idx].TravelDocumentPath = &uploadedURL + } + } + + itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { itemMap[purchase.Items[i].Id] = &purchase.Items[i] } @@ -622,23 +700,30 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati payload validation.ReceivePurchaseItemRequest receivedDate time.Time warehouseID uint + supplierID uint + transportPerItem *float64 overrideWarehouse bool receivedQty float64 } - visitedItems := make(map[uint64]struct{}, len(req.Items)) + visitedItems := make(map[uint]struct{}, len(req.Items)) prepared := make([]preparedReceiving, 0, len(req.Items)) + var earliestReceived *time.Time for _, payload := range req.Items { item, exists := itemMap[payload.PurchaseItemID] if !exists { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) } - receivedDate, err := time.Parse("2006-01-02", payload.ReceivedDate) + receivedDate, err := utils.ParseDateString(payload.ReceivedDate) if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) } receivedDate = receivedDate.UTC() + if earliestReceived == nil || receivedDate.Before(*earliestReceived) { + copy := receivedDate + earliestReceived = © + } warehouseID := uint(item.WarehouseId) overrideWarehouse := false @@ -647,7 +732,10 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati overrideWarehouse = true } if warehouseID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) + } + if payload.WarehouseID != nil && uint(item.WarehouseId) != warehouseID { + return nil, utils.BadRequest("Receiving does not allow changing warehouse") } var receivedQty float64 @@ -657,22 +745,38 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati receivedQty = item.SubQty } if receivedQty < 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID)) } if receivedQty > item.SubQty { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) + return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } if _, dup := visitedItems[payload.PurchaseItemID]; dup { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) } visitedItems[payload.PurchaseItemID] = struct{}{} + supplierID := purchase.SupplierId + if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 { + supplierID = *payload.ExpeditionVendorID + } + + var transportPerItem *float64 + if payload.TransportPerItem != nil { + if *payload.TransportPerItem < 0 { + return nil, utils.BadRequest(fmt.Sprintf("transport_per_item for item %d cannot be negative", payload.PurchaseItemID)) + } + val := *payload.TransportPerItem + transportPerItem = &val + } + prepared = append(prepared, preparedReceiving{ item: item, payload: payload, receivedDate: receivedDate, warehouseID: warehouseID, + supplierID: supplierID, + transportPerItem: transportPerItem, overrideWarehouse: overrideWarehouse, receivedQty: receivedQty, }) @@ -681,13 +785,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati // Require receiving payload to cover all purchase items so that // receiving cannot be submitted partially item-by-item. if len(visitedItems) != len(itemMap) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must be provided for all purchase items") + return nil, utils.BadRequest("Receiving data must be provided for all purchase items") } - receivingAction := entity.ApprovalActionApproved + receivingAction := action completedAction := entity.ApprovalActionApproved + approvalSvc := commonSvc.NewApprovalService( + commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()), + ) - approvalSvc := s.approvalServiceForDB(nil) if approvalSvc != nil { filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { @@ -695,52 +801,64 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati } } - latestReceiving, err := approvalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchase.Id), filterStep(utils.PurchaseStepReceiving)) + latestReceiving, err := approvalSvc.LatestByTarget( + c.Context(), + utils.ApprovalWorkflowPurchase, + uint(purchase.Id), + filterStep(utils.PurchaseStepReceiving), + ) if err != nil { s.Log.Errorf("Failed to inspect receiving approval for purchase %d: %+v", purchase.Id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") + return nil, utils.Internal("Failed to record purchase receiving") } if latestReceiving != nil { receivingAction = entity.ApprovalActionUpdated } } - - transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { repoTx := rPurchase.NewPurchaseRepository(tx) pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) deltas := make(map[uint]float64) affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) + priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared)) + fifoAdds := make([]struct { + itemID uint + pwID uint + qty float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item - var oldPWID *uint - if item.ProductWarehouseId != nil { - idCopy := uint(*item.ProductWarehouseId) - oldPWID = &idCopy - } - var newPWID *uint - clearPW := false - if prep.receivedQty > 0 { - pwID, err := pwRepoTx.EnsureProductWarehouse(ctx, uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) - if err != nil { - return err - } - newPWID = &pwID - deltas[pwID] += prep.receivedQty - affected[pwID] = struct{}{} - } else { - clearPW = true + // Always ensure PW after receiving so linkage stays stable. + pwID, err := pwRepoTx.EnsureProductWarehouse( + c.Context(), + uint(item.ProductId), + prep.warehouseID, + item.ProjectFlockKandangId, + purchase.CreatedBy, + ) + if err != nil { + return err } + newPWID = &pwID - if oldPWID != nil { - deltas[*oldPWID] -= item.TotalQty - affected[*oldPWID] = struct{}{} + deltaQty := prep.receivedQty - item.TotalQty + switch { + case deltaQty > 0 && newPWID != nil: + fifoAdds = append(fifoAdds, struct { + itemID uint + pwID uint + qty float64 + }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + case deltaQty < 0 && newPWID != nil: + deltas[*newPWID] += deltaQty // negative + affected[*newPWID] = struct{}{} } dateCopy := prep.receivedDate @@ -753,7 +871,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati VehicleNumber: prep.payload.VehicleNumber, ReceivedQty: &qtyCopy, ProductWarehouseID: newPWID, - ClearProductWarehouse: clearPW, + ClearProductWarehouse: false, } if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID { @@ -762,43 +880,76 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati } updates = append(updates, update) + + if item.Price > 0 && prep.receivedQty >= 0 { + priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{ + ItemID: item.Id, + Price: item.Price, + TotalPrice: item.Price * prep.receivedQty, + }) + } } - if err := repoTx.UpdateReceivingDetails(ctx, purchase.Id, updates); err != nil { + if err := repoTx.UpdateReceivingDetails(c.Context(), purchase.Id, updates); err != nil { return err } - if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil { + if err := pwRepoTx.AdjustQuantities(c.Context(), deltas, nil); err != nil { return err } - if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil { + if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { return err } - if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { - return err + if len(priceUpdates) > 0 { + if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { + return err + } } - if err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { - return err + // Update due_date based on earliest received date when receiving approved. + if earliestReceived != nil { + due := earliestReceived.AddDate(0, 0, purchase.CreditTerm) + if err := tx.Model(&entity.Purchase{}). + Where("id = ?", purchase.Id). + Update("due_date", due).Error; err != nil { + return err + } + } + + if s.FifoSvc != nil { + for _, adj := range fifoAdds { + if adj.pwID == 0 || adj.qty <= 0 { + continue + } + if _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + StockableKey: purchaseStockableKey, + StockableID: adj.itemID, + ProductWarehouseID: adj.pwID, + Quantity: adj.qty, + Tx: tx, + }); err != nil { + return err + } + } } return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found for receiving") + return nil, utils.NotFound("Purchase item not found for receiving") } s.Log.Errorf("Failed to save purchase receiving %d: %+v", purchase.Id, transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") + return nil, utils.Internal("Failed to record purchase receiving") } - updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase ") + return nil, utils.Internal("Failed to load purchase ") } - if err := s.attachLatestApproval(ctx, updated); err != nil { + if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } @@ -806,57 +957,121 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati for _, prep := range prepared { date := prep.receivedDate payload := ExpenseReceivingPayload{ - PurchaseItemID: prep.item.Id, - ProductID: prep.item.ProductId, - WarehouseID: uint64(prep.warehouseID), - ReceivedQty: prep.receivedQty, - ReceivedDate: &date, + PurchaseItemID: prep.item.Id, + ProductID: prep.item.ProductId, + WarehouseID: uint(prep.warehouseID), + SupplierID: prep.supplierID, + TransportPerItem: prep.transportPerItem, + ReceivedQty: prep.receivedQty, + ReceivedDate: &date, } receivingPayloads = append(receivingPayloads, payload) } - s.notifyExpenseItemsReceived(ctx, purchase.Id, receivingPayloads) + if err := s.notifyExpenseItemsReceived(c, purchase.Id, receivingPayloads); err != nil { + s.Log.Errorf("Failed to sync expense for purchase %d: %+v", purchase.Id, err) + if fe, ok := err.(*fiber.Error); ok { + return nil, fe + } + return nil, utils.Internal("Failed to sync expense") + } + + // Create approvals only after expense sync succeeds + if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { + return nil, err + } + if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { + return nil, err + } return updated, nil } -func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { +func (s *purchaseService) uploadTravelDocument( + ctx context.Context, + actorID uint, + itemID uint, + file *multipart.FileHeader, +) (string, error) { + if file == nil { + return "", errors.New("travel document file is required") + } + if s.DocumentSvc == nil { + return "", errors.New("document service not available") + } + + documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(itemID)) + if err != nil { + return "", err + } + if len(documents) > 0 { + var ids []uint + for _, doc := range documents { + if doc.Type == string(utils.DocumentTypePurchaseTravel) { + ids = append(ids, doc.Id) + } + } + if err := s.DocumentSvc.DeleteDocuments(ctx, ids, true); err != nil { + return "", err + } + } + + documentFiles := []commonSvc.DocumentFile{{ + File: file, + Type: string(utils.DocumentTypePurchaseTravel), + }} + results, err := s.DocumentSvc.UploadDocuments(ctx, commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypePurchaseItem), + DocumentableID: uint64(itemID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return "", err + } + if len(results) == 0 { + return "", errors.New("upload result is empty") + } + return results[0].Document.Path, nil +} + +func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } ctx := c.Context() - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") + return nil, utils.NotFound("Purchase not found") } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + return nil, utils.Internal("Failed to get purchase") } if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return nil, err + } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber == uint16(utils.PurchaseStepPengajuan) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot delete items before staff purchase approval") + return nil, utils.BadRequest("Purchase cannot delete items before staff purchase approval") } if len(purchase.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to delete") + return nil, utils.BadRequest("Purchase has no items to delete") } - requested := make(map[uint64]struct{}, len(req.ItemIDs)) + requested := make(map[uint]struct{}, len(req.ItemIDs)) for _, id := range req.ItemIDs { requested[id] = struct{}{} } - toDelete := make([]uint64, 0, len(req.ItemIDs)) + toDelete := make([]uint, 0, len(req.ItemIDs)) var remainingTotal float64 for _, item := range purchase.Items { if _, ok := requested[item.Id]; ok { - if item.TotalQty > 0 || item.TotalUsed > 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete item %d because it already has receiving data", item.Id)) - } toDelete = append(toDelete, item.Id) } else { remainingTotal += item.TotalPrice @@ -864,11 +1079,22 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.D } if len(toDelete) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Requested items were not found in this purchase") + return nil, utils.BadRequest("Requested items were not found in this purchase") + } + + toDeleteSet := make(map[uint]struct{}, len(toDelete)) + for _, id := range toDelete { + toDeleteSet[id] = struct{}{} + } + itemsToDelete := make([]entity.PurchaseItem, 0, len(toDelete)) + for _, item := range purchase.Items { + if _, ok := toDeleteSet[item.Id]; ok { + itemsToDelete = append(itemsToDelete, item) + } } if len(purchase.Items)-len(toDelete) <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item") + return nil, utils.BadRequest("Purchase must keep at least one item") } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -878,26 +1104,32 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.D return err } - if err := repoTx.UpdateGrandTotal(ctx, purchase.Id, remainingTotal); err != nil { - return err - } - return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") + return nil, utils.NotFound("Purchase item not found") } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items") + return nil, utils.Internal("Failed to delete purchase items") } - if len(toDelete) > 0 { - s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete) + if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil { + return nil, utils.Internal("Failed to delete purchase documents") } - updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if len(itemsToDelete) > 0 { + if err := s.notifyExpenseItemsDeleted(ctx, purchase.Id, itemsToDelete); err != nil { + s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", purchase.Id, err) + if fe, ok := err.(*fiber.Error); ok { + return nil, fe + } + return nil, utils.Internal("Failed to sync expense") + } + } + + updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) @@ -906,23 +1138,23 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.D return updated, nil } -func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { +func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { if id == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + return utils.BadRequest("Invalid purchase id") } ctx := c.Context() - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + purchase, err := s.loadPurchase(ctx, id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + return err + } + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return err } - itemIDs := make([]uint64, 0, len(purchase.Items)) - for _, item := range purchase.Items { - itemIDs = append(itemIDs, item.Id) + itemsToDelete := make([]entity.PurchaseItem, len(purchase.Items)) + for i, item := range purchase.Items { + itemsToDelete[i] = item } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -939,13 +1171,23 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Purchase not found") + return utils.NotFound("Purchase not found") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase") + return utils.Internal("Failed to delete purchase") } - if len(itemIDs) > 0 { - s.notifyExpenseItemsDeleted(ctx, uint64(id), itemIDs) + if err := s.deletePurchaseItemDocuments(ctx, itemsToDelete); err != nil { + return utils.Internal("Failed to delete purchase documents") + } + + if len(itemsToDelete) > 0 { + if err := s.notifyExpenseItemsDeleted(ctx, uint(id), itemsToDelete); err != nil { + s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", id, err) + if fe, ok := err.(*fiber.Error); ok { + return fe + } + return utils.Internal("Failed to sync expense") + } } return nil @@ -954,7 +1196,7 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { func (s *purchaseService) createPurchaseApproval( ctx context.Context, db *gorm.DB, - purchaseID uint64, + purchaseID uint, step approvalutils.ApprovalStep, action entity.ApprovalAction, actorID uint, @@ -962,13 +1204,16 @@ func (s *purchaseService) createPurchaseApproval( allowDuplicate bool, ) error { if purchaseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval") + return utils.BadRequest("Purchase is invalid for approval") } if actorID == 0 { actorID = 1 } svc := s.approvalServiceForDB(db) + if svc == nil { + return utils.Internal("Approval service not available") + } modifier := func(db *gorm.DB) *gorm.DB { return db.Where("step_number = ?", uint16(step)) @@ -997,87 +1242,42 @@ func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalSe if s.ApprovalSvc != nil { return s.ApprovalSvc } - return commonSvc.NewApprovalService(s.ApprovalRepo) + if s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil { + return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB())) + } + return nil } -func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error { - if len(items) == 0 || s.ApprovalSvc == nil { +func (s *purchaseService) notifyExpenseItemsReceived(c *fiber.Ctx, purchaseID uint, payloads []ExpenseReceivingPayload) error { + if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 { return nil } + return s.ExpenseBridge.OnItemsReceived(c, purchaseID, payloads) +} - ids := make([]uint, 0, len(items)) - visited := make(map[uint64]struct{}, len(items)) +func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error { + if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { + return nil + } + return s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, items) + +} + +func (s *purchaseService) deletePurchaseItemDocuments(ctx context.Context, items []entity.PurchaseItem) error { + if s.DocumentSvc == nil || len(items) == 0 { + return nil + } for _, item := range items { if item.Id == 0 { continue } - if _, ok := visited[item.Id]; ok { - continue - } - visited[item.Id] = struct{}{} - ids = append(ids, uint(item.Id)) - } - - if len(ids) == 0 { - return nil - } - - latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowPurchase, ids, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - return err - } - - for i := range items { - if items[i].Id == 0 { - continue - } - if approval, ok := latestMap[uint(items[i].Id)]; ok { - items[i].LatestApproval = approval - } else { - items[i].LatestApproval = nil + if err := s.DocumentSvc.DeleteByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id), true); err != nil { + return err } } - return nil } -func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint64, items []entity.PurchaseItem) { - if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { - return - } - if err := s.ExpenseBridge.OnItemsCreated(ctx, purchaseID, items); err != nil { - s.Log.Warnf("Failed to notify expense bridge for created purchase %d: %+v", purchaseID, err) - } -} - -func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint64, payloads []ExpenseReceivingPayload) { - if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 { - return - } - if err := s.ExpenseBridge.OnItemsReceived(ctx, purchaseID, payloads); err != nil { - s.Log.Warnf("Failed to notify expense bridge for received purchase %d: %+v", purchaseID, err) - } -} - -func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) { - if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 { - return - } - if err := s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, itemIDs); err != nil { - s.Log.Warnf("Failed to notify expense bridge for deleted purchase %d: %+v", purchaseID, err) - } -} - -func actorIDFromContext(c *fiber.Ctx) (uint, error) { - user, ok := authmiddleware.AuthenticatedUser(c) - if !ok || user == nil || user.Id == 0 { - return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } - return user.Id, nil -} - func (s *purchaseService) buildStaffAdjustmentPayload( ctx context.Context, purchase *entity.Purchase, @@ -1085,10 +1285,10 @@ func (s *purchaseService) buildStaffAdjustmentPayload( syncReceiving bool, ) (*staffAdjustmentPayload, error) { if len(req.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + return nil, utils.BadRequest("Items must not be empty") } - requestItems := make(map[uint64]validation.StaffPurchaseApprovalItem, len(req.Items)) + requestItems := make(map[uint]validation.StaffPurchaseApprovalItem, len(req.Items)) newPayloads := make([]validation.StaffPurchaseApprovalItem, 0) for _, item := range req.Items { @@ -1097,13 +1297,12 @@ func (s *purchaseService) buildStaffAdjustmentPayload( continue } if _, exists := requestItems[item.PurchaseItemID]; exists { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID)) } requestItems[item.PurchaseItemID] = item } updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) - var grandTotal float64 existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads)) for _, item := range purchase.Items { @@ -1111,39 +1310,36 @@ func (s *purchaseService) buildStaffAdjustmentPayload( existingCombos[key] = struct{}{} } - allowedWarehouses := make(map[uint64]struct{}, len(purchase.Items)) + allowedWarehouses := make(map[uint]struct{}, len(purchase.Items)) for _, item := range purchase.Items { allowedWarehouses[item.WarehouseId] = struct{}{} } if len(allowedWarehouses) == 0 && len(newPayloads) > 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "No available warehouses for this purchase") + return nil, utils.BadRequest("No available warehouses for this purchase") } for _, item := range purchase.Items { data, ok := requestItems[item.Id] if !ok { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Missing pricing data for item %d", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Missing pricing data for item %d", item.Id)) } if data.ProductID != 0 && data.ProductID != item.ProductId { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id), - ) + return nil, utils.BadRequest(fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id)) } if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) } effectiveQty := item.SubQty if data.Qty != nil { if *data.Qty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) } if item.TotalUsed > 0 && *data.Qty < item.TotalUsed { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) + return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) } if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id)) } effectiveQty = *data.Qty } @@ -1169,49 +1365,40 @@ func (s *purchaseService) buildStaffAdjustmentPayload( } updates = append(updates, update) - grandTotal += totalPrice delete(requestItems, item.Id) } if len(requestItems) > 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase") + return nil, utils.BadRequest("Found pricing data for items that do not belong to this purchase") } - productSupplierCache := make(map[uint64]bool) + productSupplierCache := make(map[uint]bool) newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) + emptyVehicle := "" for _, payload := range newPayloads { if payload.ProductID == 0 || payload.WarehouseID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Product and warehouse must be provided for new items") + return nil, utils.BadRequest("Product and warehouse must be provided for new items") } if payload.Qty == nil || *payload.Qty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID)) + return nil, utils.BadRequest(fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID)) } if _, ok := allowedWarehouses[payload.WarehouseID]; !ok { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID), - ) + return nil, utils.BadRequest(fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID)) } key := fmt.Sprintf("%d:%d", payload.ProductID, payload.WarehouseID) if _, exists := existingCombos[key]; exists { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID), - ) + return nil, utils.BadRequest(fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID)) } if _, checked := productSupplierCache[payload.ProductID]; !checked { linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(payload.ProductID), uint(purchase.SupplierId)) if err != nil { s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", payload.ProductID, purchase.SupplierId, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + return nil, utils.Internal("Failed to validate product for supplier") } if !linked { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId), - ) + return nil, utils.BadRequest(fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId)) } productSupplierCache[payload.ProductID] = true } @@ -1224,50 +1411,49 @@ func (s *purchaseService) buildStaffAdjustmentPayload( } newItem := &entity.PurchaseItem{ - PurchaseId: purchase.Id, - ProductId: payload.ProductID, - WarehouseId: payload.WarehouseID, - SubQty: qty, - TotalQty: 0, - TotalUsed: 0, - Price: payload.Price, - TotalPrice: totalPrice, + PurchaseId: purchase.Id, + ProductId: payload.ProductID, + WarehouseId: payload.WarehouseID, + SubQty: qty, + TotalQty: 0, + TotalUsed: 0, + Price: payload.Price, + TotalPrice: totalPrice, + VehicleNumber: &emptyVehicle, } newItems = append(newItems, newItem) existingCombos[key] = struct{}{} - grandTotal += totalPrice } if len(updates) == 0 && len(newItems) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to process") + return nil, utils.BadRequest("Purchase has no items to process") } return &staffAdjustmentPayload{ PricingUpdates: updates, NewItems: newItems, - GrandTotal: grandTotal, }, nil } +// ? helper func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) { if quantity <= 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for %s must be greater than 0", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Quantity for %s must be greater than 0", ref)) } if price <= 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for %s must be greater than 0", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Price for %s must be greater than 0", ref)) } - fmt.Println(price, quantity) expectedTotal := price * quantity if provided == nil { return expectedTotal, nil } if *provided <= 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must be greater than 0", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must be greater than 0", ref)) } if math.Abs(*provided-expectedTotal) > priceTolerance { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must equal quantity x price", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must equal quantity x price", ref)) } return *provided, nil } @@ -1288,53 +1474,127 @@ func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity return nil } -func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) { - var fromPtr *time.Time - var toPtr *time.Time - - if strings.TrimSpace(fromStr) != "" { - parsed, err := time.Parse(queryDateLayout, fromStr) - if err != nil { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must use format YYYY-MM-DD") - } - fromValue := parsed - fromPtr = &fromValue - } - - if strings.TrimSpace(toStr) != "" { - parsed, err := time.Parse(queryDateLayout, toStr) - if err != nil { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_to must use format YYYY-MM-DD") - } - toValue := parsed.AddDate(0, 0, 1) - toPtr = &toValue - } - - if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must be earlier than created_to") - } - - return fromPtr, toPtr, nil -} - -func parseApprovalAction(status string) (*entity.ApprovalAction, bool, error) { - value := strings.TrimSpace(strings.ToUpper(status)) - if value == "" { - return nil, false, nil - } - - if value == "COMPLETED" { - return nil, true, nil - } - - action := entity.ApprovalAction(value) - switch action { - case entity.ApprovalActionApproved, - entity.ApprovalActionRejected, - entity.ApprovalActionCreated, - entity.ApprovalActionUpdated: - return &action, false, nil +func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) { + value := strings.ToUpper(strings.TrimSpace(raw)) + switch value { + case string(entity.ApprovalActionApproved): + return entity.ApprovalActionApproved, nil + case string(entity.ApprovalActionRejected): + return entity.ApprovalActionRejected, nil default: - return nil, false, fiber.NewError(fiber.StatusBadRequest, "Invalid status filter") + return "", utils.BadRequest("action must be APPROVED or REJECTED") } } + +func (s *purchaseService) rejectAndReload( + c *fiber.Ctx, + step approvalutils.ApprovalStep, + purchaseID uint, + actorID uint, + notes *string, +) (*entity.Purchase, error) { + + if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil { + return nil, err + } + return s.loadPurchase(c.Context(), purchaseID) +} +func (s *purchaseService) loadPurchase( + ctx context.Context, + id uint, +) (*entity.Purchase, error) { + purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, utils.NotFound("Purchase not found") + } + s.Log.Errorf("Failed to get purchase %d: %+v", id, err) + return nil, utils.Internal("Failed to get purchase") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) + } + s.applyTravelDocumentURLs(ctx, purchase) + + return purchase, nil +} + +func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase *entity.Purchase) { + if purchase == nil || s.DocumentSvc == nil { + return + } + + for i := range purchase.Items { + item := &purchase.Items[i] + documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id)) + if err != nil { + s.Log.Warnf("Unable to load travel documents for purchase item %d: %+v", item.Id, err) + } else { + var targetDoc *entity.Document + for j := len(documents) - 1; j >= 0; j-- { + if documents[j].Type == string(utils.DocumentTypePurchaseTravel) { + targetDoc = &documents[j] + break + } + } + if targetDoc != nil { + url, err := s.DocumentSvc.PresignURL(ctx, *targetDoc, 15*time.Minute) + if err != nil { + s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err) + } else if url != "" { + item.TravelNumberDocs = &url + continue + } + } + } + + path := item.TravelNumberDocs + if path == nil || strings.TrimSpace(*path) == "" { + continue + } + url, err := commonSvc.ResolveDocumentURL(ctx, s.DocumentSvc, *path, 15*time.Minute) + if err != nil { + s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err) + continue + } + if url == "" { + continue + } + item.TravelNumberDocs = &url + } +} + +func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { + seen := make(map[uint]struct{}) + ids := make([]uint, 0) + + for _, item := range p.Items { + if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 { + continue + } + id := uint(*item.ProjectFlockKandangId) + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids +} +func (s *purchaseService) ensureProjectFlockNotClosedForPurchase( + ctx context.Context, + purchase *entity.Purchase, +) error { + pfkIDs := collectPFKIDsFromPurchase(purchase) + if len(pfkIDs) == 0 { + return nil + } + + db := s.PurchaseRepo.DB() + if db == nil { + return utils.Internal("DB not available for project flock validation") + } + + return commonSvc.EnsureProjectFlockNotClosedByProjectFlockKandangID(ctx, db, pfkIDs) +} diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 4994a927..564cc96f 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -1,5 +1,7 @@ package validation +import "mime/multipart" + type PurchaseItemPayload struct { WarehouseID uint `json:"warehouse_id" validate:"required,gt=0"` ProductID uint `json:"product_id" validate:"required,gt=0"` @@ -8,56 +10,64 @@ type PurchaseItemPayload struct { type CreatePurchaseRequest struct { SupplierID uint `json:"supplier_id" validate:"required,gt=0"` - CreditTerm int `json:"credit_term" validate:"required,gte=0"` + CreditTerm int `json:"credit_term" validate:"required,number,gte=0"` + DueDate *string `json:"due_date,omitempty" validate:"omitempty,datetime=2006-01-02"` Notes *string `json:"notes" validate:"omitempty,max=500"` Items []PurchaseItemPayload `json:"items" validate:"required,min=1,dive"` } type StaffPurchaseApprovalItem struct { - PurchaseItemID uint64 `json:"purchase_item_id,omitempty" validate:"omitempty,gt=0"` + PurchaseItemID uint `json:"purchase_item_id,omitempty" validate:"omitempty,gt=0"` // For new items (no purchase_item_id), product_id is required. - ProductID uint64 `json:"product_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` - WarehouseID uint64 `json:"warehouse_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + ProductID uint `json:"product_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` + WarehouseID uint `json:"warehouse_id,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` Qty *float64 `json:"qty,omitempty" validate:"required_without=PurchaseItemID,omitempty,gt=0"` Price float64 `json:"price" validate:"required,gt=0"` TotalPrice float64 `json:"total_price" validate:"required,gt=0"` } type ApproveStaffPurchaseRequest struct { - Items []StaffPurchaseApprovalItem `json:"items" validate:"required,min=1,dive"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"` + Items []StaffPurchaseApprovalItem `json:"items" validate:"omitempty,min=1,dive"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } type ApproveManagerPurchaseRequest struct { - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } type ReceivePurchaseItemRequest struct { - PurchaseItemID uint64 `json:"purchase_item_id" validate:"required,gt=0"` - WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"` - ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"` - TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"` - TravelDocumentPath *string `json:"travel_document_path" validate:"omitempty,max=255"` - VehicleNumber *string `json:"vehicle_number" validate:"omitempty,max=100"` - ReceivedQty *float64 `json:"received_qty" validate:"omitempty,gte=0"` + PurchaseItemID uint `form:"purchase_item_id" json:"purchase_item_id" validate:"required,gt=0"` + WarehouseID *uint `form:"warehouse_id" json:"warehouse_id" validate:"omitempty,gt=0"` + ReceivedDate string `form:"received_date" json:"received_date" validate:"required,datetime=2006-01-02"` + ExpeditionVendorID *uint `form:"expedition_vendor_id" json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"` + TransportPerItem *float64 `form:"transport_per_item" json:"transport_per_item,omitempty" validate:"omitempty,gte=0"` + TravelNumber *string `form:"travel_number" json:"travel_number" validate:"omitempty,max=100"` + TravelDocumentPath *string `form:"travel_document_path" json:"travel_document_path" validate:"omitempty,max=1024"` + VehicleNumber *string `form:"vehicle_number" json:"vehicle_number" validate:"omitempty,max=100"` + ReceivedQty *float64 `form:"received_qty" json:"received_qty" validate:"omitempty,gte=0"` } type ReceivePurchaseRequest struct { - Items []ReceivePurchaseItemRequest `json:"items" validate:"required,min=1,dive"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + Action string `form:"action" json:"action" validate:"required,oneof=APPROVED REJECTED"` + Items []ReceivePurchaseItemRequest `form:"items" json:"items" validate:"min=1,dive"` + TravelDocuments []*multipart.FileHeader `form:"travel_documents" json:"-" validate:"omitempty,dive"` + Notes *string `form:"notes" json:"notes,omitempty" validate:"omitempty,max=500"` } type DeletePurchaseItemsRequest struct { - ItemIDs []uint64 `json:"item_ids" validate:"required,min=1,dive,gt=0"` + ItemIDs []uint `json:"item_ids" validate:"required,min=1,dive,gt=0"` } -type PurchaseQuery struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` - Search string `query:"search" validate:"omitempty,max=100"` - PrNumber string `query:"pr_number" validate:"omitempty,max=50"` - CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` - CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` - Status string `query:"status" validate:"omitempty,oneof=CREATED UPDATED APPROVED REJECTED COMPLETED"` +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + SupplierID uint `query:"supplier_id" validate:"omitempty,gt=0"` + AreaID uint `query:"area_id" validate:"omitempty,gt=0"` + LocationID uint `query:"location_id" validate:"omitempty,gt=0"` + ProductCategoryID uint `query:"product_category_id" validate:"omitempty,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` + CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"` + CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"` } diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go new file mode 100644 index 00000000..39136e85 --- /dev/null +++ b/internal/modules/repports/controllers/repport.controller.go @@ -0,0 +1,229 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +// === Marketing Report Response === + +type MarketingReportResponse struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta response.Meta `json:"meta"` + Data []dto.RepportMarketingItemDTO `json:"data"` + Total *dto.Summary `json:"total,omitempty"` +} + +type RepportController struct { + RepportService service.RepportService +} + +func NewRepportController(repportService service.RepportService) *RepportController { + return &RepportController{ + RepportService: repportService, + } +} + +func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { + query := &validation.ExpenseQuery{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + Search: ctx.Query("search", ""), + Category: ctx.Query("category", ""), + SupplierId: int64(ctx.QueryInt("supplier_id", 0)), + KandangId: int64(ctx.QueryInt("kandang_id", 0)), + ProjectFlockKandangId: int64(ctx.QueryInt("project_flock_kandang_id", 0)), + NonstockId: int64(ctx.QueryInt("nonstock_id", 0)), + AreaId: int64(ctx.QueryInt("area_id", 0)), + LocationId: int64(ctx.QueryInt("location_id", 0)), + RealizationDate: ctx.Query("realization_date", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := c.RepportService.GetExpense(ctx, query) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.RepportExpenseListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get expense report successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + }) +} + +func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { + query := &validation.MarketingQuery{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + Search: ctx.Query("search", ""), + CustomerId: int64(ctx.QueryInt("customer_id", 0)), + ProductId: int64(ctx.QueryInt("product_id", 0)), + WarehouseId: int64(ctx.QueryInt("warehouse_id", 0)), + SalesPersonId: int64(ctx.QueryInt("sales_person_id", 0)), + FilterBy: ctx.Query("filter_by", ""), + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), + SortBy: ctx.Query("sort_by", ""), + SortOrder: ctx.Query("sort_order", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := c.RepportService.GetMarketing(ctx, query) + if err != nil { + return err + } + total := dto.ToSummaryFromDTOItems(result) + + return ctx.Status(fiber.StatusOK). + JSON(MarketingReportResponse{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get marketing report successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + Total: total, + }) +} + +func (c *RepportController) GetPurchaseSupplier(ctx *fiber.Ctx) error { + query := &validation.PurchaseSupplierQuery{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + AreaId: int64(ctx.QueryInt("area_id", 0)), + SupplierId: int64(ctx.QueryInt("supplier_id", 0)), + ProductId: int64(ctx.QueryInt("product_id", 0)), + ProductCategoryId: int64(ctx.QueryInt("product_category_id", 0)), + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), + SortBy: ctx.Query("sort_by", ""), + FilterBy: ctx.Query("filter_by", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := c.RepportService.GetPurchaseSupplier(ctx, query) + if err != nil { + return err + } + + filters := map[string]interface{}{ + "area_id": query.AreaId, + "supplier_id": query.SupplierId, + "product_id": query.ProductId, + "product_category_id": query.ProductCategoryId, + "start_date": query.StartDate, + "end_date": query.EndDate, + "sort_by": query.SortBy, + "filter_by": query.FilterBy, + } + + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.PurchaseSupplierDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get supplier purchase recap successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + Filters: filters, + }, + Data: result, + }) +} + +func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error { + data, meta, err := c.RepportService.GetHppPerKandang(ctx) + if err != nil { + return err + } + + resp := struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Meta dto.HppPerKandangMetaDTO `json:"meta"` + Data dto.HppPerKandangResponseData `json:"data"` + }{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get HPP harian kandang layer successfully", + Meta: *meta, + Data: *data, + } + + return ctx.Status(fiber.StatusOK).JSON(resp) +} + +func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { + idParam := ctx.Params("idProjectFlockKandang") + if idParam == "" { + return fiber.NewError(fiber.StatusBadRequest, "idProjectFlockKandang is required") + } + + projectFlockKandangID, err := strconv.ParseUint(idParam, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid idProjectFlockKandang") + } + + query := &validation.ProductionResultQuery{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + ProjectFlockKandangID: uint(projectFlockKandangID), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + data, totalResults, err := c.RepportService.GetProductionResult(ctx, query) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductionResultDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get Laporan Hasil Produksi successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: data, + }) +} diff --git a/internal/modules/repports/dto/repportExpense.dto.go b/internal/modules/repports/dto/repportExpense.dto.go new file mode 100644 index 00000000..3e71df2c --- /dev/null +++ b/internal/modules/repports/dto/repportExpense.dto.go @@ -0,0 +1,179 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" +) + +// === DTO Structs === + +type RepportExpenseBaseDTO struct { + Id uint64 `json:"id"` + ReferenceNumber string `json:"reference_number"` + PoNumber string `json:"po_number"` + Category string `json:"category"` + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"` + RealizationDate *time.Time `json:"realization_date,omitempty"` + TransactionDate time.Time `json:"transaction_date"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RepportExpensePengajuanDTO struct { + Id uint64 `json:"id"` + ExpenseId *uint64 `json:"expense_id,omitempty"` + ProjectFlockKandangId *uint64 `json:"project_flock_kandang_id,omitempty"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Notes string `json:"notes"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type RepportExpenseRealisasiDTO struct { + Id *uint64 `json:"id,omitempty"` + ExpenseNonstockId *uint64 `json:"expense_nonstock_id,omitempty"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Notes string `json:"notes"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type RepportExpenseListDTO struct { + RepportExpenseBaseDTO + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + Pengajuan RepportExpensePengajuanDTO `json:"pengajuan"` + Realisasi RepportExpenseRealisasiDTO `json:"realisasi"` + TotalPengajuan float64 `json:"total_pengajuan"` + TotalRealisasi float64 `json:"total_realisasi"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval,omitempty"` +} + +// === MAPPERS === + +func ToRepportExpenseBaseDTO(e *entity.Expense) RepportExpenseBaseDTO { + var realizationDate *time.Time + if !e.RealizationDate.IsZero() { + realizationDate = &e.RealizationDate + } + + var supplier *supplierDTO.SupplierRelationDTO + if e.Supplier != nil && e.Supplier.Id != 0 { + mapped := supplierDTO.ToSupplierRelationDTO(*e.Supplier) + supplier = &mapped + } + + return RepportExpenseBaseDTO{ + Id: e.Id, + ReferenceNumber: e.ReferenceNumber, + PoNumber: e.PoNumber, + Category: e.Category, + Supplier: supplier, + RealizationDate: realizationDate, + TransactionDate: e.TransactionDate, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToRepportExpensePengajuanDTO(ns *entity.ExpenseNonstock) RepportExpensePengajuanDTO { + var nonstock *nonstockDTO.NonstockRelationDTO + if ns.Nonstock != nil && ns.Nonstock.Id != 0 { + mapped := nonstockDTO.ToNonstockRelationDTO(*ns.Nonstock) + nonstock = &mapped + } + + return RepportExpensePengajuanDTO{ + Id: ns.Id, + ExpenseId: ns.ExpenseId, + ProjectFlockKandangId: ns.ProjectFlockKandangId, + Qty: ns.Qty, + Price: ns.Price, + Notes: ns.Notes, + Nonstock: nonstock, + CreatedAt: ns.CreatedAt, + } +} + +func ToRepportExpenseRealisasiDTO(r *entity.ExpenseRealization) RepportExpenseRealisasiDTO { + var nonstock *nonstockDTO.NonstockRelationDTO + if r.ExpenseNonstock != nil && r.ExpenseNonstock.Nonstock != nil && r.ExpenseNonstock.Nonstock.Id != 0 { + mapped := nonstockDTO.ToNonstockRelationDTO(*r.ExpenseNonstock.Nonstock) + nonstock = &mapped + } + + return RepportExpenseRealisasiDTO{ + Id: r.ExpenseNonstockId, + ExpenseNonstockId: r.ExpenseNonstockId, + Qty: r.Qty, + Price: r.Price, + Notes: r.Notes, + Nonstock: nonstock, + CreatedAt: r.CreatedAt, + } +} + +func ToRepportExpenseListDTO(baseDTO RepportExpenseBaseDTO, ns *entity.ExpenseNonstock, latestApproval *approvalDTO.ApprovalRelationDTO) RepportExpenseListDTO { + var realisasi RepportExpenseRealisasiDTO + if ns.Realization != nil { + realisasi = ToRepportExpenseRealisasiDTO(ns.Realization) + } + + totalPengajuan := ns.Qty * ns.Price + totalRealisasi := float64(0) + if ns.Realization != nil { + totalRealisasi = ns.Realization.Qty * ns.Realization.Price + } + + // Get kandang data at the main level + var kandang *kandangDTO.KandangRelationDTO + if ns.Kandang != nil && ns.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(*ns.Kandang) + kandang = &mapped + } + + return RepportExpenseListDTO{ + RepportExpenseBaseDTO: baseDTO, + Kandang: kandang, + Pengajuan: ToRepportExpensePengajuanDTO(ns), + Realisasi: realisasi, + TotalPengajuan: totalPengajuan, + TotalRealisasi: totalRealisasi, + LatestApproval: latestApproval, + } +} + +func ToRepportExpenseListDTOs(realizations []entity.ExpenseRealization) []RepportExpenseListDTO { + result := make([]RepportExpenseListDTO, 0, len(realizations)) + + for _, realization := range realizations { + if realization.ExpenseNonstock == nil || realization.ExpenseNonstock.Expense == nil { + continue + } + + expense := realization.ExpenseNonstock.Expense + baseDTO := ToRepportExpenseBaseDTO(expense) + + var latestApproval *approvalDTO.ApprovalRelationDTO + if expense.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*expense.LatestApproval) + latestApproval = &mapped + } + + // Create a temporary realization with the current realization data + if realization.ExpenseNonstock.Realization == nil { + realization.ExpenseNonstock.Realization = &realization + } + + dto := ToRepportExpenseListDTO(baseDTO, realization.ExpenseNonstock, latestApproval) + result = append(result, dto) + } + + return result +} diff --git a/internal/modules/repports/dto/repportHpp.dto.go b/internal/modules/repports/dto/repportHpp.dto.go new file mode 100644 index 00000000..63c5dce9 --- /dev/null +++ b/internal/modules/repports/dto/repportHpp.dto.go @@ -0,0 +1,123 @@ +package dto + +type HppPerKandangFiltersDTO struct { + AreaID string `json:"area_id"` + LocationID string `json:"location_id"` + KandangID string `json:"kandang_id"` + WeightMin string `json:"weight_min"` + WeightMax string `json:"weight_max"` + Period string `json:"period"` + ShowUnrecorded string `json:"show_unrecorded"` +} + +type HppPerKandangMetaDTO struct { + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int64 `json:"total_pages"` + TotalResults int64 `json:"total_results"` + Filters HppPerKandangFiltersDTO `json:"filters"` +} + +type HppPerKandangResponseData struct { + Period string `json:"period"` + Rows []HppPerKandangRowDTO `json:"rows"` + Summary HppPerKandangSummaryDTO `json:"summary"` +} + +type HppPerKandangRowDTO struct { + ID int `json:"id"` + Kandang HppPerKandangRowKandangDTO `json:"kandang"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + RemainingChickenBirds int64 `json:"remaining_chicken_birds"` + RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` + AvgWeightKg float64 `json:"avg_weight_kg"` + EggProductionPieces int64 `json:"egg_production_pieces"` + EggProductionKg float64 `json:"egg_production_kg"` + // FeedCostRp float64 `json:"feed_cost_rp"` + // OvkCostRp float64 `json:"ovk_cost_rp"` + EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` + EggValueRp int64 `json:"egg_value_rp"` + FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` + DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` + AverageDocPriceRp int64 `json:"average_doc_price_rp"` + HppRp float64 `json:"hpp_rp"` + RemainingValueRp int64 `json:"remaining_value_rp"` +} + +type HppPerKandangRowKandangDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Location HppPerKandangLocationDTO `json:"location"` + Pic HppPerKandangPICDTO `json:"pic"` +} + +type HppPerKandangLocationDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type HppPerKandangPICDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +type HppPerKandangWeightRangeDTO struct { + WeightMin float64 `json:"weight_min"` + WeightMax float64 `json:"weight_max"` +} + +type HppPerKandangSupplierDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` +} + +type HppPerKandangSummaryDTO struct { + PerWeightRange []HppPerKandangSummaryWeightRangeDTO `json:"per_weight_range"` + Total HppPerKandangSummaryTotalDTO `json:"total"` +} + +type HppPerKandangSummaryWeightRangeDTO struct { + ID int `json:"id"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + Label string `json:"label"` + RemainingChickenBirds int64 `json:"remaining_chicken_birds"` + RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` + AvgWeightKg float64 `json:"avg_weight_kg"` + EggProductionPieces int64 `json:"egg_production_pieces"` + EggProductionKg float64 `json:"egg_production_kg"` + EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` + EggValueRp int64 `json:"egg_value_rp"` + FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` + DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` + AverageDocPriceRp float64 `json:"average_doc_price_rp"` + HppRp float64 `json:"hpp_rp"` + RemainingValueRp int64 `json:"remaining_value_rp"` +} + +type HppPerKandangSummaryTotalDTO struct { + TotalRemainingChickenBirds int64 `json:"total_remaining_chicken_birds"` + TotalRemainingChickenWeightKg float64 `json:"total_remaining_chicken_weight_kg"` + AverageWeightKg float64 `json:"average_weight_kg"` + TotalRemainingValueRp int64 `json:"total_remaining_value_rp"` + TotalEggProductionPieces int64 `json:"total_egg_production_pieces"` + TotalEggProductionKg float64 `json:"total_egg_production_kg"` + AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"` + TotalEggValueRp int64 `json:"total_egg_value_rp"` + TotalHppRp float64 `json:"total_hpp_rp"` + TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"` +} + +func NewHppPerKandangFiltersDTO(area, location, kandang, weightMin, weightMax, period, showUnrecorded string) HppPerKandangFiltersDTO { + return HppPerKandangFiltersDTO{ + AreaID: area, + LocationID: location, + KandangID: kandang, + WeightMin: weightMin, + WeightMax: weightMax, + Period: period, + ShowUnrecorded: showUnrecorded, + } +} diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go new file mode 100644 index 00000000..90c2fe50 --- /dev/null +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -0,0 +1,261 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + marketingDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type RepportMarketingItemDTO struct { + ID int `json:"id"` + SoDate time.Time `json:"so_date"` + RealizationDate time.Time `json:"realization_date"` + AgingDays int `json:"aging_days"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` + DoNumber string `json:"do_number"` + Sales *userDTO.UserRelationDTO `json:"sales,omitempty"` + VehicleNumber string `json:"vehicle_number"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + MarketingType string `json:"marketing_type"` + Qty float64 `json:"qty"` + AverageWeightKg float64 `json:"average_weight_kg"` + TotalWeightKg float64 `json:"total_weight_kg"` + SalesPricePerKg float64 `json:"sales_price_per_kg"` + HppPricePerKg float64 `json:"hpp_price_per_kg"` + SalesAmount float64 `json:"sales_amount"` + HppAmount float64 `json:"hpp_amount"` +} + +type Summary struct { + TotalQty int `json:"total_qty"` + TotalWeightKg float64 `json:"total_weight_kg"` + TotalSalesAmount int64 `json:"total_sales_amount"` + TotalHppAmount int64 `json:"total_hpp_amount"` + TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"` +} + +type RepportMarketingResponseDTO struct { + Items []RepportMarketingItemDTO `json:"items"` + Total *Summary `json:"total,omitempty"` +} + +func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingItemDTO { + soDate := time.Time{} + agingDays := 0 + if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { + soDate = mdp.MarketingProduct.Marketing.SoDate + agingDays = int(time.Since(soDate).Hours() / 24) + } + + realizationDate := time.Time{} + if mdp.DeliveryDate != nil { + realizationDate = *mdp.DeliveryDate + } + + doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId) + + totalWeightKg := mdp.UsageQty * mdp.AvgWeight + salesAmount := totalWeightKg * mdp.UnitPrice + + var hpp float64 + var hppAmount float64 + if isProductEligibleForHpp(mdp, category) { + hpp = hppPricePerKg + hppAmount = totalWeightKg * hppPricePerKg + } + + item := RepportMarketingItemDTO{ + ID: int(mdp.Id), + SoDate: soDate, + RealizationDate: realizationDate, + AgingDays: agingDays, + DoNumber: doNumber, + MarketingType: getMarketingType(mdp), + Qty: mdp.UsageQty, + AverageWeightKg: mdp.AvgWeight, + TotalWeightKg: totalWeightKg, + SalesPricePerKg: mdp.UnitPrice, + HppPricePerKg: hpp, + SalesAmount: salesAmount, + HppAmount: hppAmount, + } + + if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 { + mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse) + item.Warehouse = &mapped + } + + if mdp.MarketingProduct.Marketing.CustomerId != 0 { + mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer) + item.Customer = &mapped + } + + if mdp.MarketingProduct.Marketing.SalesPersonId != 0 { + mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson) + item.Sales = &mapped + } + + item.VehicleNumber = mdp.VehicleNumber + + if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { + mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) + item.Product = &mapped + } + + return item +} + +func ToRepportMarketingItemDTOs(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) []RepportMarketingItemDTO { + items := make([]RepportMarketingItemDTO, 0, len(mdps)) + for _, mdp := range mdps { + items = append(items, ToRepportMarketingItemDTO(mdp, hppPricePerKg, category)) + } + return items +} + +func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { + items := make([]RepportMarketingItemDTO, 0, len(mdps)) + for _, mdp := range mdps { + hppPerKg := float64(0) + category := "" + if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { + if hpp, exists := hppMap[projectFlockKandang.ProjectFlockId]; exists { + hppPerKg = hpp + } + category = projectFlockKandang.ProjectFlock.Category + } + + item := ToRepportMarketingItemDTO(mdp, hppPerKg, category) + items = append(items, item) + } + return items +} + +func getMarketingType(mdp entity.MarketingDeliveryProduct) string { + hasAyam, hasTelur := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) + + if hasAyam { + return "ayam" + } + if hasTelur { + return "telur" + } + return "trading" +} + +func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur bool) { + if len(flags) == 0 { + return false, false + } + + for _, flag := range flags { + ft := utils.FlagType(flag.Name) + + if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || + ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer { + hasAyam = true + } + + if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah || + ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak { + hasTelur = true + } + } + + return hasAyam, hasTelur +} + +func isProductEligibleForHpp(mdp entity.MarketingDeliveryProduct, category string) bool { + hasAyam, hasTelur := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) + + if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { + return hasAyam + } + + return hasAyam || hasTelur +} + +func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) *Summary { + if len(mdps) == 0 { + return nil + } + + totalQty := 0 + totalWeightKg := 0.0 + totalEligibleWeightKg := 0.0 + totalSalesAmount := int64(0) + totalHppAmount := int64(0) + + for _, mdp := range mdps { + calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight + totalQty += int(mdp.UsageQty) + totalWeightKg += calculatedTotalWeight + totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice) + + if isProductEligibleForHpp(mdp, category) { + totalEligibleWeightKg += calculatedTotalWeight + totalHppAmount += int64(calculatedTotalWeight * hppPricePerKg) + } + } + + totalHppPricePerKg := float64(0) + if totalEligibleWeightKg > 0 { + totalHppPricePerKg = float64(totalHppAmount) / totalEligibleWeightKg + } + + return &Summary{ + TotalQty: totalQty, + TotalWeightKg: totalWeightKg, + TotalSalesAmount: totalSalesAmount, + TotalHppAmount: totalHppAmount, + TotalHppPricePerKg: totalHppPricePerKg, + } +} + +func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { + if len(items) == 0 { + return nil + } + + totalQty := 0 + totalWeightKg := 0.0 + totalSalesAmount := int64(0) + totalHppAmount := int64(0) + + for _, item := range items { + totalQty += int(item.Qty) + totalWeightKg += item.TotalWeightKg + totalSalesAmount += int64(item.SalesAmount) + totalHppAmount += int64(item.HppAmount) + } + + totalHppPricePerKg := float64(0) + if totalWeightKg > 0 { + totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg + } + + return &Summary{ + TotalQty: totalQty, + TotalWeightKg: totalWeightKg, + TotalSalesAmount: totalSalesAmount, + TotalHppAmount: totalHppAmount, + TotalHppPricePerKg: totalHppPricePerKg, + } +} + +func ToRepportMarketingResponseDTO(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingResponseDTO { + items := ToRepportMarketingItemDTOs(mdps, hppPricePerKg, category) + total := ToSummary(mdps, hppPricePerKg, category) + + return RepportMarketingResponseDTO{ + Items: items, + Total: total, + } +} diff --git a/internal/modules/repports/dto/repportProductionResult.dto.go b/internal/modules/repports/dto/repportProductionResult.dto.go new file mode 100644 index 00000000..ab2b3e0c --- /dev/null +++ b/internal/modules/repports/dto/repportProductionResult.dto.go @@ -0,0 +1,43 @@ +package dto + +import "time" + +type ProductionResultDTO struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Woa float64 `json:"woa"` + Bw float64 `json:"bw"` + StdBw float64 `json:"std_bw"` + Uniformity float64 `json:"uniformity"` + StdUniformity string `json:"std_uniformity"` + DepKum float64 `json:"dep_kum"` + DepStd float64 `json:"dep_std"` + ButiranUtuh int64 `json:"butiran_utuh"` + ButiranPutih int64 `json:"butiran_putih"` + ButiranRetak int64 `json:"butiran_retak"` + ButiranPecah int64 `json:"butiran_pecah"` + ButiranJumlah int64 `json:"butiran_jumlah"` + TotalButir int64 `json:"total_butir"` + KgUtuh float64 `json:"kg_utuh"` + KgPutih float64 `json:"kg_putih"` + KgRetak float64 `json:"kg_retak"` + KgPecah float64 `json:"kg_pecah"` + KgJumlah float64 `json:"kg_jumlah"` + TotalKg float64 `json:"total_kg"` + PersenUtuh float64 `json:"persen_utuh"` + PersenPutih float64 `json:"persen_putih"` + PersenRetak float64 `json:"persen_retak"` + PersenPecah float64 `json:"persen_pecah"` + Hd float64 `json:"hd"` + HdStd float64 `json:"hd_std"` + Fi float64 `json:"fi"` + FiStd float64 `json:"fi_std"` + Em float64 `json:"em"` + EmStd float64 `json:"em_std"` + Ew float64 `json:"ew"` + EwStd float64 `json:"ew_std"` + Fcr float64 `json:"fcr"` + FcrStd float64 `json:"fcr_std"` + Hh float64 `json:"hh"` + HhStd float64 `json:"hh_std"` +} diff --git a/internal/modules/repports/dto/repportPurchase.dto.go b/internal/modules/repports/dto/repportPurchase.dto.go new file mode 100644 index 00000000..830a076f --- /dev/null +++ b/internal/modules/repports/dto/repportPurchase.dto.go @@ -0,0 +1,159 @@ +package dto + +import ( + "math" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" +) + +type PurchaseSupplierRowDTO struct { + ReceiveDate string `json:"receive_date"` + PoDate string `json:"po_date"` + PoNumber string `json:"po_number"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + PurchaseValue float64 `json:"purchase_value"` + TransportUnitPrice float64 `json:"transport_unit_price"` + TransportValue float64 `json:"transport_value"` + TotalAmount float64 `json:"total_amount"` + Expedition string `json:"expedition"` + DeliveryNumber string `json:"delivery_number"` +} + +type PurchaseSupplierSummaryDTO struct { + TotalQty float64 `json:"total_qty"` + TotalPurchaseValue float64 `json:"total_purchase_value"` + TotalTransportValue float64 `json:"total_transport_value"` + TotalAmount float64 `json:"total_amount"` + TotalUnitPrice float64 `json:"total_unit_price"` + TotalTransportUnitPrice float64 `json:"total_transport_unit_price"` +} + +type PurchaseSupplierDTO struct { + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` + Rows []PurchaseSupplierRowDTO `json:"rows"` + Summary PurchaseSupplierSummaryDTO `json:"summary"` +} + +func formatDatePtr(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Format("02-Jan-2006") +} + +func ToPurchaseSupplierRowDTO(item *entity.PurchaseItem) PurchaseSupplierRowDTO { + row := PurchaseSupplierRowDTO{ + ReceiveDate: formatDatePtr(item.ReceivedDate), + Qty: item.TotalQty, + UnitPrice: item.Price, + } + + if item.Purchase != nil { + row.PoDate = formatDatePtr(item.Purchase.PoDate) + if item.Purchase.PoNumber != nil { + row.PoNumber = *item.Purchase.PoNumber + } + } + + if item.Product != nil && item.Product.Id != 0 { + product := productDTO.ToProductRelationDTO(*item.Product) + row.Product = &product + } + + if item.Warehouse != nil && item.Warehouse.Id != 0 { + warehouse := warehouseDTO.ToWarehouseRelationDTO(*item.Warehouse) + row.Warehouse = &warehouse + } + + qty := row.Qty + if qty < 0 { + qty = 0 + } + + row.PurchaseValue = row.UnitPrice * qty + + var transportUnit float64 + var expeditionName string + + if item.ExpenseNonstock != nil { + transportUnit = item.ExpenseNonstock.Price + + if item.ExpenseNonstock.Expense != nil && + item.ExpenseNonstock.Expense.Supplier != nil && + item.ExpenseNonstock.Expense.Supplier.Id != 0 { + expSupplier := item.ExpenseNonstock.Expense.Supplier + expeditionName = expSupplier.Name + } + } + + row.TransportUnitPrice = transportUnit + row.TransportValue = transportUnit * qty + row.TotalAmount = row.PurchaseValue + row.TransportValue + + if expeditionName == "" { + row.Expedition = "-" + } else { + row.Expedition = expeditionName + } + + if item.TravelNumber != nil && *item.TravelNumber != "" { + row.DeliveryNumber = *item.TravelNumber + } else { + row.DeliveryNumber = "-" + } + + return row +} + +func ToPurchaseSupplierDTO(supplier entity.Supplier, items []entity.PurchaseItem) PurchaseSupplierDTO { + var supplierDTORef *supplierDTO.SupplierRelationDTO + if supplier.Id != 0 { + mapped := supplierDTO.ToSupplierRelationDTO(supplier) + supplierDTORef = &mapped + } + + rows := make([]PurchaseSupplierRowDTO, 0, len(items)) + summary := PurchaseSupplierSummaryDTO{} + + var unitPriceSum float64 + var unitPriceCount int + var transportUnitPriceSum float64 + var transportUnitPriceCount int + + for i := range items { + row := ToPurchaseSupplierRowDTO(&items[i]) + rows = append(rows, row) + + summary.TotalQty += row.Qty + summary.TotalPurchaseValue += row.PurchaseValue + summary.TotalTransportValue += row.TransportValue + summary.TotalAmount += row.TotalAmount + + unitPriceSum += row.UnitPrice + unitPriceCount++ + + transportUnitPriceSum += row.TransportUnitPrice + transportUnitPriceCount++ + } + + if unitPriceCount > 0 { + summary.TotalUnitPrice = math.Round(unitPriceSum / float64(unitPriceCount)) + } + + if transportUnitPriceCount > 0 { + summary.TotalTransportUnitPrice = math.Round(transportUnitPriceSum / float64(transportUnitPriceCount)) + } + + return PurchaseSupplierDTO{ + Supplier: supplierDTORef, + Rows: rows, + Summary: summary, + } +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go new file mode 100644 index 00000000..40a3c0f3 --- /dev/null +++ b/internal/modules/repports/module.go @@ -0,0 +1,43 @@ +package repports + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" + repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" + sRepport "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" + + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type RepportModule struct{} + +func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + + expenseRealizationRepository := expenseRepo.NewExpenseRealizationRepository(db) + marketingDeliveryProductRepository := marketingRepo.NewMarketingDeliveryProductRepository(db) + purchaseRepository := purchaseRepo.NewPurchaseRepository(db) + chickinRepository := chickinRepo.NewChickinRepository(db) + recordingRepository := recordingRepo.NewRecordingRepository(db) + approvalRepository := commonRepo.NewApprovalRepository(db) + purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) + hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) + productionResultRepository := repportRepo.NewProductionResultRepository(db) + userRepository := rUser.NewUserRepository(db) + + approvalSvc := approvalService.NewApprovalService(approvalRepository) + repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, hppPerKandangRepository, productionResultRepository) + userService := sUser.NewUserService(userRepository, validate) + + RepportRoutes(router, userService, repportService) +} diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go new file mode 100644 index 00000000..7e1c8143 --- /dev/null +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -0,0 +1,361 @@ +package repositories + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type HppPerKandangRow struct { + KandangID uint + KandangName string + KandangStatus string + LocationID uint + LocationName string + PicID uint + PicName string + RemainingChickenBirds float64 + RemainingChickenWeight float64 + EggProductionWeightKg float64 + EggProductionPieces float64 +} + +type HppPerKandangCostRow struct { + KandangID uint + FeedCost float64 + OvkCost float64 + DocCost float64 + DocQty float64 + BudgetCost float64 + ExpenseCost float64 +} + +type HppPerKandangSupplierRow struct { + KandangID uint + SupplierID uint + SupplierName string + SupplierAlias string + Category string +} + +type HppPerKandangRepository interface { + GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) + GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) +} + +type hppPerKandangRepository struct { + db *gorm.DB +} + +func NewHppPerKandangRepository(db *gorm.DB) HppPerKandangRepository { + return &hppPerKandangRepository{db: db} +} + +func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) { + var rows []HppPerKandangRow + + query := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + k.id AS kandang_id, + k.name AS kandang_name, + k.status AS kandang_status, + loc.id AS location_id, + loc.name AS location_name, + pic.id AS pic_id, + pic.name AS pic_name, + COALESCE(MAX(r.total_chick_qty), 0) AS remaining_chicken_birds, + COALESCE(SUM(rbw.total_weight), 0) AS remaining_chicken_weight, + COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg, + COALESCE(SUM(re.qty), 0) AS egg_production_pieces`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("JOIN users AS pic ON pic.id = k.pic_id"). + Joins("LEFT JOIN recording_bws AS rbw ON rbw.recording_id = r.id"). + Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) + + query = query.Group("k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). + Order("k.id ASC") + + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) { + var rows []HppPerKandangCostRow + + recordingPfk := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("DISTINCT pfk.id"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs) + + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS").String() + transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_DETAILS").String() + + query := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + k.id AS kandang_id, + COALESCE(SUM(CASE + WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END), 0) AS feed_cost, + COALESCE(SUM(CASE + WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END), 0) AS ovk_cost`, + utils.FlagPakan, transferStockableKey, utils.FlagPakan, + utils.FlagOVK, transferStockableKey, utils.FlagOVK). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). + Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey). + Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct). + Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + + query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) + + query = query.Group("k.id").Order("k.id ASC") + + if err := query.Scan(&rows).Error; err != nil { + return nil, nil, err + } + + docRows := make([]struct { + KandangID uint + DocCost float64 + DocQty float64 + SupplierID *uint + SupplierName *string + SupplierAlias *string + }, 0) + + docQuery := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select(` + pfk.kandang_id AS kandang_id, + COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost, + COALESCE(SUM(pc.usage_qty), 0) AS doc_qty, + s.id AS supplier_id, + s.name AS supplier_name, + s.alias AS supplier_alias`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). + Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). + Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). + Where("pc.project_flock_kandang_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Group("pfk.kandang_id, s.id, s.name, s.alias") + docQuery = applyLocationFilters(docQuery, areaIDs, locationIDs, kandangIDs) + + if err := docQuery.Scan(&docRows).Error; err != nil { + return nil, nil, err + } + + costMap := make(map[uint]*HppPerKandangCostRow, len(rows)) + for i := range rows { + row := rows[i] + costMap[row.KandangID] = &rows[i] + } + + docSuppliers := make([]HppPerKandangSupplierRow, 0) + docSeen := make(map[uint]map[uint]bool) + for _, doc := range docRows { + entry, ok := costMap[doc.KandangID] + if !ok { + rows = append(rows, HppPerKandangCostRow{ + KandangID: doc.KandangID, + }) + entry = &rows[len(rows)-1] + costMap[doc.KandangID] = entry + } + entry.DocCost += doc.DocCost + entry.DocQty += doc.DocQty + if doc.SupplierID != nil { + if docSeen[doc.KandangID] == nil { + docSeen[doc.KandangID] = make(map[uint]bool) + } + if !docSeen[doc.KandangID][*doc.SupplierID] { + docSeen[doc.KandangID][*doc.SupplierID] = true + supplierName := "" + if doc.SupplierName != nil { + supplierName = *doc.SupplierName + } + supplierAlias := "" + if doc.SupplierAlias != nil { + supplierAlias = *doc.SupplierAlias + } + docSuppliers = append(docSuppliers, HppPerKandangSupplierRow{ + KandangID: doc.KandangID, + SupplierID: *doc.SupplierID, + SupplierName: supplierName, + SupplierAlias: supplierAlias, + Category: "DOC", + }) + } + } + } + + budgetRows := make([]struct { + KandangID uint + BudgetCost float64 + }, 0) + + pfkUsageSub := r.db. + Table("project_chickins AS pc"). + Select(` + pc.project_flock_kandang_id, + SUM(pc.usage_qty) AS kandang_usage_qty`). + Group("pc.project_flock_kandang_id") + + projectUsageSub := r.db. + Table("project_chickins AS pc"). + Select(` + pfk.project_flock_id, + SUM(pc.usage_qty) AS project_usage_qty`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Group("pfk.project_flock_id") + + budgetQuery := r.db.WithContext(ctx). + Table("project_flock_kandangs AS pfk"). + Select(` + k.id AS kandang_id, + COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0) AS budget_cost`). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id"). + Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub). + Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub). + Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Group("k.id") + budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) + + if err := budgetQuery.Scan(&budgetRows).Error; err != nil { + return nil, nil, err + } + + for _, budget := range budgetRows { + entry, ok := costMap[budget.KandangID] + if !ok { + rows = append(rows, HppPerKandangCostRow{ + KandangID: budget.KandangID, + }) + entry = &rows[len(rows)-1] + costMap[budget.KandangID] = entry + } + entry.BudgetCost += budget.BudgetCost + } + + expenseRows := make([]struct { + KandangID uint + ExpenseCost float64 + }, 0) + + expenseQuery := r.db.WithContext(ctx). + Table("project_flock_kandangs AS pfk"). + Select(` + k.id AS kandang_id, + COALESCE(SUM(er.qty * er.price), 0) AS expense_cost`). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id"). + Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). + Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Group("k.id") + expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) + + if err := expenseQuery.Scan(&expenseRows).Error; err != nil { + return nil, nil, err + } + + for _, exp := range expenseRows { + entry, ok := costMap[exp.KandangID] + if !ok { + rows = append(rows, HppPerKandangCostRow{ + KandangID: exp.KandangID, + }) + entry = &rows[len(rows)-1] + costMap[exp.KandangID] = entry + } + entry.ExpenseCost += exp.ExpenseCost + } + + feedSuppliers := make([]HppPerKandangSupplierRow, 0) + + feedQuery := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("DISTINCT k.id AS kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). + Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). + Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}). + Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL") + feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs) + + if err := feedQuery.Scan(&feedSuppliers).Error; err != nil { + return nil, nil, err + } + + for i := range feedSuppliers { + if _, exists := costMap[feedSuppliers[i].KandangID]; !exists { + rows = append(rows, HppPerKandangCostRow{ + KandangID: feedSuppliers[i].KandangID, + }) + costMap[feedSuppliers[i].KandangID] = &rows[len(rows)-1] + } + feedSuppliers[i].Category = "FEED" + } + + supplierRows := append(docSuppliers, feedSuppliers...) + + return rows, supplierRows, nil +} + +func applyLocationFilters(query *gorm.DB, areaIDs, locationIDs, kandangIDs []int64) *gorm.DB { + if len(areaIDs) > 0 { + query = query.Where("loc.area_id IN ?", areaIDs) + } + if len(locationIDs) > 0 { + query = query.Where("k.location_id IN ?", locationIDs) + } + if len(kandangIDs) > 0 { + query = query.Where("k.id IN ?", kandangIDs) + } + return query +} diff --git a/internal/modules/repports/repositories/production_result.repository.go b/internal/modules/repports/repositories/production_result.repository.go new file mode 100644 index 00000000..f2decedf --- /dev/null +++ b/internal/modules/repports/repositories/production_result.repository.go @@ -0,0 +1,79 @@ +package repositories + +import ( + "context" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "gorm.io/gorm" +) + +type ProductionResultRepository interface { + GetRecordingsByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint, offset, limit int) ([]entity.Recording, int64, error) +} + +type productionResultRepositoryImpl struct { + db *gorm.DB +} + +func NewProductionResultRepository(db *gorm.DB) ProductionResultRepository { + return &productionResultRepositoryImpl{db: db} +} + +func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang( + ctx context.Context, + projectFlockKandangID uint, + offset, limit int, +) ([]entity.Recording, int64, error) { + if projectFlockKandangID == 0 { + return []entity.Recording{}, 0, nil + } + + countQuery := r.db.WithContext(ctx). + Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", projectFlockKandangID) + + var total int64 + if err := countQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + if total == 0 { + return []entity.Recording{}, 0, nil + } + + if limit <= 0 { + limit = 10 + } + if offset < 0 { + offset = 0 + } + + flagNames := []string{ + string(utils.FlagTelurUtuh), + string(utils.FlagTelurPutih), + string(utils.FlagTelurRetak), + string(utils.FlagTelurPecah), + } + + dataQuery := r.db.WithContext(ctx). + Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", projectFlockKandangID). + Preload("BodyWeights"). + Preload("Eggs", func(db *gorm.DB) *gorm.DB { + return db.Select("recording_eggs.*, f.name AS product_flag_name"). + Joins("LEFT JOIN product_warehouses pw ON pw.id = recording_eggs.product_warehouse_id"). + Joins("LEFT JOIN flags f ON f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?", entity.FlagableTypeProduct, flagNames) + }). + Preload("Eggs.ProductWarehouse"). + Order("record_datetime ASC"). + Offset(offset). + Limit(limit) + + var recordings []entity.Recording + if err := dataQuery.Find(&recordings).Error; err != nil { + return nil, 0, err + } + + return recordings, total, nil +} diff --git a/internal/modules/repports/repositories/purchase_supplier.repository.go b/internal/modules/repports/repositories/purchase_supplier.repository.go new file mode 100644 index 00000000..979623fc --- /dev/null +++ b/internal/modules/repports/repositories/purchase_supplier.repository.go @@ -0,0 +1,195 @@ +package repositories + +import ( + "context" + "fmt" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "gorm.io/gorm" +) + +type PurchaseSupplierRepository interface { + GetSuppliersWithPurchases(ctx context.Context, offset, limit int, filters *validation.PurchaseSupplierQuery) ([]entity.Supplier, int64, error) + GetItemsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.PurchaseSupplierQuery) ([]entity.PurchaseItem, error) +} + +type purchaseSupplierRepositoryImpl struct { + db *gorm.DB +} + +func NewPurchaseSupplierRepository(db *gorm.DB) PurchaseSupplierRepository { + return &purchaseSupplierRepositoryImpl{db: db} +} + +func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filters *validation.PurchaseSupplierQuery) *gorm.DB { + dateColumn := "purchase_items.received_date" + switch strings.ToLower(strings.TrimSpace(filters.FilterBy)) { + case "po_date": + dateColumn = "purchases.po_date" + case "receive_date", "": + dateColumn = "purchase_items.received_date" + } + + db := r.db.WithContext(ctx). + Model(&entity.Supplier{}). + Joins("JOIN purchases ON purchases.supplier_id = suppliers.id"). + Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id") + + if filters.SupplierId > 0 { + db = db.Where("suppliers.id = ?", filters.SupplierId) + } + + if filters.ProductId > 0 { + db = db.Where("purchase_items.product_id = ?", filters.ProductId) + } + + if filters.ProductCategoryId > 0 { + db = db. + Joins("JOIN products ON products.id = purchase_items.product_id"). + Where("products.product_category_id = ?", filters.ProductCategoryId) + } + + if filters.AreaId > 0 { + db = db. + Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). + Where("warehouses.area_id = ?", filters.AreaId) + } + + if filters.StartDate != "" { + if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), dateFrom) + } + } + + if filters.EndDate != "" { + if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil { + db = db.Where(fmt.Sprintf("DATE(%s) <= ?", dateColumn), dateTo) + } + } + + return db +} + +func (r *purchaseSupplierRepositoryImpl) GetSuppliersWithPurchases(ctx context.Context, offset, limit int, filters *validation.PurchaseSupplierQuery) ([]entity.Supplier, int64, error) { + query := r.baseSupplierQuery(ctx, filters) + + var totalSuppliers int64 + if err := query. + Distinct("suppliers.id"). + Count(&totalSuppliers).Error; err != nil { + return nil, 0, err + } + + if totalSuppliers == 0 { + return []entity.Supplier{}, 0, nil + } + + if offset < 0 { + offset = 0 + } + + var supplierIDs []uint + if err := query. + Select("suppliers.id"). + Order("suppliers.id ASC"). + Offset(offset). + Limit(limit). + Pluck("suppliers.id", &supplierIDs).Error; err != nil { + return nil, 0, err + } + + if len(supplierIDs) == 0 { + return []entity.Supplier{}, totalSuppliers, nil + } + + var suppliers []entity.Supplier + if err := r.db.WithContext(ctx). + Where("id IN ?", supplierIDs). + Find(&suppliers).Error; err != nil { + return nil, 0, err + } + + return suppliers, totalSuppliers, nil +} + +func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.PurchaseSupplierQuery) ([]entity.PurchaseItem, error) { + if len(supplierIDs) == 0 { + return []entity.PurchaseItem{}, nil + } + + // Tentukan kolom tanggal yang akan dipakai untuk filter & sort + dateColumn := "purchase_items.received_date" + switch strings.ToLower(strings.TrimSpace(filters.FilterBy)) { + case "po_date": + dateColumn = "purchases.po_date" + case "receive_date", "": + dateColumn = "purchase_items.received_date" + } + + orderDirection := "ASC" + switch strings.ToUpper(strings.TrimSpace(filters.SortBy)) { + case "DESC": + orderDirection = "DESC" + case "ASC", "": + orderDirection = "ASC" + } + + db := r.db.WithContext(ctx). + Model(&entity.PurchaseItem{}). + Preload("Purchase"). + Preload("Purchase.Supplier"). + Preload("Product"). + Preload("Product.ProductCategory"). + Preload("Warehouse"). + Preload("Warehouse.Area"). + Preload("Warehouse.Location"). + Preload("Warehouse.Kandang"). + Preload("ExpenseNonstock"). + Preload("ExpenseNonstock.Expense"). + Preload("ExpenseNonstock.Expense.Supplier"). + Joins("JOIN purchases ON purchases.id = purchase_items.purchase_id"). + Where("purchases.supplier_id IN ?", supplierIDs) + + if filters.ProductId > 0 { + db = db.Where("purchase_items.product_id = ?", filters.ProductId) + } + + if filters.ProductCategoryId > 0 { + db = db. + Joins("JOIN products ON products.id = purchase_items.product_id"). + Where("products.product_category_id = ?", filters.ProductCategoryId) + } + + if filters.AreaId > 0 { + db = db. + Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id"). + Where("warehouses.area_id = ?", filters.AreaId) + } + + if filters.StartDate != "" { + if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { + db = db.Where(fmt.Sprintf("DATE(%s) >= ?", dateColumn), dateFrom) + } + } + + if filters.EndDate != "" { + if dateTo, err := utils.ParseDateString(filters.EndDate); err == nil { + db = db.Where(fmt.Sprintf("DATE(%s) <= ?", dateColumn), dateTo) + } + } + + // Urutkan berdasarkan kolom tanggal yang dipilih dan arah sort + db = db.Order(fmt.Sprintf("%s %s", dateColumn, orderDirection)). + Order("purchase_items.id ASC") + + var items []entity.PurchaseItem + if err := db.Find(&items).Error; err != nil { + return nil, err + } + + return items, nil +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go new file mode 100644 index 00000000..0da9adb2 --- /dev/null +++ b/internal/modules/repports/route.go @@ -0,0 +1,24 @@ +package repports + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/controllers" + repport "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService) { + ctrl := controller.NewRepportController(s) + + route := v1.Group("/reports") + route.Use(m.Auth(u)) + + route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense) + route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing) + route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) + route.Get("/hpp-per-kandang", ctrl.GetHppPerKandang) + route.Get("/production-result/:idProjectFlockKandang", ctrl.GetProductionResult) + +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go new file mode 100644 index 00000000..ebf68867 --- /dev/null +++ b/internal/modules/repports/services/repport.service.go @@ -0,0 +1,1070 @@ +package service + +import ( + "context" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" + repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type RepportService interface { + GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) + GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) + GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) + GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) + GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) +} + +type repportService struct { + Log *logrus.Logger + Validate *validator.Validate + ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository + MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository + PurchaseRepo purchaseRepo.PurchaseRepository + ChickinRepo chickinRepo.ProjectChickinRepository + RecordingRepo recordingRepo.RecordingRepository + ApprovalSvc approvalService.ApprovalService + PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository + HppPerKandangRepo repportRepo.HppPerKandangRepository + ProductionResultRepo repportRepo.ProductionResultRepository +} + +type HppCostAggregate struct { + FeedCost float64 + OvkCost float64 + DocCost float64 + DocQty float64 + BudgetCost float64 + ExpenseCost float64 +} + +func NewRepportService( + validate *validator.Validate, + expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, + marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository, + purchaseRepo purchaseRepo.PurchaseRepository, + chickinRepo chickinRepo.ProjectChickinRepository, + recordingRepo recordingRepo.RecordingRepository, + approvalSvc approvalService.ApprovalService, + purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, + hppPerKandangRepo repportRepo.HppPerKandangRepository, + productionResultRepo repportRepo.ProductionResultRepository, +) RepportService { + return &repportService{ + Log: utils.Log, + Validate: validate, + ExpenseRealizationRepo: expenseRealizationRepo, + MarketingDeliveryRepo: marketingDeliveryRepo, + PurchaseRepo: purchaseRepo, + ChickinRepo: chickinRepo, + RecordingRepo: recordingRepo, + ApprovalSvc: approvalSvc, + PurchaseSupplierRepo: purchaseSupplierRepo, + HppPerKandangRepo: hppPerKandangRepo, + ProductionResultRepo: productionResultRepo, + } +} + +func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + realizations, total, err := s.ExpenseRealizationRepo.GetAllWithFilters(c.Context(), offset, params.Limit, params) + if err != nil { + s.Log.Errorf("GetAllWithFilters error: %v", err) + return nil, 0, err + } + + result := dto.ToRepportExpenseListDTOs(realizations) + + expenseIDs := make([]uint, 0, len(result)) + for i := range result { + expenseIDs = append(expenseIDs, uint(result[i].Id)) + } + + approvals, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowExpense, expenseIDs, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("LatestByTargets error: %v", err) + } + + for i := range result { + expenseIDAsUint := uint(result[i].Id) + if approval, exists := approvals[expenseIDAsUint]; exists && approval != nil { + mapped := approvalDTO.ToApprovalDTO(*approval) + result[i].LatestApproval = &mapped + } + } + + return result, total, nil +} + +func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + deliveryProducts, total, err := s.MarketingDeliveryRepo.GetAllWithFilters(c.Context(), offset, params.Limit, params) + if err != nil { + return nil, 0, err + } + + projectFlockIDMap := make(map[uint]bool) + hppMap := make(map[uint]float64) + + for _, dp := range deliveryProducts { + if projectFlockKandang := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { + projectFlockID := projectFlockKandang.ProjectFlockId + if projectFlockID > 0 && !projectFlockIDMap[projectFlockID] { + projectFlockIDMap[projectFlockID] = true + + category := projectFlockKandang.ProjectFlock.Category + hppPerKg := s.calculateHppPricePerKg(c.Context(), projectFlockID, category) + hppMap[projectFlockID] = hppPerKg + } + } + } + + items := dto.ToRepportMarketingItemDTOsWithHppMap(deliveryProducts, hppMap) + return items, total, nil +} + +func (s *repportService) calculateHppPricePerKg(ctx context.Context, projectFlockID uint, category string) float64 { + totalCost := s.getTotalProjectCost(ctx, projectFlockID) + if totalCost == 0 { + return 0 + } + + chickinQty, err := s.ChickinRepo.GetTotalChickinQtyByProjectFlockID(ctx, projectFlockID) + if err != nil { + s.Log.Warnf("HPP calculation: Failed to get chickin qty for project flock ID %d: %v", projectFlockID, err) + } + + depletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(ctx, projectFlockID) + if err != nil { + s.Log.Warnf("HPP calculation: Failed to get depletion for project flock ID %d: %v", projectFlockID, err) + } + + avgWeight, err := s.RecordingRepo.GetLatestAvgWeightByProjectFlockID(ctx, projectFlockID) + if err != nil { + s.Log.Warnf("HPP calculation: Failed to get avg weight for project flock ID %d: %v", projectFlockID, err) + } + + var totalWeight float64 + if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { + totalWeight = (chickinQty - depletion) * avgWeight + } else { + eggWeight, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(ctx, projectFlockID) + if err != nil { + s.Log.Warnf("HPP calculation: Failed to get egg weight for project flock ID %d: %v", projectFlockID, err) + } + totalWeight = (chickinQty-depletion)*avgWeight + eggWeight + } + + if totalWeight == 0 { + return 0 + } + + hppPricePerKg := totalCost / totalWeight + return hppPricePerKg +} + +func (s *repportService) getTotalProjectCost(ctx context.Context, projectFlockID uint) float64 { + if projectFlockID == 0 { + return 0 + } + + purchases, err := s.PurchaseRepo.GetItemsByProjectFlockID(ctx, projectFlockID) + if err != nil { + s.Log.Errorf("getTotalProjectCost: GetItemsByProjectFlockID error for project flock ID %d: %v", projectFlockID, err) + return 0 + } + + cost := float64(0) + purchaseCost := float64(0) + for _, p := range purchases { + purchaseCost += p.TotalPrice + } + cost += purchaseCost + + realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(ctx, projectFlockID) + if err != nil { + s.Log.Warnf("getTotalProjectCost: GetByProjectFlockID error for project flock ID %d: %v", projectFlockID, err) + } + + bopCost := float64(0) + for _, r := range realizations { + if r.ExpenseNonstock != nil && r.ExpenseNonstock.Expense != nil && + r.ExpenseNonstock.Expense.Category == string(utils.ExpenseCategoryBOP) { + bopCost += r.Price * r.Qty + } + } + cost += bopCost + + return cost +} + +func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + const ( + recordsPerWeek = 7 + defaultStartWoa = 18 + defaultStdBw = 1951 + defaultBw = 0 + defaultUniformText = "90% up" + ) + + if params.Limit <= 0 { + params.Limit = 10 + } + if params.Page <= 0 { + params.Page = 1 + } + + weeksPerPage := params.Limit + recordLimit := weeksPerPage * recordsPerWeek + if recordLimit <= 0 { + recordLimit = recordsPerWeek + } + recordOffset := (params.Page - 1) * recordLimit + if recordOffset < 0 { + recordOffset = 0 + } + + recordings, totalRecordings, err := s.ProductionResultRepo.GetRecordingsByProjectFlockKandang(ctx.Context(), params.ProjectFlockKandangID, recordOffset, recordLimit) + if err != nil { + return nil, 0, err + } + + dailyResults := make([]dto.ProductionResultDTO, len(recordings)) + for i := range recordings { + dailyResults[i] = mapRecordingToProductionResultDTO(recordings[i]) + if dailyResults[i].StdUniformity == "" { + dailyResults[i].StdUniformity = defaultUniformText + } + } + + weeklyResults := summarizeProductionResults(dailyResults, recordsPerWeek) + + var cumulativeButir int64 + var cumulativeKg float64 + for i := range weeklyResults { + weeklyResults[i].Woa = float64(defaultStartWoa + i) + weeklyResults[i].StdBw = defaultStdBw + weeklyResults[i].Bw = defaultBw + if weeklyResults[i].StdUniformity == "" { + weeklyResults[i].StdUniformity = defaultUniformText + } + + cumulativeButir += weeklyResults[i].ButiranJumlah + weeklyResults[i].TotalButir = cumulativeButir + + cumulativeKg += weeklyResults[i].KgJumlah + weeklyResults[i].TotalKg = cumulativeKg + } + + totalWeeks := int64(math.Ceil(float64(totalRecordings) / float64(recordsPerWeek))) + + return weeklyResults, totalWeeks, nil +} + +func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionResultDTO { + result := dto.ProductionResultDTO{ + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + StdUniformity: "90% up", + DepKum: valueOrZero(record.CumDepletionRate), + DepStd: valueOrZero(record.TotalDepletionQty), + Fcr: valueOrZero(record.FcrValue), + Hh: valueOrZero(record.TotalChickQty), + } + + if record.Day != nil { + result.Woa = float64(*record.Day) + } + if record.CumIntake != nil { + result.Fi = float64(*record.CumIntake) + } + + // avgWeight := calculateAverageBodyWeight(record.BodyWeights) + avgWeight := 1.0 + if avgWeight > 0 { + result.Bw = avgWeight + } + + eggSummary := summarizeEggs(record.Eggs) + result.ButiranUtuh = eggSummary.Utuh + result.ButiranPutih = eggSummary.Putih + result.ButiranRetak = eggSummary.Retak + result.ButiranPecah = eggSummary.Pecah + result.ButiranJumlah = eggSummary.TotalQty + result.TotalButir = eggSummary.TotalQty + result.KgUtuh = eggSummary.KgUtuh + result.KgPutih = eggSummary.KgPutih + result.KgRetak = eggSummary.KgRetak + result.KgPecah = eggSummary.KgPecah + result.KgJumlah = eggSummary.TotalKg + result.TotalKg = eggSummary.TotalKg + + if eggSummary.TotalQty > 0 { + total := float64(eggSummary.TotalQty) + result.PersenUtuh = roundFloat((float64(result.ButiranUtuh)/total)*100, 2) + result.PersenPutih = roundFloat((float64(result.ButiranPutih)/total)*100, 2) + result.PersenRetak = roundFloat((float64(result.ButiranRetak)/total)*100, 2) + result.PersenPecah = roundFloat((float64(result.ButiranPecah)/total)*100, 2) + result.Ew = (eggSummary.TotalKg * 1000) / total + result.Em = eggSummary.TotalKg + } + + return result +} + +// func calculateAverageBodyWeight(bodyWeights []entity.RecordingBW) float64 { +// var totalQty float64 +// var totalWeight float64 + +// for _, bw := range bodyWeights { +// totalQty += bw.Qty +// if bw.TotalWeight > 0 { +// totalWeight += bw.TotalWeight +// } else { +// totalWeight += bw.AvgWeight * bw.Qty +// } +// } + +// if totalQty == 0 { +// return 0 +// } + +// return totalWeight / totalQty +// } + +type eggSummary struct { + TotalQty int64 + TotalKg float64 + + Utuh int64 + Putih int64 + Retak int64 + Pecah int64 + + KgUtuh float64 + KgPutih float64 + KgRetak float64 + KgPecah float64 +} + +func summarizeEggs(eggs []entity.RecordingEgg) eggSummary { + var summary eggSummary + + for _, egg := range eggs { + qty := int64(egg.Qty) + weightKg := valueOrZero(egg.Weight) + + summary.TotalQty += qty + summary.TotalKg += weightKg + + if flagType, ok := getEggFlagType(egg); ok { + switch flagType { + case utils.FlagTelurUtuh: + summary.Utuh += qty + summary.KgUtuh += weightKg + case utils.FlagTelurPutih: + summary.Putih += qty + summary.KgPutih += weightKg + case utils.FlagTelurRetak: + summary.Retak += qty + summary.KgRetak += weightKg + case utils.FlagTelurPecah: + summary.Pecah += qty + summary.KgPecah += weightKg + } + } + } + + return summary +} + +func valueOrZero(value *float64) float64 { + if value == nil { + return 0 + } + return *value +} + +func roundFloat(val float64, precision int) float64 { + if precision < 0 { + return val + } + factor := math.Pow(10, float64(precision)) + return math.Round(val*factor) / factor +} + +func getEggFlagType(egg entity.RecordingEgg) (utils.FlagType, bool) { + if egg.ProductFlagName == nil || *egg.ProductFlagName == "" { + return "", false + } + + flagType := utils.FlagType(*egg.ProductFlagName) + switch flagType { + case utils.FlagTelurUtuh, utils.FlagTelurPutih, utils.FlagTelurRetak, utils.FlagTelurPecah: + return flagType, true + } + + return "", false +} + +func summarizeProductionResults(daily []dto.ProductionResultDTO, groupSize int) []dto.ProductionResultDTO { + if groupSize <= 0 || len(daily) == 0 { + return daily + } + + result := make([]dto.ProductionResultDTO, 0, (len(daily)+groupSize-1)/groupSize) + for i := 0; i < len(daily); i += groupSize { + end := i + groupSize + if end > len(daily) { + end = len(daily) + } + result = append(result, aggregateProductionResultGroup(daily[i:end])) + } + + return result +} + +func aggregateProductionResultGroup(group []dto.ProductionResultDTO) dto.ProductionResultDTO { + count := len(group) + if count == 0 { + return dto.ProductionResultDTO{} + } + + agg := dto.ProductionResultDTO{ + CreatedAt: group[0].CreatedAt, + UpdatedAt: group[0].UpdatedAt, + StdUniformity: group[0].StdUniformity, + } + + var sumBw, sumStdBw, sumUniformity float64 + var sumDepStd float64 + var sumKgUtuh, sumKgPutih, sumKgRetak, sumKgPecah float64 + var sumKgJumlah, sumTotalKg float64 + var sumPersenUtuh, sumPersenPutih, sumPersenRetak, sumPersenPecah float64 + var percentSamples int + var sumHd, sumHdStd float64 + var sumFi, sumFiStd float64 + var sumEm, sumEmStd float64 + var sumEw, sumEwStd float64 + var sumFcr, sumFcrStd float64 + var sumHh, sumHhStd float64 + + var sumButiranUtuh, sumButiranPutih int64 + var sumButiranRetak, sumButiranPecah int64 + var sumButiranJumlah, sumTotalButir int64 + + for _, item := range group { + sumBw += item.Bw + sumStdBw += item.StdBw + sumUniformity += item.Uniformity + sumDepStd += item.DepStd + sumKgUtuh += item.KgUtuh + sumKgPutih += item.KgPutih + sumKgRetak += item.KgRetak + sumKgPecah += item.KgPecah + sumKgJumlah += item.KgJumlah + sumTotalKg += item.TotalKg + if item.ButiranJumlah > 0 { + sumPersenUtuh += item.PersenUtuh + sumPersenPutih += item.PersenPutih + sumPersenRetak += item.PersenRetak + sumPersenPecah += item.PersenPecah + percentSamples++ + } + sumHd += item.Hd + sumHdStd += item.HdStd + sumFi += item.Fi + sumFiStd += item.FiStd + sumEm += item.Em + sumEmStd += item.EmStd + sumEw += item.Ew + sumEwStd += item.EwStd + sumFcr += item.Fcr + sumFcrStd += item.FcrStd + sumHh += item.Hh + sumHhStd += item.HhStd + + sumButiranUtuh += item.ButiranUtuh + sumButiranPutih += item.ButiranPutih + sumButiranRetak += item.ButiranRetak + sumButiranPecah += item.ButiranPecah + sumButiranJumlah += item.ButiranJumlah + sumTotalButir += item.TotalButir + } + + divider := float64(count) + if divider == 0 { + divider = 1 + } + + agg.Bw = sumBw / divider + agg.StdBw = sumStdBw / divider + agg.Uniformity = sumUniformity / divider + agg.DepKum = group[count-1].DepKum + agg.DepStd = sumDepStd / divider + agg.KgUtuh = sumKgUtuh + agg.KgPutih = sumKgPutih + agg.KgRetak = sumKgRetak + agg.KgPecah = sumKgPecah + agg.KgJumlah = sumKgJumlah + agg.TotalKg = sumTotalKg + + agg.ButiranUtuh = sumButiranUtuh + agg.ButiranPutih = sumButiranPutih + agg.ButiranRetak = sumButiranRetak + agg.ButiranPecah = sumButiranPecah + agg.ButiranJumlah = sumButiranJumlah + agg.TotalButir = sumTotalButir + + if percentSamples > 0 { + percentDivider := float64(percentSamples) + agg.PersenUtuh = roundFloat(sumPersenUtuh/percentDivider, 2) + agg.PersenPutih = roundFloat(sumPersenPutih/percentDivider, 2) + agg.PersenRetak = roundFloat(sumPersenRetak/percentDivider, 2) + agg.PersenPecah = roundFloat(sumPersenPecah/percentDivider, 2) + } + + agg.Hd = sumHd / divider + agg.HdStd = sumHdStd / divider + agg.Fi = sumFi / divider + agg.FiStd = sumFiStd / divider + agg.Em = sumEm / divider + agg.EmStd = sumEmStd / divider + agg.Ew = sumEw / divider + agg.EwStd = sumEwStd / divider + agg.Fcr = sumFcr / divider + agg.FcrStd = sumFcrStd / divider + agg.Hh = sumHh / divider + agg.HhStd = sumHhStd / divider + + return agg +} + +func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + if offset < 0 { + offset = 0 + } + + suppliers, totalSuppliers, err := s.PurchaseSupplierRepo.GetSuppliersWithPurchases(c.Context(), offset, params.Limit, params) + if err != nil { + return nil, 0, err + } + + if totalSuppliers == 0 || len(suppliers) == 0 { + return []dto.PurchaseSupplierDTO{}, totalSuppliers, nil + } + + supplierMap := make(map[uint]entity.Supplier, len(suppliers)) + supplierIDs := make([]uint, 0, len(suppliers)) + for _, supplier := range suppliers { + supplierMap[supplier.Id] = supplier + supplierIDs = append(supplierIDs, supplier.Id) + } + + items, err := s.PurchaseSupplierRepo.GetItemsBySuppliers(c.Context(), supplierIDs, params) + if err != nil { + return nil, 0, err + } + + itemsBySupplier := make(map[uint][]entity.PurchaseItem) + for _, item := range items { + if item.Purchase == nil { + continue + } + supplierID := item.Purchase.SupplierId + itemsBySupplier[supplierID] = append(itemsBySupplier[supplierID], item) + } + + result := make([]dto.PurchaseSupplierDTO, 0, len(supplierIDs)) + for _, supplierID := range supplierIDs { + supplier, exists := supplierMap[supplierID] + if !exists { + continue + } + + supplierItems := itemsBySupplier[supplierID] + dtoItem := dto.ToPurchaseSupplierDTO(supplier, supplierItems) + result = append(result, dtoItem) + } + + return result, totalSuppliers, nil +} + +func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { + params, filters, err := s.parseHppPerKandangQuery(ctx) + if err != nil { + return nil, nil, err + } + + if err := s.Validate.Struct(params); err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") + } + + periodDate, err := time.ParseInLocation("2006-01-02", params.Period, location) + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD") + } + + startOfDay := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, location) + endOfDay := startOfDay.Add(24 * time.Hour) + + repoRows, err := s.HppPerKandangRepo.GetRowsByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs) + if err != nil { + return nil, nil, err + } + costRows, supplierRows, err := s.HppPerKandangRepo.GetFeedOvkDocCostByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs) + if err != nil { + return nil, nil, err + } + costMap := make(map[uint]HppCostAggregate, len(costRows)) + for _, row := range costRows { + costMap[row.KandangID] = HppCostAggregate{ + FeedCost: row.FeedCost, + OvkCost: row.OvkCost, + DocCost: row.DocCost, + DocQty: row.DocQty, + BudgetCost: row.BudgetCost, + ExpenseCost: row.ExpenseCost, + } + } + + docSupplierMap := make(map[uint][]dto.HppPerKandangSupplierDTO) + feedSupplierMap := make(map[uint][]dto.HppPerKandangSupplierDTO) + docSeen := make(map[uint]map[uint]bool) + feedSeen := make(map[uint]map[uint]bool) + + for _, sup := range supplierRows { + if sup.SupplierID == 0 { + continue + } + + targetMap := feedSupplierMap + seen := feedSeen + category := "FEED" + if strings.EqualFold(sup.Category, "DOC") { + targetMap = docSupplierMap + seen = docSeen + category = "DOC" + } + + if seen[sup.KandangID] == nil { + seen[sup.KandangID] = make(map[uint]bool) + } + if seen[sup.KandangID][sup.SupplierID] { + continue + } + seen[sup.KandangID][sup.SupplierID] = true + + targetMap[sup.KandangID] = append(targetMap[sup.KandangID], dto.HppPerKandangSupplierDTO{ + ID: int64(sup.SupplierID), + Name: sup.SupplierName, + Alias: sup.SupplierAlias, + Category: category, + }) + } + + type weightRangeKey struct { + Min float64 + Max float64 + } + type weightRangeAggregate struct { + Summary *dto.HppPerKandangSummaryWeightRangeDTO + EggHppSum float64 + EggHppCount int + } + + dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) + perRangeMap := make(map[weightRangeKey]*weightRangeAggregate) + var totalBirds int64 + var totalWeight float64 + var totalEggPieces int64 + var totalEggKg float64 + var totalRemainingValueRp int64 + var totalEggValueRp int64 + var totalHppSum float64 + var totalHppCount int + var totalDocPriceSum float64 + var totalDocPriceCount int + var totalEggHppSum float64 + var totalEggHppCount int + + for _, row := range repoRows { + birdsFloat := row.RemainingChickenBirds + if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { + birdsFloat = 0 + } + weightFloat := row.RemainingChickenWeight + if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { + weightFloat = 0 + } + eggPiecesFloat := row.EggProductionPieces + if math.IsNaN(eggPiecesFloat) || math.IsInf(eggPiecesFloat, 0) { + eggPiecesFloat = 0 + } + eggWeightFloat := row.EggProductionWeightKg + if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) { + eggWeightFloat = 0 + } + + avgWeight := 0.0 + if birdsFloat > 0 { + avgWeight = weightFloat / birdsFloat + } + weightMin := math.Floor(avgWeight*10) / 10 + if weightMin < 0 { + weightMin = 0 + } + weightMax := weightMin + 0.09 + rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} + + rowBirds := int64(math.Round(birdsFloat)) + costEntry := costMap[row.KandangID] + totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost + hppRp := 0.0 + if weightFloat > 0 { + hppRp = totalCost / weightFloat + } + eggHpp := 0.0 + if eggWeightFloat > 0 { + eggHpp = totalCost / eggWeightFloat + } + + rowEggPieces := int64(math.Round(eggPiecesFloat)) + rowEggValue := int64(eggHpp * eggWeightFloat) + rowRemainingValue := int64(hppRp * weightFloat) + avgDocPrice := int64(0) + if costEntry.DocQty > 0 { + avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) + } + + dataRows = append(dataRows, dto.HppPerKandangRowDTO{ + ID: int(row.KandangID), + Kandang: dto.HppPerKandangRowKandangDTO{ + ID: int64(row.KandangID), + Name: row.KandangName, + Status: row.KandangStatus, + Location: dto.HppPerKandangLocationDTO{ + ID: int64(row.LocationID), + Name: row.LocationName, + }, + Pic: dto.HppPerKandangPICDTO{ + ID: int64(row.PicID), + Name: row.PicName, + }, + }, + WeightRange: dto.HppPerKandangWeightRangeDTO{ + WeightMin: weightMin, + WeightMax: weightMax, + }, + RemainingChickenBirds: rowBirds, + RemainingChickenWeightKg: weightFloat, + AvgWeightKg: avgWeight, + // FeedCostRp: costEntry.FeedCost, + // OvkCostRp: costEntry.OvkCost, + DocSuppliers: docSupplierMap[row.KandangID], + FeedSuppliers: feedSupplierMap[row.KandangID], + EggProductionPieces: rowEggPieces, + EggProductionKg: eggWeightFloat, + AverageDocPriceRp: avgDocPrice, + HppRp: hppRp, + EggHppRpPerKg: eggHpp, + RemainingValueRp: rowRemainingValue, + EggValueRp: rowEggValue, + }) + + totalBirds += rowBirds + totalWeight += weightFloat + totalEggPieces += rowEggPieces + totalEggKg += eggWeightFloat + totalRemainingValueRp += rowRemainingValue + totalEggValueRp += rowEggValue + if weightFloat > 0 { + totalHppSum += hppRp + totalHppCount++ + } + if avgDocPrice > 0 { + totalDocPriceSum += float64(avgDocPrice) + totalDocPriceCount++ + } + if eggWeightFloat > 0 { + totalEggHppSum += eggHpp + totalEggHppCount++ + } + + rangeAgg, exists := perRangeMap[rangeKey] + if !exists { + rangeAgg = &weightRangeAggregate{ + Summary: &dto.HppPerKandangSummaryWeightRangeDTO{ + WeightRange: dto.HppPerKandangWeightRangeDTO{ + WeightMin: weightMin, + WeightMax: weightMax, + }, + Label: fmt.Sprintf("%.2f - %.2f", weightMin, weightMax), + }, + } + perRangeMap[rangeKey] = rangeAgg + } + + rangeSummary := rangeAgg.Summary + rangeSummary.RemainingChickenBirds += rowBirds + rangeSummary.RemainingChickenWeightKg += row.RemainingChickenWeight + rangeSummary.EggProductionPieces += rowEggPieces + rangeSummary.EggProductionKg += eggWeightFloat + rangeSummary.RemainingValueRp += rowRemainingValue + rangeSummary.EggValueRp += rowEggValue + if eggWeightFloat > 0 { + rangeAgg.EggHppSum += eggHpp + rangeAgg.EggHppCount++ + } + } + + rangeKeys := make([]weightRangeKey, 0, len(perRangeMap)) + for key := range perRangeMap { + rangeKeys = append(rangeKeys, key) + } + sort.Slice(rangeKeys, func(i, j int) bool { + if rangeKeys[i].Min == rangeKeys[j].Min { + return rangeKeys[i].Max < rangeKeys[j].Max + } + return rangeKeys[i].Min < rangeKeys[j].Min + }) + + perRangeSummary := make([]dto.HppPerKandangSummaryWeightRangeDTO, 0, len(rangeKeys)) + for idx, key := range rangeKeys { + agg := perRangeMap[key] + entry := agg.Summary + entry.ID = idx + 1 + if entry.RemainingChickenBirds > 0 { + entry.AvgWeightKg = entry.RemainingChickenWeightKg / float64(entry.RemainingChickenBirds) + } + if agg.EggHppCount > 0 { + entry.EggHppRpPerKg = agg.EggHppSum / float64(agg.EggHppCount) + } + perRangeSummary = append(perRangeSummary, *entry) + } + + totalSummary := dto.HppPerKandangSummaryTotalDTO{ + TotalRemainingChickenBirds: totalBirds, + TotalRemainingChickenWeightKg: totalWeight, + TotalEggProductionPieces: totalEggPieces, + TotalEggProductionKg: totalEggKg, + TotalRemainingValueRp: totalRemainingValueRp, + TotalEggValueRp: totalEggValueRp, + } + if totalBirds > 0 { + totalSummary.AverageWeightKg = totalWeight / float64(totalBirds) + } + if totalEggHppCount > 0 { + totalSummary.AverageEggHppRpPerKg = totalEggHppSum / float64(totalEggHppCount) + } + if totalHppCount > 0 { + totalSummary.TotalHppRp = totalHppSum / float64(totalHppCount) + } + if totalDocPriceCount > 0 { + totalSummary.TotalAverageDocPriceRp = totalDocPriceSum / float64(totalDocPriceCount) + } + + limit := params.Limit + if limit <= 0 { + limit = 10 + } + totalCount := len(dataRows) + offset := (params.Page - 1) * limit + if offset < 0 { + offset = 0 + } + if offset > totalCount { + offset = totalCount + } + end := offset + limit + if end > totalCount { + end = totalCount + } + pagedRows := dataRows[offset:end] + + data := dto.HppPerKandangResponseData{ + Period: params.Period, + Rows: pagedRows, + Summary: dto.HppPerKandangSummaryDTO{ + PerWeightRange: perRangeSummary, + Total: totalSummary, + }, + } + + totalResults := int64(totalCount) + + totalPages := int64(0) + if totalResults > 0 { + totalPages = int64(math.Ceil(float64(totalResults) / float64(limit))) + } + if totalPages == 0 { + totalPages = 1 + } + + meta := &dto.HppPerKandangMetaDTO{ + Page: params.Page, + Limit: limit, + TotalPages: totalPages, + TotalResults: totalResults, + Filters: filters, + } + + return &data, meta, nil +} + +func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.HppPerKandangQuery, dto.HppPerKandangFiltersDTO, error) { + page := ctx.QueryInt("page", 1) + if page < 1 { + page = 1 + } + limit := ctx.QueryInt("limit", 10) + if limit < 1 { + limit = 10 + } + + rawArea := ctx.Query("area_id", "") + rawLocation := ctx.Query("location_id", "") + rawKandang := ctx.Query("kandang_id", "") + rawWeightMin := ctx.Query("weight_min", "") + rawWeightMax := ctx.Query("weight_max", "") + period := ctx.Query("period", "") + showUnrecorded := ctx.QueryBool("show_unrecorded", false) + + areaIDs, err := parseCommaSeparatedInt64s(rawArea) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + locationIDs, err := parseCommaSeparatedInt64s(rawLocation) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + kandangIDs, err := parseCommaSeparatedInt64s(rawKandang) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + weightMin, err := parseOptionalFloat64(rawWeightMin) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + weightMax, err := parseOptionalFloat64(rawWeightMax) + if err != nil { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + params := &validation.HppPerKandangQuery{ + Page: page, + Limit: limit, + Period: period, + ShowUnrecorded: showUnrecorded, + AreaIDs: areaIDs, + LocationIDs: locationIDs, + KandangIDs: kandangIDs, + WeightMin: weightMin, + WeightMax: weightMax, + } + + showUnrecordedFilter := "" + if showUnrecorded { + showUnrecordedFilter = "true" + } + + filters := dto.NewHppPerKandangFiltersDTO( + rawArea, + rawLocation, + rawKandang, + rawWeightMin, + rawWeightMax, + period, + showUnrecordedFilter, + ) + + return params, filters, nil +} + +func parseCommaSeparatedInt64s(raw string) ([]int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + parts := strings.Split(raw, ",") + result := make([]int64, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + id, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid integer value '%s'", part) + } + result = append(result, id) + } + + return result, nil +} + +func parseOptionalFloat64(raw string) (*float64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil, fmt.Errorf("invalid float value '%s'", raw) + } + + return &value, nil +} diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go new file mode 100644 index 00000000..b909d77c --- /dev/null +++ b/internal/modules/repports/validations/repport.validation.go @@ -0,0 +1,62 @@ +package validation + +type ExpenseQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` + Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"` + SupplierId int64 `query:"supplier_id" validate:"omitempty"` + KandangId int64 `query:"kandang_id" validate:"omitempty"` + ProjectFlockKandangId int64 `query:"project_flock_kandang_id" validate:"omitempty"` + ProjectFlockId int64 `query:"project_flock_id" validate:"omitempty"` + NonstockId int64 `query:"nonstock_id" validate:"omitempty"` + AreaId int64 `query:"area_id" validate:"omitempty"` + LocationId int64 `query:"location_id" validate:"omitempty"` + RealizationDate string `query:"realization_date" validate:"omitempty"` +} + +type MarketingQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` + CustomerId int64 `query:"customer_id" validate:"omitempty"` + ProductId int64 `query:"product_id" validate:"omitempty"` + WarehouseId int64 `query:"warehouse_id" validate:"omitempty"` + SalesPersonId int64 `query:"sales_person_id" validate:"omitempty"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date realization_date"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` +} + +type PurchaseSupplierQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` + AreaId int64 `query:"area_id" validate:"omitempty"` + SupplierId int64 `query:"supplier_id" validate:"omitempty"` + ProductId int64 `query:"product_id" validate:"omitempty"` + ProductCategoryId int64 `query:"product_category_id" validate:"omitempty"` + StartDate string `query:"start_date" validate:"omitempty"` + EndDate string `query:"end_date" validate:"omitempty"` + SortBy string `query:"sort_by" validate:"omitempty"` + FilterBy string `query:"filter_by" validate:"omitempty"` +} + +type HppPerKandangQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Period string `query:"period" validate:"required"` + ShowUnrecorded bool `query:"show_unrecorded"` + AreaIDs []int64 `query:"-"` + LocationIDs []int64 `query:"-"` + KandangIDs []int64 `query:"-"` + WeightMin *float64 `query:"-"` + WeightMax *float64 `query:"-"` +} + +type ProductionResultQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"` +} diff --git a/internal/modules/shared/repositories/stock-logs.repository.go b/internal/modules/shared/repositories/stock-logs.repository.go index 77ed78ce..ad1e8974 100644 --- a/internal/modules/shared/repositories/stock-logs.repository.go +++ b/internal/modules/shared/repositories/stock-logs.repository.go @@ -30,7 +30,7 @@ func (r *StockLogRepositoryImpl) GetByFlaggable(ctx context.Context, logType str var stockLogs []*entity.StockLog err := r.DB().WithContext(ctx). - Where("log_type = ? AND log_id = ?", logType, logId). + Where("loggable_type = ? AND log_id = ?", logType, logId). Preload("ProductWarehouse"). Preload("ProductWarehouse.Product"). Preload("ProductWarehouse.Warehouse"). diff --git a/internal/modules/sso/controllers/refresh_token_response.go b/internal/modules/sso/controllers/refresh_token_response.go new file mode 100644 index 00000000..1825342a --- /dev/null +++ b/internal/modules/sso/controllers/refresh_token_response.go @@ -0,0 +1,13 @@ +package controllers + +type refreshTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` + Error string `json:"error"` + Description string `json:"error_description"` +} + diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index f11a31c8..410e9577 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -138,6 +138,95 @@ func (h *Controller) Start(c *fiber.Ctx) error { return c.Redirect(authorizeURL.String(), fiber.StatusFound) } +// Refresh exchanges the current SSO refresh token for a new access/refresh pair +// without redirecting the browser to the SSO login page. +func (h *Controller) Refresh(c *fiber.Ctx) error { + refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") + refreshToken := strings.TrimSpace(c.Cookies(refreshName)) + if refreshToken == "" { + if target := buildStartRedirect(defaultSSOClientAlias()); target != "" { + return c.Redirect(target, fiber.StatusFound) + } + return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") + } + + tokenEndpoint := strings.TrimSpace(config.SSOTokenURL) + if tokenEndpoint == "" { + return fiber.NewError(fiber.StatusInternalServerError, "token endpoint not configured") + } + + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + + req, err := http.NewRequestWithContext(c.Context(), http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to create refresh request") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := h.httpClient.Do(req) + if err != nil { + utils.Log.Errorf("token refresh request failed: %v", err) + return fiber.NewError(fiber.StatusBadGateway, "failed to refresh access token") + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + utils.Log.Warnf("token refresh response status %d", resp.StatusCode) + if resp.StatusCode == fiber.StatusTooManyRequests { + return fiber.NewError(fiber.StatusTooManyRequests, "Too many attempts, please slow down") + } + if target := buildStartRedirect(defaultSSOClientAlias()); target != "" { + return c.Redirect(target, fiber.StatusFound) + } + return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") + } + + var tokenResp refreshTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return fiber.NewError(fiber.StatusBadGateway, "invalid token response") + } + if tokenResp.Error != "" { + return fiber.NewError(fiber.StatusBadGateway, tokenResp.Description) + } + if tokenResp.AccessToken == "" { + return fiber.NewError(fiber.StatusBadGateway, "missing access token") + } + + verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) + if err != nil { + utils.Log.Errorf("access token verification failed: %v", err) + return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") + } + + issueCookies(c, struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` + Error string `json:"error"` + Description string `json:"error_description"` + }{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + TokenType: tokenResp.TokenType, + ExpiresIn: tokenResp.ExpiresIn, + Scope: tokenResp.Scope, + IDToken: tokenResp.IDToken, + Error: tokenResp.Error, + Description: tokenResp.Description, + }, verification) + + utils.Log.WithFields(logrus.Fields{ + "user_id": verification.UserID, + }).Info("sso refresh successful") + + return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) +} + // Callback handles the redirect from SSO containing the authorization code. func (h *Controller) Callback(c *fiber.Ctx) error { state := strings.TrimSpace(c.Query("state")) @@ -342,6 +431,7 @@ func (h *Controller) Logout(c *fiber.Ctx) error { refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") var accessToken, refreshToken string + var verification *sso.VerificationResult if accessName != "" { accessToken = strings.TrimSpace(c.Cookies(accessName)) } @@ -363,9 +453,10 @@ func (h *Controller) Logout(c *fiber.Ctx) error { } if hadAccessCookie { - if verification, err := sso.VerifyAccessToken(accessToken); err != nil { + if v, err := sso.VerifyAccessToken(accessToken); err != nil { utils.Log.WithError(err).Warn("failed to verify access token during logout") } else { + verification = v if revoker := session.GetRevocationStore(); revoker != nil { if err := revoker.MarkUserLogout(c.Context(), verification.UserID, time.Now().UTC()); err != nil { utils.Log.WithError(err).Warn("failed to mark user logout") @@ -392,6 +483,28 @@ func (h *Controller) Logout(c *fiber.Ctx) error { } else if rawReturn != "" { utils.Log.WithError(err).Warn("invalid return_to during logout") } + } else if rawReturn == "" && config.SSOPortalURL != "" { + if alias, singleCfg, ok := singleClientFromToken(verification); ok { + if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" { + redirectTarget = normalized + alias, cfg, hasClientInfo = alias, singleCfg, true + } else { + redirectTarget = config.SSOPortalURL + } + } else if accessToken != "" { + if alias, singleCfg, ok := h.singleClientFromSSO(c.Context(), accessToken); ok { + if normalized, err := normalizeReturnTarget(singleCfg.DefaultReturnURI, singleCfg); err == nil && normalized != "" { + redirectTarget = normalized + alias, cfg, hasClientInfo = alias, singleCfg, true + } else { + redirectTarget = config.SSOPortalURL + } + } else { + redirectTarget = config.SSOPortalURL + } + } else { + redirectTarget = config.SSOPortalURL + } } else if rawReturn != "" { if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") { redirectTarget = rawReturn @@ -411,6 +524,177 @@ func (h *Controller) Logout(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"}) } +func singleSSOClient() (string, config.SSOClientConfig, bool) { + if len(config.SSOClients) != 1 { + return "", config.SSOClientConfig{}, false + } + for alias, cfg := range config.SSOClients { + if strings.TrimSpace(alias) == "" || strings.TrimSpace(cfg.PublicID) == "" { + return "", config.SSOClientConfig{}, false + } + return alias, cfg, true + } + return "", config.SSOClientConfig{}, false +} + +func singleClientFromToken(verification *sso.VerificationResult) (string, config.SSOClientConfig, bool) { + if verification == nil || verification.Claims == nil { + return "", config.SSOClientConfig{}, false + } + return singleClientFromScopes(verification.Claims.Scopes()) +} + +func (h *Controller) singleClientFromSSO(ctx context.Context, accessToken string) (string, config.SSOClientConfig, bool) { + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return "", config.SSOClientConfig{}, false + } + meURL := strings.TrimSpace(config.SSOGetMeURL) + if meURL == "" { + return "", config.SSOClientConfig{}, false + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) + if err != nil { + utils.Log.WithError(err).Warn("failed to build SSO getme request") + return "", config.SSOClientConfig{}, false + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := h.httpClient.Do(req) + if err != nil { + utils.Log.WithError(err).Warn("SSO getme request failed") + return "", config.SSOClientConfig{}, false + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + utils.Log.WithField("status", resp.StatusCode).Warn("SSO getme responded with error") + return "", config.SSOClientConfig{}, false + } + + var payload struct { + Data struct { + Roles []struct { + Client *struct { + Alias string `json:"alias"` + } `json:"client"` + } `json:"roles"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + utils.Log.WithError(err).Warn("failed to decode SSO getme response") + return "", config.SSOClientConfig{}, false + } + + aliases := make(map[string]struct{}) + for _, role := range payload.Data.Roles { + if role.Client == nil { + continue + } + alias := strings.ToLower(strings.TrimSpace(role.Client.Alias)) + if alias != "" { + aliases[alias] = struct{}{} + } + } + if len(aliases) != 1 { + return "", config.SSOClientConfig{}, false + } + for alias := range aliases { + if normalized, cfg, ok := findClientAlias(alias); ok { + return normalized, cfg, true + } + return "", config.SSOClientConfig{}, false + } + return "", config.SSOClientConfig{}, false +} + +func singleClientFromScopes(scopes []string) (string, config.SSOClientConfig, bool) { + if len(scopes) == 0 { + return "", config.SSOClientConfig{}, false + } + seen := make(map[string]struct{}) + for _, scope := range scopes { + if alias, ok := matchClientAliasFromScope(scope); ok { + seen[alias] = struct{}{} + } + if len(seen) > 1 { + return "", config.SSOClientConfig{}, false + } + } + if len(seen) != 1 { + return "", config.SSOClientConfig{}, false + } + for alias := range seen { + if normalized, cfg, ok := findClientAlias(alias); ok { + return normalized, cfg, true + } + } + return "", config.SSOClientConfig{}, false +} + +func matchClientAliasFromScope(scope string) (string, bool) { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + return "", false + } + prefix := scope + if idx := strings.IndexAny(prefix, ".:"); idx > 0 { + prefix = prefix[:idx] + } + if prefix == "" { + return "", false + } + if alias, _, ok := findClientAlias(prefix); ok { + return alias, true + } + if prefix == "user-management" { + if alias, _, ok := findClientAlias("umgmt"); ok { + return alias, true + } + } + if prefix == "umgmt" { + if alias, _, ok := findClientAlias("user-management"); ok { + return alias, true + } + } + return "", false +} + +func findClientAlias(alias string) (string, config.SSOClientConfig, bool) { + alias = strings.TrimSpace(alias) + if alias == "" { + return "", config.SSOClientConfig{}, false + } + if cfg, ok := config.SSOClients[alias]; ok && strings.TrimSpace(cfg.PublicID) != "" { + return alias, cfg, true + } + for key, cfg := range config.SSOClients { + if strings.EqualFold(key, alias) && strings.TrimSpace(cfg.PublicID) != "" { + return key, cfg, true + } + } + return "", config.SSOClientConfig{}, false +} + +func defaultSSOClientAlias() string { + for alias := range config.SSOClients { + if strings.TrimSpace(alias) == "" { + continue + } + return alias + } + return "" +} + +func buildStartRedirect(alias string) string { + alias = strings.TrimSpace(alias) + if alias == "" { + return "" + } + return "/api/sso/start?client=" + url.QueryEscape(alias) +} + func (h *Controller) revokeToken(ctx context.Context, token string, verification *sso.VerificationResult) { if h.revoker == nil || verification == nil || verification.Claims == nil { return diff --git a/internal/modules/sso/route.go b/internal/modules/sso/route.go index a7288ef9..3f2a699e 100644 --- a/internal/modules/sso/route.go +++ b/internal/modules/sso/route.go @@ -31,6 +31,7 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { group.Get("/start", middleware.NewLimiter(30, time.Minute), ctrl.Start) group.Get("/callback", ctrl.Callback) group.Get("/userinfo", middleware.NewLimiter(60, time.Minute), ctrl.UserInfo) + group.Post("/refresh", middleware.NewLimiter(60, time.Minute), ctrl.Refresh) group.Post("/logout", middleware.NewLimiter(60, time.Minute), ctrl.Logout) group.Post("/users/sync", middleware.NewLimiter(30, time.Minute), syncCtrl.Sync) } diff --git a/internal/modules/users/repositories/user.repository.go b/internal/modules/users/repositories/user.repository.go index f9bee9ed..b3cac2dc 100644 --- a/internal/modules/users/repositories/user.repository.go +++ b/internal/modules/users/repositories/user.repository.go @@ -42,7 +42,7 @@ func (r *UserRepositoryImpl) GetByIdUser( modifier func(*gorm.DB) *gorm.DB, ) (*entity.User, error) { return r.BaseRepositoryImpl.First(ctx, func(db *gorm.DB) *gorm.DB { - return db.Where("id_user = ?", idUser) + return db.Where("id_user::bigint = ?::bigint", idUser) }) } @@ -93,7 +93,7 @@ func (r *UserRepositoryImpl) UpsertByIdUser(ctx context.Context, user *entity.Us } func (r *UserRepositoryImpl) SoftDeleteByIdUser(ctx context.Context, idUser int64) error { - query := r.DB().WithContext(ctx).Where("id_user = ?", idUser) + query := r.DB().WithContext(ctx).Where("id_user::bigint = ?::bigint", idUser) result := query.Delete(&entity.User{}) if result.Error != nil { return result.Error diff --git a/internal/modules/users/route.go b/internal/modules/users/route.go index 9ba6bfb3..d6aa03fe 100644 --- a/internal/modules/users/route.go +++ b/internal/modules/users/route.go @@ -3,7 +3,7 @@ package users import ( "github.com/gofiber/fiber/v2" - "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/users/controllers" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" ) @@ -12,11 +12,11 @@ func UserRoutes(v1 fiber.Router, s user.UserService) { ctrl := controller.NewUserController(s) route := v1.Group("/users") - route.Use(middleware.Auth(s)) + route.Use(m.Auth(s)) - route.Get("/", ctrl.GetAll) + route.Get("/", m.RequirePermissions(m.P_UserGetAll), ctrl.GetAll) // route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) + route.Get("/:id", m.RequirePermissions(m.P_UserGetOne), ctrl.GetOne) // route.Patch("/:id", ctrl.UpdateOne) // route.Delete("/:id", ctrl.DeleteOne) } diff --git a/internal/response/response.go b/internal/response/response.go index c4ecca0f..710d320e 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -14,10 +14,11 @@ type Success struct { } type Meta struct { - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int64 `json:"total_pages"` - TotalResults int64 `json:"total_results"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int64 `json:"total_pages"` + TotalResults int64 `json:"total_results"` + Filters interface{} `json:"filters,omitempty"` } type SuccessWithPaginate[T any] struct { diff --git a/internal/route/route.go b/internal/route/route.go index ac7fb486..519ea5aa 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -9,13 +9,17 @@ import ( "gorm.io/gorm" approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals" + closings "gitlab.com/mbugroup/lti-api.git/internal/modules/closings" constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" + dailyChecklists "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists" expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses" + finance "gitlab.com/mbugroup/lti-api.git/internal/modules/finance" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" marketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" + repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" // MODULE IMPORTS @@ -40,6 +44,10 @@ func Routes(app *fiber.App, db *gorm.DB) { ssoModule.Module{}, expenses.ExpenseModule{}, ssoModule.Module{}, + closings.ClosingModule{}, + repports.RepportModule{}, + finance.FinanceModule{}, + dailyChecklists.DailyChecklistModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 98381df6..6ec50447 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -29,6 +29,18 @@ const ( FlagVitamin FlagType = "VITAMIN" FlagKimia FlagType = "KIMIA" FlagEkspedisi FlagType = "EKSPEDISI" + + // flag ayam + FlagAyamAfkir FlagType = "AYAM-AFKIR" + FlagAyamCulling FlagType = "AYAM-CULLING" + FlagAyamMati FlagType = "AYAM-MATI" + + //flag telur + FlagTelur FlagType = "TELUR" + FlagTelurUtuh FlagType = "TELUR-UTUH" + FlagTelurPecah FlagType = "TELUR-PECAH" + FlagTelurPutih FlagType = "TELUR-PUTIH" + FlagTelurRetak FlagType = "TELUR-RETAK" ) const ( @@ -99,6 +111,8 @@ type StockLogType string const ( StockLogTypeAdjustment StockLogType = "ADJUSTMENT" StockLogTypeTransfer StockLogType = "TRANSFER" + StockLogTypeMarketing StockLogType = "MARKETING" + StockLogTypeChikin StockLogType = "CHICKIN" ) // ------------------------------------------------------------------- @@ -123,6 +137,56 @@ const ( SupplierCategorySapronak SupplierCategory = "SAPRONAK" ) +// ------------------------------------------------------------------- +// ExpenseCategory +// ------------------------------------------------------------------- + +type ExpenseCategory string + +const ( + ExpenseCategoryBOP ExpenseCategory = "BOP" + ExpenseCategoryNonBOP ExpenseCategory = "NON-BOP" +) + +// ------------------------------------------------------------------- +// Payment Method +// ------------------------------------------------------------------- + +type PaymentMethod string + +const ( + PaymentMethodTransfer PaymentMethod = "TRANSFER" + PaymentMethodCash PaymentMethod = "CASH" + PaymentMethodCard PaymentMethod = "CARD" + PaymentMethodCheque PaymentMethod = "CHEQUE" + PaymentMethodSaldo PaymentMethod = "SALDO" +) + +// ------------------------------------------------------------------- +// Trasaction Type +// ------------------------------------------------------------------- + +type TransactionType string + +const ( + TransactionTypePenjualan TransactionType = "PENJUALAN" + TransactionTypePembelian TransactionType = "PEMBELIAN" + TransactionTypeBiaya TransactionType = "BIAYA" + TransactionTypeInjection TransactionType = "INJECTION" + TransactionTypeSaldoAwal TransactionType = "SALDO_AWAL" +) + +// ------------------------------------------------------------------- +// Payment Party +// ------------------------------------------------------------------- + +type PaymentParty string + +const ( + PaymentPartyCustomer PaymentParty = "CUSTOMER" + PaymentPartySupplier PaymentParty = "SUPPLIER" +) + // ------------------------------------------------------------------- // Kandang Status // ------------------------------------------------------------------- @@ -154,26 +218,44 @@ const ( ApprovalWorkflowProjectFlock approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PROJECT_FLOCKS") ProjectFlockStepPengajuan approvalutils.ApprovalStep = 1 ProjectFlockStepAktif approvalutils.ApprovalStep = 2 + ProjectFlockStepSelesai approvalutils.ApprovalStep = 3 ) // projectFlockApprovalSteps keeps the workflow step definitions for project flock approvals. var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{ ProjectFlockStepPengajuan: "Pengajuan", ProjectFlockStepAktif: "Aktif", + ProjectFlockStepSelesai: "Selesai", } // ------------------------------------------------------------------- -// Project Flock Kandang Approval +// Chickin Approval +// ------------------------------------------------------------------- +const ( + ApprovalWorkflowChickin approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("CHICKINS") + ChickinStepPengajuan approvalutils.ApprovalStep = 1 + ChickinStepDisetujui approvalutils.ApprovalStep = 2 +) + +var ChickinApprovalSteps = map[approvalutils.ApprovalStep]string{ + ChickinStepPengajuan: "Pengajuan", + ChickinStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Project-Flock kandang Approval // ------------------------------------------------------------------- const ( ApprovalWorkflowProjectFlockKandang approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PROJECT_FLOCK_KANDANGS") ProjectFlockKandangStepPengajuan approvalutils.ApprovalStep = 1 ProjectFlockKandangStepDisetujui approvalutils.ApprovalStep = 2 + ProjectFlockKandangStepClosed approvalutils.ApprovalStep = 3 ) var ProjectFlockKandangApprovalSteps = map[approvalutils.ApprovalStep]string{ ProjectFlockKandangStepPengajuan: "Pengajuan", ProjectFlockKandangStepDisetujui: "Disetujui", + ProjectFlockKandangStepClosed: "Selesai", } // ------------------------------------------------------------------- @@ -198,15 +280,28 @@ var TransferToLayingApprovalSteps = map[approvalutils.ApprovalStep]string{ const ( ApprovalWorkflowRecording approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("RECORDINGS") - RecordingStepGradingTelur approvalutils.ApprovalStep = 1 - RecordingStepPengajuan approvalutils.ApprovalStep = 2 - RecordingStepDisetujui approvalutils.ApprovalStep = 3 + RecordingStepPengajuan approvalutils.ApprovalStep = 1 + RecordingStepDisetujui approvalutils.ApprovalStep = 2 ) var RecordingApprovalSteps = map[approvalutils.ApprovalStep]string{ - RecordingStepGradingTelur: "Grading-Telur", - RecordingStepPengajuan: "Pengajuan", - RecordingStepDisetujui: "Disetujui", + RecordingStepPengajuan: "Pengajuan", + RecordingStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Uniformity Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowUniformity approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("UNIFORMITIES") + UniformityStepPengajuan approvalutils.ApprovalStep = 1 + UniformityStepDisetujui approvalutils.ApprovalStep = 2 +) + +var UniformityApprovalSteps = map[approvalutils.ApprovalStep]string{ + UniformityStepPengajuan: "Pengajuan", + UniformityStepDisetujui: "Disetujui", } // ------------------------------------------------------------------- @@ -243,6 +338,9 @@ const ( MarketingStepPengajuan approvalutils.ApprovalStep = 1 MarketingStepSalesOrder approvalutils.ApprovalStep = 2 MarketingDeliveryOrder approvalutils.ApprovalStep = 3 + + MarketingSoNumberPrefix = "SO-" + MarketingNumberPadding = 5 ) var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{ @@ -272,6 +370,70 @@ var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepSelesai: "Selesai", } +// ------------------------------------------------------------------- +// Payment Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowPayment approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PAYMENTS") + PaymentStepPengajuan approvalutils.ApprovalStep = 1 + PaymentStepDisetujui approvalutils.ApprovalStep = 2 +) + +var PaymentApprovalSteps = map[approvalutils.ApprovalStep]string{ + PaymentStepPengajuan: "Pengajuan", + PaymentStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Inisial Balance Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInitial approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INITIAL_BALANCES") + InitialStepPengajuan approvalutils.ApprovalStep = 1 + InitialStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InitialApprovalSteps = map[approvalutils.ApprovalStep]string{ + InitialStepPengajuan: "Pengajuan", + InitialStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Injection Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInjection approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INJECTIONS") + InjectionStepPengajuan approvalutils.ApprovalStep = 1 + InjectionStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InjectionApprovalSteps = map[approvalutils.ApprovalStep]string{ + InjectionStepPengajuan: "Pengajuan", + InjectionStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Document +// ------------------------------------------------------------------- + +type DocumentType string +type DocumentableType string + +const ( + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" + DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" + DocumentTypePurchaseTravel DocumentType = "PURCHASE_TRAVEL_DOCUMENT" + + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeExpense DocumentableType = "EXPENSE" + DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" + DocumentableTypePurchaseItem DocumentableType = "PURCHASE_ITEM" +) + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -398,6 +560,38 @@ func IsValidSupplierCategory(v string) bool { return false } +func IsValidExpenseCategory(v string) bool { + switch ExpenseCategory(v) { + case ExpenseCategoryBOP, ExpenseCategoryNonBOP: + return true + } + return false +} + +func IsValidPaymentMethod(v string) bool { + switch PaymentMethod(v) { + case PaymentMethodTransfer, PaymentMethodCash, PaymentMethodCard, PaymentMethodCheque, PaymentMethodSaldo: + return true + } + return false +} + +func IsValidTransactionType(v string) bool { + switch TransactionType(v) { + case TransactionTypePenjualan, TransactionTypePembelian, TransactionTypeBiaya, TransactionTypeInjection, TransactionTypeSaldoAwal: + return true + } + return false +} + +func IsValidPaymentParty(v string) bool { + switch PaymentParty(v) { + case PaymentPartyCustomer, PaymentPartySupplier: + return true + } + return false +} + // example use // Recording helper diff --git a/internal/utils/error.go b/internal/utils/error.go index e409e50c..ead06aeb 100644 --- a/internal/utils/error.go +++ b/internal/utils/error.go @@ -25,3 +25,16 @@ func ErrorHandler(c *fiber.Ctx, err error) error { func NotFoundHandler(c *fiber.Ctx) error { return response.Error(c, fiber.StatusNotFound, "Endpoint Not Found", nil) } + + +func BadRequest(msg string) error { + return fiber.NewError(fiber.StatusBadRequest, msg) +} + +func NotFound(msg string) error { + return fiber.NewError(fiber.StatusNotFound, msg) +} + +func Internal(msg string) error { + return fiber.NewError(fiber.StatusInternalServerError, msg) +} diff --git a/internal/utils/fifo/README.md b/internal/utils/fifo/README.md new file mode 100644 index 00000000..86f2af6d --- /dev/null +++ b/internal/utils/fifo/README.md @@ -0,0 +1,67 @@ +# Mesin Stok FIFO + +Utilitas FIFO bersifat reusable dan dibagi menjadi dua lapis: + +1. **Registry (`internal/utils/fifo`)** – mendeklarasikan tabel mana yang bersifat `Stockable` (sumber stok) atau `Usable` (pemakai stok). Setiap modul cukup menyebutkan nama tabel dan kolom wajib: + - Stockable: `id`, `product_warehouse_id`, `total_qty`, `total_used_qty`, `created_at` + - Usable: `id`, `product_warehouse_id`, `usage_qty`, `pending_qty`, `created_at` +2. **Service (`internal/common/service/common.fifo.service.go`)** – memakai registry tersebut untuk: + - Menambah stok baru (`Replenish`). + - Menyinkronkan total pemakaian (`Consume`). Method ini idempotent: panggil dengan *total kuantitas yang diinginkan* (mis. saat create/update/delete). Service menghitung selisih terhadap `usage_qty + pending_qty`, kemudian otomatis mengalokasikan tambahan atau melepaskan selisihnya. + - Membatalkan pemakaian (`ReleaseUsage`) yang mengembalikan stok lalu memicu alokasi ulang ke antrian pending. + - Baik `Replenish` maupun pelepasan stok akan menjalankan `resolvePendingForWarehouse`, sehingga pending tertua langsung terisi ketika stok tersedia. + +## Registrasi tabel + +```go +import ( + commonservice "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" +) + +func init() { + fifoSvc := commonservice.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("PURCHASE_DETAIL"), + Table: "purchase_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used_qty", + CreatedAt: "created_at", + }, + }) + + fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("RECORDING_STOCK"), + Table: "recording_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }) +} +``` + +Each registration optionally accepts an order clause or base scope (e.g. to exclude drafts). + +Setiap registrasi bisa diberi klausa urutan atau scope dasar (mis. untuk mengecualikan draft). + +## Menggunakan service di modul + +1. **Saat stok masuk** (mis. purchase selesai): panggil `fifoSvc.Replenish(...)` dengan key stockable, id record, id product warehouse, dan kuantitas yang baru tersedia. Service akan: + - Menambah `total_qty` pada tabel stockable, + - Menambah `product_warehouses.quantity`, + - Mencoba membersihkan `pending_qty` dari semua usable yang terdaftar (sesuai urutan FIFO). +2. **Saat modul memakai stok** (recording, marketing, dsb.) panggil `fifoSvc.Consume(...)` dengan total qty terbaru. + - Jika qty baru lebih besar, service mengambil stok FIFO dan menambah `usage_qty`; kekurangan dicatat sebagai `pending_qty`. + - Jika qty baru lebih kecil, service otomatis menurunkan `pending_qty` lebih dulu, lalu melepaskan alokasi aktif (stok kembali ke gudang) dan langsung dipakai untuk mengisi pending milik entitas lain. + - Hapus data? panggil `Consume` dengan qty 0 atau gunakan `ReleaseUsage`. +3. **Jika dibatalkan penuh**: `fifoSvc.ReleaseUsage(...)` mengosongkan `usage_qty/pending_qty` dan menandai baris pivot sebagai `RELEASED`. + +Tabel pivot (`stock_allocations`) menyimpan asal pemakaian secara presisi, sehingga audit trail dan rollback stok menjadi deterministik. diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go new file mode 100644 index 00000000..ea6f96c0 --- /dev/null +++ b/internal/utils/fifo/constants.go @@ -0,0 +1,7 @@ +package fifo + +const ( + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" +) diff --git a/internal/utils/fifo/registry.go b/internal/utils/fifo/registry.go new file mode 100644 index 00000000..61fed294 --- /dev/null +++ b/internal/utils/fifo/registry.go @@ -0,0 +1,204 @@ +package fifo + +import ( + "errors" + "fmt" + "strings" + "sync" + + "gorm.io/gorm" +) + +// QueryScope allows callers to inject custom query modifiers (preloads, filters, etc). +type QueryScope func(*gorm.DB) *gorm.DB + +type StockableKey string +type UsableKey string + +func (k StockableKey) String() string { + return string(k) +} + +func (k UsableKey) String() string { + return string(k) +} + +// StockableColumns describes the minimum columns required for a stock-bearing row. +type StockableColumns struct { + ID string + ProductWarehouseID string + TotalQuantity string + TotalUsedQuantity string + CreatedAt string +} + +// UsableColumns describes the required columns for rows that consume stock. +type UsableColumns struct { + ID string + ProductWarehouseID string + UsageQuantity string + PendingQuantity string + CreatedAt string +} + +// StockableConfig registers a table that introduces stock into the system (purchases, transfers, etc). +type StockableConfig struct { + Key StockableKey + Table string + Columns StockableColumns + // OrderBy accepts raw column expressions, evaluated in-order (e.g. []string{"created_at ASC", "id ASC"}). + OrderBy []string + // Scope lets a module append base filters (e.g. exclude drafts). + Scope QueryScope +} + +// UsableConfig registers a table that consumes stock (recordings, adjustments, sales, etc). +type UsableConfig struct { + Key UsableKey + Table string + Columns UsableColumns + OrderBy []string + Scope QueryScope +} + +var ( + stockableRegistry = make(map[StockableKey]StockableConfig) + usableRegistry = make(map[UsableKey]UsableConfig) + registryMu sync.RWMutex +) + +// RegisterStockable stores the configuration so services can perform FIFO operations generically. +func RegisterStockable(cfg StockableConfig) error { + if err := validateStockableConfig(cfg); err != nil { + return err + } + + registryMu.Lock() + defer registryMu.Unlock() + + key := StockableKey(strings.TrimSpace(cfg.Key.String())) + if _, exists := stockableRegistry[key]; exists { + return fmt.Errorf("stockable key %q already registered", key) + } + + stockableRegistry[key] = cfg + return nil +} + +// RegisterUsable stores the configuration for stock-consuming tables. +func RegisterUsable(cfg UsableConfig) error { + if err := validateUsableConfig(cfg); err != nil { + return err + } + + registryMu.Lock() + defer registryMu.Unlock() + + key := UsableKey(strings.TrimSpace(cfg.Key.String())) + if _, exists := usableRegistry[key]; exists { + return fmt.Errorf("usable key %q already registered", key) + } + + usableRegistry[key] = cfg + return nil +} + +// Stockable returns the registered configuration for the key (if any). +func Stockable(key StockableKey) (StockableConfig, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + + cfg, ok := stockableRegistry[key] + return cfg, ok +} + +// Usable returns the registered configuration for the key (if any). +func Usable(key UsableKey) (UsableConfig, bool) { + registryMu.RLock() + defer registryMu.RUnlock() + + cfg, ok := usableRegistry[key] + return cfg, ok +} + +// Stockables exposes a copy of the current registry (useful for iterating pending requests). +func Stockables() map[StockableKey]StockableConfig { + registryMu.RLock() + defer registryMu.RUnlock() + + if len(stockableRegistry) == 0 { + return nil + } + + result := make(map[StockableKey]StockableConfig, len(stockableRegistry)) + for key, cfg := range stockableRegistry { + result[key] = cfg + } + return result +} + +// Usables exposes a copy of the usable registry. +func Usables() map[UsableKey]UsableConfig { + registryMu.RLock() + defer registryMu.RUnlock() + + if len(usableRegistry) == 0 { + return nil + } + + result := make(map[UsableKey]UsableConfig, len(usableRegistry)) + for key, cfg := range usableRegistry { + result[key] = cfg + } + return result +} + +func validateStockableConfig(cfg StockableConfig) error { + if strings.TrimSpace(cfg.Key.String()) == "" { + return errors.New("stockable key is required") + } + if strings.TrimSpace(cfg.Table) == "" { + return fmt.Errorf("table name is required for stockable %q", cfg.Key) + } + + cols := cfg.Columns + switch { + case strings.TrimSpace(cols.ID) == "": + return fmt.Errorf("column id is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.ProductWarehouseID) == "": + return fmt.Errorf("column product warehouse id is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.TotalQuantity) == "": + return fmt.Errorf("column total quantity is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.TotalUsedQuantity) == "": + return fmt.Errorf("column total used quantity is required for stockable %q", cfg.Key) + case strings.TrimSpace(cols.CreatedAt) == "": + return fmt.Errorf("column created_at is required for stockable %q", cfg.Key) + } + + return nil +} + +func validateUsableConfig(cfg UsableConfig) error { + if strings.TrimSpace(cfg.Key.String()) == "" { + return errors.New("usable key is required") + } + if strings.TrimSpace(cfg.Table) == "" { + return fmt.Errorf("table name is required for usable %q", cfg.Key) + } + + cols := cfg.Columns + switch { + case strings.TrimSpace(cols.ID) == "": + return fmt.Errorf("column id is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.ProductWarehouseID) == "": + return fmt.Errorf("column product warehouse id is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.UsageQuantity) == "": + return fmt.Errorf("column usage quantity is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.PendingQuantity) == "": + return fmt.Errorf("column pending quantity is required for usable %q", cfg.Key) + case strings.TrimSpace(cols.CreatedAt) == "": + return fmt.Errorf("column created_at is required for usable %q", cfg.Key) + } + + return nil +} diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index 8f0fe81f..91c9cc4b 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -5,31 +5,6 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" ) -func MapBodyWeights(recordingID uint, items []validation.BodyWeight) []entity.RecordingBW { - if len(items) == 0 { - return nil - } - - result := make([]entity.RecordingBW, 0, len(items)) - for _, item := range items { - var totalWeight float64 - if item.TotalWeight != nil { - totalWeight = *item.TotalWeight - } - if totalWeight <= 0 { - totalWeight = item.AvgWeight * item.Qty - } - - result = append(result, entity.RecordingBW{ - RecordingId: recordingID, - AvgWeight: item.AvgWeight, - Qty: item.Qty, - TotalWeight: totalWeight, - }) - } - return result -} - func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingStock { if len(items) == 0 { return nil @@ -80,25 +55,9 @@ func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity. RecordingId: recordingID, ProductWarehouseId: item.ProductWarehouseId, Qty: item.Qty, + Weight: item.Weight, CreatedBy: createdBy, }) } return result } - -func ToGrams(weight float64) float64 { - if weight <= 0 { - return 0 - } - if weight < 10 { - return weight * 1000 - } - return weight -} - -func GramsToKg(grams float64) float64 { - if grams <= 0 { - return 0 - } - return grams / 1000 -} diff --git a/internal/utils/time.go b/internal/utils/time.go index f57a3bb3..5f34923e 100644 --- a/internal/utils/time.go +++ b/internal/utils/time.go @@ -1,8 +1,9 @@ package utils import ( - "time" "errors" + "strings" + "time" ) // ParseDateString mengubah string "YYYY-MM-DD" menjadi time.Time @@ -23,3 +24,35 @@ func ParseDateString(dateStr string) (time.Time, error) { func FormatDate(t time.Time) string { return t.Format("2006-01-02") } + +// ParseDateRangeForQuery parses optional YYYY-MM-DD from/to strings for list filters. +// It returns a start pointer (inclusive) and an end pointer advanced by one day +// so callers can safely use "< end" to achieve an inclusive upper bound. +func ParseDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) { + var fromPtr *time.Time + var toPtr *time.Time + + if strings.TrimSpace(fromStr) != "" { + parsed, err := ParseDateString(strings.TrimSpace(fromStr)) + if err != nil { + return nil, nil, errors.New("created_from must use format YYYY-MM-DD") + } + fromValue := parsed + fromPtr = &fromValue + } + + if strings.TrimSpace(toStr) != "" { + parsed, err := ParseDateString(strings.TrimSpace(toStr)) + if err != nil { + return nil, nil, errors.New("created_to must use format YYYY-MM-DD") + } + nextDay := parsed.AddDate(0, 0, 1) + toPtr = &nextDay + } + + if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { + return nil, nil, errors.New("created_from must be earlier than created_to") + } + + return fromPtr, toPtr, nil +} diff --git a/test/integration/inventory/transfers/transfer_fifo_integration_test.go b/test/integration/inventory/transfers/transfer_fifo_integration_test.go new file mode 100644 index 00000000..d9f127a1 --- /dev/null +++ b/test/integration/inventory/transfers/transfer_fifo_integration_test.go @@ -0,0 +1,304 @@ +package test + +import ( + "context" + "math" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" +) + +// Test Transfer FIFO with Purchase as initial stockable +func TestTransferFIFO_PurchaseToTransfer(t *testing.T) { + db, fifoSvc := setupTransferFIFOTest(t) + ctx := context.Background() + + // Setup warehouses + sourcePW := createProductWarehouseRow(t, db, 100) // 100 qty from purchase + destPW := createProductWarehouseRow(t, db, 0) // 0 qty initially + + // Step 1: Simulate Purchase - Replenish stock to source warehouse + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS") + if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: purchaseStockableKey, + StockableID: 1, // PurchaseItem ID + ProductWarehouseID: sourcePW.Id, + Quantity: 100, + }); err != nil { + t.Fatalf("Failed to replenish from purchase: %v", err) + } + + // Verify source warehouse has stock + assertWarehouseQuantity(t, db, sourcePW.Id, 100) + assertAllocationCount(t, db, 1) // 1 allocation from purchase + + // Step 2: Create Transfer - will consume from source (usable) and replenish to dest (stockable) + + // Register Transfer as Usable (source warehouse - STOCK_TRANSFER_OUT) + transferUsableKey := fifo.UsableKey("STOCK_TRANSFER_OUT") + if err := fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: transferUsableKey, + Table: "stock_transfer_details", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("Failed to register STOCK_TRANSFER_OUT as Usable: %v", err) + } + + // Register Transfer as Stockable (destination warehouse - STOCK_TRANSFER_IN) + transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_IN") + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: transferStockableKey, + Table: "stock_transfer_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "dest_product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("Failed to register STOCK_TRANSFER_IN as Stockable: %v", err) + } + + // Create transfer detail record + transferDetail := entity.StockTransferDetail{ + Id: 1, + StockTransferId: 1, + ProductId: 1, + SourceProductWarehouseID: uint64Ptr(uint64(sourcePW.Id)), + DestProductWarehouseID: uint64Ptr(uint64(destPW.Id)), + UsageQty: 0, + PendingQty: 0, + TotalQty: 0, + TotalUsed: 0, + } + transferDetailID := uint(transferDetail.Id) + if err := db.Create(&transferDetail).Error; err != nil { + t.Fatalf("Failed to create transfer detail: %v", err) + } + + transferQty := 50.0 + + // Consume from source warehouse (STOCK_TRANSFER_OUT) + consumeResult, err := fifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: "STOCK_TRANSFER_OUT", + UsableID: transferDetailID, + ProductWarehouseID: sourcePW.Id, + Quantity: transferQty, + AllowPending: false, // Don't allow pending + }) + if err != nil { + t.Fatalf("Failed to consume from source warehouse: %v", err) + } + + // Verify consumption + if mathAbs(consumeResult.UsageQuantity-transferQty) > 1e-6 { + t.Fatalf("Expected usage quantity %.2f, got %.2f", transferQty, consumeResult.UsageQuantity) + } + if mathAbs(consumeResult.PendingQuantity) > 1e-6 { + t.Fatalf("Expected pending quantity 0, got %.2f", consumeResult.PendingQuantity) + } + + // Update transfer detail usable fields + if err := db.Model(&entity.StockTransferDetail{}). + Where("id = ?", transferDetail.Id). + Updates(map[string]interface{}{ + "usage_qty": consumeResult.UsageQuantity, + "pending_qty": consumeResult.PendingQuantity, + }).Error; err != nil { + t.Fatalf("Failed to update transfer detail usable fields: %v", err) + } + + // Verify source warehouse decreased + assertWarehouseQuantity(t, db, sourcePW.Id, 50) // 100 - 50 = 50 + + // Verify allocation updated - should have 50 allocated to transfer + allocations := fetchAllocationsByUsable(t, db, "STOCK_TRANSFER_OUT", transferDetailID) + if len(allocations) != 1 { + t.Fatalf("Expected 1 allocation, got %d", len(allocations)) + } + if mathAbs(allocations[0].Qty-transferQty) > 1e-6 { + t.Fatalf("Expected allocation qty %.2f, got %.2f", transferQty, allocations[0].Qty) + } + + // Replenish to destination warehouse (STOCK_TRANSFER_IN) + note := "Transfer #1" + replenishResult, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: "STOCK_TRANSFER_IN", + StockableID: transferDetailID, + ProductWarehouseID: destPW.Id, + Quantity: transferQty, + Note: ¬e, + }) + if err != nil { + t.Fatalf("Failed to replenish to destination warehouse: %v", err) + } + + // Verify replenishment + if mathAbs(replenishResult.AddedQuantity-transferQty) > 1e-6 { + t.Fatalf("Expected added quantity %.2f, got %.2f", transferQty, replenishResult.AddedQuantity) + } + + // Update transfer detail stockable fields + if err := db.Model(&entity.StockTransferDetail{}). + Where("id = ?", transferDetail.Id). + Updates(map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }).Error; err != nil { + t.Fatalf("Failed to update transfer detail stockable fields: %v", err) + } + + // Verify destination warehouse increased + assertWarehouseQuantity(t, db, destPW.Id, transferQty) + + // Verify new stockable allocation created + stockableAllocations := fetchAllocationsByStockable(t, db, "STOCK_TRANSFER_IN", transferDetailID) + if len(stockableAllocations) != 1 { + t.Fatalf("Expected 1 stockable allocation, got %d", len(stockableAllocations)) + } + if mathAbs(stockableAllocations[0].Qty-transferQty) > 1e-6 { + t.Fatalf("Expected stockable allocation qty %.2f, got %.2f", transferQty, stockableAllocations[0].Qty) + } + + t.Logf("✅ Transfer FIFO test passed:") + t.Logf(" - Source warehouse: 100 → 50 (consumed %d)", int(transferQty)) + t.Logf(" - Destination warehouse: 0 → %d (replenished)", int(transferQty)) + t.Logf(" - Usable allocation: %.2f allocated to transfer", allocations[0].Qty) + t.Logf(" - Stockable allocation: %.2f available at destination", stockableAllocations[0].Qty) +} + +// Setup function for transfer FIFO test +func setupTransferFIFOTest(t *testing.T) (*gorm.DB, commonSvc.FifoService) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open db: %v", err) + } + + if err := db.AutoMigrate( + &entity.ProductWarehouse{}, + &entity.StockAllocation{}, + &entity.StockTransferDetail{}, + ); err != nil { + t.Fatalf("auto migrate entities: %v", err) + } + + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + // Register Purchase as Stockable + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS") + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: purchaseStockableKey, + Table: "purchase_items", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("register purchase stockable: %v", err) + } + + return db, fifoSvc +} + +// Helper functions + +func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse { + t.Helper() + pw := entity.ProductWarehouse{ + ProductId: 1, + WarehouseId: 1, + Quantity: qty, + } + if err := db.Create(&pw).Error; err != nil { + t.Fatalf("create product warehouse: %v", err) + } + return pw +} + +func assertWarehouseQuantity(t *testing.T, db *gorm.DB, pwID uint, expected float64) { + t.Helper() + var pw entity.ProductWarehouse + if err := db.First(&pw, pwID).Error; err != nil { + t.Fatalf("fetch product warehouse %d: %v", pwID, err) + } + if mathAbs(pw.Quantity-expected) > 1e-6 { + t.Fatalf("expected warehouse quantity %.2f, got %.2f", expected, pw.Quantity) + } +} + +func assertAllocationCount(t *testing.T, db *gorm.DB, expected int) { + t.Helper() + var count int64 + if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil { + t.Fatalf("count allocations: %v", err) + } + if int(count) != expected { + t.Fatalf("expected %d allocations, got %d", expected, count) + } +} + +func fetchAllocationsByUsable(t *testing.T, db *gorm.DB, usableType string, usableID uint) []entity.StockAllocation { + t.Helper() + var allocations []entity.StockAllocation + if err := db.Where("usable_type = ? AND usable_id = ?", usableType, usableID). + Find(&allocations).Error; err != nil { + t.Fatalf("fetch allocations by usable: %v", err) + } + return allocations +} + +func fetchAllocationsByStockable(t *testing.T, db *gorm.DB, stockableType string, stockableID uint) []entity.StockAllocation { + t.Helper() + var allocations []entity.StockAllocation + if err := db.Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID). + Find(&allocations).Error; err != nil { + t.Fatalf("fetch allocations by stockable: %v", err) + } + return allocations +} + +func floatPtr(f float64) *float64 { + return &f +} + +func uint64Ptr(u uint64) *uint64 { + return &u +} + +func mathAbs(f float64) float64 { + return math.Abs(f) +} + +func sanitizeKey(name string) string { + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return '_' + }, name) +} diff --git a/test/integration/production/recordings/recording_fifo_integration_test.go b/test/integration/production/recordings/recording_fifo_integration_test.go new file mode 100644 index 00000000..dd5f7d53 --- /dev/null +++ b/test/integration/production/recordings/recording_fifo_integration_test.go @@ -0,0 +1,446 @@ +package test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + servicePkg "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" +) + +func TestRecordingFIFO_CreatePendingWithoutStock(t *testing.T) { + db, svc, _, _ := setupRecordingFIFOTableTest(t) + ctx := context.Background() + + recordingID := uint(1) + productWarehouse := createProductWarehouseRow(t, db, 0) + stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) + + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks (pending) failed: %v", err) + } + + updated := fetchRecordingStock(t, db, stock.Id) + assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should remain zero when no stock is available") + assertFloatEqual(t, 10, updated.PendingQty, "pending_qty should capture the entire request") + assertWarehouseQuantity(t, db, productWarehouse.Id, 0) + assertAllocationCount(t, db, 0) + + assertAllocationCount(t, db, 0) +} + +func TestRecordingFIFO_EditReallocatesUsage(t *testing.T) { + db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t) + ctx := context.Background() + + recordingID := uint(1) + productWarehouse := createProductWarehouseRow(t, db, 0) + stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) + lot := createStockLot(t, db, productWarehouse.Id) + + if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: stockableKey, + StockableID: lot.Id, + ProductWarehouseID: productWarehouse.Id, + Quantity: 12, + }); err != nil { + t.Fatalf("replenish failed: %v", err) + } + + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks (initial) failed: %v", err) + } + + assertWarehouseQuantity(t, db, productWarehouse.Id, 2) + + desired := 4.0 + stock.UsageQty = &desired + + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks (edit) failed: %v", err) + } + + updated := fetchRecordingStock(t, db, stock.Id) + assertFloatEqual(t, 4, updated.UsageQty, "usage_qty should reflect edited request") + assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should remain zero after downsize") + assertWarehouseQuantity(t, db, productWarehouse.Id, 8) + + alloc := fetchSingleAllocation(t, db, stock.Id) + if alloc.Status != entity.StockAllocationStatusActive { + t.Fatalf("expected ACTIVE allocation, got %s", alloc.Status) + } + if mathAbs(alloc.Qty-4) > 1e-6 { + t.Fatalf("expected allocation qty 4, got %.3f", alloc.Qty) + } +} + +func TestRecordingFIFO_DeleteReleasesStock(t *testing.T) { + db, svc, fifoSvc, stockableKey := setupRecordingFIFOTableTest(t) + ctx := context.Background() + + recordingID := uint(1) + productWarehouse := createProductWarehouseRow(t, db, 0) + stock := createRecordingStockRow(t, db, recordingID, productWarehouse.Id, 10) + lot := createStockLot(t, db, productWarehouse.Id) + + if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: stockableKey, + StockableID: lot.Id, + ProductWarehouseID: productWarehouse.Id, + Quantity: 10, + }); err != nil { + t.Fatalf("replenish failed: %v", err) + } + if err := svc.ConsumeRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("consumeRecordingStocks failed: %v", err) + } + + if err := svc.ReleaseRecordingStocks(ctx, db, []entity.RecordingStock{stock}); err != nil { + t.Fatalf("releaseRecordingStocks failed: %v", err) + } + + updated := fetchRecordingStock(t, db, stock.Id) + assertFloatEqual(t, 0, updated.UsageQty, "usage_qty should be cleared after delete") + assertFloatEqual(t, 0, updated.PendingQty, "pending_qty should be cleared after delete") + assertWarehouseQuantity(t, db, productWarehouse.Id, 10) + + alloc := fetchSingleAllocation(t, db, stock.Id) + if alloc.Status != entity.StockAllocationStatusReleased { + t.Fatalf("expected allocation to be released, got %s", alloc.Status) + } +} + +// --- helpers ---------------------------------------------------------------- + +type recordingStockTable struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + UsageQty *float64 `gorm:"column:usage_qty"` + PendingQty *float64 `gorm:"column:pending_qty"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (recordingStockTable) TableName() string { return "recording_stocks" } + +type productWarehouseTable struct { + Id uint `gorm:"primaryKey"` + ProductId uint `gorm:"column:product_id"` + WarehouseId uint `gorm:"column:warehouse_id"` + Quantity float64 `gorm:"column:quantity"` + CreatedBy uint `gorm:"column:created_by"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (productWarehouseTable) TableName() string { return "product_warehouses" } + +type stockAllocationTable struct { + Id uint `gorm:"primaryKey"` + ProductWarehouseId uint `gorm:"not null"` + StockableType string `gorm:"size:100"` + StockableId uint + UsableType string `gorm:"size:100"` + UsableId uint + Qty float64 `gorm:"column:qty"` + Status string `gorm:"size:20"` + Note *string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time + ReleasedAt *time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (stockAllocationTable) TableName() string { return "stock_allocations" } + +type testStockSource struct { + Id uint `gorm:"primaryKey"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + TotalQty float64 `gorm:"column:total_qty"` + TotalUsedQty float64 `gorm:"column:total_used_qty"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time +} + +func (testStockSource) TableName() string { return "test_fifo_stockables" } + +func setupRecordingFIFOTableTest(t *testing.T) (*gorm.DB, servicePkg.RecordingFIFOIntegrationService, commonSvc.FifoService, fifo.StockableKey) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.AutoMigrate( + &recordingStockTable{}, + &productWarehouseTable{}, + &stockAllocationTable{}, + &testStockSource{}, + ); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + if err := db.AutoMigrate( + &entity.ProductWarehouse{}, + &entity.StockAllocation{}, + &entity.RecordingStock{}, + ); err != nil { + t.Fatalf("auto migrate entities: %v", err) + } + + stockAllocRepo := newFifoTestStockAllocationRepo(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + registerRecordingUsable(t, fifoSvc) + + key := fifo.StockableKey(fmt.Sprintf("TEST_STOCKABLE_%s_%d", sanitizeKey(t.Name()), time.Now().UnixNano())) + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: key, + Table: "test_fifo_stockables", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used_qty", + CreatedAt: "created_at", + }, + }); err != nil { + t.Fatalf("register stockable: %v", err) + } + + svc := servicePkg.NewRecordingFIFOIntegrationService( + recordingRepo.NewRecordingRepository(db), + productWarehouseRepo, + fifoSvc, + ) + + return db, svc, fifoSvc, key +} + +func registerRecordingUsable(t *testing.T, fifoSvc commonSvc.FifoService) { + t.Helper() + err := fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyRecordingStock, + Table: "recording_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }) + if err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("register usable: %v", err) + } + if _, ok := fifo.Usable(fifo.UsableKeyRecordingStock); !ok { + t.Fatal("recording stock usable key not registered") + } +} + +func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse { + t.Helper() + pw := entity.ProductWarehouse{ + ProductId: 1, + WarehouseId: 1, + Quantity: qty, + // CreatedBy: 1, + } + if err := db.Create(&pw).Error; err != nil { + t.Fatalf("create product warehouse: %v", err) + } + return pw +} + +func createRecordingStockRow(t *testing.T, db *gorm.DB, recordingID, productWarehouseID uint, desired float64) entity.RecordingStock { + t.Helper() + stock := entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: productWarehouseID, + UsageQty: floatPtr(0), + PendingQty: floatPtr(0), + } + if err := db.Create(&stock).Error; err != nil { + t.Fatalf("create recording stock: %v", err) + } + stock.UsageQty = floatPtr(desired) + return stock +} + +func createStockLot(t *testing.T, db *gorm.DB, productWarehouseID uint) testStockSource { + t.Helper() + lot := testStockSource{ + ProductWarehouseId: productWarehouseID, + CreatedAt: time.Now(), + } + if err := db.Create(&lot).Error; err != nil { + t.Fatalf("create stock lot: %v", err) + } + return lot +} + +func fetchRecordingStock(t *testing.T, db *gorm.DB, id uint) entity.RecordingStock { + t.Helper() + var stock entity.RecordingStock + if err := db.First(&stock, id).Error; err != nil { + t.Fatalf("fetch recording stock: %v", err) + } + return stock +} + +func fetchSingleAllocation(t *testing.T, db *gorm.DB, usableID uint) entity.StockAllocation { + t.Helper() + var alloc entity.StockAllocation + if err := db.Where("usable_id = ?", usableID).Order("created_at ASC").First(&alloc).Error; err != nil { + t.Fatalf("fetch allocation: %v", err) + } + return alloc +} + +func assertAllocationCount(t *testing.T, db *gorm.DB, expected int64) { + t.Helper() + var count int64 + if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil { + t.Fatalf("count allocations: %v", err) + } + if count != expected { + t.Fatalf("expected %d allocations, got %d", expected, count) + } +} + +func assertWarehouseQuantity(t *testing.T, db *gorm.DB, id uint, expected float64) { + t.Helper() + var pw entity.ProductWarehouse + if err := db.First(&pw, id).Error; err != nil { + t.Fatalf("fetch product warehouse: %v", err) + } + if mathAbs(pw.Quantity-expected) > 1e-6 { + t.Fatalf("expected warehouse quantity %.3f, got %.3f", expected, pw.Quantity) + } +} + +func assertFloatEqual(t *testing.T, expected float64, value *float64, msg string) { + t.Helper() + if value == nil { + t.Fatalf("expected %s %.3f, got nil", msg, expected) + } + if mathAbs(*value-expected) > 1e-6 { + t.Fatalf("%s: expected %.3f, got %.3f", msg, expected, *value) + } +} + +func floatPtr(v float64) *float64 { + p := new(float64) + *p = v + return p +} + +func mathAbs(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +func sanitizeKey(name string) string { + if name == "" { + return "CASE" + } + clean := strings.Map(func(r rune) rune { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + if r >= 'a' && r <= 'z' { + return r - 32 + } + return '_' + }, name) + return clean +} + +type fifoTestStockAllocationRepo struct { + commonRepo.StockAllocationRepository + db *gorm.DB +} + +func newFifoTestStockAllocationRepo(db *gorm.DB) commonRepo.StockAllocationRepository { + return &fifoTestStockAllocationRepo{ + StockAllocationRepository: commonRepo.NewStockAllocationRepository(db), + db: db, + } +} + +func (r *fifoTestStockAllocationRepo) PatchOne( + ctx context.Context, + id uint, + updates map[string]any, + modifier func(*gorm.DB) *gorm.DB, +) error { + base := r.db + + setClauses := make([]string, 0, len(updates)) + args := make([]any, 0, len(updates)+1) + for column, value := range updates { + colName := column + if strings.EqualFold(column, "quantity") { + colName = "qty" + } + setClauses = append(setClauses, fmt.Sprintf("%s = ?", colName)) + args = append(args, value) + } + args = append(args, id) + sql := fmt.Sprintf("UPDATE stock_allocations SET %s WHERE id = ?", strings.Join(setClauses, ", ")) + + result := base.Exec(sql, args...) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *fifoTestStockAllocationRepo) ReleaseByUsable( + ctx context.Context, + usableType string, + usableID uint, + note *string, + modifier func(*gorm.DB) *gorm.DB, +) error { + base := r.db + + setClause := "status = ?, released_at = ?" + args := []any{entity.StockAllocationStatusReleased, time.Now()} + if note != nil { + setClause += ", note = ?" + args = append(args, *note) + } + args = append(args, usableType, usableID, entity.StockAllocationStatusActive) + sql := fmt.Sprintf( + "UPDATE stock_allocations SET %s WHERE usable_type = ? AND usable_id = ? AND status = ?", + setClause, + ) + + result := base.Exec(sql, args...) + return result.Error +} diff --git a/tools/templates/route.tmpl b/tools/templates/route.tmpl index 26958deb..9dea2530 100644 --- a/tools/templates/route.tmpl +++ b/tools/templates/route.tmpl @@ -1,7 +1,7 @@ {{define "route"}}package {{Kebab .Entity}}s import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/controllers" {{Camel .Entity}} "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,17 +13,12 @@ func {{Pascal .Entity}}Routes(v1 fiber.Router, u user.UserService, s {{Camel .En ctrl := controller.New{{Pascal .Entity}}Controller(s) route := v1.Group("/{{Kebab .Entity}}s") + route.Use(m.Auth(u)) - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - - route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_FinanceGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_FinanceGetOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_FinanceCreateOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_FinanceUpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_FinanceDeleteOne), ctrl.DeleteOne) } {{end}}