diff --git a/go.mod b/go.mod index 517bcdc1..355f8e5c 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,17 @@ 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 @@ -20,17 +26,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 @@ -51,6 +73,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.1.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect @@ -75,4 +98,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..188b0dae 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= @@ -146,6 +192,9 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE= github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -306,4 +355,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/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.stock_allocation.repository.go b/internal/common/repository/common.stock_allocation.repository.go new file mode 100644 index 00000000..38b1a93b --- /dev/null +++ b/internal/common/repository/common.stock_allocation.repository.go @@ -0,0 +1,75 @@ +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 + } + + q := r.DB().WithContext(ctx). + Model(&entity.StockAllocation{}). + Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + + if modifier != nil { + q = modifier(q) + } + + return q.Updates(updates).Error +} diff --git a/internal/common/service/common.document.service.go b/internal/common/service/common.document.service.go new file mode 100644 index 00000000..fe2a41cc --- /dev/null +++ b/internal/common/service/common.document.service.go @@ -0,0 +1,411 @@ +package service + +import ( + "context" + "errors" + "fmt" + "mime" + "mime/multipart" + "path/filepath" + "strings" + + 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 +} + +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) 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..24e6fade --- /dev/null +++ b/internal/common/service/common.document.storage.go @@ -0,0 +1,160 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "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 +} + +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 + 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 + }) + + 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, + 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) +} diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go new file mode 100644 index 00000000..e3b80268 --- /dev/null +++ b/internal/common/service/common.fifo.service.go @@ -0,0 +1,820 @@ +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 { + 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{ + "quantity": 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..5f76a9e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,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 +114,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") 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/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/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..38b661a4 --- /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 NOT NULL 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/seed/seeder.go b/internal/database/seed/seeder.go index d848711e..8da408ca 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -910,7 +910,7 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { ProductId: product.Id, WarehouseId: warehouse.Id, Quantity: seed.Quantity, - CreatedBy: createdBy, + // CreatedBy: createdBy, } if err := tx.Create(&productWarehouse).Error; err != nil { return err 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/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/expense.go b/internal/entities/expense.go index 74998e6a..e6ab1d77 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -13,18 +13,16 @@ 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"` + DocumentPath sql.NullString `gorm:"type:json"` + RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` + 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"` 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/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/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/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/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/projectflock.go b/internal/entities/projectflock.go index e8745455..0a92b54b 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -24,6 +24,7 @@ type ProjectFlock struct { 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/purchase.go b/internal/entities/purchase.go index 36b698b2..fe9b7100 100644 --- a/internal/entities/purchase.go +++ b/internal/entities/purchase.go @@ -5,19 +5,17 @@ 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"` 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..22cb62ed 100644 --- a/internal/entities/purchase_item.go +++ b/internal/entities/purchase_item.go @@ -5,20 +5,22 @@ 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 Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"` diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index 28eafeb7..775d15dc 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -7,24 +7,11 @@ 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"` 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/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..310d8cf8 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -1,10 +1,6 @@ package entities -import ( - "time" - - "gorm.io/gorm" -) +import "time" const ( LogTypeAdjustment = "ADJUSTMENT" @@ -17,19 +13,18 @@ const ( ) 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/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/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..881c3a67 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -105,6 +105,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) diff --git a/internal/modules/approvals/route.go b/internal/modules/approvals/route.go index b7d66abd..5dd39616 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) } diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go new file mode 100644 index 00000000..dc39a666 --- /dev/null +++ b/internal/modules/closings/controllers/closing.controller.go @@ -0,0 +1,190 @@ +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 +} + +func NewClosingController(closingService service.ClosingService) *ClosingController { + return &ClosingController{ + ClosingService: closingService, + } +} + +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.SapronakQuery{ + 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, + }) +} diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go new file mode 100644 index 00000000..1f1cb492 --- /dev/null +++ b/internal/modules/closings/dto/closing.dto.go @@ -0,0 +1,160 @@ +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"` + 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"` +} + +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, + 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), + } +} diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go new file mode 100644 index 00000000..a442fc9d --- /dev/null +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -0,0 +1,108 @@ +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 { + ProjectType string `json:"project_type"` + FlockId uint `json:"flock_id"` + Period int `json:"period"` + Sales []SalesDTO `json:"sales"` +} + +// === Mapper Functions === + +func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { + + // todo: usia ayam masih dummy + age := 0 + + 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.Qty, + 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 { + period := extractPeriodFromRealisasi(e) + return PenjualanRealisasiResponseDTO{ + ProjectType: projectType, + FlockId: projectFlockID, + Period: period, + 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 +} diff --git a/internal/modules/closings/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go new file mode 100644 index 00000000..95f3e10b --- /dev/null +++ b/internal/modules/closings/dto/closingOverhead.dto.go @@ -0,0 +1,175 @@ +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 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, totalChickinQty) + + 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, totalChickinQty), + }, + 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, totalChickinQty float64) float64 { + if totalChickinQty > 0 { + return totalPrice / totalChickinQty + } + 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/sapronak.dto.go b/internal/modules/closings/dto/sapronak.dto.go new file mode 100644 index 00000000..50fc67cc --- /dev/null +++ b/internal/modules/closings/dto/sapronak.dto.go @@ -0,0 +1,26 @@ +package dto + +import "time" + +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"` +} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go new file mode 100644 index 00000000..566f26b2 --- /dev/null +++ b/internal/modules/closings/module.go @@ -0,0 +1,39 @@ +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" + + 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) + projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db) + marketingRepo := rMarketings.NewMarketingRepository(db) + marketingDeliveryProductRepo := rMarketings.NewMarketingDeliveryProductRepository(db) + expenseRealizationRepo := rExpenseRealization.NewExpenseRealizationRepository(db) + chickinRepo := rChickin.NewChickinRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + + closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ClosingRoutes(router, userService, closingService) +} diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go new file mode 100644 index 00000000..fe555378 --- /dev/null +++ b/internal/modules/closings/repositories/closing.repository.go @@ -0,0 +1,224 @@ +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" + "gorm.io/gorm" +) + +type ClosingRepository interface { + repository.BaseRepository[entity.ProjectFlock] + GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, 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 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 +} + +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, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(f.name, ' ') + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + 'External Supplier' AS source_warehouse, + 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 product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pi.warehouse_id +WHERE pi.warehouse_id IN ? +` + + 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, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(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.quantity 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 product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +WHERE st.to_warehouse_id IN ? +` + + 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, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(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.quantity 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 product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +WHERE st.from_warehouse_id IN ? +` + + 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, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(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 product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pw.warehouse_id +WHERE pw.project_flock_kandang_id IN ? +` +) diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go new file mode 100644 index 00000000..4d142f44 --- /dev/null +++ b/internal/modules/closings/route.go @@ -0,0 +1,28 @@ +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) { + ctrl := controller.NewClosingController(s) + + route := v1.Group("/closings") + + // 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("/:project_flock_id/penjualan", ctrl.GetPenjualan) + route.Get("/:project_flock_id/overhead", ctrl.GetOverhead) + route.Get("/:projectFlockId", ctrl.GetClosingSummary) + route.Get("/:projectFlockId/sapronak", ctrl.GetClosingSapronak) +} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go new file mode 100644 index 00000000..cfc22948 --- /dev/null +++ b/internal/modules/closings/services/closing.service.go @@ -0,0 +1,381 @@ +package service + +import ( + "context" + "errors" + "strconv" + + 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" + "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) + GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.SapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, 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 +} + +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, 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, + } +} + +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.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.SapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) { + if projectFlockID == 0 { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + if params == nil { + params = &validation.SapronakQuery{} + } + + 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 + ) + + for _, rec := range records { + if minStep == 0 || rec.StepNumber < minStep { + minStep = rec.StepNumber + statusProject = rec.StepName + } + if rec.StepNumber == uint16(utils.ProjectFlockStepSelesai) { + completed++ + } + } + + 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 + } + + result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty) + + return &result, 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..9b17b00d --- /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 SapronakQuery 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/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 08256b24..55114ec8 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -96,30 +96,30 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { } 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 { + if singleExpenseNonstock.KandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required") } - req.CostPerKandangs = []validation.CostPerKandang{singleCostPerKandang} + req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock} } 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)) + for i, expenseNonstock := range req.ExpenseNonstocks { + if expenseNonstock.KandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i)) } } } } else { - return fiber.NewError(fiber.StatusBadRequest, "Field cost_per_kandangs is required") + return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required") } result, err := u.ExpenseService.CreateOne(c, req) @@ -151,24 +151,40 @@ 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 + } + + 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)) + for i, expenseNonstock := range expenseNonstocks { + if expenseNonstock.KandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i)) } } - req.CostPerKandang = &costPerKandang + req.ExpenseNonstocks = &expenseNonstocks } result, err := u.ExpenseService.UpdateOne(c, req, uint(id)) @@ -300,13 +316,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..c55dba2c 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -15,10 +15,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 +27,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"` } @@ -55,21 +53,26 @@ 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 { @@ -89,10 +92,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, } } @@ -124,8 +126,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, } } @@ -192,10 +193,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 +203,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 +218,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,12 +249,16 @@ 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, } } @@ -264,11 +269,13 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali 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 + } } } diff --git a/internal/modules/expenses/repositories/expense.repository.go b/internal/modules/expenses/repositories/expense.repository.go index 588583da..9e97a180 100644 --- a/internal/modules/expenses/repositories/expense.repository.go +++ b/internal/modules/expenses/repositories/expense.repository.go @@ -10,7 +10,7 @@ import ( 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) } @@ -25,8 +25,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) { diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 77f075f7..e60324ca 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -12,6 +12,7 @@ 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) } type ExpenseRealizationRepositoryImpl struct { @@ -30,11 +31,22 @@ 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.Expense"). + Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). + Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Where("expenses.category = ?", "BOP"). + Find(&realizations).Error + return realizations, err } diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index 805cb886..1fc5c07a 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,7 +13,9 @@ 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) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 0d0779f0..0b768f0a 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -7,11 +7,11 @@ import ( "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" @@ -148,8 +148,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen 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 +184,22 @@ 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(1) //todo get from auth + createdBy := uint64(actorID) expense = &entity.Expense{ ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, Category: req.Category, SupplierId: req.SupplierID, - ExpenseDate: expenseDate, - GrandTotal: grandTotal, + TransactionDate: expenseDate, CreatedBy: createdBy, } @@ -211,15 +207,15 @@ 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 { var projectFlockKandangId *uint64 if req.Category == "BOP" { - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(costPerKandang.KandangID)) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") @@ -230,16 +226,16 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen projectFlockKandangId = &id } - 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) + id := uint64(expenseNonstock.KandangID) kandangId = &id } else if req.Category == "BOP" { if projectFlockKandangId != nil { - kandangId = &costPerKandang.KandangID + kandangId = &expenseNonstock.KandangID } } @@ -249,8 +245,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen 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 { @@ -302,9 +298,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 +322,27 @@ 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 len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 { responseDTO, err := s.GetOne(c, id) if err != nil { @@ -346,6 +357,21 @@ 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") + } + + 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,41 +381,79 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - if req.CostPerKandang != nil { + if categoryChanged { + if currentExpense.Category == "BOP" && newCategory == "NON-BOP" { - if err := tx.Where("expense_id = ?", id).Delete(&entity.ExpenseNonstock{}).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense items") + 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 _, ens := range existingExpenseNonstocks { + updateData := map[string]interface{}{ + "project_flock_kandang_id": nil, + } + 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 == "NON-BOP" && newCategory == "BOP" { + + 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") + } + + 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 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") + } + } + } + } + } + + 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") } - var grandTotal float64 - for _, cpk := range *req.CostPerKandang { - for _, costItem := range cpk.CostItems { - grandTotal += costItem.TotalCost + 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") } } - 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") + 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 _, cpk := range *req.CostPerKandang { + for _, expenseNonstock := range *req.ExpenseNonstocks { 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") - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") - } - - if expense.Category == "BOP" { - + if updatedExpense.Category == "BOP" { projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(cpk.KandangID)) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") @@ -400,7 +464,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) projectFlockKandangId = &id } - for _, costItem := range cpk.CostItems { + for _, costItem := range expenseNonstock.CostItems { nonstockId := uint(costItem.NonstockID) if err := commonSvc.EnsureRelations(c.Context(), @@ -410,13 +474,12 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } var kandangId *uint64 - if expense.Category == "NON-BOP" { - id := uint64(cpk.KandangID) + if updatedExpense.Category == "NON-BOP" { + id := uint64(expenseNonstock.KandangID) kandangId = &id - } else if expense.Category == "BOP" { - + } else if updatedExpense.Category == "BOP" { if projectFlockKandangId != nil { - kandangId = &cpk.KandangID + kandangId = &expenseNonstock.KandangID } } @@ -427,8 +490,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) 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 { @@ -438,7 +501,10 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - 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 @@ -481,9 +547,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) 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 err } @@ -506,9 +570,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,8 +580,6 @@ 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 - if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) @@ -543,13 +603,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 { @@ -576,7 +637,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 +658,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,14 +714,12 @@ 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 } @@ -669,66 +729,62 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va 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 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 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 len(req.Documents) > 0 { @@ -737,9 +793,28 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va } } + 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) @@ -825,9 +900,7 @@ func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx reposito 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 } @@ -898,20 +971,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 } @@ -996,17 +1070,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) 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..9dc2b07b 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -5,15 +5,15 @@ 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"` + 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 { +type ExpenseNonstock struct { KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"` CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"` } @@ -21,14 +21,16 @@ type CostPerKandang struct { 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"` + Price float64 `form:"price" json:"price" validate:"required,gt=0"` Notes string `form:"notes" json:"notes" validate:"required,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"` + 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 +46,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/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index f91e6eda..556050f4 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -104,12 +104,12 @@ 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, + Id: e.Id, + // TransactionType: e.LoggableType, + // Quantity: e.Q, + // BeforeQuantity: e.BeforeQuantity, + // AfterQuantity: e.AfterQuantity, + Note: e.Notes, ProductWarehouseId: e.ProductWarehouseId, ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), } @@ -136,6 +136,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..c4ca6129 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -9,6 +9,7 @@ import ( 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" @@ -21,10 +22,11 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat stockLogsRepo := rStockLogs.NewStockLogRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) - adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate) + adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate, projectFlockKandangRepo) userService := sUser.NewUserService(userRepo, validate) AdjustmentRoutes(router, userService, adjustmentService) diff --git a/internal/modules/inventory/adjustments/route.go b/internal/modules/inventory/adjustments/route.go index 8f58bb4d..57200215 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,7 +13,7 @@ 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 diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index e1c4166d..39ed5b19 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -1,16 +1,19 @@ 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" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" 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" @@ -27,22 +30,24 @@ 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 } -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, 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, } } @@ -66,7 +71,7 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LogType != entity.LogTypeAdjustment { + if stockLog.LoggableType != entity.LogTypeAdjustment { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") } @@ -78,7 +83,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}, @@ -102,12 +110,16 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") } if !isProductWarehouseExist { - + projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) + if err != nil { + return nil, err + } 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, + // CreatedBy: 1, // TODO: should Get from auth middleware } if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil { @@ -125,25 +137,23 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } afterQuantity := productWarehouse.Quantity + newLog := &entity.StockLog{ + // TransactionType: transactionType, + LoggableType: entity.LogTypeAdjustment, + LoggableId: 0, + Notes: req.Note, + ProductWarehouseId: productWarehouse.Id, + CreatedBy: actorID, // TODO: should Get from auth middleware + } if transactionType == entity.TransactionTypeIncrease { 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 { @@ -169,6 +179,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 @@ -197,7 +233,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 = ?", entity.LogTypeAdjustment) 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..c7bb37f8 --- /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.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) +} 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..a0765d84 --- /dev/null +++ b/internal/modules/inventory/product-stocks/services/product-stock.service.go @@ -0,0 +1,91 @@ +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 = s.withRelations(db) + if params.Search != "" { + return db.Where("name ILIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("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..81fbec1f 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -98,8 +98,8 @@ 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 @@ -140,13 +140,13 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT } // Map CreatedUser relation jika ada - if e.CreatedUser.Id != 0 { - user := UserRelationDTO{ - Id: e.CreatedUser.Id, - Username: e.CreatedUser.Name, - } - dto.CreatedUser = &user - } + // 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..641ce531 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -27,7 +27,7 @@ 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, createdBy uint) (uint, error) } type ProductWarehouseRepositoryImpl struct { @@ -151,7 +151,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 +171,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 } @@ -199,7 +199,7 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( ctx context.Context, productID uint, warehouseID uint, - createdBy uint64, + createdBy uint, ) (uint, error) { record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) if err == nil { @@ -213,11 +213,11 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( ProductId: productID, WarehouseId: warehouseID, Quantity: 0, - CreatedBy: uint(createdBy), - } - if entity.CreatedBy == 0 { - entity.CreatedBy = 1 + // CreatedBy: uint(createdBy), } + // if entity.CreatedBy == 0 { + // entity.CreatedBy = 1 + // } if err := r.CreateOne(ctx, entity, nil); err != nil { return 0, err @@ -257,7 +257,7 @@ 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"). + Order("product_warehouses.id DESC"). Preload("Product").Preload("Warehouse"). Find(&productWarehouses).Error if err != nil { 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..f690b2a2 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,7 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Warehouse.Location"). Preload("Warehouse.Area"). Preload("Warehouse.Kandang"). - Preload("CreatedUser") + Preload("ProjectFlockKandang") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { @@ -104,7 +104,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/module.go b/internal/modules/inventory/transfers/module.go index 734f0f03..19a0ded6 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -9,6 +9,8 @@ import ( 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" @@ -25,8 +27,10 @@ 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) - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo) + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index dd6c0068..a0edad0a 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -1,15 +1,19 @@ package service import ( + "context" "errors" "fmt" "strings" 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" @@ -35,9 +39,11 @@ type transferService struct { StockLogsRepository rStockLogs.StockLogRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository SupplierRepo rSupplier.SupplierRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -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) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -48,6 +54,8 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr StockLogsRepository: stockLogsRepo, ProductWarehouseRepo: productWarehouseRepo, SupplierRepo: supplierRepo, + WarehouseRepo: warehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, } } func (s transferService) withRelations(db *gorm.DB) *gorm.DB { @@ -127,6 +135,10 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) } } + 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) @@ -174,7 +186,7 @@ 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 @@ -267,17 +279,20 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) // create stock log for decrease (source) - beforeQty := sourcePW.Quantity + product.ProductQty // sourcePW already decreased + // beforeQty := sourcePW.Quantity + product.ProductQty // sourcePW already decreased decreaseLog := &entity.StockLog{ - TransactionType: entity.TransactionTypeDecrease, - Quantity: product.ProductQty, - BeforeQuantity: beforeQty, - AfterQuantity: sourcePW.Quantity, - LogType: entity.LogTypeTransfer, - LogId: uint(entityTransfer.Id), - Note: "", + // TransactionType: entity.TransactionTypeDecrease, + // Quantity: product.ProductQty, + // BeforeQuantity: beforeQty, + // AfterQuantity: sourcePW.Qty, + // LogType: entity.LogTypeTransfer, + // LogId: uint(entityTransfer.Id), + Decrease: product.ProductQty, + Notes: "", + LoggableType: entity.LogTypeTransfer, + LoggableId: uint(entityTransfer.Id), ProductWarehouseId: sourcePW.Id, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { s.Log.Errorf("Failed to create stock log decrease: %+v", err) @@ -294,11 +309,17 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { // Jika belum ada record untuk produk di gudang tujuan, buat baru + 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, - CreatedBy: 1, // TODO: should Get from auth middleware + ProductId: uint(product.ProductID), + WarehouseId: uint(req.DestinationWarehouseID), + Quantity: 0, + ProjectFlockKandangId: &projectFlockKandangID, + // 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) @@ -315,17 +336,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) // create stock log for increase (destination) - beforeDestQty := destPW.Quantity - product.ProductQty + // 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: "", + // TransactionType: entity.TransactionTypeIncrease, + // Quantity: product.ProductQty, + // BeforeQuantity: beforeDestQty, + // AfterQuantity: destPW.Qty, + Increase: product.ProductQty, + LoggableType: entity.LogTypeTransfer, + LoggableId: uint(entityTransfer.Id), + Notes: "", ProductWarehouseId: destPW.Id, - CreatedBy: 1, + CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { s.Log.Errorf("Failed to create stock log increase: %+v", err) @@ -349,3 +371,29 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } 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)) + } + 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 +} 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..b2bb70d7 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, @@ -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..33048bdf 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -1,13 +1,48 @@ package marketing 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" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) 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) + + // Initialize approval service + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalSvc := commonSvc.NewApprovalService(approvalRepo) + + // Register workflow steps for marketing approval + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) + } + + // Initialize services + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) + userService := sUser.NewUserService(userRepo, validate) + + // Register routes + 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..a3c2af88 --- /dev/null +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -0,0 +1,76 @@ +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] + 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) +} + +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 + + // JOIN digunakan untuk filter WHERE clause ke ProjectFlockID yang berada 3 level relasi atas + // Entity relations digunakan di Preload (callback) untuk load data, bukan untuk filter + 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 +} 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..75ecc0f6 100644 --- a/internal/modules/marketing/route.go +++ b/internal/modules/marketing/route.go @@ -1,27 +1,31 @@ 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)) - for _, m := range allModules { - m.RegisterRoutes(group, db, validate) - } + route.Get("/", deliveryOrdersCtrl.GetAll) + route.Get("/:id", deliveryOrdersCtrl.GetOne) + route.Delete("/:id", salesOrdersCtrl.DeleteOne) + + route.Post("/sales-orders", salesOrdersCtrl.CreateOne) + route.Patch("/sales-orders/:id", salesOrdersCtrl.UpdateOne) + route.Post("/sales-orders/approvals", salesOrdersCtrl.Approval) + + route.Get("/delivery-orders", deliveryOrdersCtrl.GetAll) + route.Get("/delivery-orders/:id", deliveryOrdersCtrl.GetOne) + route.Post("/delivery-orders", deliveryOrdersCtrl.CreateOne) + route.Patch("/delivery-orders/:id", deliveryOrdersCtrl.UpdateOne) } 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 91% rename from internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go rename to internal/modules/marketing/services/deliveryorder.service.go index 712c6ace..85b15dc5 100644 --- a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -9,11 +9,11 @@ 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" "github.com/go-playground/validator/v10" @@ -23,10 +23,10 @@ import ( ) 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 { @@ -34,14 +34,14 @@ type deliveryOrdersService struct { Validate *validator.Validate MarketingRepo marketingRepo.MarketingRepository MarketingProductRepo marketingRepo.MarketingProductRepository - MarketingDeliveryProductRepo marketingDeliveryProductRepo.MarketingDeliveryProductRepository + MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService } func NewDeliveryOrdersService( marketingRepo marketingRepo.MarketingRepository, marketingProductRepo marketingRepo.MarketingProductRepository, - marketingDeliveryProductRepo marketingDeliveryProductRepo.MarketingDeliveryProductRepository, + marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) DeliveryOrdersService { @@ -85,7 +85,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 } @@ -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) @@ -256,7 +258,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 +285,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 +299,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) { diff --git a/internal/modules/marketing/sales-orders/services/sales-orders.service.go b/internal/modules/marketing/services/salesorder.service.go similarity index 95% rename from internal/modules/marketing/sales-orders/services/sales-orders.service.go rename to internal/modules/marketing/services/salesorder.service.go index d750c4a4..7d60cd6c 100644 --- a/internal/modules/marketing/sales-orders/services/sales-orders.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -9,10 +9,10 @@ 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" userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -90,6 +90,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 +114,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,7 +133,7 @@ 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") @@ -143,7 +147,6 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } } - actorID := uint(1) // TODO: ambil dari auth context approvalAction := entity.ApprovalActionCreated if _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -180,6 +183,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}, @@ -211,7 +219,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u 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 { @@ -321,7 +329,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(), @@ -405,6 +412,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 @@ -448,7 +460,7 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e } } - 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 +491,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 +526,7 @@ 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 { marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, diff --git a/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go similarity index 91% rename from internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go rename to internal/modules/marketing/validations/deliveryorder.validation.go index 3317e952..7db2cdd1 100644 --- a/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -11,22 +11,22 @@ type DeliveryProduct struct { 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 100% rename from internal/modules/marketing/sales-orders/validations/sales-orders.validation.go rename to internal/modules/marketing/validations/salesorder.validation.go 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/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/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/kandangs/route.go b/internal/modules/master/kandangs/route.go index 1e384b1f..6a425b64 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,7 +13,7 @@ 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) 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/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/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index 9d93ce3d..c421b7ec 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.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"` 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"` } 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"` 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/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/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/suppliers/route.go b/internal/modules/master/suppliers/route.go index 3a57f645..17271d4a 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,7 +13,7 @@ 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) 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/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/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/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go index a98dab67..bef062f5 100644 --- a/internal/modules/production/chickins/repositories/project_chickin.repository.go +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -11,6 +11,7 @@ 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) @@ -40,6 +41,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). diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index a130740a..54fd2cb1 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -8,6 +8,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" 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" @@ -125,7 +126,10 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti 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) for _, chickinReq := range req.ChickinRequests { @@ -193,7 +197,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if category == string(utils.ProjectFlockCategoryLaying) { for _, chickin := range newChikins { - updates := map[string]any{"quantity": gorm.Expr("quantity - ?", chickin.PendingUsageQty)} + updates := map[string]any{"qty": gorm.Expr("qty - ?", chickin.PendingUsageQty)} if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -356,6 +360,11 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit 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 @@ -397,14 +406,13 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit step = utils.ProjectFlockKandangStepDisetujui } - 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, @@ -490,7 +498,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit for _, chickin := range chickins { if categoryForRejection == string(utils.ProjectFlockCategoryGrowing) { - updates := map[string]any{"quantity": gorm.Expr("quantity + ?", chickin.PendingUsageQty)} + updates := map[string]any{"qty": gorm.Expr("qty + ?", chickin.PendingUsageQty)} if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -549,7 +557,7 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId ProductId: product.Id, WarehouseId: warehouseId, Quantity: 0, - CreatedBy: actorID, + // CreatedBy: actorID, } if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { @@ -592,7 +600,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti if chickin.ProductWarehouseId != targetPW.Id { if err := productWarehouseTx.PatchOne(ctx.Context(), chickin.ProductWarehouseId, map[string]any{ - "quantity": gorm.Expr("quantity - ?", quantityToConvert), + "qty": gorm.Expr("qty - ?", quantityToConvert), }, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Source product warehouse %d not found", chickin.ProductWarehouseId)) @@ -602,7 +610,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti } if err := productWarehouseTx.PatchOne(ctx.Context(), targetPW.Id, map[string]any{ - "quantity": gorm.Expr("quantity + ?", quantityToConvert), + "qty": gorm.Expr("qty + ?", quantityToConvert), }, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Target product warehouse %d not found", targetPW.Id)) diff --git a/internal/modules/production/project-flock-kandangs/route.go b/internal/modules/production/project-flock-kandangs/route.go index 8057e847..7bab770e 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,7 +13,7 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo ctrl := controller.NewProjectFlockKandangController(s) route := v1.Group("/project-flock-kandangs") - + 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) diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 6d78520e..52d53be5 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -329,3 +329,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..0922b160 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -9,6 +9,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" + nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/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 +25,16 @@ 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"` + 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 +53,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 { @@ -110,6 +119,7 @@ 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, Location: locationSummary, @@ -184,3 +194,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/module.go b/internal/modules/production/project_flocks/module.go index 4fd932a4..acd77338 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -12,7 +12,9 @@ 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" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" @@ -27,10 +29,12 @@ 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) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + projectBudgetRepo := rProjectBudget.NewProjectBudgetRepository(db) userRepo := rUser.NewUserRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) @@ -39,7 +43,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, 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/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index de4df25d..15afaf59 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) @@ -42,24 +41,26 @@ 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("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 diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index eb806129..710f5225 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,7 +13,7 @@ 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) @@ -23,5 +23,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) route.Get("/locations/:location_id/periods", ctrl.GetPeriodSummary) + route.Put("/:id/resubmit", 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..1a7fc6f2 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -10,13 +10,14 @@ 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" @@ -40,6 +41,7 @@ type ProjectflockService interface { 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,8 +50,10 @@ 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 ApprovalSvc commonSvc.ApprovalService approvalWorkflow approvalutils.ApprovalWorkflowKey @@ -68,8 +72,11 @@ func NewProjectflockService( pivotRepo repository.ProjectFlockKandangRepository, warehouseRepo warehouseRepository.WarehouseRepository, productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository, + projectBudgetRepo projectBudgetRepository.ProjectBudgetRepository, + nonstockRepo nonstockRepository.NonstockRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, + ) ProjectflockService { return &projectflockService{ Log: utils.Log, @@ -77,6 +84,7 @@ func NewProjectflockService( Repository: repo, FlockRepo: flockRepo, KandangRepo: kandangRepo, + NonstockRepo: nonstockRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, PivotRepo: pivotRepo, @@ -85,18 +93,17 @@ func NewProjectflockService( } } +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 +119,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 +163,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 +190,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 +228,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 } @@ -291,7 +298,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 +308,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 +316,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( @@ -344,7 +353,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id return nil, err } - actorID, err := actorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } @@ -602,7 +611,7 @@ func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([] return nil, err } - actorID, err := actorIDFromContext(c) + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } @@ -847,7 +856,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 } @@ -1047,11 +1056,138 @@ 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) 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..00b01456 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,12 +1,13 @@ 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"` + FlockName string `json:"flock_name" validate:"required_strict"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` + Category string `json:"category" validate:"required_strict"` + FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` + ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"` } type Update struct { @@ -36,3 +37,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..51fba8a4 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,19 @@ 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"` + 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"` } type RecordingListDTO struct { @@ -72,8 +68,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 +80,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 { @@ -140,25 +131,20 @@ 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, + DailyGain: dailyGain, + AvgDailyGain: avgDailyGain, + CumIntake: cumIntake, + FcrValue: fcrValue, + TotalChickQty: totalChickQty, + Approval: latestApproval, } } @@ -253,29 +239,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 +259,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/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index d9512edd..60457074 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -25,6 +25,7 @@ type RecordingRepository interface { 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,8 +35,6 @@ 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) @@ -75,8 +74,7 @@ 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) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { @@ -120,6 +118,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 +185,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 +193,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 diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go index c492c39f..83b426db 100644 --- a/internal/modules/production/recordings/route.go +++ b/internal/modules/production/recordings/route.go @@ -18,7 +18,6 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS 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) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index b31a90c0..a83c1128 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -4,20 +4,21 @@ 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" 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 +33,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 +52,7 @@ type recordingService struct { ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewRecordingService( @@ -54,6 +62,7 @@ func NewRecordingService( projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -65,6 +74,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, } } @@ -169,7 +192,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,7 +219,7 @@ 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 { @@ -219,6 +245,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } + if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { + return err + } + mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { s.Log.Errorf("Failed to persist depletions: %+v", err) @@ -231,7 +261,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, nil, nil, mappedEggs)); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err } @@ -242,7 +272,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 } @@ -316,16 +346,6 @@ 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) @@ -344,6 +364,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } + if err := s.releaseRecordingStocks(ctx, tx, existingStocks); err != nil { + return err + } + if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear stocks: %+v", err) return err @@ -355,8 +379,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin 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.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { return err } } @@ -407,9 +430,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin 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 { @@ -422,23 +442,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 { @@ -483,109 +490,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin 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 { - return nil, err - } - - if len(req.EggsGrading) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "eggs_grading must contain at least one item") - } - - 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) -} - func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -613,7 +517,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 +592,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, nil, nil, oldEggs, nil)); err != nil { return err } @@ -740,6 +651,77 @@ 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 + } + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + ProductWarehouseID: stock.ProductWarehouseId, + Quantity: desired, + 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, @@ -752,12 +734,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 +743,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 @@ -835,14 +804,10 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return fmt.Errorf("getFeedUsageInGrams: %w", err) } - fcrId, err := s.Repository.GetFcrID(tx, recording.ProjectFlockKandangId) - if err != nil { - return fmt.Errorf("getFcrID: %w", err) - } - currentAvgGrams := recordingutil.ToGrams(currentAvgWeight) currentAvgKg := recordingutil.GramsToKg(currentAvgGrams) prevAvgGrams := recordingutil.ToGrams(prevAvgWeight) + prevAvgKg := recordingutil.GramsToKg(prevAvgGrams) currentDepletion := float64(totalDepletionQty) cumDepletionQty := prevCumDepletionQty + currentDepletion @@ -852,9 +817,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 } @@ -879,24 +845,19 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm updates["daily_gain"] = dailyGainKg recording.DailyGain = &dailyGainKg } else { - updates["daily_gain"] = gorm.Expr("NULL") - recording.DailyGain = nil + dailyGainKg := 0.0 + updates["daily_gain"] = dailyGainKg + recording.DailyGain = &dailyGainKg } - 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 - } + if currentAvgKg > 0 && remainingChick > 0 { + avgDailyGain := (currentAvgKg - prevAvgKg) / remainingChick + updates["avg_daily_gain"] = avgDailyGain + recording.AvgDailyGain = &avgDailyGain } else { - updates["avg_daily_gain"] = gorm.Expr("NULL") - recording.AvgDailyGain = nil + avgDailyGain := 0.0 + updates["avg_daily_gain"] = avgDailyGain + recording.AvgDailyGain = &avgDailyGain } if usageInGrams > 0 && totalChick > 0 { @@ -951,7 +912,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 diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 28ea8a9f..28c38ff5 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -19,8 +19,9 @@ 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"` } ) @@ -45,16 +46,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/transfer_layings/route.go b/internal/modules/production/transfer_layings/route.go index ad0cb9e1..868454c5 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) 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/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index d10f42af..b4cf5660 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -23,21 +23,19 @@ func NewPurchaseController(s service.PurchaseService) *PurchaseController { } func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { - query := &validation.PurchaseQuery{ + query := &validation.Query{ 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")), + 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 +43,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 +60,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 +86,7 @@ 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,7 +155,7 @@ 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") } @@ -176,7 +165,7 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - result, err := ctrl.service.ReceiveProducts(c, id, req) + result, err := ctrl.service.ReceiveProducts(c, uint(id), req) if err != nil { return err } @@ -192,7 +181,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 +191,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 +207,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..d6114952 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"` + 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"` @@ -61,6 +61,17 @@ type PurchaseItemDTO struct { VehicleNumber *string `json:"vehicle_number"` } + +func ToPurchaseRelationDTO(p *entity.Purchase) PurchaseRelationDTO { + return PurchaseRelationDTO{ + Id: p.Id, + PrNumber: p.PrNumber, + PoNumber: p.PoNumber, + PoDate: p.PoDate, + Notes: p.Notes, + } +} + func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { dto := PurchaseItemDTO{ Id: item.Id, @@ -77,10 +88,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 +106,7 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { } dto.Warehouse = &summary } + return dto } @@ -104,70 +118,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..ec1b24f7 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -8,15 +8,20 @@ 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" + 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 +33,49 @@ 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) + expenseRepository := expenseRepo.NewExpenseRepository(db) + expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) + projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) 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, + validate, + ) + expenseBridge := service.NewExpenseBridge( + db, + purchaseRepo, + projectFlockKandangRepository, + 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 +84,10 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo, supplierRepo, productWarehouseRepo, - approvalRepo, + projectFlockKandangRepository, approvalService, expenseBridge, + fifoService, ) userRepo := rUser.NewUserRepository(db) diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index bc1c038a..bcb35e85 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -18,16 +18,13 @@ 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 } type PurchaseRepositoryImpl struct { @@ -40,19 +37,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 +59,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 +102,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 +112,7 @@ type PurchasePricingUpdate struct { } type PurchaseReceivingUpdate struct { - ItemID uint64 + ItemID uint ReceivedDate *time.Time TravelNumber *string TravelDocumentPath *string @@ -152,9 +125,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 +160,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 { @@ -257,21 +220,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") } @@ -361,63 +310,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..5145bc94 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,7 +13,7 @@ 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) diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index b7c96d03..1f42872c 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -2,42 +2,757 @@ 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" + 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 + expenseSvc expenseSvc.ExpenseService +} + +func NewExpenseBridge( + db *gorm.DB, + purchaseRepo rPurchase.PurchaseRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, + expenseSvc expenseSvc.ExpenseService, +) PurchaseExpenseBridge { + return &expenseBridge{ + db: db, + purchaseRepo: purchaseRepo, + projectFlockKandangRepo: projectFlockKandangRepo, + 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) cleanupExistingNonstocks(ctx context.Context, updates []ExpenseReceivingPayload) error { + if len(updates) == 0 { + return nil + } + + itemIDs := make([]uint, 0, len(updates)) + for _, upd := range updates { + if upd.PurchaseItemID != 0 { + itemIDs = append(itemIDs, upd.PurchaseItemID) + } + } + if len(itemIDs) == 0 { + return nil + } + + return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var links []struct { + ItemID uint + ExpenseNonstockID *uint64 + } + if err := tx.Model(&entity.PurchaseItem{}). + Select("id as item_id, expense_nonstock_id"). + Where("id IN ?", itemIDs). + Scan(&links).Error; err != nil { + return err + } + + expenseIDs := make(map[uint64]struct{}) + expenseNonstockIDs := make([]uint64, 0) + for _, link := range links { + if link.ExpenseNonstockID != nil && *link.ExpenseNonstockID != 0 { + expenseNonstockIDs = append(expenseNonstockIDs, *link.ExpenseNonstockID) + } + } + + if len(expenseNonstockIDs) == 0 { + return nil + } + + 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 + } + + 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) cleanupEmptyExpenses(ctx context.Context, expenseIDs []uint64) error { + if len(expenseIDs) == 0 { + return nil + } + return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + for _, id := range expenseIDs { + var count int64 + if err := tx.Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", id). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(id)); err != nil { + return err + } + if err := tx.Delete(&entity.Expense{}, id).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 { + if item.Warehouse == nil || item.Warehouse.KandangId == nil || *item.Warehouse.KandangId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") + } + 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, + "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) + } + if projectFK != nil { + updateBody["project_flock_kandang_id"] = uint64(*projectFK) + } + + 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 + if kandangID == nil || *kandangID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") + } + + 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), + ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ + KandangID: uint64(*kandangID), + 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..bbaa1b40 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -11,15 +11,17 @@ 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" 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 +30,39 @@ 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 + approvalWorkflow approvalutils.ApprovalWorkflowKey } type staffAdjustmentPayload struct { PricingUpdates []rPurchase.PurchasePricingUpdate NewItems []*entity.PurchaseItem - GrandTotal float64 } func NewPurchaseService( @@ -69,85 +72,128 @@ 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, ) 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, + 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("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") +} + +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, fiber.NewError(fiber.StatusBadRequest, 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") } - 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) +func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { + purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -156,10 +202,9 @@ func (s *purchaseService) GetOne(c *fiber.Ctx, id uint64) (*entity.Purchase, err return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - if err := s.attachLatestApproval(ctx, purchase); err != nil { + if err := s.attachLatestApproval(c.Context(), purchase); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) } - return purchase, nil } @@ -168,14 +213,12 @@ 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") } @@ -184,9 +227,10 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase } type aggregatedItem struct { - productId uint64 - warehouseId uint64 + productId uint + warehouseId uint subQty float64 + pfkID *uint } if len(req.Items) == 0 { @@ -195,35 +239,52 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase 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, fiber.NewError(fiber.StatusNotFound, 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, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + } + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse %d is not linked to a kandang", id)) + } + var pfkID *uint + if s.ProjectFlockKandangRepo != nil { + if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil { + idCopy := uint(pfk.Id) + pfkID = &idCopy + } else if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse %d has no active project flock", id)) + } else if err != nil { + s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err) + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "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") @@ -234,8 +295,8 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase 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 +308,64 @@ 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 + if req.DueDate != nil && strings.TrimSpace(*req.DueDate) != "" { + parsed, err := utils.ParseDateString(strings.TrimSpace(*req.DueDate)) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid due_date, expected YYYY-MM-DD") + } + parsed = parsed.UTC() + dueDate = &parsed + } purchase := &entity.Purchase{ - SupplierId: uint64(req.SupplierID), - CreditTerm: creditTerm, + SupplierId: uint(req.SupplierID), 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 } @@ -308,37 +376,37 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return nil, fiber.NewError(fiber.StatusInternalServerError, "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") } - 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 { + return nil, err + } + + 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") @@ -355,8 +423,8 @@ 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) @@ -374,68 +442,48 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, syncReceiving := !isInitialApproval && hasReceivingData - payload, err := s.buildStaffAdjustmentPayload(ctx, purchase, req, syncReceiving) + if len(req.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "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 } } @@ -452,41 +500,33 @@ func (s *purchaseService) processStaffPurchaseApproval(c *fiber.Ctx, id uint64, return nil, fiber.NewError(fiber.StatusInternalServerError, "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") } - 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() + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -495,7 +535,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - if err := s.attachLatestApproval(ctx, purchase); err != nil { + if err := s.attachLatestApproval(c.Context(), purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } @@ -504,16 +544,19 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint64, req *v return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must reach staff purchase step before manager approval") } - action := entity.ApprovalActionApproved + if action == entity.ApprovalActionRejected { + return s.rejectAndReload(c, utils.PurchaseStepManager, purchase.Id, actorID, req.Notes) + } + 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 +567,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 +580,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 +593,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 } @@ -566,32 +609,37 @@ 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") } - 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) - if err != nil { - return nil, err - } - ctx := c.Context() - purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + action, err := parseApprovalActionInput(req.Action) + if err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + 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") @@ -603,7 +651,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase order has not been generated") } - if err := s.attachLatestApproval(ctx, purchase); err != nil { + if err := s.attachLatestApproval(c.Context(), purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } @@ -612,7 +660,25 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must be approved by manager before receiving products") } - itemMap := make(map[uint64]*entity.PurchaseItem, len(purchase.Items)) + if action == entity.ApprovalActionApproved && len(req.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must not be empty") + } + + 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, fiber.NewError(fiber.StatusInternalServerError, "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 + } + + itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { itemMap[purchase.Items[i].Id] = &purchase.Items[i] } @@ -622,11 +688,13 @@ 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)) for _, payload := range req.Items { item, exists := itemMap[payload.PurchaseItemID] @@ -634,7 +702,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusBadRequest, 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)) } @@ -649,6 +717,9 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati if warehouseID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) } + if payload.WarehouseID != nil && uint(item.WarehouseId) != warehouseID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving does not allow changing warehouse") + } var receivedQty float64 if payload.ReceivedQty != nil { @@ -668,11 +739,27 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati } 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, fiber.NewError(fiber.StatusBadRequest, 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, }) @@ -684,10 +771,12 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusBadRequest, "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,7 +784,12 @@ 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") @@ -705,14 +799,18 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati 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)) + fifoAdds := make([]struct { + itemID uint + pwID uint + qty float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item @@ -726,21 +824,29 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati var newPWID *uint clearPW := false + // Always ensure PW when qty > 0 so stockable has target. if prep.receivedQty > 0 { - pwID, err := pwRepoTx.EnsureProductWarehouse(ctx, uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) + pwID, err := pwRepoTx.EnsureProductWarehouse(c.Context(), uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) if err != nil { return err } newPWID = &pwID - deltas[pwID] += prep.receivedQty - affected[pwID] = struct{}{} - } else { + } else if oldPWID != nil { + newPWID = oldPWID clearPW = true } - 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 @@ -764,24 +870,33 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati updates = append(updates, update) } - 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 err := s.createPurchaseApproval(ctx, tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); 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 @@ -794,11 +909,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint64, req *validati return nil, fiber.NewError(fiber.StatusInternalServerError, "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 ") } - 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,26 +921,42 @@ 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, fiber.NewError(fiber.StatusInternalServerError, "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) 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") @@ -844,19 +975,16 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.D return nil, fiber.NewError(fiber.StatusBadRequest, "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 @@ -867,6 +995,17 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.D return nil, fiber.NewError(fiber.StatusBadRequest, "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") } @@ -878,10 +1017,6 @@ 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 { @@ -891,11 +1026,17 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint64, req *validation.D return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items") } - if len(toDelete) > 0 { - s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete) + 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, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense") + } } - updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") } @@ -906,13 +1047,13 @@ 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") } 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 fiber.NewError(fiber.StatusNotFound, "Purchase not found") @@ -920,9 +1061,9 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - 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 { @@ -944,8 +1085,14 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint64) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase") } - if len(itemIDs) > 0 { - s.notifyExpenseItemsDeleted(ctx, uint64(id), itemIDs) + 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 fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense") + } } return nil @@ -954,7 +1101,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, @@ -969,6 +1116,9 @@ func (s *purchaseService) createPurchaseApproval( } svc := s.approvalServiceForDB(db) + if svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Approval service not available") + } modifier := func(db *gorm.DB) *gorm.DB { return db.Where("step_number = ?", uint16(step)) @@ -997,85 +1147,25 @@ func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalSe if s.ApprovalSvc != nil { return s.ApprovalSvc } - return commonSvc.NewApprovalService(s.ApprovalRepo) -} - -func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error { - if len(items) == 0 || s.ApprovalSvc == nil { - return nil + if s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil { + return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB())) } - - ids := make([]uint, 0, len(items)) - visited := make(map[uint64]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, 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 - } - } - 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) { +func (s *purchaseService) notifyExpenseItemsReceived(c *fiber.Ctx, purchaseID uint, payloads []ExpenseReceivingPayload) error { 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) + return nil } + return s.ExpenseBridge.OnItemsReceived(c, purchaseID, payloads) } -func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint64, itemIDs []uint64) { - if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 { - return +func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error { + if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { + return nil } - 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) - } -} + return s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, items) -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( @@ -1088,7 +1178,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( return nil, fiber.NewError(fiber.StatusBadRequest, "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 { @@ -1103,7 +1193,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload( } 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,7 +1200,7 @@ 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{}{} } @@ -1169,15 +1258,15 @@ 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") } - 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 { @@ -1224,18 +1313,18 @@ 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 { @@ -1245,10 +1334,10 @@ func (s *purchaseService) buildStaffAdjustmentPayload( 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)) @@ -1257,7 +1346,6 @@ func calculateTotalPrice(quantity float64, price float64, provided *float64, ref return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for %s must be greater than 0", ref)) } - fmt.Println(price, quantity) expectedTotal := price * quantity if provided == nil { @@ -1288,53 +1376,36 @@ 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 "", fiber.NewError(fiber.StatusBadRequest, "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 + } + + updated, err := s.PurchaseRepo.GetByID(c.Context(), purchaseID, s.withRelations) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + } + 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 +} diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 4994a927..6bbe9ddc 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -8,34 +8,38 @@ type PurchaseItemPayload struct { type CreatePurchaseRequest struct { SupplierID uint `json:"supplier_id" validate:"required,gt=0"` - CreditTerm int `json:"credit_term" validate:"required,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,omitempty" 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"` + PurchaseItemID uint `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"` + ExpeditionVendorID *uint `json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"` + TransportPerItem *float64 `json:"transport_per_item,omitempty" validate:"omitempty,gte=0"` 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"` @@ -43,21 +47,23 @@ type ReceivePurchaseItemRequest struct { } type ReceivePurchaseRequest struct { - Items []ReceivePurchaseItemRequest `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 []ReceivePurchaseItemRequest `json:"items,omitempty" validate:"omitempty,min=1,dive"` + Notes *string `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..e4b6088e --- /dev/null +++ b/internal/modules/repports/controllers/repport.controller.go @@ -0,0 +1,98 @@ +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" +) + +type RepportController struct { + RepportService service.RepportService +} + +func NewRepportController(repportService service.RepportService) *RepportController { + return &RepportController{ + RepportService: repportService, + } +} + +func (c *RepportController) GetAll(ctx *fiber.Ctx) error { + query := &validation.Query{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + Search: ctx.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 := c.RepportService.GetAll(ctx, query) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.RepportListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all reports 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) GetOne(ctx *fiber.Ctx) error { + param := ctx.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := c.RepportService.GetOne(ctx, uint(id)) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get report successfully", + Data: result, + }) +} + +func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { + param := ctx.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := c.RepportService.GetOne(ctx, uint(id)) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get report successfully", + Data: result, + }) +} diff --git a/internal/modules/repports/dto/repport.dto.go b/internal/modules/repports/dto/repport.dto.go new file mode 100644 index 00000000..154c6f47 --- /dev/null +++ b/internal/modules/repports/dto/repport.dto.go @@ -0,0 +1,16 @@ +package dto + +import "time" + +// === DTO Structs === + +type RepportListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RepportDetailDTO struct { + RepportListDTO +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go new file mode 100644 index 00000000..be0ba7a3 --- /dev/null +++ b/internal/modules/repports/module.go @@ -0,0 +1,23 @@ +package repports + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + sRepport "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" + + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" +) + +type RepportModule struct{} + +func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + // Initialize expense realization repository + expRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) + + // Initialize report service with expense realization repo + repportService := sRepport.NewRepportService(validate, expRealizationRepo) + + RepportRoutes(router, repportService) +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go new file mode 100644 index 00000000..d01fd4b2 --- /dev/null +++ b/internal/modules/repports/route.go @@ -0,0 +1,20 @@ +package repports + +import ( + + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/controllers" + repport "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" + + "github.com/gofiber/fiber/v2" +) + +func RepportRoutes(v1 fiber.Router, s repport.RepportService) { + ctrl := controller.NewRepportController(s) + + route := v1.Group("/repports") + + route.Get("/", ctrl.GetAll) + route.Get("/:id", ctrl.GetOne) + + route.Get("expense", ctrl.GetExpense) +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go new file mode 100644 index 00000000..82fd5470 --- /dev/null +++ b/internal/modules/repports/services/repport.service.go @@ -0,0 +1,106 @@ +package service + +import ( + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" +) + +type RepportService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.RepportListDTO, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*dto.RepportListDTO, error) + GetExpense(ctx *fiber.Ctx, id uint) (*dto.RepportListDTO, error) +} + +type repportService struct { + Log *logrus.Logger + Validate *validator.Validate + dummyData map[uint]dto.RepportListDTO + ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository +} + +func NewRepportService(validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository) RepportService { + // Initialize with dummy data + now := time.Now() + dummyData := map[uint]dto.RepportListDTO{ + 1: { + Id: 1, + Name: "Sales Report", + CreatedAt: now, + UpdatedAt: now, + }, + 2: { + Id: 2, + Name: "Inventory Report", + CreatedAt: now, + UpdatedAt: now, + }, + 3: { + Id: 3, + Name: "Production Report", + CreatedAt: now, + UpdatedAt: now, + }, + } + + return &repportService{ + Log: utils.Log, + Validate: validate, + dummyData: dummyData, + ExpenseRealizationRepo: expenseRealizationRepo, + } +} + +func (s *repportService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.RepportListDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + // Convert map to slice + var results []dto.RepportListDTO + for _, v := range s.dummyData { + // Apply search filter if provided + if params.Search != "" && !strings.Contains(strings.ToLower(v.Name), strings.ToLower(params.Search)) { + continue + } + results = append(results, v) + } + + // Apply pagination + total := int64(len(results)) + offset := (params.Page - 1) * params.Limit + + if offset >= int(total) { + return []dto.RepportListDTO{}, total, nil + } + + end := offset + params.Limit + if end > int(total) { + end = int(total) + } + + return results[offset:end], total, nil +} + +func (s *repportService) GetOne(c *fiber.Ctx, id uint) (*dto.RepportListDTO, error) { + if data, ok := s.dummyData[id]; ok { + return &data, nil + } + return nil, fiber.NewError(fiber.StatusNotFound, "Report not found") +} + +func (s *repportService) GetExpense(c *fiber.Ctx, id uint) (*dto.RepportListDTO, error) { + if data, ok := s.dummyData[id]; ok { + return &data, nil + } + return nil, fiber.NewError(fiber.StatusNotFound, "Report not found") +} diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go new file mode 100644 index 00000000..a7ec4a6d --- /dev/null +++ b/internal/modules/repports/validations/repport.validation.go @@ -0,0 +1,7 @@ +package validation + +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/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/route/route.go b/internal/route/route.go index ac7fb486..294fc900 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -9,6 +9,7 @@ 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" expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" @@ -18,6 +19,7 @@ import ( purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" + repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" // MODULE IMPORTS ) @@ -40,6 +42,8 @@ func Routes(app *fiber.App, db *gorm.DB) { ssoModule.Module{}, expenses.ExpenseModule{}, ssoModule.Module{}, + closings.ClosingModule{}, + repports.RepportModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 98381df6..6594ac6b 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -154,12 +154,14 @@ 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", } // ------------------------------------------------------------------- @@ -198,13 +200,11 @@ 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", } @@ -243,6 +243,9 @@ const ( MarketingStepPengajuan approvalutils.ApprovalStep = 1 MarketingStepSalesOrder approvalutils.ApprovalStep = 2 MarketingDeliveryOrder approvalutils.ApprovalStep = 3 + + MarketingSoNumberPrefix = "SO-" + MarketingNumberPadding = 5 ) var MarketingApprovalSteps = map[approvalutils.ApprovalStep]string{ 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..c47d3cd7 --- /dev/null +++ b/internal/utils/fifo/constants.go @@ -0,0 +1,5 @@ +package fifo + +const ( + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" +) 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..f10926dc 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -80,6 +80,7 @@ func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity. RecordingId: recordingID, ProductWarehouseId: item.ProductWarehouseId, Qty: item.Qty, + Weight: item.Weight, CreatedBy: createdBy, }) } 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/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 +}